Files
clide/lib/builtin/claude/src/claude_meta_sidebar.dart
T
Jeroen SchweitzerandClaude Opus 4.8 6337bec152 make Claude sidebar sub-tabs keyboard-activatable
The Activity / Team / Config sub-tabs were built on a raw GestureDetector,
so Tab traversal skipped them and Enter/Space did nothing — a gap against
the repo's a11y contract. Switch to ClideTappable (focusable, Enter/Space
activates) wrapped in button + selected semantics, and add a test that
drives the switch via ActivateIntent rather than a pointer tap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:19:35 +02:00

1270 lines
45 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// Claude meta sidebar (T-141, T-157, T-171, T-182): a left-panel tab split
/// into a sub-tab strip — Activity / Team / Config.
///
/// - **Activity** — Claude usage stats (from `~/.claude/stats-cache.json`,
/// polled) plus the primary session's live runtime (model / mode / context /
/// skills).
/// - **Team** — the roster cockpit (T-171): live per-member status (T-157) plus
/// per-row controls (show/hide, mute, close, inject-message) and a TASKS
/// section that renders [TeamBroker.tasks] live, with per-task owner and a
/// reassign control. Auto-fronted when a team spawns (T-182); mostly empty
/// when solo.
/// - **Config** — the Claude environment settings table (model / output style /
/// permission mode / source) over [ClaudeConfig]. The expandable
/// skills/agents/commands/permissions/MCP browser is T-183.
///
/// Activity and Config render their key→value rows on the SAME table geometry
/// ([_metaTable]) so switching tabs doesn't visually jump.
///
/// The account/team token budget is intentionally absent: it isn't
/// programmatically exposed under subscription auth (see project memory /
/// GitHub anthropics/claude-code#44328).
library;
import 'dart:async';
import 'dart:io';
import 'package:clide/builtin/claude/src/claude_config.dart';
import 'package:clide/builtin/claude/src/claude_stats.dart';
import 'package:clide/builtin/claude/src/claude_status.dart' show formatTokenCount, permissionModeLabel, shortModelLabel;
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
import 'package:clide/builtin/claude/src/team_broker.dart' show TeamBroker, TeamTask;
import 'package:clide/builtin/claude/src/team_chat_sidebar.dart' show TeamChatSidebar;
import 'package:clide/builtin/claude/src/team_panel_host.dart' show teamColor;
import 'package:clide/builtin/claude/src/transcript_publisher.dart' show ClaudeConversation;
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart' show HardwareKeyboard;
import 'package:flutter/widgets.dart';
/// The shared label-column width + row pitch the Activity and Config tables both
/// use, so toggling between tabs keeps every value at the same x and y.
const double _labelColumnWidth = 110;
const double _rowPitch = 4;
enum SidebarTab { activity, team, config }
class ClaudeMetaSidebar extends StatefulWidget {
const ClaudeMetaSidebar({
super.key,
this.statsLoader,
this.pollInterval = const Duration(seconds: 20),
this.config,
this.orchestrator,
this.initialTab = SidebarTab.activity,
});
/// Loads the activity stats; defaults to reading `~/.claude/stats-cache.json`.
/// Injected in tests so they don't touch the real filesystem.
final Future<ClaudeStats> Function()? statsLoader;
/// How often to reload the stats. `Duration.zero` disables polling
/// (initial load only) — used by tests to avoid a pending timer.
final Duration pollInterval;
/// The Claude environment source for the Config tab; defaults to the app-wide
/// [activeClaudeConfig]. Injected in tests.
final ClaudeConfig? config;
/// The session set whose `primary` drives the Activity runtime row; defaults
/// to [activeSessionOrchestrator]. Injected in tests.
final ClaudeSessionOrchestrator? orchestrator;
/// Starting tab (tests pin this; production defaults to Activity).
final SidebarTab initialTab;
@override
State<ClaudeMetaSidebar> createState() => _ClaudeMetaSidebarState();
}
class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
ClaudeStats _stats = const ClaudeStats();
final List<TeamMemberJoined> _members = [];
final Map<String, SessionStatus> _memberStatus = {};
StreamSubscription<TeamMemberJoined>? _joinSub;
StreamSubscription<TeamMemberLeft>? _leftSub;
StreamSubscription<Message>? _statusSub;
StreamSubscription<SessionStatus>? _primarySub;
StreamSubscription<void>? _brokerChangeSub;
Timer? _timer;
late final Future<ClaudeStats> Function() _load;
bool _subscribed = false;
late SidebarTab _tab = widget.initialTab;
ClaudeConfig? _config;
ClaudeSessionOrchestrator? _orchestrator;
SessionStatus? _primaryStatus;
// T-183: Config accordion expansion state — each section starts collapsed.
final Set<_ConfigSection> _expanded = {};
/// agentId currently in "inject message" mode (shows the text field).
String? _injectingAgentId;
final _injectCtl = TextEditingController();
List<TeamTask> _tasks = const [];
@override
void initState() {
super.initState();
_load = widget.statsLoader ?? _fileLoader();
_config = widget.config ?? activeClaudeConfig;
_orchestrator = widget.orchestrator ?? activeSessionOrchestrator;
_config?.addListener(_onConfigChange);
_orchestrator?.addListener(_onOrchestratorChange);
_bindPrimary();
_subscribeBroker();
unawaited(_refreshStats());
if (widget.pollInterval > Duration.zero) {
_timer = Timer.periodic(widget.pollInterval, (_) => unawaited(_refreshStats()));
}
}
void _subscribeBroker() {
_brokerChangeSub?.cancel();
final broker = _orchestrator?.broker;
if (broker == null) return;
_tasks = List.of(broker.tasks);
_brokerChangeSub = broker.changes.listen((_) {
if (mounted) setState(() => _tasks = List.of(broker.tasks));
});
}
static Future<ClaudeStats> Function() _fileLoader() {
final home = Platform.environment['HOME'];
final file = home == null ? null : File('$home/.claude/stats-cache.json');
return () async {
if (file == null || !await file.exists()) return const ClaudeStats();
try {
return parseClaudeStats(await file.readAsString());
} catch (_) {
return const ClaudeStats();
}
};
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_subscribed) return;
_subscribed = true;
final kernel = ClideKernel.of(context);
_joinSub = kernel.events.on<TeamMemberJoined>().listen((m) {
if (_members.any((x) => x.agentId == m.agentId)) return;
final wasEmpty = _members.isEmpty;
setState(() {
_members.add(m);
// A team coming to life fronts its tab (T-182).
if (wasEmpty) _tab = SidebarTab.team;
});
});
_leftSub = kernel.events.on<TeamMemberLeft>().listen((m) {
setState(() {
_members.removeWhere((x) => x.agentId == m.agentId);
_memberStatus.remove(m.agentId);
});
});
// Live per-member status forwarded by the observer (T-157).
_statusSub = kernel.messages.subscribe(channel: ClaudeConversation.memberStatusChannel).listen((msg) {
final agentId = msg.data['agentId'] as String?;
if (agentId == null || !mounted) return;
setState(() {
_memberStatus[agentId] = SessionStatus(
model: msg.data['model'] as String?,
permissionMode: msg.data['permissionMode'] as String?,
contextTokens: msg.data['contextTokens'] as int?,
);
});
});
}
/// (Re)bind to the primary managed session's status as the orchestrator's set
/// changes — the Activity runtime row reflects the live session.
/// The orchestrator notifies on any session change (spawn/close, and the
/// visible/muted toggles the cockpit controls drive). Re-bind the primary
/// status stream and rebuild so the roster rows reflect the new state.
void _onOrchestratorChange() {
_bindPrimary();
if (mounted) setState(() {});
}
void _bindPrimary() {
final session = _orchestrator?.byId('primary')?.session;
_primarySub?.cancel();
_primarySub = null;
if (session == null) {
if (_primaryStatus != null && mounted) setState(() => _primaryStatus = null);
return;
}
final seed = session.status;
if (mounted) setState(() => _primaryStatus = seed);
_primarySub = session.statusStream.listen((s) {
if (mounted) setState(() => _primaryStatus = s);
});
}
void _onConfigChange() {
if (mounted) setState(() {});
}
/// Fork the team session identified by [memberName] (T-172, roster button).
///
/// Resolves the managed session, then spawns a new fork session via the
/// orchestrator. The fork appears in the roster (it's a team session, visible
/// by default) and is independent from the source — the original is unaffected.
void _forkMember(String memberName) {
final orch = _orchestrator;
if (orch == null) return;
final managed = orch.byMemberName(memberName);
if (managed == null) return;
final forkId = 'fork:$memberName-${DateTime.now().millisecondsSinceEpoch}';
unawaited(orch.spawn(SpawnSpec(
id: forkId,
role: 'fork of $memberName',
// sessionId is a placeholder; real claude session id arrives via init.
sessionId: forkId,
cwd: managed.cwd,
forkSourceSessionId: managed.sessionId,
)));
}
Future<void> _refreshStats() async {
final stats = await _load();
if (mounted) setState(() => _stats = stats);
}
@override
void dispose() {
_timer?.cancel();
_joinSub?.cancel();
_leftSub?.cancel();
_statusSub?.cancel();
_primarySub?.cancel();
_brokerChangeSub?.cancel();
_injectCtl.dispose();
_config?.removeListener(_onConfigChange);
_orchestrator?.removeListener(_onOrchestratorChange);
super.dispose();
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_TabStrip(current: _tab, memberCount: _members.length, onPick: (t) => setState(() => _tab = t)),
Expanded(
child: switch (_tab) {
SidebarTab.activity => _activityBody(tokens),
SidebarTab.team => _teamBody(tokens),
SidebarTab.config => _configBody(tokens),
},
),
],
);
}
// --- Activity -------------------------------------------------------------
Widget _activityBody(SurfaceTokens tokens) {
final latest = _stats.latest;
final sections = <_MetaSection>[
if (latest != null)
_MetaSection('TODAY', [
_MetaRow('messages', '${latest.messageCount}'),
_MetaRow('sessions', '${latest.sessionCount}'),
_MetaRow('tool calls', '${latest.toolCallCount}'),
]),
if (latest != null)
_MetaSection('LIFETIME', [
_MetaRow('messages', '${_stats.lifetimeMessages}'),
_MetaRow('sessions', '${_stats.lifetimeSessions}'),
]),
..._runtimeSection(tokens),
];
if (sections.isEmpty) {
return _placeholder('No activity recorded yet.');
}
return _metaTable(tokens, sections);
}
List<_MetaSection> _runtimeSection(SurfaceTokens tokens) {
final st = _primaryStatus;
final skills = _config?.skills.length;
final rows = <_MetaRow>[
if (st?.model != null) _MetaRow('model', shortModelLabel(st!.model!), valueColor: tokens.globalFocus),
if (st?.contextTokens != null) _MetaRow('context', '${formatTokenCount(st!.contextTokens!)} ctx'),
if (st?.permissionMode != null) _MetaRow('mode', permissionModeLabel(st!.permissionMode!)),
if (skills != null) _MetaRow('skills', '$skills'),
];
return rows.isEmpty ? const [] : [_MetaSection('RUNTIME · primary', rows)];
}
// --- Team -----------------------------------------------------------------
Widget _teamBody(SurfaceTokens tokens) {
if (_members.isEmpty) {
return _placeholder('No team active.');
}
final children = <Widget>[
for (final m in _members)
_AgentRosterRow(
key: ValueKey(m.agentId),
member: m,
status: _memberStatus[m.agentId],
orchestrator: _orchestrator,
injectingAgentId: _injectingAgentId,
injectController: _injectCtl,
onToggleInject: (name) => setState(() {
if (_injectingAgentId == name) {
_injectingAgentId = null;
_injectCtl.clear();
} else {
_injectingAgentId = name;
_injectCtl.clear();
}
}),
onInjectSubmit: (name, text) {
final managed = _orchestrator?.byMemberName(name);
if (managed != null) {
_orchestrator!.injectMessage(managed.id, text);
}
setState(() {
_injectingAgentId = null;
_injectCtl.clear();
});
},
onClose: (name) {
final managed = _orchestrator?.byMemberName(name);
if (managed != null) _orchestrator!.close(managed.id);
},
onSetPermissionMode: (name, mode) {
final managed = _orchestrator?.byMemberName(name);
managed?.session.setPermissionMode(mode);
},
onFork: (name) => _forkMember(name),
),
];
if (_tasks.isNotEmpty) {
children.add(const SizedBox(height: 12));
children.add(_taskSection(tokens));
}
// MESSAGES section (T-180): live broker chat feed + quick-post composer.
final chatModel = _orchestrator?.chatModel;
final broker = _orchestrator?.broker;
if (chatModel != null && broker != null) {
children.add(const SizedBox(height: 12));
children.add(TeamChatSidebar(
model: chatModel,
broker: broker,
onPopOut: _openChatPane,
));
}
return ListView(
padding: const EdgeInsets.all(12),
children: children,
);
}
Widget _taskSection(SurfaceTokens tokens) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText('TASKS', fontSize: clideFontSmall, color: tokens.globalTextMuted),
const SizedBox(height: 4),
for (final t in _tasks) _TaskRow(task: t, members: _members, broker: _orchestrator?.broker),
],
);
}
/// Open the full team chat pane in the workspace (T-180).
void _openChatPane() {
ClideKernel.of(context).panels.activateTab(Slots.workspace, 'claude.team-chat');
}
// --- Config (T-183) -------------------------------------------------------
Widget _configBody(SurfaceTokens tokens) {
final config = _config;
if (config == null) {
return _placeholder('Claude environment not loaded.');
}
final settings = config.settings;
final model = config.probe?.model ?? settings['model']?.toString() ?? '—';
final outputStyle = settings['outputStyle']?.toString() ?? 'default';
final mode = config.probe?.permissionMode ?? settings['permissionMode']?.toString() ?? 'default';
final children = <Widget>[
// Pinned SETTINGS table — not collapsible.
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: ClideText('SETTINGS', fontSize: clideFontSmall, color: tokens.globalTextMuted),
),
_configRow(tokens, 'model', model, valueColor: tokens.globalFocus),
_configRow(tokens, 'output style', outputStyle),
_configRow(tokens, 'permission mode', permissionModeLabel(mode)),
_configRow(tokens, 'source', '~/.claude + .claude'),
// ---- Accordion sections ----
_configAccordion(tokens, config, _ConfigSection.skills),
_configAccordion(tokens, config, _ConfigSection.agents),
_configAccordion(tokens, config, _ConfigSection.commands),
_configAccordion(tokens, config, _ConfigSection.hooks),
_configAccordion(tokens, config, _ConfigSection.permissions),
_configAccordion(tokens, config, _ConfigSection.mcpServers),
// Footer hint.
Padding(
padding: const EdgeInsets.only(top: 12),
child: ClideText(
'expand a list to see all · click a skill/agent/command → opens its .md',
muted: true,
fontSize: clideFontSmall,
),
),
];
return ListView(
padding: const EdgeInsets.all(12),
children: children,
);
}
/// One key→value row in the pinned SETTINGS table.
Widget _configRow(SurfaceTokens tokens, String label, String value, {Color? valueColor}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: _rowPitch),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: _labelColumnWidth,
child: ClideText(label, muted: true, fontSize: clideFontSmall),
),
Expanded(
child: ClideText(value, fontSize: clideFontSmall, color: valueColor ?? tokens.globalForeground),
),
],
),
);
}
String _configSectionLabel(_ConfigSection section) => switch (section) {
_ConfigSection.skills => 'SKILLS',
_ConfigSection.agents => 'AGENTS',
_ConfigSection.commands => 'COMMANDS',
_ConfigSection.hooks => 'HOOKS',
_ConfigSection.permissions => 'PERMISSIONS',
_ConfigSection.mcpServers => 'MCP SERVERS',
};
int _configSectionCount(ClaudeConfig config, _ConfigSection section) => switch (section) {
_ConfigSection.skills => config.skills.length,
_ConfigSection.agents => config.agents.length,
_ConfigSection.commands => config.commands.length,
_ConfigSection.hooks => config.hooks.length,
_ConfigSection.permissions => config.permissions.allow.length + config.permissions.deny.length + config.permissions.ask.length,
_ConfigSection.mcpServers => config.mcpServers.length,
};
Widget _configAccordion(SurfaceTokens tokens, ClaudeConfig config, _ConfigSection section) {
final expanded = _expanded.contains(section);
final children = expanded ? _configSectionChildren(tokens, config, section) : const <Widget>[];
return ClideAccordion(
label: _configSectionLabel(section),
count: _configSectionCount(config, section),
expanded: expanded,
onToggle: () => setState(() {
if (expanded) {
_expanded.remove(section);
} else {
_expanded.add(section);
}
}),
children: children,
);
}
List<Widget> _configSectionChildren(SurfaceTokens tokens, ClaudeConfig config, _ConfigSection section) {
switch (section) {
case _ConfigSection.skills:
return [
for (final skill in config.skills) _configFileRow(tokens, skill.name, skill.path),
];
case _ConfigSection.agents:
return [
for (final agent in config.agents) _configFileRow(tokens, agent.name, agent.path),
];
case _ConfigSection.commands:
return [
for (final cmd in config.commands) _configFileRow(tokens, cmd.name, cmd.path),
];
case _ConfigSection.hooks:
return [
for (final hook in config.hooks)
Padding(
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(hook.event, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
for (final cmd in hook.commands)
Padding(
padding: const EdgeInsets.only(left: 8, top: 1),
child: ClideText(cmd, fontSize: clideFontSmall, muted: true),
),
],
),
),
];
case _ConfigSection.permissions:
return _permissionRows(tokens, config.permissions);
case _ConfigSection.mcpServers:
return [
for (final srv in config.mcpServers)
Padding(
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
child: ClideText(srv.name, fontSize: clideFontSmall, color: tokens.globalForeground),
),
];
}
}
/// A tappable row for file-backed items (skills, agents, commands).
/// All config items are .md files — opens in the markdown reader panel
/// via the kernel MessageBus (D-6, T-183).
Widget _configFileRow(SurfaceTokens tokens, String name, String? path) {
final row = Padding(
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
child: ClideText(
name,
fontSize: clideFontSmall,
color: path != null ? tokens.globalFocus : tokens.globalForeground,
),
);
if (path == null) return row;
void openMarkdown() => ClideKernel.of(context).messages.publish('builtin.markdown', 'selection', {'path': path});
return Semantics(
button: true,
label: name,
excludeSemantics: true,
onTap: openMarkdown,
child: ClideTappable(
tooltip: path,
onTap: openMarkdown,
builder: (ctx, hovered, _) => Padding(
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
child: ClideText(
name,
fontSize: clideFontSmall,
color: hovered ? tokens.globalForeground : tokens.globalFocus,
),
),
),
);
}
/// Renders grouped allow/ask/deny permission rows, colour-coded by kind.
List<Widget> _permissionRows(SurfaceTokens tokens, ClaudePermissions perms) {
const kindAllow = 'allow';
const kindAsk = 'ask';
const kindDeny = 'deny';
// allow → statusSuccess, ask → statusWarning, deny → statusError
Color kindColor(_ConfigPermKind k) => switch (k) {
_ConfigPermKind.allow => tokens.statusSuccess,
_ConfigPermKind.ask => tokens.statusWarning,
_ConfigPermKind.deny => tokens.statusError,
};
String kindLabel(_ConfigPermKind k) => switch (k) {
_ConfigPermKind.allow => kindAllow,
_ConfigPermKind.ask => kindAsk,
_ConfigPermKind.deny => kindDeny,
};
final groups = [
(_ConfigPermKind.allow, perms.allow),
(_ConfigPermKind.ask, perms.ask),
(_ConfigPermKind.deny, perms.deny),
];
final rows = <Widget>[];
for (final (kind, rules) in groups) {
if (rules.isEmpty) continue;
final color = kindColor(kind);
rows.add(
Padding(
padding: const EdgeInsets.only(left: 16, top: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 36,
child: ClideText(kindLabel(kind), fontSize: clideFontSmall, color: color),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final rule in rules)
Padding(
padding: const EdgeInsets.only(bottom: 1),
child: ClideText(rule, fontSize: clideFontSmall, color: tokens.globalForeground),
),
],
),
),
],
),
),
);
}
return rows;
}
// --- Shared rendering -----------------------------------------------------
Widget _placeholder(String text) => Padding(
padding: const EdgeInsets.all(12),
child: ClideText(text, muted: true, fontSize: clideFontSmall),
);
Widget _metaTable(SurfaceTokens tokens, List<_MetaSection> sections) {
final children = <Widget>[];
for (var i = 0; i < sections.length; i++) {
final s = sections[i];
children.add(Padding(
padding: EdgeInsets.only(top: i == 0 ? 0 : 16, bottom: 6),
child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.globalTextMuted),
));
for (final r in s.rows) {
children.add(Padding(
padding: const EdgeInsets.symmetric(vertical: _rowPitch),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: _labelColumnWidth,
child: ClideText(r.label, muted: true, fontSize: clideFontSmall),
),
Expanded(
child: ClideText(
r.value,
fontSize: clideFontSmall,
color: r.valueColor ?? tokens.globalForeground,
),
),
],
),
));
}
}
return ListView(
padding: const EdgeInsets.all(12),
children: children,
);
}
}
// T-183: accordion sections for the Config tab.
enum _ConfigSection { skills, agents, commands, hooks, permissions, mcpServers }
/// Permission kind for colour-coding in the Config tab (T-183).
enum _ConfigPermKind { allow, ask, deny }
class _MetaSection {
const _MetaSection(this.header, this.rows);
final String header;
final List<_MetaRow> rows;
}
class _MetaRow {
const _MetaRow(this.label, this.value, {this.valueColor});
final String label;
final String value;
final Color? valueColor;
}
// ---------------------------------------------------------------------------
// Reusable roster-row widget (T-171)
//
// Extract point for future siblings:
// - T-181 adds a permission-mode badge to the trailing region.
// - T-172 adds a fork button next to the close/mute icons.
// Extend _AgentRosterRow or compose it from a shared _RosterRowBase to avoid
// forking the layout. The trailing region is the explicit seam: the control
// icons column may grow with new additions.
// ---------------------------------------------------------------------------
/// A single agent roster row: color dot + name + status sub-text + controls.
///
/// Controls (trailing region):
/// - permission-mode badge (T-181) — D/A/P cycles the safe trio; shift-click
/// reaches bypassPermissions behind a confirm
/// - eye / eye-slash — show / hide the session pane
/// - speaker / speaker-slash — mute / unmute broker delivery
/// - inject (chat icon) — expand the inline message input
/// - fork (git-branch icon) — open a new pane branching from this session (T-172)
/// - close (×) — kill the session
class _AgentRosterRow extends StatefulWidget {
const _AgentRosterRow({
super.key,
required this.member,
required this.status,
required this.orchestrator,
required this.injectingAgentId,
required this.injectController,
required this.onToggleInject,
required this.onInjectSubmit,
required this.onClose,
required this.onSetPermissionMode,
required this.onFork,
});
final TeamMemberJoined member;
final SessionStatus? status;
final ClaudeSessionOrchestrator? orchestrator;
/// The member name currently in inject mode (null = none).
final String? injectingAgentId;
/// Shared text controller for the inject field (cleared on submit/cancel).
final TextEditingController injectController;
final void Function(String memberName) onToggleInject;
final void Function(String memberName, String text) onInjectSubmit;
final void Function(String memberName) onClose;
/// Called when the badge cycles to a new [mode] string for this member.
/// Handles both safe-trio clicks and confirmed bypass. The parent sends
/// the mode to the session via [StreamJsonSession.setPermissionMode].
final void Function(String memberName, String mode) onSetPermissionMode;
/// Called when the fork button is tapped (T-172). The session id of the
/// member's managed session is passed so the host can open a fork pane.
final void Function(String memberName) onFork;
@override
State<_AgentRosterRow> createState() => _AgentRosterRowState();
}
class _AgentRosterRowState extends State<_AgentRosterRow> {
/// Whether the bypass-confirm inline prompt is showing.
bool _confirmingBypass = false;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final managed = widget.orchestrator?.byMemberName(widget.member.name);
final color = teamColor(widget.member.color, fallback: tokens.globalForeground);
final st = widget.status;
final model = st?.model ?? widget.member.model;
final sub = [
widget.member.agentType,
if (model != null) shortModelLabel(model),
if (st?.permissionMode != null) permissionModeLabel(st!.permissionMode!),
if (st?.contextTokens != null) '${formatTokenCount(st!.contextTokens!)} ctx',
].join(' · ');
final isVisible = managed?.visible ?? true;
final isMuted = managed?.muted ?? false;
final isInjecting = widget.injectingAgentId == widget.member.name;
final currentMode = st?.permissionMode ?? 'default';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Color dot
Padding(
padding: const EdgeInsets.only(top: 3),
child: Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
),
const SizedBox(width: 8),
// Name + status
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(widget.member.name, fontSize: clideFontSmall, color: tokens.globalForeground, maxLines: 1, overflow: TextOverflow.ellipsis),
if (sub.isNotEmpty) ClideText(sub, muted: true, fontSize: clideFontSmall, maxLines: 1, overflow: TextOverflow.ellipsis),
// T-181: permission-mode badge (inline below the status sub-text).
if (managed != null)
_PermissionModeBadge(
mode: currentMode,
tokens: tokens,
onCycle: () {
final next = _nextSafeMode(currentMode);
widget.onSetPermissionMode(widget.member.name, next);
},
onBypass: () => setState(() => _confirmingBypass = true),
),
],
),
),
const SizedBox(width: 4),
// Trailing controls (T-171).
// T-172 seam: append a fork icon button to this row.
if (managed != null) _buildControls(context, tokens, managed, isVisible, isMuted, isInjecting),
],
),
// Bypass confirm: replaces inject field area when active.
if (_confirmingBypass) _buildBypassConfirm(tokens),
// Inline inject-message field — visible only when toggled.
if (isInjecting && !_confirmingBypass) _buildInjectField(context, tokens),
],
),
);
}
/// Safe-mode cycle: default → acceptEdits → plan → default (T-181).
static String _nextSafeMode(String current) {
const cycle = ['default', 'acceptEdits', 'plan'];
final idx = cycle.indexOf(current);
return cycle[(idx + 1) % cycle.length];
}
Widget _buildBypassConfirm(SurfaceTokens tokens) {
return Padding(
padding: const EdgeInsets.only(left: 16, top: 4),
child: Row(
children: [
Expanded(
child: ClideText(
'Enable bypassPermissions? All tool calls will be auto-allowed.',
fontSize: clideFontSmall,
color: tokens.globalTextMuted,
),
),
const SizedBox(width: 4),
// Confirm
Semantics(
button: true,
label: 'Confirm bypass',
excludeSemantics: true,
onTap: () {
setState(() => _confirmingBypass = false);
widget.onSetPermissionMode(widget.member.name, 'bypassPermissions');
},
child: ClideTappable(
tooltip: 'Confirm',
onTap: () {
setState(() => _confirmingBypass = false);
widget.onSetPermissionMode(widget.member.name, 'bypassPermissions');
},
builder: (ctx, hovered, _) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
child: ClideText('OK', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalFocus),
),
),
),
const SizedBox(width: 4),
// Cancel
Semantics(
button: true,
label: 'Cancel bypass',
excludeSemantics: true,
onTap: () => setState(() => _confirmingBypass = false),
child: ClideTappable(
tooltip: 'Cancel',
onTap: () => setState(() => _confirmingBypass = false),
builder: (ctx, hovered, _) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
child: ClideText('Cancel', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
),
),
),
],
),
);
}
Widget _buildControls(
BuildContext context,
SurfaceTokens tokens,
ManagedSession managed,
bool isVisible,
bool isMuted,
bool isInjecting,
) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// Show / hide
_IconButton(
painter: isVisible ? PhosphorIcons.eye : PhosphorIcons.eyeSlash,
tooltip: isVisible ? 'Hide pane' : 'Show pane',
color: tokens.globalTextMuted,
onTap: () => isVisible ? widget.orchestrator!.hide(managed.id) : widget.orchestrator!.show(managed.id),
),
// Mute / unmute
_IconButton(
painter: isMuted ? PhosphorIcons.eyeSlash : PhosphorIcons.eye,
// NOTE: We use eye/eyeSlash as stand-ins until a dedicated speaker
// icon is added to PhosphorIcons (no speaker codepoint yet).
// The semantic tooltip still says mute/unmute so AT users are clear.
tooltip: isMuted ? 'Unmute messages' : 'Mute messages',
color: isMuted ? tokens.globalFocus : tokens.globalTextMuted,
onTap: () => isMuted ? widget.orchestrator!.unmute(managed.id) : widget.orchestrator!.mute(managed.id),
),
// Inject message
_IconButton(
painter: PhosphorIcons.chatCircle,
tooltip: 'Inject message',
color: isInjecting ? tokens.globalFocus : tokens.globalTextMuted,
onTap: () => widget.onToggleInject(widget.member.name),
),
// Fork session (T-172): branch into a new pane without touching the original.
_IconButton(
painter: PhosphorIcons.gitBranch,
tooltip: 'Fork session',
color: tokens.globalTextMuted,
onTap: () => widget.onFork(widget.member.name),
),
// Close session
_IconButton(
painter: PhosphorIcons.xMark,
tooltip: 'Close session',
color: tokens.globalTextMuted,
onTap: () => widget.onClose(widget.member.name),
),
],
);
}
Widget _buildInjectField(BuildContext context, SurfaceTokens tokens) {
return Padding(
padding: const EdgeInsets.only(left: 16, top: 4),
child: Row(
children: [
Expanded(
child: _InjectTextField(
controller: widget.injectController,
tokens: tokens,
onSubmit: (text) {
if (text.trim().isNotEmpty) widget.onInjectSubmit(widget.member.name, text.trim());
},
),
),
const SizedBox(width: 4),
_IconButton(
painter: PhosphorIcons.xMark,
tooltip: 'Cancel',
color: tokens.globalTextMuted,
onTap: () => widget.onToggleInject(widget.member.name),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Permission-mode badge (T-181)
// ---------------------------------------------------------------------------
/// Maps a permission-mode string to a single-letter badge label.
String _permissionModeBadge(String mode) => switch (mode) {
'acceptEdits' => 'A',
'plan' => 'P',
'bypassPermissions' => 'B',
_ => 'D', // default
};
/// Clickable permission-mode badge shown in each roster row (T-181).
///
/// - Plain click → cycles the safe trio: default → acceptEdits → plan → default.
/// - Shift-click → shows the bypass confirm inline in the parent row.
///
/// The badge reflects the LIVE mode from [SessionStatus.permissionMode] (T-157).
/// It is a custom painted label (no Material), consistent with the rendering
/// stack rules (D-7, CLAUDE.md guardrails).
class _PermissionModeBadge extends StatelessWidget {
const _PermissionModeBadge({
required this.mode,
required this.tokens,
required this.onCycle,
required this.onBypass,
});
final String mode;
final SurfaceTokens tokens;
/// Called on a plain click — the parent cycles to the next safe mode.
final VoidCallback onCycle;
/// Called on a shift-click — the parent shows the bypass confirm.
final VoidCallback onBypass;
@override
Widget build(BuildContext context) {
final label = _permissionModeBadge(mode);
final isBypass = mode == 'bypassPermissions';
final badgeColor = isBypass ? const Color(0xFFF06C6F) : tokens.globalFocus;
final tooltip = 'Permission mode: ${permissionModeLabel(mode)}. '
'Click to cycle default/acceptEdits/plan; Shift-click for bypassPermissions.';
return Padding(
padding: const EdgeInsets.only(top: 3),
child: Semantics(
button: true,
label: 'Permission mode: $label',
excludeSemantics: true,
onTap: () {
if (HardwareKeyboard.instance.isShiftPressed) {
onBypass();
} else {
onCycle();
}
},
child: ClideTappable(
tooltip: tooltip,
onTap: () {
if (HardwareKeyboard.instance.isShiftPressed) {
onBypass();
} else {
onCycle();
}
},
builder: (ctx, hovered, _) => Container(
width: 16,
height: 14,
alignment: Alignment.center,
decoration: BoxDecoration(
color: badgeColor.withAlpha(hovered ? 51 : 26),
borderRadius: BorderRadius.circular(2),
border: Border.all(color: badgeColor.withAlpha(hovered ? 180 : 100), width: 1),
),
child: ClideText(
label,
fontSize: 9,
color: badgeColor,
),
),
),
),
);
}
}
/// A single icon-button used in the roster row controls.
class _IconButton extends StatelessWidget {
const _IconButton({
required this.painter,
required this.tooltip,
required this.color,
required this.onTap,
});
final ClideIconPainter painter;
final String tooltip;
final Color color;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
// Icon-only button: expose the tooltip text as the Semantics button label
// so AT (and widget tests) can find and activate it by name.
return Semantics(
button: true,
label: tooltip,
excludeSemantics: true,
onTap: onTap,
child: ClideTappable(
tooltip: tooltip,
onTap: onTap,
builder: (ctx, hovered, _) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
child: ClideIcon(painter, size: 12, color: hovered ? ClideTheme.of(ctx).surface.globalForeground : color),
),
),
);
}
}
/// Inline text input for injecting a message into a session (T-171).
/// Submits on Enter; Cancel is handled by the parent via [_IconButton].
class _InjectTextField extends StatelessWidget {
const _InjectTextField({
required this.controller,
required this.tokens,
required this.onSubmit,
});
final TextEditingController controller;
final SurfaceTokens tokens;
final void Function(String text) onSubmit;
@override
Widget build(BuildContext context) {
return Container(
height: 22,
padding: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
color: tokens.panelBackground,
border: Border.all(color: tokens.panelBorder),
borderRadius: BorderRadius.circular(3),
),
child: EditableText(
controller: controller,
focusNode: FocusNode(debugLabel: 'inject-${controller.hashCode}')..requestFocus(),
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: clideFontSmall,
color: tokens.globalForeground,
height: 1.4,
),
cursorColor: tokens.globalFocus,
backgroundCursorColor: tokens.globalTextMuted,
onSubmitted: onSubmit,
),
);
}
}
// ---------------------------------------------------------------------------
// Task row (T-171)
// ---------------------------------------------------------------------------
/// One row in the TASKS section: status marker + title + owner + reassign.
class _TaskRow extends StatelessWidget {
const _TaskRow({
required this.task,
required this.members,
required this.broker,
});
final TeamTask task;
final List<TeamMemberJoined> members;
final TeamBroker? broker;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final marker = switch (task.status) {
'done' => '✓',
'claimed' => '◈',
_ => '○',
};
final markerColor = switch (task.status) {
'done' => tokens.globalTextMuted,
'claimed' => tokens.globalFocus,
_ => tokens.globalForeground,
};
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
ClideText(marker, fontSize: clideFontSmall, color: markerColor),
const SizedBox(width: 6),
Expanded(
child: ClideText(
task.title,
fontSize: clideFontSmall,
color: task.status == 'done' ? tokens.globalTextMuted : tokens.globalForeground,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (task.owner != null)
Padding(
padding: const EdgeInsets.only(left: 4),
child: ClideText(task.owner!, fontSize: clideFontSmall, color: tokens.globalFocus),
),
// Reassign: cycle to the next roster member.
if (broker != null && broker!.members.length > 1)
_IconButton(
painter: PhosphorIcons.arrowClockwise,
tooltip: 'Reassign task',
color: tokens.globalTextMuted,
onTap: () => _reassign(context),
),
],
),
);
}
void _reassign(BuildContext context) {
final b = broker;
if (b == null || members.isEmpty) return;
final brokerMembers = b.members;
if (brokerMembers.isEmpty) return;
// Cycle to the next member after the current owner.
final currentIndex = brokerMembers.indexWhere((m) => m.name == task.owner);
final nextIndex = (currentIndex + 1) % brokerMembers.length;
b.reassignTask(task.id, brokerMembers[nextIndex].id);
}
}
/// The Activity / Team / Config sub-tab strip — same interaction as the pql
/// panel's view tabs, with an underline under the active tab.
class _TabStrip extends StatelessWidget {
const _TabStrip({required this.current, required this.memberCount, required this.onPick});
final SidebarTab current;
final int memberCount;
final ValueChanged<SidebarTab> onPick;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: tokens.panelBorder)),
),
child: Row(
children: [
for (final t in SidebarTab.values)
Padding(
padding: const EdgeInsets.only(right: 16),
child: Semantics(
button: true,
selected: t == current,
label: _label(t),
excludeSemantics: true,
onTap: () => onPick(t),
child: ClideTappable(
onTap: () => onPick(t),
builder: (ctx, hovered, _) => Container(
padding: const EdgeInsets.only(bottom: 3),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: t == current ? tokens.globalFocus : const Color(0x00000000),
width: 2,
),
),
),
child: ClideText(
_label(t),
fontSize: clideFontSmall,
color: t == current || hovered ? tokens.globalForeground : tokens.globalTextMuted,
),
),
),
),
),
],
),
);
}
String _label(SidebarTab t) => switch (t) {
SidebarTab.activity => 'Activity',
SidebarTab.team => memberCount == 0 ? 'Team' : 'Team · $memberCount',
SidebarTab.config => 'Config',
};
}