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