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:
2026-06-12 02:38:57 +02:00
co-authored by Claude Fable 5
parent 7f16016bc3
commit 31df537770
13 changed files with 1162 additions and 938 deletions
+37
View File
@@ -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;
+45
View File
@@ -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),
],
);
}
}