split claude_meta_sidebar.dart into meta_sidebar/ (T-395)
The 1192-LOC sidebar monolith now keeps only its lifecycle — stats polling, team membership streams, primary-session binding, broker subscription, inject + accordion state — and switches between stateless, props-driven tab views under meta_sidebar/: - models.dart — SidebarTab/ConfigSection/ConfigPermKind enums, the MetaSection/MetaRow models, and the shared table geometry both Activity and Config render on - activity_tab.dart / team_tab.dart / config_tab.dart — the three bodies; accordion expansion stays in the parent (survives tab switches) and arrives as prop + callback - roster_row.dart, permission_badge.dart, task_row.dart, tab_strip.dart, icon_button.dart, inject_field.dart — the widgets Public API unchanged: ClaudeMetaSidebar stays put and SidebarTab is re-exported from the root file, so all 44 sidebar tests (and extension.dart) pass without a single edit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4091,3 +4091,40 @@ Correction (2026-06-12, verified during T-394 breakdown): ColumnHat is NOT dupli
|
||||
|
||||
Done 2026-06-12 across six commits. Notes: ColumnHat''s file carried the LIVE hatHeight constant (app hat bar + menu bar) — moved to widgets/src/chrome_metrics.dart before deleting the dead widget. TranscriptPublisher class removed; the ClaudeConversation addressing constants stay (still consumed). The TeamMemberJoined ghost-event rewiring is real work, split out as T-396. mocktail dropped with D-25 amended (hand-rolled fakes throughout). ptyc binary untracked+deleted (no licenses.yaml entry existed). Coverage rose 95.03% → 95.13% with the dead denominator gone; full push-check green.', NULL, '2026-06-12 00:23:24', '2026-06-12 00:23:24', '2026-06-12 00:23:24', NULL, '0791573d68f344d3edab4cd8b89d2d69', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDGPXQN31NNRPJ00PFRAG4', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 00:23:56', '2026-06-12 00:23:56', '2026-06-12 00:23:56', NULL, 'e2cf08e3a17b8f8ad8288061d263744c', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDGPXQN31NNRPJ00PFRAG4', 'status', 'in_progress', 'done', NULL, '2026-06-12 00:29:57', '2026-06-12 00:29:57', '2026-06-12 00:29:57', NULL, 'f7a67bd27641e35f6cf1955fe78d41a6', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDGPXQN31NNRPJ00PFRAG4', 'description', 'lib/app.dart is 1187 LOC mixing five confirmed concerns. Split plan (verified against the file 2026-06-12):
|
||||
|
||||
Inventory: ClideApp (14-29), _AppRoot (31-45), _RootShell/_RootShellState keyboard+intent routing (47-229), RootLayout (231-350), hat bar family _HatBar/_LeftHatContent/_RightHatContent/_WinBtn (352-439), project switcher family _ProjectSwitcherButton/_ProjectSwitcherDropdown/_RecentProjectRow/_ActionRow (441-672), SlotHost/_SlotHostState/_SlotBody/_SidebarSlot/_WorkspaceSlot/_RevealedTab/_ContextSlot (674-1030), _BottomRail (1032-1078), StatusbarCollapseToggle (1093-1126), StatusbarHost (1128-1170), _EditorDragHandle (924-1010), _WelcomeOverlay (1172-1187).
|
||||
|
||||
Target layout:
|
||||
- app.dart keeps ClideApp + _AppRoot and RE-EXPORTS the public symbols so tests keep importing package:clide/app.dart.
|
||||
- lib/widgets/root_shell.dart: _RootShell/_RootShellState (keyboard routing; depends on ModifierTapTracker, MenuBarController).
|
||||
- lib/builtin/hat/hat_bar.dart: _HatBar, _LeftHatContent, _RightHatContent, _WinBtn, hatHeight.
|
||||
- lib/builtin/hat/project_switcher.dart: the switcher family (~230 LOC).
|
||||
- lib/widgets/slot_host.dart: SlotHost + slot bodies (NOTE: _RevealedTab references _SlotBody._resolveTitle — keep them together or extract the helper).
|
||||
- lib/widgets/layout_status.dart: RootLayout internals, StatusbarHost, StatusbarCollapseToggle, _BottomRail, _EditorDragHandle(+Intent), _WelcomeOverlay.
|
||||
|
||||
Order: mechanical first (hat bar, switcher rows, welcome overlay), then root shell, then slot host (tangled: _SlotHostState registers focus scopes via ClideKernel.of in didChangeDependencies), then layout/status.
|
||||
|
||||
Tests importing app.dart: test/app_test.dart (RootLayout, StatusbarHost, ClideApp), test/app_statusbar_test.dart (StatusbarHost), test/app_collapse_toggle_test.dart (StatusbarCollapseToggle) — re-exports keep them unchanged.
|
||||
|
||||
CORRECTION to fable-ous.md: ColumnHat is NOT duplicated line-for-line in app.dart (verified); ColumnHat lives only in lib/widgets/src/clide_column_hat.dart. The rat-sweep ticket T-385 was annotated accordingly — verify whether ColumnHat is dead before deleting.', 'lib/app.dart is 1187 LOC mixing five confirmed concerns. Split plan (verified against the file 2026-06-12):
|
||||
|
||||
Inventory: ClideApp (14-29), _AppRoot (31-45), _RootShell/_RootShellState keyboard+intent routing (47-229), RootLayout (231-350), hat bar family _HatBar/_LeftHatContent/_RightHatContent/_WinBtn (352-439), project switcher family _ProjectSwitcherButton/_ProjectSwitcherDropdown/_RecentProjectRow/_ActionRow (441-672), SlotHost/_SlotHostState/_SlotBody/_SidebarSlot/_WorkspaceSlot/_RevealedTab/_ContextSlot (674-1030), _BottomRail (1032-1078), StatusbarCollapseToggle (1093-1126), StatusbarHost (1128-1170), _EditorDragHandle (924-1010), _WelcomeOverlay (1172-1187).
|
||||
|
||||
Target layout:
|
||||
- app.dart keeps ClideApp + _AppRoot and RE-EXPORTS the public symbols so tests keep importing package:clide/app.dart.
|
||||
- lib/widgets/root_shell.dart: _RootShell/_RootShellState (keyboard routing; depends on ModifierTapTracker, MenuBarController).
|
||||
- lib/builtin/hat/hat_bar.dart: _HatBar, _LeftHatContent, _RightHatContent, _WinBtn, hatHeight.
|
||||
- lib/builtin/hat/project_switcher.dart: the switcher family (~230 LOC).
|
||||
- lib/widgets/slot_host.dart: SlotHost + slot bodies (NOTE: _RevealedTab references _SlotBody._resolveTitle — keep them together or extract the helper).
|
||||
- lib/widgets/layout_status.dart: RootLayout internals, StatusbarHost, StatusbarCollapseToggle, _BottomRail, _EditorDragHandle(+Intent), _WelcomeOverlay.
|
||||
|
||||
Order: mechanical first (hat bar, switcher rows, welcome overlay), then root shell, then slot host (tangled: _SlotHostState registers focus scopes via ClideKernel.of in didChangeDependencies), then layout/status.
|
||||
|
||||
Tests importing app.dart: test/app_test.dart (RootLayout, StatusbarHost, ClideApp), test/app_statusbar_test.dart (StatusbarHost), test/app_collapse_toggle_test.dart (StatusbarCollapseToggle) — re-exports keep them unchanged.
|
||||
|
||||
CORRECTION to fable-ous.md: ColumnHat is NOT duplicated line-for-line in app.dart (verified); ColumnHat lives only in lib/widgets/src/clide_column_hat.dart. The rat-sweep ticket T-385 was annotated accordingly — verify whether ColumnHat is dead before deleting.
|
||||
|
||||
Done 2026-06-12. One deviation from the plan: the shell pieces went to lib/src/shell/ rather than lib/widgets/ + lib/builtin/hat/ — SlotHost/RootLayout know kernel + contribution types (not widget primitives), and the hat bar isn''t extension-shaped (no contributions), so neither home fit. app.dart kept the planned re-exports; zero test edits needed.', NULL, '2026-06-12 00:30:11', '2026-06-12 00:30:11', '2026-06-12 00:30:11', NULL, '871c3a1d4c3545dfa41bdafa3a39aaa8', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDH8TE9MJBXZ4GQ5YT10JM', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 00:30:17', '2026-06-12 00:30:17', '2026-06-12 00:30:17', NULL, '95cd4a80d4e61af091427c6d20bf701a', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -4299,3 +4299,48 @@ Order: mechanical first (hat bar, switcher rows, welcome overlay), then root she
|
||||
Tests importing app.dart: test/app_test.dart (RootLayout, StatusbarHost, ClideApp), test/app_statusbar_test.dart (StatusbarHost), test/app_collapse_toggle_test.dart (StatusbarCollapseToggle) — re-exports keep them unchanged.
|
||||
|
||||
CORRECTION to fable-ous.md: ColumnHat is NOT duplicated line-for-line in app.dart (verified); ColumnHat lives only in lib/widgets/src/clide_column_hat.dart. The rat-sweep ticket T-385 was annotated accordingly — verify whether ColumnHat is dead before deleting.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 22:03:21', '2026-06-12 00:23:56', NULL, '06f8ead95c7934a07b4b74813f74f001', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDGPXQN31NNRPJ00PFRAG4', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split lib/app.dart (1187 LOC, five concerns)', 'lib/app.dart is 1187 LOC mixing five confirmed concerns. Split plan (verified against the file 2026-06-12):
|
||||
|
||||
Inventory: ClideApp (14-29), _AppRoot (31-45), _RootShell/_RootShellState keyboard+intent routing (47-229), RootLayout (231-350), hat bar family _HatBar/_LeftHatContent/_RightHatContent/_WinBtn (352-439), project switcher family _ProjectSwitcherButton/_ProjectSwitcherDropdown/_RecentProjectRow/_ActionRow (441-672), SlotHost/_SlotHostState/_SlotBody/_SidebarSlot/_WorkspaceSlot/_RevealedTab/_ContextSlot (674-1030), _BottomRail (1032-1078), StatusbarCollapseToggle (1093-1126), StatusbarHost (1128-1170), _EditorDragHandle (924-1010), _WelcomeOverlay (1172-1187).
|
||||
|
||||
Target layout:
|
||||
- app.dart keeps ClideApp + _AppRoot and RE-EXPORTS the public symbols so tests keep importing package:clide/app.dart.
|
||||
- lib/widgets/root_shell.dart: _RootShell/_RootShellState (keyboard routing; depends on ModifierTapTracker, MenuBarController).
|
||||
- lib/builtin/hat/hat_bar.dart: _HatBar, _LeftHatContent, _RightHatContent, _WinBtn, hatHeight.
|
||||
- lib/builtin/hat/project_switcher.dart: the switcher family (~230 LOC).
|
||||
- lib/widgets/slot_host.dart: SlotHost + slot bodies (NOTE: _RevealedTab references _SlotBody._resolveTitle — keep them together or extract the helper).
|
||||
- lib/widgets/layout_status.dart: RootLayout internals, StatusbarHost, StatusbarCollapseToggle, _BottomRail, _EditorDragHandle(+Intent), _WelcomeOverlay.
|
||||
|
||||
Order: mechanical first (hat bar, switcher rows, welcome overlay), then root shell, then slot host (tangled: _SlotHostState registers focus scopes via ClideKernel.of in didChangeDependencies), then layout/status.
|
||||
|
||||
Tests importing app.dart: test/app_test.dart (RootLayout, StatusbarHost, ClideApp), test/app_statusbar_test.dart (StatusbarHost), test/app_collapse_toggle_test.dart (StatusbarCollapseToggle) — re-exports keep them unchanged.
|
||||
|
||||
CORRECTION to fable-ous.md: ColumnHat is NOT duplicated line-for-line in app.dart (verified); ColumnHat lives only in lib/widgets/src/clide_column_hat.dart. The rat-sweep ticket T-385 was annotated accordingly — verify whether ColumnHat is dead before deleting.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 22:03:21', '2026-06-12 00:29:57', NULL, '472bd43765ad37a110c7760a5da6f814', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDGPXQN31NNRPJ00PFRAG4', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split lib/app.dart (1187 LOC, five concerns)', 'lib/app.dart is 1187 LOC mixing five confirmed concerns. Split plan (verified against the file 2026-06-12):
|
||||
|
||||
Inventory: ClideApp (14-29), _AppRoot (31-45), _RootShell/_RootShellState keyboard+intent routing (47-229), RootLayout (231-350), hat bar family _HatBar/_LeftHatContent/_RightHatContent/_WinBtn (352-439), project switcher family _ProjectSwitcherButton/_ProjectSwitcherDropdown/_RecentProjectRow/_ActionRow (441-672), SlotHost/_SlotHostState/_SlotBody/_SidebarSlot/_WorkspaceSlot/_RevealedTab/_ContextSlot (674-1030), _BottomRail (1032-1078), StatusbarCollapseToggle (1093-1126), StatusbarHost (1128-1170), _EditorDragHandle (924-1010), _WelcomeOverlay (1172-1187).
|
||||
|
||||
Target layout:
|
||||
- app.dart keeps ClideApp + _AppRoot and RE-EXPORTS the public symbols so tests keep importing package:clide/app.dart.
|
||||
- lib/widgets/root_shell.dart: _RootShell/_RootShellState (keyboard routing; depends on ModifierTapTracker, MenuBarController).
|
||||
- lib/builtin/hat/hat_bar.dart: _HatBar, _LeftHatContent, _RightHatContent, _WinBtn, hatHeight.
|
||||
- lib/builtin/hat/project_switcher.dart: the switcher family (~230 LOC).
|
||||
- lib/widgets/slot_host.dart: SlotHost + slot bodies (NOTE: _RevealedTab references _SlotBody._resolveTitle — keep them together or extract the helper).
|
||||
- lib/widgets/layout_status.dart: RootLayout internals, StatusbarHost, StatusbarCollapseToggle, _BottomRail, _EditorDragHandle(+Intent), _WelcomeOverlay.
|
||||
|
||||
Order: mechanical first (hat bar, switcher rows, welcome overlay), then root shell, then slot host (tangled: _SlotHostState registers focus scopes via ClideKernel.of in didChangeDependencies), then layout/status.
|
||||
|
||||
Tests importing app.dart: test/app_test.dart (RootLayout, StatusbarHost, ClideApp), test/app_statusbar_test.dart (StatusbarHost), test/app_collapse_toggle_test.dart (StatusbarCollapseToggle) — re-exports keep them unchanged.
|
||||
|
||||
CORRECTION to fable-ous.md: ColumnHat is NOT duplicated line-for-line in app.dart (verified); ColumnHat lives only in lib/widgets/src/clide_column_hat.dart. The rat-sweep ticket T-385 was annotated accordingly — verify whether ColumnHat is dead before deleting.
|
||||
|
||||
Done 2026-06-12. One deviation from the plan: the shell pieces went to lib/src/shell/ rather than lib/widgets/ + lib/builtin/hat/ — SlotHost/RootLayout know kernel + contribution types (not widget primitives), and the hat bar isn''t extension-shaped (no contributions), so neither home fit. app.dart kept the planned re-exports; zero test edits needed.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 22:03:21', '2026-06-12 00:30:11', NULL, '72f1fd3cab79bd6fcd777fa857b0f245', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDH8TE9MJBXZ4GQ5YT10JM', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split lib/builtin/claude/src/claude_meta_sidebar.dart (1192 LOC)', 'lib/builtin/claude/src/claude_meta_sidebar.dart is 1192 LOC. Split plan (verified against the file 2026-06-12). Public API (ClaudeMetaSidebar widget + SidebarTab enum) stays in the root file — tests and extension.dart need zero import changes.
|
||||
|
||||
Inventory: consts _labelColumnWidth/_rowPitch (43-44), SidebarTab enum (46), ClaudeMetaSidebar (48-79), _ClaudeMetaSidebarState monolith (81-638: lifecycle, stats polling, team membership streams, primary-session binding, broker subscription, config listener, inject state, accordion state, plus the three tab bodies), _ConfigSection/_ConfigPermKind enums (641/644), _MetaSection/_MetaRow models (646-657), _AgentRosterRow(+State) (680-928), _permissionModeBadge + _PermissionModeBadge T-181 (935-1009), _IconButton (1012-1039), _InjectTextField (1043-1070), _TaskRow T-171 (1077-1141), _TabStrip (1145-1192).
|
||||
|
||||
Target layout under lib/builtin/claude/src/meta_sidebar/: models.dart (enums + _MetaSection/_MetaRow + layout consts), activity_tab.dart (ActivityTabView ~70 LOC, from _activityBody/_runtimeSection 272-302), team_tab.dart (TeamTabView ~120 LOC, from _teamBody/_taskSection 304-380, props-driven with callbacks), config_tab.dart (ConfigTabView ~200 LOC, from _configBody family 382-597; accordion _expanded state stays in parent, passed as prop+callback), roster_row.dart (_AgentRosterRow ~250 LOC incl. bypass-confirm state), permission_badge.dart (~80), task_row.dart (~70), tab_strip.dart (~55), icon_button.dart + inject_field.dart (~30 each — keep here initially; promote to lib/widgets/ only when a second consumer appears). Root file shrinks to ~150 LOC of lifecycle + event bindings + tab switch.
|
||||
|
||||
Execution order (each phase independently green): 1) primitives (icon button, inject field, permission badge, models); 2) stateless tab views (activity, config, tab strip) with prop threading; 3) team tab + roster row (most orchestrator coupling: verify show/hide, mute, inject submit-and-clear, shift-click bypass confirm, fork, close, task reassign cycle, auto-front on TeamMemberJoined at line 158); 4) cleanup of moved methods from the root state.
|
||||
|
||||
Tests: test/builtin/claude/claude_meta_sidebar_test.dart imports only the root file and public symbols — no changes needed; no goldens reference the sidebar. Caveat from the rat sweep (T-385): TeamMemberJoined currently has no emitter — coordinate before investing in the team tab plumbing.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 22:03:26', '2026-06-12 00:30:17', NULL, '9c58fdb1224831d870110543a53389a6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
/// The Activity tab: usage stats (stats-cache.json) + the primary
|
||||
/// session's live runtime row. Split out of claude_meta_sidebar.dart
|
||||
/// (T-395).
|
||||
library;
|
||||
|
||||
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/meta_sidebar/models.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ActivityTabView extends StatelessWidget {
|
||||
const ActivityTabView({super.key, required this.stats, required this.primaryStatus, required this.config});
|
||||
|
||||
final ClaudeStats stats;
|
||||
final SessionStatus? primaryStatus;
|
||||
final ClaudeConfig? config;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
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 metaPlaceholder('No activity recorded yet.');
|
||||
}
|
||||
return buildMetaTable(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)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/// The Config tab (T-183): the pinned settings table over [ClaudeConfig]
|
||||
/// plus the skills/agents/commands/hooks/permissions/MCP accordion.
|
||||
/// Split out of claude_meta_sidebar.dart (T-395). The accordion's
|
||||
/// expansion state lives in the parent (it survives tab switches) and
|
||||
/// arrives as a prop + toggle callback.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_config.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show permissionModeLabel;
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ConfigTabView extends StatelessWidget {
|
||||
const ConfigTabView({super.key, required this.config, required this.expanded, required this.onToggleSection});
|
||||
|
||||
final ClaudeConfig? config;
|
||||
|
||||
/// Sections currently expanded — owned by the parent state.
|
||||
final Set<ConfigSection> expanded;
|
||||
final void Function(ConfigSection section) onToggleSection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final cfg = config;
|
||||
if (cfg == null) {
|
||||
return metaPlaceholder('Claude environment not loaded.');
|
||||
}
|
||||
final settings = cfg.settings;
|
||||
final model = cfg.probe?.model ?? settings['model']?.toString() ?? '—';
|
||||
final outputStyle = settings['outputStyle']?.toString() ?? 'default';
|
||||
final mode = cfg.probe?.permissionMode ?? settings['permissionMode']?.toString() ?? 'default';
|
||||
|
||||
final children = <Widget>[
|
||||
// Pinned SETTINGS table — not collapsible.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: ClideText('SETTINGS', fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
||||
),
|
||||
_configRow(tokens, 'model', model, valueColor: tokens.globalFocus),
|
||||
_configRow(tokens, 'output style', outputStyle),
|
||||
_configRow(tokens, 'permission mode', permissionModeLabel(mode)),
|
||||
_configRow(tokens, 'source', '~/.claude + .claude'),
|
||||
|
||||
// ---- Accordion sections ----
|
||||
for (final section in ConfigSection.values) _accordion(context, tokens, cfg, section),
|
||||
|
||||
// Footer hint.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: ClideText('expand a list to see all · click a skill/agent/command → opens its .md', muted: true, fontSize: clideFontSmall),
|
||||
),
|
||||
];
|
||||
|
||||
return ListView(padding: const EdgeInsets.all(12), children: children);
|
||||
}
|
||||
|
||||
/// One key→value row in the pinned SETTINGS table.
|
||||
Widget _configRow(SurfaceTokens tokens, String label, String value, {Color? valueColor}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: kMetaLabelColumnWidth,
|
||||
child: ClideText(label, muted: true, fontSize: clideFontSmall),
|
||||
),
|
||||
Expanded(
|
||||
child: ClideText(value, fontSize: clideFontSmall, color: valueColor ?? tokens.globalForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _sectionLabel(ConfigSection section) => switch (section) {
|
||||
ConfigSection.skills => 'SKILLS',
|
||||
ConfigSection.agents => 'AGENTS',
|
||||
ConfigSection.commands => 'COMMANDS',
|
||||
ConfigSection.hooks => 'HOOKS',
|
||||
ConfigSection.permissions => 'PERMISSIONS',
|
||||
ConfigSection.mcpServers => 'MCP SERVERS',
|
||||
};
|
||||
|
||||
int _sectionCount(ClaudeConfig config, ConfigSection section) => switch (section) {
|
||||
ConfigSection.skills => config.skills.length,
|
||||
ConfigSection.agents => config.agents.length,
|
||||
ConfigSection.commands => config.commands.length,
|
||||
ConfigSection.hooks => config.hooks.length,
|
||||
ConfigSection.permissions => config.permissions.allow.length + config.permissions.deny.length + config.permissions.ask.length,
|
||||
ConfigSection.mcpServers => config.mcpServers.length,
|
||||
};
|
||||
|
||||
Widget _accordion(BuildContext context, SurfaceTokens tokens, ClaudeConfig config, ConfigSection section) {
|
||||
final isExpanded = expanded.contains(section);
|
||||
final children = isExpanded ? _sectionChildren(context, tokens, config, section) : const <Widget>[];
|
||||
return ClideAccordion(
|
||||
label: _sectionLabel(section),
|
||||
count: _sectionCount(config, section),
|
||||
expanded: isExpanded,
|
||||
onToggle: () => onToggleSection(section),
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _sectionChildren(BuildContext context, SurfaceTokens tokens, ClaudeConfig config, ConfigSection section) {
|
||||
switch (section) {
|
||||
case ConfigSection.skills:
|
||||
return [for (final skill in config.skills) _fileRow(context, tokens, skill.name, skill.path)];
|
||||
case ConfigSection.agents:
|
||||
return [for (final agent in config.agents) _fileRow(context, tokens, agent.name, agent.path)];
|
||||
case ConfigSection.commands:
|
||||
return [for (final cmd in config.commands) _fileRow(context, tokens, cmd.name, cmd.path)];
|
||||
case ConfigSection.hooks:
|
||||
return [
|
||||
for (final hook in config.hooks)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(hook.event, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
|
||||
for (final cmd in hook.commands)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8, top: 1),
|
||||
child: ClideText(cmd, fontSize: clideFontSmall, muted: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
case ConfigSection.permissions:
|
||||
return _permissionRows(tokens, config.permissions);
|
||||
case ConfigSection.mcpServers:
|
||||
return [
|
||||
for (final srv in config.mcpServers)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
|
||||
child: ClideText(srv.name, fontSize: clideFontSmall, color: tokens.globalForeground),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// A tappable row for file-backed items (skills, agents, commands).
|
||||
/// All config items are .md files — opens in the markdown reader panel
|
||||
/// via the kernel MessageBus (D-6, T-183).
|
||||
Widget _fileRow(BuildContext context, SurfaceTokens tokens, String name, String? path) {
|
||||
final row = Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
|
||||
child: ClideText(name, fontSize: clideFontSmall, color: path != null ? tokens.globalFocus : tokens.globalForeground),
|
||||
);
|
||||
if (path == null) return row;
|
||||
void openMarkdown() => ClideKernel.of(context).messages.publish('builtin.markdown', 'selection', {'path': path});
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: name,
|
||||
excludeSemantics: true,
|
||||
onTap: openMarkdown,
|
||||
child: ClideTappable(
|
||||
tooltip: path,
|
||||
onTap: openMarkdown,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
|
||||
child: ClideText(name, fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalFocus),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders grouped allow/ask/deny permission rows, colour-coded by kind.
|
||||
List<Widget> _permissionRows(SurfaceTokens tokens, ClaudePermissions perms) {
|
||||
// allow → statusSuccess, ask → statusWarning, deny → statusError
|
||||
Color kindColor(ConfigPermKind k) => switch (k) {
|
||||
ConfigPermKind.allow => tokens.statusSuccess,
|
||||
ConfigPermKind.ask => tokens.statusWarning,
|
||||
ConfigPermKind.deny => tokens.statusError,
|
||||
};
|
||||
|
||||
String kindLabel(ConfigPermKind k) => switch (k) {
|
||||
ConfigPermKind.allow => 'allow',
|
||||
ConfigPermKind.ask => 'ask',
|
||||
ConfigPermKind.deny => 'deny',
|
||||
};
|
||||
|
||||
final groups = [(ConfigPermKind.allow, perms.allow), (ConfigPermKind.ask, perms.ask), (ConfigPermKind.deny, perms.deny)];
|
||||
|
||||
final rows = <Widget>[];
|
||||
for (final (kind, rules) in groups) {
|
||||
if (rules.isEmpty) continue;
|
||||
final color = kindColor(kind);
|
||||
rows.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 36,
|
||||
child: ClideText(kindLabel(kind), fontSize: clideFontSmall, color: color),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final rule in rules)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 1),
|
||||
child: ClideText(rule, fontSize: clideFontSmall, color: tokens.globalForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/// A single icon-button used by the roster row controls + task rows.
|
||||
/// Split out of claude_meta_sidebar.dart (T-395). Promote to
|
||||
/// lib/widgets/ only when a second consumer appears.
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class MetaIconButton extends StatelessWidget {
|
||||
const MetaIconButton({super.key, required this.painter, required this.tooltip, required this.color, required this.onTap});
|
||||
|
||||
final ClideIconPainter painter;
|
||||
final String tooltip;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Icon-only button: expose the tooltip text as the Semantics button label
|
||||
// so AT (and widget tests) can find and activate it by name.
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: tooltip,
|
||||
excludeSemantics: true,
|
||||
onTap: onTap,
|
||||
child: ClideTappable(
|
||||
tooltip: tooltip,
|
||||
onTap: onTap,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
|
||||
child: ClideIcon(painter, size: 12, color: hovered ? ClideTheme.of(ctx).surface.globalForeground : color),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/// Inline text input for injecting a message into a session (T-171).
|
||||
/// Submits on Enter; Cancel is handled by the parent's icon button.
|
||||
/// Split out of claude_meta_sidebar.dart (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class InjectTextField extends StatelessWidget {
|
||||
const InjectTextField({super.key, required this.controller, required this.tokens, required this.onSubmit});
|
||||
|
||||
final TextEditingController controller;
|
||||
final SurfaceTokens tokens;
|
||||
final void Function(String text) onSubmit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 22,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
border: Border.all(color: tokens.panelBorder),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: EditableText(
|
||||
controller: controller,
|
||||
focusNode: FocusNode(debugLabel: 'inject-${controller.hashCode}')..requestFocus(),
|
||||
style: TextStyle(fontFamily: 'JetBrains Mono', fontSize: clideFontSmall, color: tokens.globalForeground, height: 1.4),
|
||||
cursorColor: tokens.globalFocus,
|
||||
backgroundCursorColor: tokens.globalTextMuted,
|
||||
onSubmitted: onSubmit,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/// Shared models + table geometry for the Claude meta sidebar's tabs.
|
||||
/// Split out of claude_meta_sidebar.dart (T-395).
|
||||
library;
|
||||
|
||||
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 kMetaLabelColumnWidth = 110;
|
||||
const double kMetaRowPitch = 4;
|
||||
|
||||
/// The sidebar's sub-tabs.
|
||||
enum SidebarTab { activity, team, config }
|
||||
|
||||
// T-183: accordion sections for the Config tab.
|
||||
enum ConfigSection { skills, agents, commands, hooks, permissions, mcpServers }
|
||||
|
||||
/// Permission kind for colour-coding in the Config tab (T-183).
|
||||
enum ConfigPermKind { allow, ask, deny }
|
||||
|
||||
class MetaSection {
|
||||
const MetaSection(this.header, this.rows);
|
||||
final String header;
|
||||
final List<MetaRow> rows;
|
||||
}
|
||||
|
||||
class MetaRow {
|
||||
const MetaRow(this.label, this.value, {this.valueColor});
|
||||
final String label;
|
||||
final String value;
|
||||
final Color? valueColor;
|
||||
}
|
||||
|
||||
/// The muted empty-state body shared by every tab.
|
||||
Widget metaPlaceholder(String text) => Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(text, muted: true, fontSize: clideFontSmall),
|
||||
);
|
||||
|
||||
/// Key→value sections on the shared table geometry (Activity + Config).
|
||||
Widget buildMetaTable(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: kMetaRowPitch),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: kMetaLabelColumnWidth,
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/// Clickable permission-mode badge shown in each roster row (T-181).
|
||||
/// Split out of claude_meta_sidebar.dart (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show permissionModeLabel;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart' show HardwareKeyboard;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Maps a permission-mode string to a single-letter badge label.
|
||||
String permissionModeBadgeLabel(String mode) => switch (mode) {
|
||||
'acceptEdits' => 'A',
|
||||
'plan' => 'P',
|
||||
'bypassPermissions' => 'B',
|
||||
_ => 'D', // default
|
||||
};
|
||||
|
||||
/// - Plain click → cycles the safe trio: default → acceptEdits → plan → default.
|
||||
/// - Shift-click → shows the bypass confirm inline in the parent row.
|
||||
///
|
||||
/// The badge reflects the LIVE mode from `SessionStatus.permissionMode`
|
||||
/// (T-157). It is a custom painted label (no Material), consistent with the
|
||||
/// rendering stack rules (D-7, CLAUDE.md guardrails).
|
||||
class PermissionModeBadge extends StatelessWidget {
|
||||
const PermissionModeBadge({super.key, required this.mode, required this.tokens, required this.onCycle, required this.onBypass});
|
||||
|
||||
final String mode;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
/// Called on a plain click — the parent cycles to the next safe mode.
|
||||
final VoidCallback onCycle;
|
||||
|
||||
/// Called on a shift-click — the parent shows the bypass confirm.
|
||||
final VoidCallback onBypass;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final label = permissionModeBadgeLabel(mode);
|
||||
final isBypass = mode == 'bypassPermissions';
|
||||
final badgeColor = isBypass ? const Color(0xFFF06C6F) : tokens.globalFocus;
|
||||
|
||||
final tooltip =
|
||||
'Permission mode: ${permissionModeLabel(mode)}. '
|
||||
'Click to cycle default/acceptEdits/plan; Shift-click for bypassPermissions.';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 3),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: 'Permission mode: $label',
|
||||
excludeSemantics: true,
|
||||
onTap: () {
|
||||
if (HardwareKeyboard.instance.isShiftPressed) {
|
||||
onBypass();
|
||||
} else {
|
||||
onCycle();
|
||||
}
|
||||
},
|
||||
child: ClideTappable(
|
||||
tooltip: tooltip,
|
||||
onTap: () {
|
||||
if (HardwareKeyboard.instance.isShiftPressed) {
|
||||
onBypass();
|
||||
} else {
|
||||
onCycle();
|
||||
}
|
||||
},
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
width: 16,
|
||||
height: 14,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor.withAlpha(hovered ? 51 : 26),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
border: Border.all(color: badgeColor.withAlpha(hovered ? 180 : 100), width: 1),
|
||||
),
|
||||
child: ClideText(label, fontSize: 9, color: badgeColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/// A single agent roster row: color dot + name + status sub-text +
|
||||
/// controls (T-171). Split out of claude_meta_sidebar.dart (T-395).
|
||||
///
|
||||
/// Controls (trailing region):
|
||||
/// - permission-mode badge (T-181) — D/A/P cycles the safe trio; shift-click
|
||||
/// reaches bypassPermissions behind a confirm
|
||||
/// - eye / eye-slash — show / hide the session pane
|
||||
/// - speaker / speaker-slash — mute / unmute broker delivery
|
||||
/// - inject (chat icon) — expand the inline message input
|
||||
/// - fork (git-branch icon) — open a new pane branching from this session (T-172)
|
||||
/// - close (×) — kill the session
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show formatTokenCount, permissionModeLabel, shortModelLabel;
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/icon_button.dart';
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/inject_field.dart';
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/permission_badge.dart';
|
||||
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_reader.dart' show SessionStatus;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class AgentRosterRow extends StatefulWidget {
|
||||
const AgentRosterRow({
|
||||
super.key,
|
||||
required this.member,
|
||||
required this.status,
|
||||
required this.orchestrator,
|
||||
required this.injectingAgentId,
|
||||
required this.injectController,
|
||||
required this.onToggleInject,
|
||||
required this.onInjectSubmit,
|
||||
required this.onClose,
|
||||
required this.onSetPermissionMode,
|
||||
required this.onFork,
|
||||
});
|
||||
|
||||
final TeamMemberJoined member;
|
||||
final SessionStatus? status;
|
||||
final ClaudeSessionOrchestrator? orchestrator;
|
||||
|
||||
/// The member name currently in inject mode (null = none).
|
||||
final String? injectingAgentId;
|
||||
|
||||
/// Shared text controller for the inject field (cleared on submit/cancel).
|
||||
final TextEditingController injectController;
|
||||
|
||||
final void Function(String memberName) onToggleInject;
|
||||
final void Function(String memberName, String text) onInjectSubmit;
|
||||
final void Function(String memberName) onClose;
|
||||
|
||||
/// Called when the badge cycles to a new [mode] string for this member.
|
||||
/// Handles both safe-trio clicks and confirmed bypass. The parent sends
|
||||
/// the mode to the session via `StreamJsonSession.setPermissionMode`.
|
||||
final void Function(String memberName, String mode) onSetPermissionMode;
|
||||
|
||||
/// Called when the fork button is tapped (T-172). The session id of the
|
||||
/// member's managed session is passed so the host can open a fork pane.
|
||||
final void Function(String memberName) onFork;
|
||||
|
||||
@override
|
||||
State<AgentRosterRow> createState() => _AgentRosterRowState();
|
||||
}
|
||||
|
||||
class _AgentRosterRowState extends State<AgentRosterRow> {
|
||||
/// Whether the bypass-confirm inline prompt is showing.
|
||||
bool _confirmingBypass = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final managed = widget.orchestrator?.byMemberName(widget.member.name);
|
||||
final color = teamColor(widget.member.color, fallback: tokens.globalForeground);
|
||||
final st = widget.status;
|
||||
final model = st?.model ?? widget.member.model;
|
||||
final sub = [
|
||||
widget.member.agentType,
|
||||
if (model != null) shortModelLabel(model),
|
||||
if (st?.permissionMode != null) permissionModeLabel(st!.permissionMode!),
|
||||
if (st?.contextTokens != null) '${formatTokenCount(st!.contextTokens!)} ctx',
|
||||
].join(' · ');
|
||||
|
||||
final isVisible = managed?.visible ?? true;
|
||||
final isMuted = managed?.muted ?? false;
|
||||
final isInjecting = widget.injectingAgentId == widget.member.name;
|
||||
final currentMode = st?.permissionMode ?? 'default';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Color dot
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 3),
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Name + status
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(widget.member.name, fontSize: clideFontSmall, color: tokens.globalForeground, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
if (sub.isNotEmpty) ClideText(sub, muted: true, fontSize: clideFontSmall, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
// T-181: permission-mode badge (inline below the status sub-text).
|
||||
if (managed != null)
|
||||
PermissionModeBadge(
|
||||
mode: currentMode,
|
||||
tokens: tokens,
|
||||
onCycle: () {
|
||||
final next = _nextSafeMode(currentMode);
|
||||
widget.onSetPermissionMode(widget.member.name, next);
|
||||
},
|
||||
onBypass: () => setState(() => _confirmingBypass = true),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Trailing controls (T-171).
|
||||
// T-172 seam: append a fork icon button to this row.
|
||||
if (managed != null) _buildControls(context, tokens, managed, isVisible, isMuted, isInjecting),
|
||||
],
|
||||
),
|
||||
// Bypass confirm: replaces inject field area when active.
|
||||
if (_confirmingBypass) _buildBypassConfirm(tokens),
|
||||
// Inline inject-message field — visible only when toggled.
|
||||
if (isInjecting && !_confirmingBypass) _buildInjectField(context, tokens),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Safe-mode cycle: default → acceptEdits → plan → default (T-181).
|
||||
static String _nextSafeMode(String current) {
|
||||
const cycle = ['default', 'acceptEdits', 'plan'];
|
||||
final idx = cycle.indexOf(current);
|
||||
return cycle[(idx + 1) % cycle.length];
|
||||
}
|
||||
|
||||
Widget _buildBypassConfirm(SurfaceTokens tokens) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText('Enable bypassPermissions? All tool calls will be auto-allowed.', fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Confirm
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Confirm bypass',
|
||||
excludeSemantics: true,
|
||||
onTap: () {
|
||||
setState(() => _confirmingBypass = false);
|
||||
widget.onSetPermissionMode(widget.member.name, 'bypassPermissions');
|
||||
},
|
||||
child: ClideTappable(
|
||||
tooltip: 'Confirm',
|
||||
onTap: () {
|
||||
setState(() => _confirmingBypass = false);
|
||||
widget.onSetPermissionMode(widget.member.name, 'bypassPermissions');
|
||||
},
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
|
||||
child: ClideText('OK', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalFocus),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Cancel
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Cancel bypass',
|
||||
excludeSemantics: true,
|
||||
onTap: () => setState(() => _confirmingBypass = false),
|
||||
child: ClideTappable(
|
||||
tooltip: 'Cancel',
|
||||
onTap: () => setState(() => _confirmingBypass = false),
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
|
||||
child: ClideText('Cancel', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControls(BuildContext context, SurfaceTokens tokens, ManagedSession managed, bool isVisible, bool isMuted, bool isInjecting) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Show / hide
|
||||
MetaIconButton(
|
||||
painter: isVisible ? PhosphorIcons.byName('eye') : PhosphorIcons.byName('eye-slash'),
|
||||
tooltip: isVisible ? 'Hide pane' : 'Show pane',
|
||||
color: tokens.globalTextMuted,
|
||||
onTap: () => isVisible ? widget.orchestrator!.hide(managed.id) : widget.orchestrator!.show(managed.id),
|
||||
),
|
||||
// Mute / unmute
|
||||
MetaIconButton(
|
||||
painter: isMuted ? PhosphorIcons.byName('eye-slash') : PhosphorIcons.byName('eye'),
|
||||
// NOTE: We use eye/eyeSlash as stand-ins until a dedicated speaker
|
||||
// icon is added to PhosphorIcons (no speaker codepoint yet).
|
||||
// The semantic tooltip still says mute/unmute so AT users are clear.
|
||||
tooltip: isMuted ? 'Unmute messages' : 'Mute messages',
|
||||
color: isMuted ? tokens.globalFocus : tokens.globalTextMuted,
|
||||
onTap: () => isMuted ? widget.orchestrator!.unmute(managed.id) : widget.orchestrator!.mute(managed.id),
|
||||
),
|
||||
// Inject message
|
||||
MetaIconButton(
|
||||
painter: PhosphorIcons.byName('chat-circle'),
|
||||
tooltip: 'Inject message',
|
||||
color: isInjecting ? tokens.globalFocus : tokens.globalTextMuted,
|
||||
onTap: () => widget.onToggleInject(widget.member.name),
|
||||
),
|
||||
// Fork session (T-172): branch into a new pane without touching the original.
|
||||
MetaIconButton(
|
||||
painter: PhosphorIcons.byName('git-branch'),
|
||||
tooltip: 'Fork session',
|
||||
color: tokens.globalTextMuted,
|
||||
onTap: () => widget.onFork(widget.member.name),
|
||||
),
|
||||
// Close session
|
||||
MetaIconButton(
|
||||
painter: PhosphorIcons.byName('x'),
|
||||
tooltip: 'Close session',
|
||||
color: tokens.globalTextMuted,
|
||||
onTap: () => widget.onClose(widget.member.name),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInjectField(BuildContext context, SurfaceTokens tokens) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: InjectTextField(
|
||||
controller: widget.injectController,
|
||||
tokens: tokens,
|
||||
onSubmit: (text) {
|
||||
if (text.trim().isNotEmpty) widget.onInjectSubmit(widget.member.name, text.trim());
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
MetaIconButton(
|
||||
painter: PhosphorIcons.byName('x'),
|
||||
tooltip: 'Cancel',
|
||||
color: tokens.globalTextMuted,
|
||||
onTap: () => widget.onToggleInject(widget.member.name),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/// The Activity / Team / Config sub-tab strip — same interaction as the pql
|
||||
/// panel's view tabs, with an underline under the active tab. Split out of
|
||||
/// claude_meta_sidebar.dart (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class SidebarTabStrip extends StatelessWidget {
|
||||
const SidebarTabStrip({super.key, required this.current, required this.memberCount, required this.onPick});
|
||||
final SidebarTab current;
|
||||
final int memberCount;
|
||||
final ValueChanged<SidebarTab> onPick;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: tokens.panelBorder)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final t in SidebarTab.values)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
selected: t == current,
|
||||
label: _label(t),
|
||||
excludeSemantics: true,
|
||||
onTap: () => onPick(t),
|
||||
child: ClideTappable(
|
||||
onTap: () => onPick(t),
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
padding: const EdgeInsets.only(bottom: 3),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: t == current ? tokens.globalFocus : const Color(0x00000000), width: 2)),
|
||||
),
|
||||
child: ClideText(_label(t), fontSize: clideFontSmall, color: t == current || hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _label(SidebarTab t) => switch (t) {
|
||||
SidebarTab.activity => 'Activity',
|
||||
SidebarTab.team => memberCount == 0 ? 'Team' : 'Team · $memberCount',
|
||||
SidebarTab.config => 'Config',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/// One row in the Team tab's TASKS section: status marker + title +
|
||||
/// owner + reassign control (T-171). Split out of
|
||||
/// claude_meta_sidebar.dart (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/icon_button.dart';
|
||||
import 'package:clide/builtin/claude/src/team_broker.dart' show TeamBroker, TeamTask;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class TaskRow extends StatelessWidget {
|
||||
const TaskRow({super.key, required this.task, required this.members, required this.broker});
|
||||
|
||||
final TeamTask task;
|
||||
final List<TeamMemberJoined> members;
|
||||
final TeamBroker? broker;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final marker = switch (task.status) {
|
||||
'done' => '✓',
|
||||
'claimed' => '◈',
|
||||
_ => '○',
|
||||
};
|
||||
final markerColor = switch (task.status) {
|
||||
'done' => tokens.globalTextMuted,
|
||||
'claimed' => tokens.globalFocus,
|
||||
_ => tokens.globalForeground,
|
||||
};
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(marker, fontSize: clideFontSmall, color: markerColor),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
task.title,
|
||||
fontSize: clideFontSmall,
|
||||
color: task.status == 'done' ? tokens.globalTextMuted : tokens.globalForeground,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (task.owner != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: ClideText(task.owner!, fontSize: clideFontSmall, color: tokens.globalFocus),
|
||||
),
|
||||
// Reassign: cycle to the next roster member.
|
||||
if (broker != null && broker!.members.length > 1)
|
||||
MetaIconButton(
|
||||
painter: PhosphorIcons.byName('arrow-clockwise'),
|
||||
tooltip: 'Reassign task',
|
||||
color: tokens.globalTextMuted,
|
||||
onTap: () => _reassign(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _reassign(BuildContext context) {
|
||||
final b = broker;
|
||||
if (b == null || members.isEmpty) return;
|
||||
final brokerMembers = b.members;
|
||||
if (brokerMembers.isEmpty) return;
|
||||
// Cycle to the next member after the current owner.
|
||||
final currentIndex = brokerMembers.indexWhere((m) => m.name == task.owner);
|
||||
final nextIndex = (currentIndex + 1) % brokerMembers.length;
|
||||
b.reassignTask(task.id, brokerMembers[nextIndex].id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/// The Team tab: the roster cockpit (T-171) — per-member rows with
|
||||
/// controls, the TASKS section, and the MESSAGES chat feed (T-180).
|
||||
/// Stateless and props-driven; the parent owns the member list, inject
|
||||
/// state, and orchestrator wiring. Split out of claude_meta_sidebar.dart
|
||||
/// (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/roster_row.dart';
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/task_row.dart';
|
||||
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
|
||||
import 'package:clide/builtin/claude/src/team_broker.dart' show TeamTask;
|
||||
import 'package:clide/builtin/claude/src/team_chat_sidebar.dart' show TeamChatSidebar;
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class TeamTabView extends StatelessWidget {
|
||||
const TeamTabView({
|
||||
super.key,
|
||||
required this.members,
|
||||
required this.memberStatus,
|
||||
required this.orchestrator,
|
||||
required this.tasks,
|
||||
required this.injectingAgentId,
|
||||
required this.injectController,
|
||||
required this.onToggleInject,
|
||||
required this.onInjectSubmit,
|
||||
required this.onClose,
|
||||
required this.onSetPermissionMode,
|
||||
required this.onFork,
|
||||
required this.onOpenChatPane,
|
||||
});
|
||||
|
||||
final List<TeamMemberJoined> members;
|
||||
final Map<String, SessionStatus> memberStatus;
|
||||
final ClaudeSessionOrchestrator? orchestrator;
|
||||
final List<TeamTask> tasks;
|
||||
final String? injectingAgentId;
|
||||
final TextEditingController injectController;
|
||||
final void Function(String memberName) onToggleInject;
|
||||
final void Function(String memberName, String text) onInjectSubmit;
|
||||
final void Function(String memberName) onClose;
|
||||
final void Function(String memberName, String mode) onSetPermissionMode;
|
||||
final void Function(String memberName) onFork;
|
||||
final VoidCallback onOpenChatPane;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
if (members.isEmpty) {
|
||||
return metaPlaceholder('No team active.');
|
||||
}
|
||||
final children = <Widget>[
|
||||
for (final m in members)
|
||||
AgentRosterRow(
|
||||
key: ValueKey(m.agentId),
|
||||
member: m,
|
||||
status: memberStatus[m.agentId],
|
||||
orchestrator: orchestrator,
|
||||
injectingAgentId: injectingAgentId,
|
||||
injectController: injectController,
|
||||
onToggleInject: onToggleInject,
|
||||
onInjectSubmit: onInjectSubmit,
|
||||
onClose: onClose,
|
||||
onSetPermissionMode: onSetPermissionMode,
|
||||
onFork: onFork,
|
||||
),
|
||||
];
|
||||
|
||||
if (tasks.isNotEmpty) {
|
||||
children.add(const SizedBox(height: 12));
|
||||
children.add(_taskSection(tokens));
|
||||
}
|
||||
|
||||
// MESSAGES section (T-180): live broker chat feed + quick-post composer.
|
||||
final chatModel = orchestrator?.chatModel;
|
||||
final broker = orchestrator?.broker;
|
||||
if (chatModel != null && broker != null) {
|
||||
children.add(const SizedBox(height: 12));
|
||||
children.add(TeamChatSidebar(model: chatModel, broker: broker, onPopOut: onOpenChatPane));
|
||||
}
|
||||
|
||||
return ListView(padding: const EdgeInsets.all(12), children: children);
|
||||
}
|
||||
|
||||
Widget _taskSection(SurfaceTokens tokens) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText('TASKS', fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
||||
const SizedBox(height: 4),
|
||||
for (final t in tasks) TaskRow(task: t, members: members, broker: orchestrator?.broker),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user