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

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:
2026-05-25 12:52:04 +02:00
co-authored by Claude Opus 4.7
parent 1fb08afa30
commit a79a78fc8f
4 changed files with 246 additions and 0 deletions
@@ -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;
+1
View File
@@ -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);
});
}