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
+93
View File
@@ -7104,3 +7104,96 @@ Slice 1 done: the per-workspace account picker (acceptance #2) — a custom Sett
Slice 2 done: the global Accounts registry list control (ClaudeAccountsListControl, customId ''claude.accounts'') in the Claude > Account section. Per-account sign-in dot (accountIsSignedIn read-only probe), name, config dir, re-login + remove affordances (remove guarded while a workspace is bound, matching the CLI), and an inline add-account field that registers ~/.claude-<name> + publishes login. Live via the settings notifier. Note: in-UI remove is registry-only; --purge dir deletion stays on the CLI flag. All three acceptance criteria met (list+manage, per-workspace, live).', NULL, '2026-06-27 22:35:36', '2026-06-27 22:35:36.216', '2026-06-27 22:35:36.216', NULL, 'fb876cec752b1ac391234569789ad282', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49WEE5N4PESF5G1G5JRHR', 'status', 'in_progress', 'done', NULL, '2026-06-27 22:35:36', '2026-06-27 22:35:36.250', '2026-06-27 22:35:36.250', NULL, 'd6bed97cc8a298c7cab9ae5fb5b1ede1', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGPG4WNNH3BWDRVZXYQTW7Z0', 'description', NULL, 'Split from T-481. The welcome view (lib/builtin/welcome) is intentionally decoupled from feature builtins — peer builtins only import the shared builtin, never each other. Dropping ClaudeAccountsListControl directly into welcome_view.dart would make welcome import claude (a new peer coupling) and risks the centered welcome layout overflowing. Do it cleanly via a welcome-section contribution point that the claude extension contributes the accounts section to.', NULL, '2026-06-27 22:43:31', '2026-06-27 22:43:31.675', '2026-06-27 22:43:31.675', NULL, '7929a12675a9536b11ca9914f0c641f2', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49WBTT3ESK6QG0XZ1VN2G', 'description', 'The conversational/chrome UI surfaces for the multi-account epic (T-476). Settings-UI rows live in T-482; this ticket owns the always-visible affordances.
## Claude pane chrome badge
Small badge in the pane chrome (next to the existing status/banner row see `lib/builtin/claude/src/claude_banner.dart` / status-bar pattern) showing the active account name, or "default" when unbound.
- Tap opens a popover/picker: list of registered accounts (radio-selectable) + "Add account…" entry.
- Selecting an account dispatches `clide claude account set <name>`; "Add account…" runs the add+login flow.
- The badge live-updates from the `claude.account` MessageBus channel published by T-480.
- Colour-coded subtly (one accent per account name? Hash-derived?) so the user can spot at a glance which window is which account.
## Welcome view: Claude accounts section
New section in the welcome view (peer of the existing project / toolchain rows in `lib/builtin/welcome/src/welcome_view.dart`):
- **Registered accounts** one row per registered account: name, configDir path, a per-row affordance to bind this workspace / unbind / re-login.
- **Detected (not yet registered)** rows for the T-477 bootstrap probe results (existing `~/.claude-*/` dirs not in the registry yet). Each has a "Register this" affordance that dispatches `clide claude account add <name> --dir <detected-dir>`.
- **Add new account** button that runs the full add+login flow against a default `~/.claude-<name>/` path.
All actions go through T-480''s CLI verbs; this widget never writes the registry directly.
## Constraints
- Use the existing UI-design vocabulary (theme tokens, control geometry see the `ui-design` skill). No Material/Cupertino.
- The badge is display-with-affordance, not an inline interaction surface in the D-78 sense opening the picker is a navigation gesture, the selection happens in the picker modal.
- The welcome view follows the existing one-screen welcome-layout decisions (don''t add a fold-out / accordion if it conflicts with the current layout).
## Acceptance
1. The Claude pane badge shows the active account name (or "default"), updates live across `set`/`unset`/`add` actions.
2. Tapping the badge opens a picker; selecting a registered account dispatches `clide claude account set` and respawns the pane on the new account.
3. The welcome view''s accounts section lists registered accounts + detected candidates + an add-new affordance.
4. Every action available in the UI is reachable as the corresponding `clide claude account` verb (D-6 parity verified by inspection).
5. Widget tests cover badge rendering for each state (default / bound / unknown) + the picker action dispatch (mock the CLI client).
6. A11y: badge has a semantic label naming the account; picker is keyboard-reachable.
## Depends on
- T-480 (CLI verbs). Wires every action to the dispatcher.
## Out of scope
- Settings-UI rows T-482.
- Project-picker integration (each project shows its bound account) desirable, file as a follow-up if it grows complex.
', 'The conversational/chrome UI surfaces for the multi-account epic (T-476). Settings-UI rows live in T-482; this ticket owns the always-visible affordances.
## Claude pane chrome badge
Small badge in the pane chrome (next to the existing status/banner row see `lib/builtin/claude/src/claude_banner.dart` / status-bar pattern) showing the active account name, or "default" when unbound.
- Tap opens a popover/picker: list of registered accounts (radio-selectable) + "Add account…" entry.
- Selecting an account dispatches `clide claude account set <name>`; "Add account…" runs the add+login flow.
- The badge live-updates from the `claude.account` MessageBus channel published by T-480.
- Colour-coded subtly (one accent per account name? Hash-derived?) so the user can spot at a glance which window is which account.
## Welcome view: Claude accounts section
New section in the welcome view (peer of the existing project / toolchain rows in `lib/builtin/welcome/src/welcome_view.dart`):
- **Registered accounts** one row per registered account: name, configDir path, a per-row affordance to bind this workspace / unbind / re-login.
- **Detected (not yet registered)** rows for the T-477 bootstrap probe results (existing `~/.claude-*/` dirs not in the registry yet). Each has a "Register this" affordance that dispatches `clide claude account add <name> --dir <detected-dir>`.
- **Add new account** button that runs the full add+login flow against a default `~/.claude-<name>/` path.
All actions go through T-480''s CLI verbs; this widget never writes the registry directly.
## Constraints
- Use the existing UI-design vocabulary (theme tokens, control geometry see the `ui-design` skill). No Material/Cupertino.
- The badge is display-with-affordance, not an inline interaction surface in the D-78 sense opening the picker is a navigation gesture, the selection happens in the picker modal.
- The welcome view follows the existing one-screen welcome-layout decisions (don''t add a fold-out / accordion if it conflicts with the current layout).
## Acceptance
1. The Claude pane badge shows the active account name (or "default"), updates live across `set`/`unset`/`add` actions.
2. Tapping the badge opens a picker; selecting a registered account dispatches `clide claude account set` and respawns the pane on the new account.
3. The welcome view''s accounts section lists registered accounts + detected candidates + an add-new affordance.
4. Every action available in the UI is reachable as the corresponding `clide claude account` verb (D-6 parity verified by inspection).
5. Widget tests cover badge rendering for each state (default / bound / unknown) + the picker action dispatch (mock the CLI client).
6. A11y: badge has a semantic label naming the account; picker is keyboard-reachable.
## Depends on
- T-480 (CLI verbs). Wires every action to the dispatcher.
## Out of scope
- Settings-UI rows T-482.
- Project-picker integration (each project shows its bound account) desirable, file as a follow-up if it grows complex.
Done: the Claude pane account badge (ClaudeAccountBadge) in the pane chrome trailing slot shows the bound account (or ''default''), colour-tinted per account via accountAccent (theme tokens, hash-indexed), tap opens the account picker; hidden when no accounts registered. The welcome-view accounts section is split to T-486 (welcome is builtin-decoupled; needs a contribution point to avoid welcome->claude peer coupling).', NULL, '2026-06-27 22:43:31', '2026-06-27 22:43:31.723', '2026-06-27 22:43:31.723', NULL, 'd3146ee6d4abe36714b1b160e94d3d3a', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49WBTT3ESK6QG0XZ1VN2G', 'status', 'in_progress', 'done', NULL, '2026-06-27 22:43:31', '2026-06-27 22:43:31.769', '2026-06-27 22:43:31.769', NULL, '34a8c26c8c71d27fdcb1e60e3ffa1ea0', 2) ON CONFLICT(hash) DO NOTHING;
+2
View File
@@ -312,3 +312,5 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49WEE5N4PESF5G1G5JRHR', 'T-482', '2026-06-25 09:16:42', '2026-06-25 09:16:42', NULL, '557f92392db9b5c1e6b6840fe1293b7d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49VYMPF18PYQXD9PCMHN8', 'T-483', '2026-06-25 09:16:42', '2026-06-26 21:35:04.076', NULL, '2841b381bbb03e6a9cdb970280cd2207', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49W3V175EM4F8ZHC6JFM4', 'T-484', '2026-06-25 09:16:42', '2026-06-26 21:35:07.645', NULL, 'd90df1d5e2da44e81f60edb8c5fb2823', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGN13H42TP2MP3T27CTSCDRW', 'T-485', '2026-06-27 19:17:59.713', '2026-06-27 19:17:59.713', NULL, 'b345ba60d55cfdbc7c2e9bb9187ee171', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGPG4WNNH3BWDRVZXYQTW7Z0', 'T-486', '2026-06-27 22:43:31.629', '2026-06-27 22:43:31.629', NULL, 'cd344c35e0eca5673be26790aa8d292c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
+96
View File
@@ -9061,3 +9061,99 @@ Dropdown/select listing registered accounts + a "(default)" option. Selecting is
Slice 1 done: the per-workspace account picker (acceptance #2) a custom SettingsControlContribution ''claude.workspace-account'' in the Claude settings category. Dropdown of registered accounts + Default; selecting binds/unbinds via AccountRegistry and publishes set/unset on accountActionChannel (respawn + lock sync follow). Live via the settings notifier. Empty/no-workspace states covered. Remaining (Slice 2): the global Accounts registry CRUD list with sign-in probe + add/re-login/remove(--purge) (acceptance #1).
Slice 2 done: the global Accounts registry list control (ClaudeAccountsListControl, customId ''claude.accounts'') in the Claude > Account section. Per-account sign-in dot (accountIsSignedIn read-only probe), name, config dir, re-login + remove affordances (remove guarded while a workspace is bound, matching the CLI), and an inline add-account field that registers ~/.claude-<name> + publishes login. Live via the settings notifier. Note: in-UI remove is registry-only; --purge dir deletion stays on the CLI flag. All three acceptance criteria met (list+manage, per-workspace, live).', 'done', 'medium', NULL, NULL, NULL, '2026-06-25 09:16:42', '2026-06-27 22:35:36.250', NULL, '9ed2e68e4ff8955cf57a3ce2ef27a8a9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGPG4WNNH3BWDRVZXYQTW7Z0', 'task', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'Per-repo Claude account: welcome-view accounts section (needs a welcome-section contribution point)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-27 22:43:31.629', '2026-06-27 22:43:31.629', NULL, 'a5b6f26b49f43381c8609ba0645c9551', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGPG4WNNH3BWDRVZXYQTW7Z0', 'task', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'Per-repo Claude account: welcome-view accounts section (needs a welcome-section contribution point)', 'Split from T-481. The welcome view (lib/builtin/welcome) is intentionally decoupled from feature builtins — peer builtins only import the shared builtin, never each other. Dropping ClaudeAccountsListControl directly into welcome_view.dart would make welcome import claude (a new peer coupling) and risks the centered welcome layout overflowing. Do it cleanly via a welcome-section contribution point that the claude extension contributes the accounts section to.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-27 22:43:31.629', '2026-06-27 22:43:31.675', NULL, '8a1712b25cacd79a3595641a712e62a3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49WBTT3ESK6QG0XZ1VN2G', 'story', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'Per-repo Claude account: UI — Claude pane badge + welcome view accounts section', 'The conversational/chrome UI surfaces for the multi-account epic (T-476). Settings-UI rows live in T-482; this ticket owns the always-visible affordances.
## Claude pane chrome badge
Small badge in the pane chrome (next to the existing status/banner row see `lib/builtin/claude/src/claude_banner.dart` / status-bar pattern) showing the active account name, or "default" when unbound.
- Tap opens a popover/picker: list of registered accounts (radio-selectable) + "Add account…" entry.
- Selecting an account dispatches `clide claude account set <name>`; "Add account…" runs the add+login flow.
- The badge live-updates from the `claude.account` MessageBus channel published by T-480.
- Colour-coded subtly (one accent per account name? Hash-derived?) so the user can spot at a glance which window is which account.
## Welcome view: Claude accounts section
New section in the welcome view (peer of the existing project / toolchain rows in `lib/builtin/welcome/src/welcome_view.dart`):
- **Registered accounts** one row per registered account: name, configDir path, a per-row affordance to bind this workspace / unbind / re-login.
- **Detected (not yet registered)** rows for the T-477 bootstrap probe results (existing `~/.claude-*/` dirs not in the registry yet). Each has a "Register this" affordance that dispatches `clide claude account add <name> --dir <detected-dir>`.
- **Add new account** button that runs the full add+login flow against a default `~/.claude-<name>/` path.
All actions go through T-480''s CLI verbs; this widget never writes the registry directly.
## Constraints
- Use the existing UI-design vocabulary (theme tokens, control geometry see the `ui-design` skill). No Material/Cupertino.
- The badge is display-with-affordance, not an inline interaction surface in the D-78 sense opening the picker is a navigation gesture, the selection happens in the picker modal.
- The welcome view follows the existing one-screen welcome-layout decisions (don''t add a fold-out / accordion if it conflicts with the current layout).
## Acceptance
1. The Claude pane badge shows the active account name (or "default"), updates live across `set`/`unset`/`add` actions.
2. Tapping the badge opens a picker; selecting a registered account dispatches `clide claude account set` and respawns the pane on the new account.
3. The welcome view''s accounts section lists registered accounts + detected candidates + an add-new affordance.
4. Every action available in the UI is reachable as the corresponding `clide claude account` verb (D-6 parity verified by inspection).
5. Widget tests cover badge rendering for each state (default / bound / unknown) + the picker action dispatch (mock the CLI client).
6. A11y: badge has a semantic label naming the account; picker is keyboard-reachable.
## Depends on
- T-480 (CLI verbs). Wires every action to the dispatcher.
## Out of scope
- Settings-UI rows T-482.
- Project-picker integration (each project shows its bound account) desirable, file as a follow-up if it grows complex.
Done: the Claude pane account badge (ClaudeAccountBadge) in the pane chrome trailing slot shows the bound account (or ''default''), colour-tinted per account via accountAccent (theme tokens, hash-indexed), tap opens the account picker; hidden when no accounts registered. The welcome-view accounts section is split to T-486 (welcome is builtin-decoupled; needs a contribution point to avoid welcome->claude peer coupling).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-25 09:16:42', '2026-06-27 22:43:31.723', NULL, '8dfefeeb10fc7a7322f9314fc3f971f3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49WBTT3ESK6QG0XZ1VN2G', 'story', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'Per-repo Claude account: UI — Claude pane badge + welcome view accounts section', 'The conversational/chrome UI surfaces for the multi-account epic (T-476). Settings-UI rows live in T-482; this ticket owns the always-visible affordances.
## Claude pane chrome badge
Small badge in the pane chrome (next to the existing status/banner row see `lib/builtin/claude/src/claude_banner.dart` / status-bar pattern) showing the active account name, or "default" when unbound.
- Tap opens a popover/picker: list of registered accounts (radio-selectable) + "Add account…" entry.
- Selecting an account dispatches `clide claude account set <name>`; "Add account…" runs the add+login flow.
- The badge live-updates from the `claude.account` MessageBus channel published by T-480.
- Colour-coded subtly (one accent per account name? Hash-derived?) so the user can spot at a glance which window is which account.
## Welcome view: Claude accounts section
New section in the welcome view (peer of the existing project / toolchain rows in `lib/builtin/welcome/src/welcome_view.dart`):
- **Registered accounts** one row per registered account: name, configDir path, a per-row affordance to bind this workspace / unbind / re-login.
- **Detected (not yet registered)** rows for the T-477 bootstrap probe results (existing `~/.claude-*/` dirs not in the registry yet). Each has a "Register this" affordance that dispatches `clide claude account add <name> --dir <detected-dir>`.
- **Add new account** button that runs the full add+login flow against a default `~/.claude-<name>/` path.
All actions go through T-480''s CLI verbs; this widget never writes the registry directly.
## Constraints
- Use the existing UI-design vocabulary (theme tokens, control geometry see the `ui-design` skill). No Material/Cupertino.
- The badge is display-with-affordance, not an inline interaction surface in the D-78 sense opening the picker is a navigation gesture, the selection happens in the picker modal.
- The welcome view follows the existing one-screen welcome-layout decisions (don''t add a fold-out / accordion if it conflicts with the current layout).
## Acceptance
1. The Claude pane badge shows the active account name (or "default"), updates live across `set`/`unset`/`add` actions.
2. Tapping the badge opens a picker; selecting a registered account dispatches `clide claude account set` and respawns the pane on the new account.
3. The welcome view''s accounts section lists registered accounts + detected candidates + an add-new affordance.
4. Every action available in the UI is reachable as the corresponding `clide claude account` verb (D-6 parity verified by inspection).
5. Widget tests cover badge rendering for each state (default / bound / unknown) + the picker action dispatch (mock the CLI client).
6. A11y: badge has a semantic label naming the account; picker is keyboard-reachable.
## Depends on
- T-480 (CLI verbs). Wires every action to the dispatcher.
## Out of scope
- Settings-UI rows T-482.
- Project-picker integration (each project shows its bound account) desirable, file as a follow-up if it grows complex.
Done: the Claude pane account badge (ClaudeAccountBadge) in the pane chrome trailing slot shows the bound account (or ''default''), colour-tinted per account via accountAccent (theme tokens, hash-indexed), tap opens the account picker; hidden when no accounts registered. The welcome-view accounts section is split to T-486 (welcome is builtin-decoupled; needs a contribution point to avoid welcome->claude peer coupling).', 'done', 'medium', NULL, NULL, NULL, '2026-06-25 09:16:42', '2026-06-27 22:43:31.769', NULL, '065f5914ac6b963342be92e0ea65ab82', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+3
View File
@@ -18,6 +18,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- **Claude pane account badge.** The pane header shows which account this repo
is bound to (colour-tinted per account); tap to switch. Hidden when no
accounts are registered. (T-481, epic T-476)
- **Settings → Claude → Accounts.** A registry list (sign-in status, dir,
re-login / remove, add) plus a per-workspace picker that binds this repo to an
account (or Default); switching respawns the pane onto it. (T-482, epic T-476)
@@ -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).
@@ -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)));
});
}