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
@@ -155,4 +155,65 @@ void main() {
expect(reg.accountByName('personal'), isNull);
expect(find.bySemanticsLabel(RegExp('bound to a workspace')), findsOneWidget, reason: 'the bound account guards its remove');
});
// The Claude pane account badge (T-481).
Future<void> pumpBadge(WidgetTester tester, String? root) => tester.pumpWidget(
harness(
f,
Align(
alignment: Alignment.center,
child: SizedBox(width: 220, child: ClaudeAccountBadge(workspaceRoot: root)),
),
),
);
testWidgets('badge: hidden (renders nothing) when no accounts are registered', (tester) async {
await pumpBadge(tester, f.tempDir.path);
await tester.pump();
expect(find.byType(ClaudeAccountBadge), findsOneWidget);
expect(find.text('default'), findsNothing);
});
testWidgets('badge: shows default when unbound, and switches account on pick', (tester) async {
final reg = AccountRegistry(f.services.settings);
final published = <Map<String, Object?>>[];
final sub = f.services.messages.subscribe(channel: accountActionChannel).listen((m) => published.add(m.data));
addTearDown(sub.cancel);
await tester.runAsync(() => reg.registerAccount('work', '/home/u/.claude-work'));
await pumpBadge(tester, f.tempDir.path);
await tester.pump();
expect(find.text('default'), findsOneWidget);
await tester.tap(find.byType(ClaudeAccountBadge));
await tester.pump();
await tester.tap(find.text('work'));
await tester.pump();
expect(reg.boundName(f.tempDir.path), 'work');
expect(published.single['action'], 'set');
});
testWidgets('badge: shows the bound account name', (tester) async {
final reg = AccountRegistry(f.services.settings);
await tester.runAsync(() async {
await reg.registerAccount('work', '/home/u/.claude-work');
await reg.bindWorkspace(f.tempDir.path, 'work');
});
await pumpBadge(tester, f.tempDir.path);
await tester.pump();
expect(find.text('work'), findsOneWidget);
});
testWidgets('accountAccent is stable per name, muted for default, and theme-sourced', (tester) async {
final tokens = f.services.theme.current.surface;
expect(accountAccent(null, tokens), tokens.globalTextMuted);
expect(accountAccent('work', tokens), accountAccent('work', tokens));
expect([
tokens.globalFocus,
tokens.statusSuccess,
tokens.statusWarning,
tokens.statusError,
tokens.buttonBackground,
], contains(accountAccent('work', tokens)));
});
}