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:
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user