feat(claude): per-repo account badge in the Claude pane chrome (T-481)

The always-visible affordance for the multi-account epic: a compact badge in
the pane header showing which account this workspace is bound to (or "default"),
colour-tinted per account so two windows are distinguishable at a glance. Tapping
opens a picker of the registered accounts + Default; selecting binds/unbinds via
the shared bindWorkspaceAccount helper (respawn + lock-sync follow on the bus).
Hidden when no accounts are registered, so it adds no chrome for users not using
the feature.

accountAccent derives the tint by hashing the name into a fixed set of theme
tokens — never an arbitrary colour, so the palette stays theme-owned.

The welcome-view accounts section (the other half of T-481) is split to T-486:
the welcome builtin is intentionally decoupled from feature builtins, so it needs
a welcome-section contribution point rather than importing claude directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 00:43:40 +02:00
co-authored by Claude Opus 4.8
parent 5c31190c4f
commit 222d4ae5e3
7 changed files with 403 additions and 16 deletions
@@ -6,6 +6,39 @@ import 'package:clide/src/daemon/claude_account_commands.dart' show accountActio
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
/// Bind (or, with [name] null, unbind) [cwd] to a Claude account and publish on
/// [accountActionChannel] so the session respawns (T-480) and the IDE lock
/// re-syncs (T-479). The registry write sets the in-memory binding
/// synchronously then flushes; publishing before the flush keeps the bus
/// consumers in step. Shared by the settings picker and the pane badge (T-481).
Future<void> bindWorkspaceAccount(KernelServices services, String cwd, String? name) async {
final reg = AccountRegistry(services.settings);
if (name == null) {
final previous = reg.boundName(cwd);
final write = reg.unbindWorkspace(cwd);
services.messages.publish('ui', accountActionChannel, {'action': 'unset', 'cwd': cwd, 'previous': previous});
await write;
} else {
final write = reg.bindWorkspace(cwd, name);
services.messages.publish('ui', accountActionChannel, {'action': 'set', 'name': name, 'cwd': cwd});
await write;
}
}
/// A stable, token-derived accent for an account [name] so each window's badge
/// reads at a glance (T-481). Hash-indexed into a fixed set of theme tokens —
/// never an arbitrary colour (the palette stays theme-owned). Null name (the
/// default account) returns the muted token.
Color accountAccent(String? name, SurfaceTokens tokens) {
if (name == null || name.isEmpty) return tokens.globalTextMuted;
final accents = [tokens.globalFocus, tokens.statusSuccess, tokens.statusWarning, tokens.statusError, tokens.buttonBackground];
var h = 0;
for (final unit in name.codeUnits) {
h = (h * 31 + unit) & 0x7fffffff;
}
return accents[h % accents.length];
}
/// Settings control for "Account for this workspace" (T-482, epic T-476). A
/// dropdown of the registered Claude accounts plus a Default option; picking one
/// binds (or unbinds) the current workspace and publishes on
@@ -49,21 +82,7 @@ class _ClaudeWorkspaceAccountControlState extends State<ClaudeWorkspaceAccountCo
Future<void> _bind(KernelServices services, String cwd, String? name) async {
_overlay.close();
final reg = AccountRegistry(services.settings);
// The registry write sets the in-memory binding synchronously, then flushes
// to disk; publish once the binding is live (before the flush completes) so
// the respawn (T-480) + lock-sync (T-479) consumers see the new state. The
// settings notifier rebuilds us once the write lands.
if (name == null) {
final previous = reg.boundName(cwd);
final write = reg.unbindWorkspace(cwd);
services.messages.publish('ui', accountActionChannel, {'action': 'unset', 'cwd': cwd, 'previous': previous});
await write;
} else {
final write = reg.bindWorkspace(cwd, name);
services.messages.publish('ui', accountActionChannel, {'action': 'set', 'name': name, 'cwd': cwd});
await write;
}
await bindWorkspaceAccount(services, cwd, name);
}
@override
@@ -309,3 +328,106 @@ class _ClaudeAccountsListControlState extends State<ClaudeAccountsListControl> {
);
}
}
/// Compact account badge for the Claude pane chrome (T-481, epic T-476). Shows
/// the workspace's bound account (or "default"), tinted by [accountAccent] so
/// each window is distinguishable at a glance. Tapping opens a picker of the
/// registered accounts + Default. Hidden when no accounts are registered (the
/// feature is unused). Live via the settings notifier.
class ClaudeAccountBadge extends StatefulWidget {
const ClaudeAccountBadge({super.key, required this.workspaceRoot});
final String? workspaceRoot;
@override
State<ClaudeAccountBadge> createState() => _ClaudeAccountBadgeState();
}
class _ClaudeAccountBadgeState extends State<ClaudeAccountBadge> {
final ClideOverlayController _overlay = ClideOverlayController();
SettingsStore? _settings;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final settings = ClideKernel.maybeOf(context)?.settings;
if (identical(settings, _settings)) return;
_settings?.removeListener(_onChange);
_settings = settings;
_settings?.addListener(_onChange);
}
void _onChange() {
if (mounted) setState(() {});
}
@override
void dispose() {
_settings?.removeListener(_onChange);
_overlay.dispose();
super.dispose();
}
Future<void> _pick(KernelServices services, String? name) async {
_overlay.close();
final cwd = widget.workspaceRoot;
if (cwd != null) await bindWorkspaceAccount(services, cwd, name);
}
@override
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface;
final services = ClideKernel.maybeOf(context);
final cwd = widget.workspaceRoot;
if (services == null || cwd == null) return const SizedBox.shrink();
final reg = AccountRegistry(services.settings);
final accounts = reg.accounts;
if (accounts.isEmpty) return const SizedBox.shrink(); // feature unused — no chrome noise
final boundName = reg.boundName(cwd);
final label = boundName ?? 'default';
final accent = accountAccent(boundName, tokens);
return ClideAnchoredOverlay(
controller: _overlay,
align: ClideAnchorAlign.end,
overlayBuilder: (ctx, c) => ClideMenu(
onClose: c.close,
entries: [
ClideMenuItem(label: 'default', active: boundName == null, semanticLabel: 'Account: default', onSelect: () => _pick(services, null)),
const ClideMenuSeparator(),
for (final a in accounts)
ClideMenuItem(label: a.name, active: a.name == boundName, semanticLabel: 'Account: ${a.name}', onSelect: () => _pick(services, a.name)),
],
),
anchor: Semantics(
button: true,
label: 'Claude account: $label. Click to change.',
excludeSemantics: true,
onTap: _overlay.toggle,
child: ClideTappable(
cursor: SystemMouseCursors.click,
onTap: _overlay.toggle,
builder: (ctx, hovered, _) => Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: hovered ? tokens.listItemHoverBackground : null,
border: Border.all(color: accent),
borderRadius: BorderRadius.circular(3),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(shape: BoxShape.circle, color: accent),
),
const SizedBox(width: 5),
ClideText(label, fontSize: clideFontCaption, color: boundName == null ? tokens.globalTextMuted : tokens.globalForeground),
],
),
),
),
),
);
}
}
+11 -1
View File
@@ -5,6 +5,7 @@ import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'account_settings_control.dart';
import 'claude_banner.dart';
import 'claude_composer.dart';
import 'claude_config.dart';
@@ -854,7 +855,16 @@ class _ClaudePaneState extends State<ClaudePane> {
);
}
final content = widget.showChrome ? ClidePaneChrome(title: title, subtitle: _error ?? _statusLine, child: body) : body;
final content = widget.showChrome
? ClidePaneChrome(
title: title,
subtitle: _error ?? _statusLine,
// Per-repo Claude account badge (T-481): shows + switches the
// account this workspace is bound to; hidden when none registered.
trailing: [ClaudeAccountBadge(workspaceRoot: _repoRoot)],
child: body,
)
: body;
// Surface this pane's status to the bottom status-bar slot while it's
// the focused pane (T-150).