split the Claude meta sidebar into Activity / Team / Config sub-tabs
test / unit + widget + golden + a11y (push) Failing after 29s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 34s

The sidebar had outgrown one scroll (stats + roster + config don't fit). A
sub-tab strip now switches between three surfaces: Activity (usage stats +
the primary session's live runtime), Team (the member roster, auto-fronted
when a team spawns and otherwise quiet), and Config (the Claude-environment
settings table over ClaudeConfig).

Activity and Config render their key→value rows through one shared table
(same label column + row pitch + header style) so toggling tabs doesn't move
anything. The expandable skills/agents/commands/permissions/MCP browser on
the Config tab is the follow-up (T-183). Exposes StreamJsonSession.status so
the runtime row can seed from the session's current state.

T-182, D-77.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 22:07:45 +02:00
co-authored by Claude Opus 4.7
parent 6c5c67f6dc
commit 25c11c1edb
6 changed files with 383 additions and 72 deletions
@@ -2243,3 +2243,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-169', 'status', 'backlog', 'in_progress', NULL, '2026-05-25 10:49:30', '2026-05-25 10:49:30', '2026-05-25 10:49:30', NULL, '3269bcb75eb13dffc13be53c9f71ca73', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-169', 'status', 'in_progress', 'done', NULL, '2026-05-25 12:12:29', '2026-05-25 12:12:29', '2026-05-25 12:12:29', NULL, 'c93755e2b277ae1b120a58fe3ea59246', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-170', 'status', 'backlog', 'in_progress', NULL, '2026-05-25 12:12:33', '2026-05-25 12:12:33', '2026-05-25 12:12:33', NULL, '494120eadfe8d6ba67c065fa63180cda', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-170', 'status', 'in_progress', 'done', NULL, '2026-05-25 19:38:16', '2026-05-25 19:38:16', '2026-05-25 19:38:16', NULL, '6a30f33668d2d24a78badd8fe680e724', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-182', 'status', 'backlog', 'in_progress', NULL, '2026-05-25 20:01:12', '2026-05-25 20:01:12', '2026-05-25 20:01:12', NULL, 'b0f1c94a5b064c098881defb61d27ad4', 1) ON CONFLICT(hash) DO NOTHING;
+2
View File
@@ -2761,3 +2761,5 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority,
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-169', 'task', 'T-164', 'P2: session orchestrator — one managed-session primitive (spawn/show/hide)', 'Generalize the Claude pane into a clide-managed SESSION primitive: clide spawns/owns each stream-json Claude process; a pane is just a view on a session; teammate / secondary tab / forked branch / inline subagent are all the same thing. Provide spawn / show / hide / close over the set of live sessions; visibility is a UI toggle, not a lifecycle change (sessions stay live when hidden). Re-point the T-139 TeamObserver from observing a Claude tmux team to ORCHESTRATING clide-managed sessions; the T-140 tiles render whatever sessions are shown. Acceptance: multiple managed sessions run concurrently; each can be shown/hidden as a pane from a roster without tearing down its process; tests for the orchestrator lifecycle. Blocked by T-165 (phase 1 transport).', 'in_progress', 'medium', NULL, NULL, 'D-77', '2026-05-24 16:26:49', '2026-05-25 10:49:30', NULL, '698c3527c5cdfba6a19b51fee6cc3cbc', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-169', 'task', 'T-164', 'P2: session orchestrator — one managed-session primitive (spawn/show/hide)', 'Generalize the Claude pane into a clide-managed SESSION primitive: clide spawns/owns each stream-json Claude process; a pane is just a view on a session; teammate / secondary tab / forked branch / inline subagent are all the same thing. Provide spawn / show / hide / close over the set of live sessions; visibility is a UI toggle, not a lifecycle change (sessions stay live when hidden). Re-point the T-139 TeamObserver from observing a Claude tmux team to ORCHESTRATING clide-managed sessions; the T-140 tiles render whatever sessions are shown. Acceptance: multiple managed sessions run concurrently; each can be shown/hidden as a pane from a roster without tearing down its process; tests for the orchestrator lifecycle. Blocked by T-165 (phase 1 transport).', 'done', 'medium', NULL, NULL, 'D-77', '2026-05-24 16:26:49', '2026-05-25 12:12:29', NULL, '187a203302a7f0eb983acd61122a050a', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-170', 'task', 'T-164', 'P2: clide-hosted MCP coordination broker + team-awareness injection', 'Give clide-managed sessions team behavior that Claude''s tmux mode provided (and that is undocumented + headless-unavailable). clide hosts a small MCP server (attached to each agent via --mcp-config) exposing team tools: send_message(to,text), broadcast(text), list_teammates(), inbox(), and a shared claim_task/task_status. clide is the BROKER — a tool call from agent A is delivered into agent B''s next turn on B''s stream-json stdin; clide owns routing/ordering. Inject team-awareness into each agent via --append-system-prompt (roster + role) and/or --agents. Acceptance: two managed agents exchange messages through the broker and see a shared task list; a lead can address one teammate by name; tests for the broker routing + the MCP tool surface. Blocked by T-169.', 'in_progress', 'medium', NULL, NULL, 'D-77', '2026-05-24 16:27:01', '2026-05-25 12:12:33', NULL, '8238e6266f9adc7e7e5e4e5d08813de1', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-170', 'task', 'T-164', 'P2: clide-hosted MCP coordination broker + team-awareness injection', 'Give clide-managed sessions team behavior that Claude''s tmux mode provided (and that is undocumented + headless-unavailable). clide hosts a small MCP server (attached to each agent via --mcp-config) exposing team tools: send_message(to,text), broadcast(text), list_teammates(), inbox(), and a shared claim_task/task_status. clide is the BROKER — a tool call from agent A is delivered into agent B''s next turn on B''s stream-json stdin; clide owns routing/ordering. Inject team-awareness into each agent via --append-system-prompt (roster + role) and/or --agents. Acceptance: two managed agents exchange messages through the broker and see a shared task list; a lead can address one teammate by name; tests for the broker routing + the MCP tool surface. Blocked by T-169.', 'done', 'medium', NULL, NULL, 'D-77', '2026-05-24 16:27:01', '2026-05-25 19:38:16', NULL, '67f2ccb114bf9cf4f3fec9c28827a80d', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-182', 'task', 'T-164', 'Claude sidebar sub-tabs: Activity / Team / Config', 'The Claude meta-sidebar is overloaded (roster + tasks + messages + stats + config do not fit one scroll). Split it into a sub-tab strip (reuse the search-panel mode-strip pattern) with three tabs: (1) Activity — stats + live runtime visibility (T-141/T-157); (2) Team — roster + tasks + message-feed widget (T-171 + T-180), mostly hidden when solo and auto-fronted when a team is live; (3) Config — the Claude-environment view (T-183). Default to Activity. SHARED GEOMETRY: Activity values and the Config settings table use the SAME two-column label->value table (same column x + row pitch + section-header style) so toggling between tabs does not visually jump. Acceptance: a sub-tab strip switches the sidebar body between the three surfaces; Activity + Config settings render on the shared table so toggling is seamless; Team auto-fronts on team spawn; widget tests for tab switching. Wireframes: docs/design/wireframes/claude-prompts/07-sidebar-activity-tab.png, 08-sidebar-team-tab.png, 09-sidebar-config-tab.png. Relates to T-141, T-157, T-171, T-180.', 'in_progress', 'medium', NULL, NULL, 'D-77', '2026-05-25 09:26:31', '2026-05-25 20:01:12', NULL, '0fa8449309d4b717d3b48298fbe3df05', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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);
+8
View File
@@ -16,6 +16,14 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
## [Unreleased]
### Changed
- Claude meta sidebar is now tabbed — Activity / Team / Config (T-182).
Activity shows usage stats plus the primary session's live runtime; Team
holds the roster and auto-fronts when a team spawns; Config shows the
environment settings table. Activity and Config share one table geometry so
switching doesn't jump.
### Added
- Team coordination broker (T-170, D-77) — clide hosts an in-process MCP
+276 -55
View File
@@ -1,19 +1,31 @@
/// Claude meta sidebar (T-141): an always-pickable left-panel tab showing
/// Claude *activity* (from `~/.claude/stats-cache.json`, polled) and, when a
/// tmux agent team is running, a roster of its members (from the
/// TeamObserver's join/left events — not re-tailed here).
/// Claude meta sidebar (T-141, T-157, 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** — a roster of live members (from the TeamObserver's join/left
/// events + per-member status on the message bus). Auto-fronted when a team
/// spawns; 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). Live per-member status (mode /
/// context) is a follow-up that needs the teammate status stream on the bus.
/// 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_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;
@@ -21,8 +33,22 @@ import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
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)});
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.
@@ -32,6 +58,17 @@ class ClaudeMetaSidebar extends StatefulWidget {
/// (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();
}
@@ -43,14 +80,25 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
StreamSubscription<TeamMemberJoined>? _joinSub;
StreamSubscription<TeamMemberLeft>? _leftSub;
StreamSubscription<Message>? _statusSub;
StreamSubscription<SessionStatus>? _primarySub;
Timer? _timer;
late final Future<ClaudeStats> Function() _load;
bool _subscribed = false;
late SidebarTab _tab = widget.initialTab;
ClaudeConfig? _config;
ClaudeSessionOrchestrator? _orchestrator;
SessionStatus? _primaryStatus;
@override
void initState() {
super.initState();
_load = widget.statsLoader ?? _fileLoader();
_config = widget.config ?? activeClaudeConfig;
_orchestrator = widget.orchestrator ?? activeSessionOrchestrator;
_config?.addListener(_onConfigChange);
_orchestrator?.addListener(_bindPrimary);
_bindPrimary();
unawaited(_refreshStats());
if (widget.pollInterval > Duration.zero) {
_timer = Timer.periodic(widget.pollInterval, (_) => unawaited(_refreshStats()));
@@ -78,7 +126,12 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
final kernel = ClideKernel.of(context);
_joinSub = kernel.events.on<TeamMemberJoined>().listen((m) {
if (_members.any((x) => x.agentId == m.agentId)) return;
setState(() => _members.add(m));
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(() {
@@ -100,6 +153,27 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
});
}
/// (Re)bind to the primary managed session's status as the orchestrator's set
/// changes — the Activity runtime row reflects the live session.
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(() {});
}
Future<void> _refreshStats() async {
final stats = await _load();
if (mounted) setState(() => _stats = stats);
@@ -111,60 +185,74 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
_joinSub?.cancel();
_leftSub?.cancel();
_statusSub?.cancel();
_primarySub?.cancel();
_config?.removeListener(_onConfigChange);
_orchestrator?.removeListener(_bindPrimary);
super.dispose();
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return ListView(
padding: const EdgeInsets.all(12),
children: [
_sectionHeader(tokens, 'Activity'),
_activity(tokens),
const SizedBox(height: 16),
_sectionHeader(tokens, 'Team${_members.isEmpty ? '' : ' · ${_members.length}'}'),
_roster(tokens),
],
);
}
Widget _sectionHeader(SurfaceTokens tokens, String label) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: ClideText(label, fontSize: clideFontSmall, color: tokens.globalTextMuted),
);
Widget _activity(SurfaceTokens tokens) {
final latest = _stats.latest;
if (latest == null) {
return ClideText('No activity recorded yet.', muted: true, fontSize: clideFontSmall);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(latest.date, fontSize: clideFontSmall, color: tokens.globalForeground),
const SizedBox(height: 2),
ClideText(
'${latest.messageCount} msgs · ${latest.sessionCount} sessions · ${latest.toolCallCount} tools',
muted: true,
fontSize: clideFontSmall,
),
const SizedBox(height: 6),
ClideText(
'Lifetime: ${_stats.lifetimeMessages} msgs over ${_stats.activeDays} days',
muted: true,
fontSize: clideFontSmall,
),
],
);
}
Widget _roster(SurfaceTokens tokens) {
if (_members.isEmpty) {
return ClideText('No team active.', muted: true, fontSize: clideFontSmall);
}
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.');
}
return ListView(
padding: const EdgeInsets.all(12),
children: [for (final m in _members) _memberRow(tokens, m)],
);
}
@@ -172,7 +260,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
Widget _memberRow(SurfaceTokens tokens, TeamMemberJoined m) {
final color = teamColor(m.color, fallback: tokens.globalForeground);
final st = _memberStatus[m.agentId];
final model = st?.model ?? m.model; // prefer the live model once it's known
final model = st?.model ?? m.model;
final sub = [
m.agentType,
if (model != null) shortModelLabel(model),
@@ -198,4 +286,137 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
),
);
}
// --- Config ---------------------------------------------------------------
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';
return _metaTable(tokens, [
_MetaSection('SETTINGS', [
_MetaRow('model', model, valueColor: tokens.globalFocus),
_MetaRow('output style', outputStyle),
_MetaRow('permission mode', permissionModeLabel(mode)),
_MetaRow('source', '~/.claude + .claude'),
]),
]);
}
// --- 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,
);
}
}
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;
}
/// 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: GestureDetector(
onTap: () => onPick(t),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: 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 ? tokens.globalForeground : tokens.globalTextMuted,
),
),
),
),
),
],
),
);
}
String _label(SidebarTab t) => switch (t) {
SidebarTab.activity => 'Activity',
SidebarTab.team => memberCount == 0 ? 'Team' : 'Team · $memberCount',
SidebarTab.config => 'Config',
};
}
@@ -249,6 +249,9 @@ class StreamJsonSession {
/// Session status (model / permission-mode / context tokens), on change.
Stream<SessionStatus> get statusStream => _statusCtl.stream;
/// The latest known status — the current value [statusStream] last emitted.
SessionStatus get status => _status;
/// Begin consuming the process's event stream.
void start() {
_sub = _proc.lines.listen(_onLine, onError: (Object _) {});
@@ -1,3 +1,6 @@
import 'dart:io';
import 'package:clide/builtin/claude/src/claude_config.dart';
import 'package:clide/builtin/claude/src/claude_meta_sidebar.dart';
import 'package:clide/builtin/claude/src/claude_stats.dart';
import 'package:clide/builtin/claude/src/transcript_publisher.dart';
@@ -13,31 +16,83 @@ void main() {
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
ClaudeMetaSidebar sidebar({ClaudeStats stats = const ClaudeStats()}) => ClaudeMetaSidebar(
const stats = ClaudeStats(
lastComputed: '2026-05-22',
daily: [
DailyActivity(date: '2026-05-01', messageCount: 100, sessionCount: 2, toolCallCount: 30),
DailyActivity(date: '2026-05-22', messageCount: 250, sessionCount: 5, toolCallCount: 80),
],
);
ClaudeMetaSidebar sidebar({
ClaudeStats stats = const ClaudeStats(),
ClaudeConfig? config,
SidebarTab initialTab = SidebarTab.activity,
}) =>
ClaudeMetaSidebar(
statsLoader: () async => stats,
pollInterval: Duration.zero,
config: config,
initialTab: initialTab,
);
testWidgets('shows the latest day + lifetime activity from stats', (tester) async {
const stats = ClaudeStats(
lastComputed: '2026-05-22',
daily: [
DailyActivity(date: '2026-05-01', messageCount: 100, sessionCount: 2, toolCallCount: 30),
DailyActivity(date: '2026-05-22', messageCount: 250, sessionCount: 5, toolCallCount: 80),
],
);
testWidgets('Activity tab shows today + lifetime stats on the table', (tester) async {
await tester.pumpWidget(harness(f, sidebar(stats: stats)));
await tester.pumpAndSettle();
expect(find.text('2026-05-22'), findsOneWidget);
expect(find.text('250 msgs · 5 sessions · 80 tools'), findsOneWidget);
expect(find.text('Lifetime: 350 msgs over 2 days'), findsOneWidget);
expect(find.text('No team active.'), findsOneWidget);
expect(find.text('TODAY'), findsOneWidget);
expect(find.text('LIFETIME'), findsOneWidget);
expect(find.text('250'), findsOneWidget); // today messages
expect(find.text('80'), findsOneWidget); // today tool calls
expect(find.text('350'), findsOneWidget); // lifetime messages
expect(find.text('messages'), findsNWidgets(2)); // today + lifetime rows
});
testWidgets('adds and removes team members from the roster', (tester) async {
await tester.pumpWidget(harness(f, sidebar()));
testWidgets('defaults to Activity, not the roster', (tester) async {
await tester.pumpWidget(harness(f, sidebar(stats: stats)));
await tester.pumpAndSettle();
expect(find.text('No team active.'), findsNothing);
expect(find.text('TODAY'), findsOneWidget);
});
testWidgets('tapping a sub-tab switches the body', (tester) async {
await tester.pumpWidget(harness(f, sidebar(stats: stats)));
await tester.pumpAndSettle();
await tester.tap(find.text('Team'));
await tester.pump();
expect(find.text('No team active.'), findsOneWidget);
expect(find.text('TODAY'), findsNothing);
await tester.tap(find.text('Activity'));
await tester.pump();
expect(find.text('TODAY'), findsOneWidget);
});
testWidgets('Config tab renders the settings table over ClaudeConfig', (tester) async {
final dir = Directory.systemTemp.createTempSync('cfg');
addTearDown(() => dir.deleteSync(recursive: true));
final config = ClaudeConfig(globalDir: dir, cacheDir: dir);
await tester.pumpWidget(harness(f, sidebar(config: config, initialTab: SidebarTab.config)));
await tester.pumpAndSettle();
expect(find.text('SETTINGS'), findsOneWidget);
expect(find.text('output style'), findsOneWidget);
expect(find.text('permission mode'), findsOneWidget);
expect(find.text('~/.claude + .claude'), findsOneWidget);
});
testWidgets('Config tab degrades when no environment is loaded', (tester) async {
await tester.pumpWidget(harness(f, sidebar(initialTab: SidebarTab.config)));
await tester.pumpAndSettle();
expect(find.text('Claude environment not loaded.'), findsOneWidget);
});
testWidgets('a team spawn auto-fronts the Team tab', (tester) async {
await tester.pumpWidget(harness(f, sidebar(stats: stats)));
await tester.pumpAndSettle();
expect(find.text('TODAY'), findsOneWidget); // starts on Activity
f.services.events.emit(const TeamMemberJoined(
team: 't',
@@ -50,8 +105,28 @@ void main() {
));
await tester.pump();
await tester.pump();
// Fronted to Team without a tap.
expect(find.text('Scout'), findsOneWidget);
expect(find.text('explorer · opus 4.7'), findsOneWidget);
expect(find.text('TODAY'), findsNothing);
});
testWidgets('removing the last member leaves the Team tab empty', (tester) async {
await tester.pumpWidget(harness(f, sidebar()));
await tester.pumpAndSettle();
f.services.events.emit(const TeamMemberJoined(
team: 't',
agentId: 'a1',
name: 'Scout',
agentType: 'explorer',
paneId: '%1',
color: 'blue',
));
await tester.pump();
await tester.pump();
expect(find.text('Scout'), findsOneWidget);
f.services.events.emit(const TeamMemberLeft(team: 't', agentId: 'a1', paneId: '%1'));
await tester.pump();
@@ -60,7 +135,7 @@ void main() {
expect(find.text('No team active.'), findsOneWidget);
});
testWidgets('folds live per-member status (mode + context) into the roster row', (tester) async {
testWidgets('folds live per-member status into the roster row', (tester) async {
await tester.pumpWidget(harness(f, sidebar()));
await tester.pumpAndSettle();
@@ -89,7 +164,7 @@ void main() {
expect(find.text('explorer · opus 4.7 · accept-edits · 21k ctx'), findsOneWidget);
});
testWidgets('degrades to a no-activity message with empty stats', (tester) async {
testWidgets('Activity degrades to a no-activity message with empty stats', (tester) async {
await tester.pumpWidget(harness(f, sidebar()));
await tester.pumpAndSettle();
expect(find.text('No activity recorded yet.'), findsOneWidget);