diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 6ce13626..d72a3a58 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -6951,3 +6951,77 @@ T-478 — T-479 can land in parallel; the bound `claude` just won''t have an IDE Done: McpServer writes the /ide discovery lock into every active config dir (default ~/.claude/ide + the bound account''s /ide), reconciled by syncDiscoveryLocks() on start + on every accountActionChannel event, all cleaned on stop. boundConfigDir injected from main.dart via AccountRegistry. Acceptance 1-4,6 covered by test/ipc/mcp_server_test.dart; #5 (live claude finds the bridge end-to-end) is manual.', NULL, '2026-06-27 19:24:52', '2026-06-27 19:24:52.424', '2026-06-27 19:24:52.424', NULL, 'fc350455bedf3c3c4e8c61a5599cef33', 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 ('06FFW49W6GP535GR8XD1XEHYWG', 'status', 'in_progress', 'done', NULL, '2026-06-27 19:24:52', '2026-06-27 19:24:52.456', '2026-06-27 19:24:52.456', NULL, '9a1ec766e6abf5712e32dc32ace8c02d', 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', 'backlog', 'in_progress', NULL, '2026-06-27 21:32:32', '2026-06-27 21:32:32.255', '2026-06-27 21:32:32.255', NULL, 'de26a987bbc6b273147ea85568816ef6', 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', 'description', 'Settings UI surface for the multi-account epic (T-476): two schema-driven rows under the "Claude" settings category. + +## Rows + +### Global: "Claude > Accounts" + +Registry CRUD list — one entry per registered account. Each entry shows: name, configDir, current sign-in status (a small "signed in" / "not signed in" indicator derived from whether `/.claude.json` carries an active credential — read-only probe, no auth state mutation here). Per-entry affordances: re-login, remove (with `--purge` confirmation). Plus an "Add account…" affordance. + +### Per-workspace: "Claude > Account for this workspace" + +Dropdown/select listing registered accounts + a "(default)" option. Selecting issues `clide claude account set ` (or `unset` for default). + +## Where + +- Hooks into the schema-driven settings panel coming from T-8 / settings UI. Until that lands, this row set may need to render in a placeholder host. +- All actions go through T-480''s CLI verbs. +- Scope-tag icon for each row per the 2026-06-10 settings convention (folder = project; globe = always/global) — both rows are present, distinguishing scope visually. + +## Acceptance + +1. Global Accounts row renders the registry, supports add / re-login / remove (with purge confirmation). +2. Per-workspace row renders the current binding and lets the user switch / clear it. +3. Both rows reflect live changes from the `claude.account` MessageBus channel (T-480) without a settings reload. +4. All actions issue T-480 verbs — no direct settings writes from this UI. +5. Widget tests for the row renderers in each state. + +## Depends on + +- T-480 (CLI verbs) for the action layer. +- T-477 (storage) for the schema registration. +- Gated on T-8''s settings UI maturity — until the schema-driven panel can host these rows, this ticket parks. Track T-8 progress before activating. + +## Out of scope + +- The Claude pane chrome badge + welcome view — T-481. +', 'Settings UI surface for the multi-account epic (T-476): two schema-driven rows under the "Claude" settings category. + +## Rows + +### Global: "Claude > Accounts" + +Registry CRUD list — one entry per registered account. Each entry shows: name, configDir, current sign-in status (a small "signed in" / "not signed in" indicator derived from whether `/.claude.json` carries an active credential — read-only probe, no auth state mutation here). Per-entry affordances: re-login, remove (with `--purge` confirmation). Plus an "Add account…" affordance. + +### Per-workspace: "Claude > Account for this workspace" + +Dropdown/select listing registered accounts + a "(default)" option. Selecting issues `clide claude account set ` (or `unset` for default). + +## Where + +- Hooks into the schema-driven settings panel coming from T-8 / settings UI. Until that lands, this row set may need to render in a placeholder host. +- All actions go through T-480''s CLI verbs. +- Scope-tag icon for each row per the 2026-06-10 settings convention (folder = project; globe = always/global) — both rows are present, distinguishing scope visually. + +## Acceptance + +1. Global Accounts row renders the registry, supports add / re-login / remove (with purge confirmation). +2. Per-workspace row renders the current binding and lets the user switch / clear it. +3. Both rows reflect live changes from the `claude.account` MessageBus channel (T-480) without a settings reload. +4. All actions issue T-480 verbs — no direct settings writes from this UI. +5. Widget tests for the row renderers in each state. + +## Depends on + +- T-480 (CLI verbs) for the action layer. +- T-477 (storage) for the schema registration. +- Gated on T-8''s settings UI maturity — until the schema-driven panel can host these rows, this ticket parks. Track T-8 progress before activating. + +## Out of scope + +- The Claude pane chrome badge + welcome view — T-481. + + +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).', NULL, '2026-06-27 21:46:33', '2026-06-27 21:46:33.584', '2026-06-27 21:46:33.584', NULL, '4f4f8d74d775750d79f054ba5821ce5a', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 3ea9ae00..44735351 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -8862,3 +8862,77 @@ T-478 — T-479 can land in parallel; the bound `claude` just won''t have an IDE Done: McpServer writes the /ide discovery lock into every active config dir (default ~/.claude/ide + the bound account''s /ide), reconciled by syncDiscoveryLocks() on start + on every accountActionChannel event, all cleaned on stop. boundConfigDir injected from main.dart via AccountRegistry. Acceptance 1-4,6 covered by test/ipc/mcp_server_test.dart; #5 (live claude finds the bridge end-to-end) is manual.', 'done', 'medium', NULL, NULL, NULL, '2026-06-25 09:16:42', '2026-06-27 19:24:52.456', NULL, '8798b0b97587cdbac22b75004cad8f07', 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 ('06FFW49WEE5N4PESF5G1G5JRHR', 'task', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'Per-repo Claude account: settings UI rows (accounts registry + per-workspace binding)', 'Settings UI surface for the multi-account epic (T-476): two schema-driven rows under the "Claude" settings category. + +## Rows + +### Global: "Claude > Accounts" + +Registry CRUD list — one entry per registered account. Each entry shows: name, configDir, current sign-in status (a small "signed in" / "not signed in" indicator derived from whether `/.claude.json` carries an active credential — read-only probe, no auth state mutation here). Per-entry affordances: re-login, remove (with `--purge` confirmation). Plus an "Add account…" affordance. + +### Per-workspace: "Claude > Account for this workspace" + +Dropdown/select listing registered accounts + a "(default)" option. Selecting issues `clide claude account set ` (or `unset` for default). + +## Where + +- Hooks into the schema-driven settings panel coming from T-8 / settings UI. Until that lands, this row set may need to render in a placeholder host. +- All actions go through T-480''s CLI verbs. +- Scope-tag icon for each row per the 2026-06-10 settings convention (folder = project; globe = always/global) — both rows are present, distinguishing scope visually. + +## Acceptance + +1. Global Accounts row renders the registry, supports add / re-login / remove (with purge confirmation). +2. Per-workspace row renders the current binding and lets the user switch / clear it. +3. Both rows reflect live changes from the `claude.account` MessageBus channel (T-480) without a settings reload. +4. All actions issue T-480 verbs — no direct settings writes from this UI. +5. Widget tests for the row renderers in each state. + +## Depends on + +- T-480 (CLI verbs) for the action layer. +- T-477 (storage) for the schema registration. +- Gated on T-8''s settings UI maturity — until the schema-driven panel can host these rows, this ticket parks. Track T-8 progress before activating. + +## Out of scope + +- The Claude pane chrome badge + welcome view — T-481. +', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-25 09:16:42', '2026-06-27 21:32:32.254', NULL, 'ccd495a63a508e1c6f2aa1e7585efee6', 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 ('06FFW49WEE5N4PESF5G1G5JRHR', 'task', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'Per-repo Claude account: settings UI rows (accounts registry + per-workspace binding)', 'Settings UI surface for the multi-account epic (T-476): two schema-driven rows under the "Claude" settings category. + +## Rows + +### Global: "Claude > Accounts" + +Registry CRUD list — one entry per registered account. Each entry shows: name, configDir, current sign-in status (a small "signed in" / "not signed in" indicator derived from whether `/.claude.json` carries an active credential — read-only probe, no auth state mutation here). Per-entry affordances: re-login, remove (with `--purge` confirmation). Plus an "Add account…" affordance. + +### Per-workspace: "Claude > Account for this workspace" + +Dropdown/select listing registered accounts + a "(default)" option. Selecting issues `clide claude account set ` (or `unset` for default). + +## Where + +- Hooks into the schema-driven settings panel coming from T-8 / settings UI. Until that lands, this row set may need to render in a placeholder host. +- All actions go through T-480''s CLI verbs. +- Scope-tag icon for each row per the 2026-06-10 settings convention (folder = project; globe = always/global) — both rows are present, distinguishing scope visually. + +## Acceptance + +1. Global Accounts row renders the registry, supports add / re-login / remove (with purge confirmation). +2. Per-workspace row renders the current binding and lets the user switch / clear it. +3. Both rows reflect live changes from the `claude.account` MessageBus channel (T-480) without a settings reload. +4. All actions issue T-480 verbs — no direct settings writes from this UI. +5. Widget tests for the row renderers in each state. + +## Depends on + +- T-480 (CLI verbs) for the action layer. +- T-477 (storage) for the schema registration. +- Gated on T-8''s settings UI maturity — until the schema-driven panel can host these rows, this ticket parks. Track T-8 progress before activating. + +## Out of scope + +- The Claude pane chrome badge + welcome view — T-481. + + +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).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-25 09:16:42', '2026-06-27 21:46:33.584', NULL, '31cf98ecc2a8e496cd9cb324fa75a404', 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 adf5df3f..ed9a7342 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **Settings → Claude → Account picker.** A per-workspace dropdown binds this + repo to a registered Claude account (or Default); switching respawns the pane + onto it. (T-482, epic T-476) - **Per-repo Claude accounts — `clide claude account` verbs.** Manage named Claude config dirs and bind one per workspace: `add`/`list`/`set`/`unset`/ `remove [--purge]`. `set`/`unset` respawn the workspace's Claude pane(s) onto diff --git a/assets/i18n/en_us/builtin.claude.json b/assets/i18n/en_us/builtin.claude.json index a71e4458..f9d2a10d 100644 --- a/assets/i18n/en_us/builtin.claude.json +++ b/assets/i18n/en_us/builtin.claude.json @@ -237,5 +237,8 @@ "settings.claude.effort.label": { "translation": "Effort" }, "settings.claude.effort.help": { "translation": "Reasoning effort for new sessions (applied via --effort at spawn)." }, "settings.claude.permissionMode.label": { "translation": "Permission mode" }, - "settings.claude.permissionMode.help": { "translation": "Starting permission mode for new sessions." } + "settings.claude.permissionMode.help": { "translation": "Starting permission mode for new sessions." }, + "settings.claude.account.label": { "translation": "Account" }, + "settings.claude.account.workspace.label": { "translation": "Account for this workspace" }, + "settings.claude.account.workspace.help": { "translation": "Which Claude account this repo runs under; Default uses the system login." } } diff --git a/assets/i18n/nl_nl/builtin.claude.json b/assets/i18n/nl_nl/builtin.claude.json index 861b7c73..6f75cef6 100644 --- a/assets/i18n/nl_nl/builtin.claude.json +++ b/assets/i18n/nl_nl/builtin.claude.json @@ -237,5 +237,8 @@ "settings.claude.effort.label": { "translation": "Effort" }, "settings.claude.effort.help": { "translation": "Reasoning effort voor nieuwe sessies (toegepast via --effort bij het starten)." }, "settings.claude.permissionMode.label": { "translation": "Permissiemodus" }, - "settings.claude.permissionMode.help": { "translation": "Begin-permissiemodus voor nieuwe sessies." } + "settings.claude.permissionMode.help": { "translation": "Begin-permissiemodus voor nieuwe sessies." }, + "settings.claude.account.label": { "translation": "Account" }, + "settings.claude.account.workspace.label": { "translation": "Account voor deze werkmap" }, + "settings.claude.account.workspace.help": { "translation": "Welk Claude-account deze repo gebruikt; Standaard gebruikt de systeemlogin." } } diff --git a/lib/builtin/claude/src/account_settings_control.dart b/lib/builtin/claude/src/account_settings_control.dart new file mode 100644 index 00000000..c6c1328d --- /dev/null +++ b/lib/builtin/claude/src/account_settings_control.dart @@ -0,0 +1,134 @@ +import 'package:clide/builtin/claude/src/account_registry.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/src/daemon/claude_account_commands.dart' show accountActionChannel; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; + +/// 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 +/// [accountActionChannel] — the same channel the CLI `set`/`unset` verbs use, so +/// the session respawns onto the account (T-480) and the IDE lock re-syncs +/// (T-479). Registry writes flow through the shared [SettingsStore], whose +/// notifier this control listens to, so it stays live for both UI and CLI edits. +class ClaudeWorkspaceAccountControl extends StatefulWidget { + const ClaudeWorkspaceAccountControl({super.key}); + + @override + State createState() => _ClaudeWorkspaceAccountControlState(); +} + +class _ClaudeWorkspaceAccountControlState 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(); + } + + static const _defaultLabel = 'Default'; + + Future _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; + } + } + + @override + Widget build(BuildContext context) { + final tokens = ClideSettings.theme.of(context).surface; + final services = ClideKernel.maybeOf(context); + final cwd = services?.settings.projectDir?.path; + if (services == null || cwd == null) { + return ClideText('Open a workspace to bind a Claude account.', fontSize: clideFontCaption, color: tokens.globalTextMuted); + } + + final reg = AccountRegistry(services.settings); + final accounts = reg.accounts; + if (accounts.isEmpty) { + return ClideText('No accounts yet — add one with `clide claude account add `.', fontSize: clideFontCaption, color: tokens.globalTextMuted); + } + + final boundName = reg.boundName(cwd); + final selectedLabel = boundName ?? _defaultLabel; + return ClideAnchoredOverlay( + controller: _overlay, + align: ClideAnchorAlign.start, + overlayBuilder: (ctx, c) => ClideMenu( + onClose: c.close, + entries: [ + ClideMenuItem( + label: _defaultLabel, + active: boundName == null, + semanticLabel: 'Account for this workspace: $_defaultLabel', + onSelect: () => _bind(services, cwd, null), + ), + const ClideMenuSeparator(), + for (final a in accounts) + ClideMenuItem( + label: a.name, + active: a.name == boundName, + semanticLabel: 'Account for this workspace: ${a.name}', + onSelect: () => _bind(services, cwd, a.name), + ), + ], + ), + anchor: Semantics( + button: true, + label: 'Account for this workspace: $selectedLabel. 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: 8, vertical: 4), + decoration: BoxDecoration( + color: tokens.panelBackground, + border: Border.all(color: hovered ? tokens.panelActiveBorder : tokens.dividerColor), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ClideText(selectedLabel, color: tokens.globalForeground), + const SizedBox(width: 6), + ClideIcon(PhosphorIcons.byName('caret-down'), size: 10, color: tokens.globalTextMuted), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index 19b86cb8..061c81a2 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:clide/clide.dart'; import 'package:clide/builtin/claude/src/account_registry.dart'; +import 'package:clide/builtin/claude/src/account_settings_control.dart'; import 'package:clide/builtin/claude/src/activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey, nextFoldLevel; import 'package:clide/builtin/claude/src/claude_config.dart'; import 'package:clide/builtin/claude/src/claude_status.dart' show nextSafePermissionMode; @@ -198,9 +199,31 @@ class ClaudeExtension extends ClideExtension { ), ], ), + // Per-repo Claude account (T-482, epic T-476). The dropdown binds this + // workspace to a registered account; manage the registry itself with + // the `clide claude account` verbs (T-480). + SettingsSection( + label: 'Account', + labelKey: 'settings.claude.account.label', + fields: [ + SettingsField( + // Placeholder key — a custom field is rendered by its control, + // never stored here; kept clear of the app.claude.account. + // binding namespace the registry scans (T-480). + key: 'app.claude.workspaceAccount', + kind: SettingsFieldKind.custom, + label: 'Account for this workspace', + labelKey: 'settings.claude.account.workspace.label', + help: 'Which Claude account this repo runs under; Default uses the system login.', + helpKey: 'settings.claude.account.workspace.help', + customId: 'claude.workspace-account', + ), + ], + ), ], ), ), + SettingsControlContribution(id: 'claude.workspace-account', customId: 'claude.workspace-account', builder: (_) => const ClaudeWorkspaceAccountControl()), // T-171: agent roster controls (D-6 CLI/UI parity). // Usage: clide claude.agent.show CommandContribution( diff --git a/test/builtin/claude/account_settings_control_test.dart b/test/builtin/claude/account_settings_control_test.dart new file mode 100644 index 00000000..ebbeab75 --- /dev/null +++ b/test/builtin/claude/account_settings_control_test.dart @@ -0,0 +1,98 @@ +/// T-482: the per-workspace Claude account picker settings control. Covers the +/// no-workspace and no-accounts empty states, and the bind flow — the dropdown +/// lists Default + registered accounts, and picking one binds the workspace and +/// publishes the `set` action on accountActionChannel (driving respawn + lock +/// sync, T-480/T-479). +/// +/// Registry/settings writes are real file I/O, so seeding goes through +/// [WidgetTester.runAsync] — awaiting it inside the fake-async body would hang. +library; + +import 'package:clide/builtin/claude/src/account_registry.dart'; +import 'package:clide/builtin/claude/src/account_settings_control.dart'; +import 'package:clide/src/daemon/claude_account_commands.dart' show accountActionChannel; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + Future pump(WidgetTester tester) => tester.pumpWidget( + harness( + f, + const Align( + alignment: Alignment.center, + child: SizedBox(width: 320, child: ClaudeWorkspaceAccountControl()), + ), + ), + ); + + testWidgets('no workspace open → prompts to open one', (tester) async { + await pump(tester); + await tester.pump(); + expect(find.textContaining('Open a workspace'), findsOneWidget); + }); + + testWidgets('workspace open but no accounts → hints at the CLI verb', (tester) async { + await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir)); + await pump(tester); + await tester.pump(); + expect(find.textContaining('No accounts yet'), findsOneWidget); + }); + + testWidgets('lists Default + accounts; picking one binds the workspace and publishes set', (tester) async { + final reg = AccountRegistry(f.services.settings); + await tester.runAsync(() async { + await f.services.settings.setProjectDir(f.tempDir); + await reg.registerAccount('work', '/home/u/.claude-work'); + await reg.registerAccount('personal', '/home/u/.claude-personal'); + }); + + final published = >[]; + final sub = f.services.messages.subscribe(channel: accountActionChannel).listen((m) => published.add(m.data)); + addTearDown(sub.cancel); + + await pump(tester); + await tester.pump(); + expect(find.textContaining('Default'), findsOneWidget, reason: 'anchor shows Default while unbound'); + + await tester.tap(find.byType(ClaudeWorkspaceAccountControl)); + await tester.pump(); + expect(find.text('work'), findsOneWidget); + expect(find.text('personal'), findsOneWidget); + + await tester.tap(find.text('work')); + await tester.pump(); + expect(reg.boundName(f.tempDir.path), 'work'); + expect(published.single['action'], 'set'); + expect(published.single['name'], 'work'); + }); + + testWidgets('picking Default while bound unbinds the workspace and publishes unset', (tester) async { + final reg = AccountRegistry(f.services.settings); + await tester.runAsync(() async { + await f.services.settings.setProjectDir(f.tempDir); + await reg.registerAccount('work', '/home/u/.claude-work'); + await reg.bindWorkspace(f.tempDir.path, 'work'); + }); + + final published = >[]; + final sub = f.services.messages.subscribe(channel: accountActionChannel).listen((m) => published.add(m.data)); + addTearDown(sub.cancel); + + await pump(tester); + await tester.pump(); + await tester.tap(find.byType(ClaudeWorkspaceAccountControl)); + await tester.pump(); + await tester.tap(find.textContaining('Default')); + await tester.pump(); + expect(reg.boundName(f.tempDir.path), isNull); + expect(published.single['action'], 'unset'); + expect(published.single['previous'], 'work'); + }); +}