diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index 400765e0..a2479601 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index ce1de3a8..4a9ee0a3 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -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); diff --git a/CHANGELOG.md b/CHANGELOG.md index d959e7ec..a6abd875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/builtin/claude/src/claude_meta_sidebar.dart b/lib/builtin/claude/src/claude_meta_sidebar.dart index 4ab41f41..49ba9d1a 100644 --- a/lib/builtin/claude/src/claude_meta_sidebar.dart +++ b/lib/builtin/claude/src/claude_meta_sidebar.dart @@ -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 createState() => _ClaudeMetaSidebarState(); } @@ -43,14 +80,25 @@ class _ClaudeMetaSidebarState extends State { StreamSubscription? _joinSub; StreamSubscription? _leftSub; StreamSubscription? _statusSub; + StreamSubscription? _primarySub; Timer? _timer; late final Future 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 { final kernel = ClideKernel.of(context); _joinSub = kernel.events.on().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().listen((m) { setState(() { @@ -100,6 +153,27 @@ class _ClaudeMetaSidebarState extends State { }); } + /// (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 _refreshStats() async { final stats = await _load(); if (mounted) setState(() => _stats = stats); @@ -111,60 +185,74 @@ class _ClaudeMetaSidebarState extends State { _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 { 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 { ), ); } + + // --- 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 = []; + 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 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', + }; } diff --git a/lib/builtin/claude/src/stream_json_session.dart b/lib/builtin/claude/src/stream_json_session.dart index 2af2743b..a8d01507 100644 --- a/lib/builtin/claude/src/stream_json_session.dart +++ b/lib/builtin/claude/src/stream_json_session.dart @@ -249,6 +249,9 @@ class StreamJsonSession { /// Session status (model / permission-mode / context tokens), on change. Stream 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 _) {}); diff --git a/test/builtin/claude/claude_meta_sidebar_test.dart b/test/builtin/claude/claude_meta_sidebar_test.dart index 6efd41c3..ccda7765 100644 --- a/test/builtin/claude/claude_meta_sidebar_test.dart +++ b/test/builtin/claude/claude_meta_sidebar_test.dart @@ -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);