diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 0ce5e5cd..39ebe9f1 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -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- + 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 `; "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 --dir `. +- **Add new account** — button that runs the full add+login flow against a default `~/.claude-/` 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 `; "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 --dir `. +- **Add new account** — button that runs the full add+login flow against a default `~/.claude-/` 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; diff --git a/.pql/changelog/ticket_idmap/2026-06.sql b/.pql/changelog/ticket_idmap/2026-06.sql index a7c5eacd..c6ba0740 100644 --- a/.pql/changelog/ticket_idmap/2026-06.sql +++ b/.pql/changelog/ticket_idmap/2026-06.sql @@ -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); diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index c1945907..f31869fc 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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- + 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 `; "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 --dir `. +- **Add new account** — button that runs the full add+login flow against a default `~/.claude-/` 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 `; "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 --dir `. +- **Add new account** — button that runs the full add+login flow against a default `~/.claude-/` 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); diff --git a/CHANGELOG.md b/CHANGELOG.md index ecb3b744..8ed52360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/lib/builtin/claude/src/account_settings_control.dart b/lib/builtin/claude/src/account_settings_control.dart index dcc39716..666faeb7 100644 --- a/lib/builtin/claude/src/account_settings_control.dart +++ b/lib/builtin/claude/src/account_settings_control.dart @@ -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 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 _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 { ); } } + +/// 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 createState() => _ClaudeAccountBadgeState(); +} + +class _ClaudeAccountBadgeState extends State { + 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 _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), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 14f1e650..89b86d3e 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -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 { ); } - 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). diff --git a/test/builtin/claude/account_settings_control_test.dart b/test/builtin/claude/account_settings_control_test.dart index b3653697..0de00f45 100644 --- a/test/builtin/claude/account_settings_control_test.dart +++ b/test/builtin/claude/account_settings_control_test.dart @@ -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 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 = >[]; + 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))); + }); }