feat(claude): Settings → Claude per-workspace account picker (T-482 part 1)

A custom settings control under the Claude category: a dropdown of the
registered accounts plus Default. Picking one binds (or unbinds) this
workspace via the AccountRegistry and publishes set/unset on
accountActionChannel — the same channel the CLI verbs use, so the session
respawns onto the account (T-480) and the IDE lock re-syncs (T-479). It reads
live off the shared settings notifier, so a CLI `account set` updates the
dropdown too. Empty states cover no-workspace and no-accounts, each pointing at
the CLI.

Registry writes set the in-memory binding synchronously then flush, so the
control publishes the bus event before the disk write completes.

Part 2 (the global Accounts registry CRUD list + sign-in probe) is still open
on T-482. en/nl strings added; widget test covers the states + bind/unbind.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-27 23:46:43 +02:00
co-authored by Claude Opus 4.8
parent e86543d476
commit c3efcf3855
8 changed files with 414 additions and 2 deletions
@@ -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<ClaudeWorkspaceAccountControl> createState() => _ClaudeWorkspaceAccountControlState();
}
class _ClaudeWorkspaceAccountControlState extends State<ClaudeWorkspaceAccountControl> {
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<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;
}
}
@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 <name>`.', 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),
],
),
),
),
),
);
}
}
+23
View File
@@ -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.<hash>
// 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 <sessionId>
CommandContribution(