add the clide-managed session orchestrator (T-169 core)
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 29s
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 29s
ClaudeSessionOrchestrator owns a registry of ManagedSessions, decoupling a session's lifecycle from any pane: spawn() starts + registers a stream-json process, show()/hide() toggle visibility WITHOUT killing the process, and close() tears it down. This is the one primitive Phase 2's teammate / secondary-tab / forked-branch panes all become (D-77). The process factory is injectable so the lifecycle is unit-tested without a real claude. Not yet wired into the pane — that re-pointing is the next T-169 step. T-169. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2240,3 +2240,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-183', 'description', 'A comprehensive read view (with edit affordances where sensible) of the Claude environment, as the Config sub-tab of the sidebar (T-182). Sources: ClaudeConfig (D-76 / T-151, already reads skills, commands, settings, permissions from ~/.claude + .claude layered) plus the stream-json init/initialize payloads (agents, output styles + available styles, MCP servers, models, plugins). Sections: SKILLS, AGENTS, COMMANDS, PERMISSIONS (allow/ask/deny, colour-coded), HOOKS, MCP SERVERS, SETTINGS (model, output style). Show local+global layering (local wins) and that it is watched/live. Covers the interaction-type surfaces documented on anthropic.com so the user can see everything configured for Claude in one place. Acceptance: each section lists its entries from ClaudeConfig + init data; permissions colour-coded by rule kind; layering + source shown; updates live on a watched .claude change; widget tests. Wireframe: docs/design/wireframes/claude-prompts/09-sidebar-config-tab.png. Blocked by T-182 (sub-tab shell).', 'The Config sub-tab (T-182): a browsable view of the Claude environment over ClaudeConfig (D-76 / T-151 — skills, commands, settings, permissions from ~/.claude + .claude, layered) plus the stream-json init/initialize payloads (agents, output styles, MCP servers, models, plugins). Each category is an EXPANDABLE accordion section (no ellipsis truncation): SKILLS, AGENTS, COMMANDS, HOOKS, PERMISSIONS, MCP SERVERS, SETTINGS, each with a count. File-backed entries are CLICKABLE: a skill (SKILL.md), agent (.claude/agents/*.md), or command (.claude/commands/*.md) — clicking fires an open-file action into the right-side markdown reader/editor pane (reusing the existing file-open plumbing, D-6 parity) to show what that skill/agent/command says. Non-file entries render inline: permissions (allow/ask/deny, colour-coded), MCP servers, model, output style; SETTINGS can open settings.json. Show local+global layering (local wins) and that it is watched/live. Acceptance: sections expand/collapse to the full list; clicking a file-backed item opens its .md in the reader; permissions colour-coded by rule kind; updates live on a watched .claude change; widget tests for expand + click-to-open. Wireframe: docs/design/wireframes/claude-prompts/09-sidebar-config-tab.png. Blocked by T-182.', NULL, '2026-05-25 09:31:37', '2026-05-25 09:31:37', '2026-05-25 09:31:37', NULL, '473e88ee9fa895f5dc30fcac5b001fa6', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-183', 'description', 'The Config sub-tab (T-182): a browsable view of the Claude environment over ClaudeConfig (D-76 / T-151 — skills, commands, settings, permissions from ~/.claude + .claude, layered) plus the stream-json init/initialize payloads (agents, output styles, MCP servers, models, plugins). Each category is an EXPANDABLE accordion section (no ellipsis truncation): SKILLS, AGENTS, COMMANDS, HOOKS, PERMISSIONS, MCP SERVERS, SETTINGS, each with a count. File-backed entries are CLICKABLE: a skill (SKILL.md), agent (.claude/agents/*.md), or command (.claude/commands/*.md) — clicking fires an open-file action into the right-side markdown reader/editor pane (reusing the existing file-open plumbing, D-6 parity) to show what that skill/agent/command says. Non-file entries render inline: permissions (allow/ask/deny, colour-coded), MCP servers, model, output style; SETTINGS can open settings.json. Show local+global layering (local wins) and that it is watched/live. Acceptance: sections expand/collapse to the full list; clicking a file-backed item opens its .md in the reader; permissions colour-coded by rule kind; updates live on a watched .claude change; widget tests for expand + click-to-open. Wireframe: docs/design/wireframes/claude-prompts/09-sidebar-config-tab.png. Blocked by T-182.', 'The Config sub-tab (T-182): a browsable view of the Claude environment over ClaudeConfig (D-76 / T-151 — skills, commands, settings, permissions from ~/.claude + .claude, layered, local wins) plus the stream-json init/initialize payloads (agents, output styles, MCP servers, models, plugins). Layout: SETTINGS is a FIXED key->value table pinned at the top (not collapsible) — model, output style, permission mode, source. Everything variable-length is an EXPANDABLE accordion section showing the FULL list when expanded, never truncated (truncating to first-N + ellipsis falsely prioritises the first entries and makes the list pointless): SKILLS, AGENTS, COMMANDS, HOOKS, PERMISSIONS (grouped allow/ask/deny, colour-coded, each rule on its own line), MCP SERVERS — each with a count. File-backed entries are CLICKABLE: a skill (SKILL.md), agent (.claude/agents/*.md), or command (.claude/commands/*.md) — clicking fires an open-file action into the right-side markdown reader/editor pane (reusing the existing file-open plumbing, D-6 parity) to show what it says. Watched/live on .claude changes. Acceptance: settings render as a top table; every variable section expands to its complete list (no ellipsis); clicking a file-backed item opens its .md in the reader; permissions colour-coded by kind; widget tests for table + expand + click-to-open. Wireframe: docs/design/wireframes/claude-prompts/09-sidebar-config-tab.png. Blocked by T-182.', NULL, '2026-05-25 09:35:47', '2026-05-25 09:35:47', '2026-05-25 09:35:47', NULL, '33086bf1d766da9bb8e12ddeeaa8ed92', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-182', 'description', 'The Claude meta-sidebar is overloaded (roster + tasks + messages + stats + config do not fit one scroll). Split it into a sub-tab strip (reuse the search-panel mode-strip pattern) with three tabs: (1) Activity — stats + live runtime visibility (T-141/T-157); (2) Team — roster + tasks + message-feed widget (T-171 + T-180), mostly hidden when solo and auto-fronted when a team is live; (3) Config — the Claude-environment view. Default to Activity. Acceptance: a sub-tab strip switches the sidebar body between the three surfaces; existing stats/team content slots into Activity/Team unchanged; Team auto-fronts on team spawn; widget tests for tab switching. Wireframes: docs/design/wireframes/claude-prompts/07-sidebar-activity-tab.png, 08-sidebar-team-tab.png, 09-sidebar-config-tab.png. Relates to T-141, T-157, T-171, T-180.', 'The Claude meta-sidebar is overloaded (roster + tasks + messages + stats + config do not fit one scroll). Split it into a sub-tab strip (reuse the search-panel mode-strip pattern) with three tabs: (1) Activity — stats + live runtime visibility (T-141/T-157); (2) Team — roster + tasks + message-feed widget (T-171 + T-180), mostly hidden when solo and auto-fronted when a team is live; (3) Config — the Claude-environment view (T-183). Default to Activity. SHARED GEOMETRY: Activity values and the Config settings table use the SAME two-column label->value table (same column x + row pitch + section-header style) so toggling between tabs does not visually jump. Acceptance: a sub-tab strip switches the sidebar body between the three surfaces; Activity + Config settings render on the shared table so toggling is seamless; Team auto-fronts on team spawn; widget tests for tab switching. Wireframes: docs/design/wireframes/claude-prompts/07-sidebar-activity-tab.png, 08-sidebar-team-tab.png, 09-sidebar-config-tab.png. Relates to T-141, T-157, T-171, T-180.', NULL, '2026-05-25 09:38:15', '2026-05-25 09:38:15', '2026-05-25 09:38:15', NULL, 'a41f10a11412d0f7ee0cd5e0e7700488', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-169', 'status', 'backlog', 'in_progress', NULL, '2026-05-25 10:49:30', '2026-05-25 10:49:30', '2026-05-25 10:49:30', NULL, '3269bcb75eb13dffc13be53c9f71ca73', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -2758,3 +2758,4 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority,
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-183', 'task', 'T-164', 'Claude Config sidebar tab — skills/agents/commands/settings/permissions/hooks/MCP', 'The Config sub-tab (T-182): a browsable view of the Claude environment over ClaudeConfig (D-76 / T-151 — skills, commands, settings, permissions from ~/.claude + .claude, layered) plus the stream-json init/initialize payloads (agents, output styles, MCP servers, models, plugins). Each category is an EXPANDABLE accordion section (no ellipsis truncation): SKILLS, AGENTS, COMMANDS, HOOKS, PERMISSIONS, MCP SERVERS, SETTINGS, each with a count. File-backed entries are CLICKABLE: a skill (SKILL.md), agent (.claude/agents/*.md), or command (.claude/commands/*.md) — clicking fires an open-file action into the right-side markdown reader/editor pane (reusing the existing file-open plumbing, D-6 parity) to show what that skill/agent/command says. Non-file entries render inline: permissions (allow/ask/deny, colour-coded), MCP servers, model, output style; SETTINGS can open settings.json. Show local+global layering (local wins) and that it is watched/live. Acceptance: sections expand/collapse to the full list; clicking a file-backed item opens its .md in the reader; permissions colour-coded by rule kind; updates live on a watched .claude change; widget tests for expand + click-to-open. Wireframe: docs/design/wireframes/claude-prompts/09-sidebar-config-tab.png. Blocked by T-182.', 'backlog', 'medium', NULL, NULL, 'D-76', '2026-05-25 09:26:40', '2026-05-25 09:31:37', NULL, '2011edc54063ca86e8418fdb0d4a81ac', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-183', 'task', 'T-164', 'Claude Config sidebar tab — skills/agents/commands/settings/permissions/hooks/MCP', 'The Config sub-tab (T-182): a browsable view of the Claude environment over ClaudeConfig (D-76 / T-151 — skills, commands, settings, permissions from ~/.claude + .claude, layered, local wins) plus the stream-json init/initialize payloads (agents, output styles, MCP servers, models, plugins). Layout: SETTINGS is a FIXED key->value table pinned at the top (not collapsible) — model, output style, permission mode, source. Everything variable-length is an EXPANDABLE accordion section showing the FULL list when expanded, never truncated (truncating to first-N + ellipsis falsely prioritises the first entries and makes the list pointless): SKILLS, AGENTS, COMMANDS, HOOKS, PERMISSIONS (grouped allow/ask/deny, colour-coded, each rule on its own line), MCP SERVERS — each with a count. File-backed entries are CLICKABLE: a skill (SKILL.md), agent (.claude/agents/*.md), or command (.claude/commands/*.md) — clicking fires an open-file action into the right-side markdown reader/editor pane (reusing the existing file-open plumbing, D-6 parity) to show what it says. Watched/live on .claude changes. Acceptance: settings render as a top table; every variable section expands to its complete list (no ellipsis); clicking a file-backed item opens its .md in the reader; permissions colour-coded by kind; widget tests for table + expand + click-to-open. Wireframe: docs/design/wireframes/claude-prompts/09-sidebar-config-tab.png. Blocked by T-182.', 'backlog', 'medium', NULL, NULL, 'D-76', '2026-05-25 09:26:40', '2026-05-25 09:35:47', NULL, 'a4962b8a69cffe93b72275d098251e1c', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-182', 'task', 'T-164', 'Claude sidebar sub-tabs: Activity / Team / Config', 'The Claude meta-sidebar is overloaded (roster + tasks + messages + stats + config do not fit one scroll). Split it into a sub-tab strip (reuse the search-panel mode-strip pattern) with three tabs: (1) Activity — stats + live runtime visibility (T-141/T-157); (2) Team — roster + tasks + message-feed widget (T-171 + T-180), mostly hidden when solo and auto-fronted when a team is live; (3) Config — the Claude-environment view (T-183). Default to Activity. SHARED GEOMETRY: Activity values and the Config settings table use the SAME two-column label->value table (same column x + row pitch + section-header style) so toggling between tabs does not visually jump. Acceptance: a sub-tab strip switches the sidebar body between the three surfaces; Activity + Config settings render on the shared table so toggling is seamless; Team auto-fronts on team spawn; widget tests for tab switching. Wireframes: docs/design/wireframes/claude-prompts/07-sidebar-activity-tab.png, 08-sidebar-team-tab.png, 09-sidebar-config-tab.png. Relates to T-141, T-157, T-171, T-180.', 'backlog', 'medium', NULL, NULL, 'D-77', '2026-05-25 09:26:31', '2026-05-25 09:38:15', NULL, 'de0ab01119b20a1ef66ecba1821cdcd4', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-169', 'task', 'T-164', 'P2: session orchestrator — one managed-session primitive (spawn/show/hide)', 'Generalize the Claude pane into a clide-managed SESSION primitive: clide spawns/owns each stream-json Claude process; a pane is just a view on a session; teammate / secondary tab / forked branch / inline subagent are all the same thing. Provide spawn / show / hide / close over the set of live sessions; visibility is a UI toggle, not a lifecycle change (sessions stay live when hidden). Re-point the T-139 TeamObserver from observing a Claude tmux team to ORCHESTRATING clide-managed sessions; the T-140 tiles render whatever sessions are shown. Acceptance: multiple managed sessions run concurrently; each can be shown/hidden as a pane from a roster without tearing down its process; tests for the orchestrator lifecycle. Blocked by T-165 (phase 1 transport).', 'in_progress', 'medium', NULL, NULL, 'D-77', '2026-05-24 16:26:49', '2026-05-25 10:49:30', NULL, '698c3527c5cdfba6a19b51fee6cc3cbc', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/// Owns the set of clide-managed Claude sessions (T-169, D-77).
|
||||
///
|
||||
/// A session is a `claude` stream-json process clide spawns and renders; a
|
||||
/// pane is just a *view* on one. The orchestrator decouples a session's
|
||||
/// lifecycle from any pane: [spawn] starts and registers it, [show]/[hide]
|
||||
/// toggle visibility WITHOUT tearing the process down, and [close] kills it.
|
||||
/// This is the one primitive behind teammate / secondary tab / forked branch
|
||||
/// (Phase 2): they are all just managed sessions shown as panes.
|
||||
///
|
||||
/// The process factory is injectable so tests drive the lifecycle without
|
||||
/// spawning a real `claude`.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/claude/src/conversation_controller.dart';
|
||||
import 'package:clide/builtin/claude/src/session_naming.dart';
|
||||
import 'package:clide/builtin/claude/src/stream_json_session.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Creates the subprocess for a session — production uses
|
||||
/// [ClaudeStreamJsonProcess.start]; tests inject a fake.
|
||||
typedef ProcessFactory = Future<StreamJsonProcess> Function({
|
||||
required List<String> sessionArgs,
|
||||
required String cwd,
|
||||
Map<String, String>? env,
|
||||
});
|
||||
|
||||
/// What to spawn. [id] is the orchestrator's stable key (e.g. `primary`,
|
||||
/// `teammate:tyre`); [sessionId] is claude's `--session-id`.
|
||||
class SpawnSpec {
|
||||
const SpawnSpec({
|
||||
required this.id,
|
||||
required this.role,
|
||||
required this.sessionId,
|
||||
required this.cwd,
|
||||
this.resume = false,
|
||||
this.env,
|
||||
this.visible = true,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String role;
|
||||
final String sessionId;
|
||||
final String cwd;
|
||||
|
||||
/// Resume an existing session (`--resume`) vs create one (`--session-id`).
|
||||
final bool resume;
|
||||
final Map<String, String>? env;
|
||||
final bool visible;
|
||||
}
|
||||
|
||||
/// One clide-managed session: the process wrapper plus the conversation it
|
||||
/// feeds. Owned by the orchestrator, not by any pane — so hiding a pane
|
||||
/// leaves it (and its accumulating conversation) intact.
|
||||
class ManagedSession {
|
||||
ManagedSession({
|
||||
required this.id,
|
||||
required this.role,
|
||||
required this.sessionId,
|
||||
required this.session,
|
||||
required this.conversation,
|
||||
this.visible = true,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String role;
|
||||
final String sessionId;
|
||||
final StreamJsonSession session;
|
||||
final ConversationController conversation;
|
||||
|
||||
/// Whether a pane is currently showing this session. A view toggle only —
|
||||
/// the process stays alive when hidden.
|
||||
bool visible;
|
||||
}
|
||||
|
||||
class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
ClaudeSessionOrchestrator({ProcessFactory? processFactory}) : _factory = processFactory ?? _spawnClaude;
|
||||
|
||||
final ProcessFactory _factory;
|
||||
final _sessions = <String, ManagedSession>{};
|
||||
|
||||
static Future<StreamJsonProcess> _spawnClaude({required List<String> sessionArgs, required String cwd, Map<String, String>? env}) =>
|
||||
ClaudeStreamJsonProcess.start(sessionArgs: sessionArgs, cwd: cwd, env: env);
|
||||
|
||||
/// All managed sessions, in insertion order.
|
||||
List<ManagedSession> get sessions => List.unmodifiable(_sessions.values);
|
||||
|
||||
/// Just the sessions a pane should currently render.
|
||||
List<ManagedSession> get visibleSessions => [
|
||||
for (final m in _sessions.values)
|
||||
if (m.visible) m,
|
||||
];
|
||||
|
||||
ManagedSession? byId(String id) => _sessions[id];
|
||||
|
||||
/// Spawn and register a session. Idempotent on [SpawnSpec.id] — a repeat
|
||||
/// call returns the existing session rather than starting a second process.
|
||||
Future<ManagedSession> spawn(SpawnSpec spec) async {
|
||||
final existing = _sessions[spec.id];
|
||||
if (existing != null) return existing;
|
||||
|
||||
final proc = await _factory(
|
||||
sessionArgs: claudeLaunchArgs(spec.sessionId, resume: spec.resume),
|
||||
cwd: spec.cwd,
|
||||
env: spec.env,
|
||||
);
|
||||
final session = StreamJsonSession(proc)..start();
|
||||
final conversation = ConversationController(stream: session.items, onDispose: session.dispose);
|
||||
final managed = ManagedSession(
|
||||
id: spec.id,
|
||||
role: spec.role,
|
||||
sessionId: spec.sessionId,
|
||||
session: session,
|
||||
conversation: conversation,
|
||||
visible: spec.visible,
|
||||
);
|
||||
_sessions[spec.id] = managed;
|
||||
notifyListeners();
|
||||
return managed;
|
||||
}
|
||||
|
||||
/// Show / hide a session as a pane — a visibility toggle only; the process
|
||||
/// keeps running while hidden.
|
||||
void show(String id) => _setVisible(id, true);
|
||||
void hide(String id) => _setVisible(id, false);
|
||||
|
||||
void _setVisible(String id, bool value) {
|
||||
final m = _sessions[id];
|
||||
if (m == null || m.visible == value) return;
|
||||
m.visible = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Kill and forget a session (the real teardown). The conversation's
|
||||
/// onDispose kills the process + closes its streams.
|
||||
Future<void> close(String id) async {
|
||||
final m = _sessions.remove(id);
|
||||
if (m == null) return;
|
||||
m.conversation.dispose();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final m in _sessions.values) {
|
||||
m.conversation.dispose();
|
||||
}
|
||||
_sessions.clear();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
|
||||
import 'package:clide/builtin/claude/src/stream_json_session.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
class _FakeProc implements StreamJsonProcess {
|
||||
final _ctl = StreamController<String>.broadcast();
|
||||
bool killed = false;
|
||||
@override
|
||||
Stream<String> get lines => _ctl.stream;
|
||||
@override
|
||||
void writeLine(String line) {}
|
||||
@override
|
||||
Future<void> kill() async => killed = true;
|
||||
}
|
||||
|
||||
void main() {
|
||||
late List<_FakeProc> created;
|
||||
late ClaudeSessionOrchestrator orch;
|
||||
|
||||
setUp(() {
|
||||
created = [];
|
||||
orch = ClaudeSessionOrchestrator(processFactory: ({required sessionArgs, required cwd, env}) async {
|
||||
final p = _FakeProc();
|
||||
created.add(p);
|
||||
return p;
|
||||
});
|
||||
});
|
||||
|
||||
SpawnSpec spec(String id, {bool visible = true}) => SpawnSpec(id: id, role: id, sessionId: '$id-uuid', cwd: '/repo', visible: visible);
|
||||
|
||||
test('spawns multiple concurrent sessions, each with its own process', () async {
|
||||
await orch.spawn(spec('primary'));
|
||||
await orch.spawn(spec('teammate:tyre'));
|
||||
expect(orch.sessions, hasLength(2));
|
||||
expect(created, hasLength(2));
|
||||
expect(orch.byId('teammate:tyre')!.role, 'teammate:tyre');
|
||||
});
|
||||
|
||||
test('spawn is idempotent on id — no second process', () async {
|
||||
final a = await orch.spawn(spec('primary'));
|
||||
final b = await orch.spawn(spec('primary'));
|
||||
expect(identical(a, b), isTrue);
|
||||
expect(created, hasLength(1));
|
||||
});
|
||||
|
||||
test('hide keeps the process alive and in the registry; show restores it', () async {
|
||||
await orch.spawn(spec('primary'));
|
||||
orch.hide('primary');
|
||||
expect(orch.byId('primary')!.visible, isFalse);
|
||||
expect(orch.visibleSessions, isEmpty);
|
||||
expect(orch.sessions, hasLength(1)); // still registered
|
||||
expect(created.single.killed, isFalse); // NOT torn down
|
||||
|
||||
orch.show('primary');
|
||||
expect(orch.byId('primary')!.visible, isTrue);
|
||||
expect(orch.visibleSessions, hasLength(1));
|
||||
});
|
||||
|
||||
test('close kills the process and removes the session', () async {
|
||||
await orch.spawn(spec('primary'));
|
||||
await orch.close('primary');
|
||||
expect(orch.byId('primary'), isNull);
|
||||
expect(orch.sessions, isEmpty);
|
||||
await Future<void>.delayed(Duration.zero); // session.dispose is async
|
||||
expect(created.single.killed, isTrue);
|
||||
});
|
||||
|
||||
test('visibleSessions filters hidden ones', () async {
|
||||
await orch.spawn(spec('a'));
|
||||
await orch.spawn(spec('b', visible: false));
|
||||
expect(orch.visibleSessions.map((m) => m.id), ['a']);
|
||||
});
|
||||
|
||||
test('notifies on spawn / hide / close', () async {
|
||||
var n = 0;
|
||||
orch.addListener(() => n++);
|
||||
await orch.spawn(spec('primary'));
|
||||
orch.hide('primary');
|
||||
await orch.close('primary');
|
||||
expect(n, 3);
|
||||
});
|
||||
|
||||
test('dispose kills every session', () async {
|
||||
await orch.spawn(spec('a'));
|
||||
await orch.spawn(spec('b'));
|
||||
orch.dispose();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(created.every((p) => p.killed), isTrue);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user