From e3c0b0146b9da0aa2e071921c5dda819bf9fedb7 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 30 May 2026 23:34:50 +0200 Subject: [PATCH] fork a Claude conversation into a new pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds fork-into-a-pane: /fork in the composer, a roster Fork button, and a clide.agent.fork command all branch a session via --resume --fork-session, so the branch gets its own claude session id and diverges without touching the original. SpawnSpec/ ManagedSession gain forkSourceSessionId; the orchestrator selects the fork argv via a new forkSessionArgs helper; the session host opens the fork as a new secondary pane. The branch's real claude session-id (assigned by --fork-session, arriving in the init event) is not yet captured back — tracked as T-185. T-172. Co-Authored-By: Claude --- .pql/changelog/ticket_history/2026-05.sql | 2 + .pql/changelog/tickets/2026-05.sql | 6 ++ CHANGELOG.md | 3 + .../claude/src/claude_meta_sidebar.dart | 37 ++++++- lib/builtin/claude/src/claude_pane.dart | 100 +++++++++++++----- .../claude/src/claude_session_host.dart | 22 +++- lib/builtin/claude/src/extension.dart | 42 ++++++-- lib/builtin/claude/src/session_naming.dart | 8 ++ .../claude/src/session_orchestrator.dart | 35 +++++- lib/builtin/claude/src/slash_commands.dart | 3 +- .../claude/claude_meta_sidebar_test.dart | 78 ++++++++++++++ test/builtin/claude/session_naming_test.dart | 25 +++++ .../claude/session_orchestrator_test.dart | 91 ++++++++++++++++ 13 files changed, 412 insertions(+), 40 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index e9c1863d..2c183d6f 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2290,3 +2290,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-171', 'status', 'in_progress', 'done', NULL, '2026-05-30 21:11:26', '2026-05-30 21:11:26', '2026-05-30 21:11:26', NULL, '26cb27e63ad95ae94017ada95daef9d2', 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-174', 'status', 'in_progress', 'done', NULL, '2026-05-30 21:11:26', '2026-05-30 21:11:26', '2026-05-30 21:11:26', NULL, '4df20d29d49e7dd95dd9caafbe956cb2', 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-181', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 21:14:56', '2026-05-30 21:14:56', '2026-05-30 21:14:56', NULL, '6426dc0355b68f0fd572e52d53101fc4', 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-181', 'status', 'in_progress', 'done', NULL, '2026-05-30 21:25:11', '2026-05-30 21:25:11', '2026-05-30 21:25:11', NULL, '81d8d388c7e4f53a4514f05cf5dbf37b', 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-172', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 21:25:12', '2026-05-30 21:25:12', '2026-05-30 21:25:12', NULL, 'e42556ec9d67731533b85e6da2a1bc9c', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index fb654069..6288cea3 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2861,3 +2861,9 @@ Make the actions always reachable per T-173''s acceptance: keep them in the tree Acceptance: copy + custom actions are reachable and activatable by keyboard alone (Tab to focus, Enter/Space to invoke) with no mouse; Semantics button labels present; widget test drives a copy via focus+activation (no hover); a11y gate green.', 'done', 'medium', NULL, NULL, 'D-77', '2026-05-24 17:11:17', '2026-05-30 21:11:26', NULL, '2eb96628ffebd214dca72ffcd5f5da69', 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-171', 'task', 'T-164', 'P2: sidebar cockpit — task list + messages + roster controls', 'Evolve the Claude meta sidebar (T-141/T-157) from a read-only roster into the CONTROL surface for clide-managed agents. Because clide owns the broker, the task list and inter-agent messages are local data — so the sidebar both shows them live and lets the user ACT: spawn / show / hide / mute an agent, reassign a task, inject or redirect a message. Roster rows keep the live per-member status (T-157) but gain controls. Acceptance: from the sidebar the user can spawn/show/hide an agent, watch the shared task list update, see the message flow, and intervene (reassign/inject); widget tests for the controls wired to the orchestrator + broker. Blocked by T-169 (orchestrator) and T-170 (broker).', 'done', 'medium', NULL, NULL, 'D-77', '2026-05-24 16:27:09', '2026-05-30 21:11:26', NULL, '7d186872f15ef055161e51f2ae3f7479', 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-181', 'task', 'T-164', 'Per-agent permission-mode cycle badge in the cockpit roster', 'Per-agent permission-mode control in the cockpit roster (extends T-171). Each roster row shows a mode badge (D default / A acceptEdits / P plan). Click cycles the SAFE trio default -> acceptEdits -> plan and sends set_permission_mode to that agent session (a confirmed stream-json control subtype). bypassPermissions is a footgun, so it is reachable only on SHIFT-click (behind a confirm), and the tooltip documents both behaviours. Acceptance: clicking the badge cycles the safe modes and the session receives set_permission_mode; shift-click can reach bypass behind a confirm; the badge reflects the live mode (T-157 status); tooltip explains click vs shift-click; widget + transport tests. Wireframe: docs/design/wireframes/claude-prompts/05-team-cockpit-sidebar.png. Blocked by T-169 (orchestrator).', 'in_progress', 'low', NULL, NULL, 'D-77', '2026-05-25 09:16:05', '2026-05-30 21:14:56', NULL, '3e4c5116d77bfc451e05eb54d208e83d', 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-181', 'task', 'T-164', 'Per-agent permission-mode cycle badge in the cockpit roster', 'Per-agent permission-mode control in the cockpit roster (extends T-171). Each roster row shows a mode badge (D default / A acceptEdits / P plan). Click cycles the SAFE trio default -> acceptEdits -> plan and sends set_permission_mode to that agent session (a confirmed stream-json control subtype). bypassPermissions is a footgun, so it is reachable only on SHIFT-click (behind a confirm), and the tooltip documents both behaviours. Acceptance: clicking the badge cycles the safe modes and the session receives set_permission_mode; shift-click can reach bypass behind a confirm; the badge reflects the live mode (T-157 status); tooltip explains click vs shift-click; widget + transport tests. Wireframe: docs/design/wireframes/claude-prompts/05-team-cockpit-sidebar.png. Blocked by T-169 (orchestrator).', 'done', 'low', NULL, NULL, 'D-77', '2026-05-25 09:16:05', '2026-05-30 21:25:11', NULL, '5742b99a6d5b4315dcc85bc5f9e0ed39', 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-172', 'task', 'T-164', 'P2: fork-into-a-pane (--fork-session)', 'Branch a conversation into a new managed session/pane, reusing the orchestrator''s spawn plumbing. ''Fork'' = spawn a managed session seeded from another session''s context via --resume + --fork-session (resume the source, but create a new session-id so the branch diverges without touching the original). Surface it as a slash action and a sidebar/roster button on a session. Acceptance: forking an active session opens a new pane continuing from its context, independent thereafter; the original is unaffected; tests for the fork argv + the new managed session appearing in the roster. Blocked by T-169 (orchestrator). + +--- +Refinement (2026-05-30, /whats-next): --fork-session CONFIRMED against the live binary (claude --help: "When resuming, create a new session ID instead of reusing the original (use with --resume or --continue)"). Fork argv = [''--resume'', , ''--fork-session''] (no --session-id; the new branch gets its own id from the init event). Add forkSourceSessionId to SpawnSpec (session_orchestrator.dart); build argv via a forkSessionArgs helper in session_naming.dart; add ''fork'' to kClideOwnedCommands + a case in claude_pane._send; add a fork button to _memberRow in claude_meta_sidebar.dart. SEQUENCE AFTER T-171 (shares _memberRow + orchestrator).', 'in_progress', 'low', NULL, NULL, 'D-77', '2026-05-24 16:27:16', '2026-05-30 21:25:12', NULL, '1aaeba013d95c3d2baa5488f20183775', 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-185', 'task', 'T-164', 'Capture the forked session''s real claude session-id from its init event', 'T-172 follow-up. A forked session is spawned with --resume --fork-session and no --session-id, so claude assigns the branch a NEW session-id that only arrives in the stream-json init event. Today ManagedSession.sessionId for a fork holds a placeholder UUID (the orchestrator key), and the real claude session-id is never captured back. Consequence: the fork can''t itself be resumed/forked by id later, and any UI showing the session-id is wrong for forks. Do: capture session_id from the init/system event (StreamJsonSession already parses init for status — surface the session_id) and update the ManagedSession (make sessionId mutable or store a separate claudeSessionId that the orchestrator fills on init). Acceptance: after a fork''s first init event, the ManagedSession exposes the branch''s real claude session-id; a test drives an init event through a fake process and asserts the captured id. Surfaced by T-172; the fork argv/spawn/roster/original-unaffected behaviour already works without it.', 'backlog', 'low', NULL, NULL, 'D-77', '2026-05-30 21:34:26', '2026-05-30 21:34:26', NULL, 'f041094e4dea938f3e00997226e21edf', 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); diff --git a/CHANGELOG.md b/CHANGELOG.md index c5b8558f..e45b0872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. safe trio default → acceptEdits → plan and sends `set_permission_mode` to that session; Shift-click reaches `bypassPermissions` behind a confirm. The badge reflects the live mode. (T-181, D-77) +- Fork a Claude conversation into a new pane — `/fork` (or a roster Fork + button / `clide.agent.fork`) branches a session via `--resume … --fork-session`, + opening an independent continuation that leaves the original untouched. (T-172, D-77) - Team coordination broker (T-170, D-77) — clide hosts an in-process MCP server (`clide-team`) for managed sessions over the stream-json control channel, giving agents tools to message each other, broadcast, see the diff --git a/lib/builtin/claude/src/claude_meta_sidebar.dart b/lib/builtin/claude/src/claude_meta_sidebar.dart index 69d8a837..97871531 100644 --- a/lib/builtin/claude/src/claude_meta_sidebar.dart +++ b/lib/builtin/claude/src/claude_meta_sidebar.dart @@ -203,6 +203,27 @@ class _ClaudeMetaSidebarState extends State { if (mounted) setState(() {}); } + /// Fork the team session identified by [memberName] (T-172, roster button). + /// + /// Resolves the managed session, then spawns a new fork session via the + /// orchestrator. The fork appears in the roster (it's a team session, visible + /// by default) and is independent from the source — the original is unaffected. + void _forkMember(String memberName) { + final orch = _orchestrator; + if (orch == null) return; + final managed = orch.byMemberName(memberName); + if (managed == null) return; + final forkId = 'fork:$memberName-${DateTime.now().millisecondsSinceEpoch}'; + unawaited(orch.spawn(SpawnSpec( + id: forkId, + role: 'fork of $memberName', + // sessionId is a placeholder; real claude session id arrives via init. + sessionId: forkId, + cwd: managed.cwd, + forkSourceSessionId: managed.sessionId, + ))); + } + Future _refreshStats() async { final stats = await _load(); if (mounted) setState(() => _stats = stats); @@ -318,6 +339,7 @@ class _ClaudeMetaSidebarState extends State { final managed = _orchestrator?.byMemberName(name); managed?.session.setPermissionMode(mode); }, + onFork: (name) => _forkMember(name), ), ]; @@ -450,9 +472,8 @@ class _MetaRow { /// - 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 -/// -/// Seam for T-172: add a fork button to the _buildControls row. class _AgentRosterRow extends StatefulWidget { const _AgentRosterRow({ super.key, @@ -465,6 +486,7 @@ class _AgentRosterRow extends StatefulWidget { required this.onInjectSubmit, required this.onClose, required this.onSetPermissionMode, + required this.onFork, }); final TeamMemberJoined member; @@ -486,6 +508,10 @@ class _AgentRosterRow extends StatefulWidget { /// 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(); } @@ -660,6 +686,13 @@ class _AgentRosterRowState extends State<_AgentRosterRow> { 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. + _IconButton( + painter: PhosphorIcons.gitBranch, + tooltip: 'Fork session', + color: tokens.globalTextMuted, + onTap: () => widget.onFork(widget.member.name), + ), // Close session _IconButton( painter: PhosphorIcons.xMark, diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index c1a8166c..e841887f 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -31,6 +31,8 @@ class ClaudePane extends StatefulWidget { super.key, this.isPrimary = true, this.secondaryIndex, + this.forkSourceId, + this.onFork, this.showChrome = true, this.active = true, this.contributionId = 'claude.primary', @@ -40,6 +42,16 @@ class ClaudePane extends StatefulWidget { final bool showChrome; final int? secondaryIndex; + /// When non-null, spawn this pane as a fork of the given claude session id + /// using `--resume --fork-session` (T-172). Takes precedence + /// over the normal fresh/resume logic for secondary panes. + final String? forkSourceId; + + /// Called when the user issues `/fork` to branch this session into a new + /// pane. The argument is the current pane's claude session id, which the + /// host (ClaudeSessionHost) uses to open a fork tab (T-172). + final void Function(String sourceClaudeSessionId)? onFork; + /// Whether this pane is the visible/focused sub-tab. Only the active /// pane publishes its status to the status-bar context slot (T-145). final bool active; @@ -159,16 +171,6 @@ class _ClaudePaneState extends State { } _repoRoot = repoRoot; - // Bind this pane to a specific session id (T-146). Primary: deterministic - // → resumes across restarts. Secondary: fresh → a clean session. - _sessionId ??= widget.isPrimary ? primarySessionId(repoRoot) : freshSessionId(); - - // A transcript already on disk means the session existed before, so resume - // it; `claude --session-id ` refuses an existing id (T-161/D-77). - final home = Platform.environment['HOME'] ?? ''; - final transcriptFile = '$home/.claude/projects/${repoRoot.replaceAll('/', '-')}/$_sessionId.jsonl'; - final resume = await File(transcriptFile).exists(); - // The orchestrator owns the session (T-169): spawn-or-bind by our pane key, // so the session (and its accumulating conversation) outlives this pane. final orch = activeSessionOrchestrator; @@ -176,21 +178,55 @@ class _ClaudePaneState extends State { setState(() => _error = 'Session orchestrator unavailable.'); return; } + final ManagedSession managed; - try { - managed = await orch.spawn(SpawnSpec( - id: _orchId, - role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}', - sessionId: _sessionId!, - cwd: repoRoot, - resume: resume, - transcriptPath: resume ? transcriptFile : null, - )); - } catch (e) { - if (mounted) setState(() => _error = 'Could not start claude: $e'); - return; + final forkSource = widget.forkSourceId; + if (forkSource != null) { + // Fork pane: branch source session into a new clide-managed session. + // The clide-internal id is a fresh UUID; the real claude session id is + // assigned by `--fork-session` and arrives in the init event (T-172). + _sessionId ??= freshSessionId(); + try { + managed = await orch.spawn(SpawnSpec( + id: _orchId, + role: 'fork ${widget.secondaryIndex}', + sessionId: _sessionId!, + cwd: repoRoot, + forkSourceSessionId: forkSource, + )); + } catch (e) { + if (mounted) setState(() => _error = 'Could not start fork: $e'); + return; + } + if (!mounted) return; + setState(() => _statusLine = 'fork of $forkSource'); + } else { + // Bind this pane to a specific session id (T-146). Primary: deterministic + // → resumes across restarts. Secondary: fresh → a clean session. + _sessionId ??= widget.isPrimary ? primarySessionId(repoRoot) : freshSessionId(); + + // A transcript already on disk means the session existed before, so resume + // it; `claude --session-id ` refuses an existing id (T-161/D-77). + final home = Platform.environment['HOME'] ?? ''; + final transcriptFile = '$home/.claude/projects/${repoRoot.replaceAll('/', '-')}/$_sessionId.jsonl'; + final resume = await File(transcriptFile).exists(); + + try { + managed = await orch.spawn(SpawnSpec( + id: _orchId, + role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}', + sessionId: _sessionId!, + cwd: repoRoot, + resume: resume, + transcriptPath: resume ? transcriptFile : null, + )); + } catch (e) { + if (mounted) setState(() => _error = 'Could not start claude: $e'); + return; + } + if (!mounted) return; + setState(() => _statusLine = resume ? 'resumed · $_sessionId' : 'new session · $_sessionId'); } - if (!mounted) return; _session = managed.session; _conversation = managed.conversation; @@ -198,13 +234,13 @@ class _ClaudePaneState extends State { if (!mounted) return; setState(() => _status = s); }); - setState(() => _statusLine = resume ? 'resumed · $_sessionId' : 'new session · $_sessionId'); } // Send composed text to Claude over the stream-json channel. Commands clide // owns (T-156) are handled here, never forwarded — /clear and /resume fork // the session to a new id, so clide drives them: /clear starts fresh, - // /resume picks a past session and re-binds to it. + // /resume picks a past session and re-binds to it. /fork branches the + // conversation into a new pane (T-172). void _send(String text) { switch (clideOwnedCommand(text)) { case 'clear': @@ -213,10 +249,24 @@ class _ClaudePaneState extends State { case 'resume': unawaited(_resumeFlow()); return; + case 'fork': + _forkSession(); + return; } _session?.send(text); } + /// clide-owned `/fork` (T-172): branch this conversation into a new pane. + /// + /// Delegates to the [onFork] callback supplied by [ClaudeSessionHost] with + /// the current pane's claude session id. If the session hasn't started yet + /// or no callback was supplied, the command is silently ignored. + void _forkSession() { + final sourceId = _sessionId; + if (sourceId == null) return; + widget.onFork?.call(sourceId); + } + /// clide-owned `/clear` (T-156): respawn on a brand-new, empty session. Future _clearSession() async { if (mounted) setState(() => _statusLine = 'clearing…'); diff --git a/lib/builtin/claude/src/claude_session_host.dart b/lib/builtin/claude/src/claude_session_host.dart index 244bfbca..c59194da 100644 --- a/lib/builtin/claude/src/claude_session_host.dart +++ b/lib/builtin/claude/src/claude_session_host.dart @@ -55,6 +55,20 @@ class ClaudeSessionHostState extends State { )); } + /// Open a new pane as a fork of [sourceClaudeSessionId] (T-172). + /// + /// The fork pane is a secondary tab seeded with `--resume + /// --fork-session` so the branch diverges into its own claude session + /// without touching the original. + void addFork(String sourceClaudeSessionId) { + final index = _nextSecondary++; + _controller.add(MultitabEntry<_Session>( + id: 'secondary-$index', + title: 'fork $index', + payload: _Session(isPrimary: false, secondaryIndex: index, forkSourceId: sourceClaudeSessionId), + )); + } + @override Widget build(BuildContext context) { return MultitabPane<_Session>( @@ -66,6 +80,8 @@ class ClaudeSessionHostState extends State { return ClaudePane( isPrimary: s.isPrimary, secondaryIndex: s.secondaryIndex, + forkSourceId: s.forkSourceId, + onFork: addFork, // The MultitabPane already provides the tab strip header; // suppressing the ClaudePane's own chrome avoids a double row. showChrome: false, @@ -78,7 +94,11 @@ class ClaudeSessionHostState extends State { } class _Session { - const _Session({required this.isPrimary, this.secondaryIndex}); + const _Session({required this.isPrimary, this.secondaryIndex, this.forkSourceId}); final bool isPrimary; final int? secondaryIndex; + + /// When non-null, spawn this pane as a fork of the given claude session id + /// (T-172). Forwarded to [ClaudePane.forkSourceId]. + final String? forkSourceId; } diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index 62dafebf..3e5a0270 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -174,17 +174,39 @@ class ClaudeExtension extends ClideExtension { return IpcResponse.ok(id: '', data: {'taskId': taskId, 'toId': toId, 'ok': ok}); }, ), - // claude.agent.spawn: spawning a new agent session programmatically. - // Full implementation deferred — requires the caller to supply - // SpawnSpec fields (sessionId, cwd, role, team flag, etc.) which are - // non-trivial to serialize over a flat CLI arg list. The UI affordance - // (TeamPanelHost spawn) is the primary surface for now; this stub - // satisfies D-6 parity and will be fleshed out in T-172. + // claude.agent.fork: branch a managed session into a new fork session + // (T-172, D-6 CLI/UI parity for the roster fork button). + // Usage: clide claude.agent.fork [] + // : the clide-internal id of the session to fork. + // : optional working directory; defaults to the source session's cwd. CommandContribution( - id: 'claude.agent.spawn', - command: 'claude.agent.spawn', - title: 'Claude: spawn a new agent session (stub — T-172)', - run: (_) async => IpcResponse.ok(id: '', data: const {'status': 'not-implemented', 'ticket': 'T-172'}), + id: 'claude.agent.fork', + command: 'claude.agent.fork', + title: 'Claude: fork a managed session into a new branch session', + run: (args) async { + final sourceId = args.firstOrNull; + if (sourceId == null) { + return IpcResponse.ok(id: '', data: const {'error': 'usage: claude.agent.fork []'}); + } + final orch = _orchestrator; + if (orch == null) { + return IpcResponse.ok(id: '', data: const {'error': 'orchestrator unavailable'}); + } + final source = orch.byId(sourceId); + if (source == null) { + return IpcResponse.ok(id: '', data: {'error': 'unknown session "$sourceId"'}); + } + final cwd = args.length >= 2 ? args[1] : source.cwd; + final forkId = 'fork:$sourceId-${DateTime.now().millisecondsSinceEpoch}'; + await orch.spawn(SpawnSpec( + id: forkId, + role: 'fork of $sourceId', + sessionId: forkId, + cwd: cwd, + forkSourceSessionId: source.sessionId, + )); + return IpcResponse.ok(id: '', data: {'forkId': forkId, 'sourceId': sourceId, 'status': 'spawned'}); + }, ), // Always-pickable left-panel tab: Claude activity (from // stats-cache.json) + the team roster when a team is running (T-141). diff --git a/lib/builtin/claude/src/session_naming.dart b/lib/builtin/claude/src/session_naming.dart index b34f00d5..b15226b0 100644 --- a/lib/builtin/claude/src/session_naming.dart +++ b/lib/builtin/claude/src/session_naming.dart @@ -74,6 +74,14 @@ String primarySessionId(String repoRoot) => _deterministicUuid(_primarySessionSe /// (T-161). Appended after the stream-json flags by [ClaudeStreamJsonProcess]. List claudeLaunchArgs(String sessionId, {required bool resume}) => resume ? ['--resume', sessionId] : ['--session-id', sessionId]; +/// The session-selection args for forking a session (T-172, D-77). +/// +/// `--resume --fork-session` resumes [sourceSessionId] but +/// creates a NEW claude session id so the branch diverges without touching the +/// original. No `--session-id` is passed — the fork gets its own id from the +/// `init` event. +List forkSessionArgs(String sourceSessionId) => ['--resume', sourceSessionId, '--fork-session']; + /// A fresh random session id for a secondary pane — secondaries are /// always clean sessions, never resumed. String freshSessionId() { diff --git a/lib/builtin/claude/src/session_orchestrator.dart b/lib/builtin/claude/src/session_orchestrator.dart index a087f611..f5173127 100644 --- a/lib/builtin/claude/src/session_orchestrator.dart +++ b/lib/builtin/claude/src/session_orchestrator.dart @@ -50,6 +50,7 @@ class SpawnSpec { this.visible = true, this.team = false, this.memberName, + this.forkSourceSessionId, }); final String id; @@ -75,6 +76,15 @@ class SpawnSpec { /// Name teammates address this session by (`send_message(to: …)`); defaults /// to [role] when omitted. Only meaningful when [team] is true. final String? memberName; + + /// When non-null, spawn a forked branch of this claude session id (T-172). + /// Uses `--resume --fork-session` so the branch + /// diverges into a NEW claude session without touching the original. + /// Takes precedence over [resume]/[sessionId] for arg selection. + final String? forkSourceSessionId; + + /// Whether this spec spawns a forked session. + bool get isFork => forkSourceSessionId != null; } /// One clide-managed session: the process wrapper plus the conversation it @@ -85,16 +95,27 @@ class ManagedSession { required this.id, required this.role, required this.sessionId, + required this.cwd, required this.session, required this.conversation, this.memberName, this.visible = true, this.muted = false, + this.forkSourceSessionId, }); final String id; final String role; + + /// The clide-internal session id. For fork sessions this is the placeholder + /// UUID passed via [SpawnSpec.sessionId]; the real claude-assigned session id + /// arrives in the `init` event and is not yet captured here (T-172 follow-up). final String sessionId; + + /// The working directory this session was spawned in. Retained so forks and + /// the UI can reference the source context (T-172). + final String cwd; + final StreamJsonSession session; final ConversationController conversation; @@ -110,6 +131,13 @@ class ManagedSession { /// The session process still runs; teammates' messages accumulate in its /// inbox but are not injected into stdin until unmuted (T-171). bool muted; + + /// The source claude session id this was forked from (T-172), or null for + /// non-fork sessions. For display / provenance only. + final String? forkSourceSessionId; + + /// Whether this is a forked session. + bool get isFork => forkSourceSessionId != null; } /// App-wide orchestrator, set by the Claude extension on activate (like @@ -154,7 +182,10 @@ class ClaudeSessionOrchestrator extends ChangeNotifier { // injected into their system prompt (T-170). Register the member before // spawning so a peer that messages it immediately resolves. final mcpServers = []; - var sessionArgs = claudeLaunchArgs(spec.sessionId, resume: spec.resume); + // Fork sessions use --resume --fork-session so the branch gets its + // own claude session id from the init event (T-172). All other sessions use + // the normal --resume / --session-id selection. + var sessionArgs = spec.isFork ? forkSessionArgs(spec.forkSourceSessionId!) : claudeLaunchArgs(spec.sessionId, resume: spec.resume); if (spec.team) { final name = spec.memberName ?? spec.role; broker.addMember(TeamMemberRef(id: spec.id, name: name, role: spec.role)); @@ -174,10 +205,12 @@ class ClaudeSessionOrchestrator extends ChangeNotifier { id: spec.id, role: spec.role, sessionId: spec.sessionId, + cwd: spec.cwd, session: session, conversation: conversation, memberName: spec.memberName, visible: spec.visible, + forkSourceSessionId: spec.forkSourceSessionId, ); _sessions[spec.id] = managed; notifyListeners(); diff --git a/lib/builtin/claude/src/slash_commands.dart b/lib/builtin/claude/src/slash_commands.dart index d2c7ff98..d6aae9b7 100644 --- a/lib/builtin/claude/src/slash_commands.dart +++ b/lib/builtin/claude/src/slash_commands.dart @@ -30,7 +30,8 @@ bool isKnownSlashCommand(String text, Iterable known) { /// Slash commands clide handles itself instead of forwarding to Claude: /// Claude Code's own handling forks the session to a new id that clide's /// transcript reader can't follow, so clide owns the semantics (T-156). -const Set kClideOwnedCommands = {'clear', 'resume'}; +/// `/fork` branches the current session into a new pane (T-172). +const Set kClideOwnedCommands = {'clear', 'resume', 'fork'}; /// The clide-owned command in [text] (a single-line leading-slash token in /// [kClideOwnedCommands]), or null. diff --git a/test/builtin/claude/claude_meta_sidebar_test.dart b/test/builtin/claude/claude_meta_sidebar_test.dart index 733855d5..c014b4e0 100644 --- a/test/builtin/claude/claude_meta_sidebar_test.dart +++ b/test/builtin/claude/claude_meta_sidebar_test.dart @@ -624,4 +624,82 @@ void main() { orch.dispose(); }); }); + + // T-172: fork button in the roster ------------------------------------------- + + group('T-172 fork session button', () { + Future orchWithMember(WidgetTester tester, {String name = 'Forker', String agentId = 'f1'}) async { + final orch = _fakeOrchestrator(); + await orch.spawn(SpawnSpec( + id: 'teammate:$name', + role: 'teammate', + sessionId: '$name-uuid', + cwd: '/repo', + team: true, + memberName: name, + )); + + await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.team))); + + f.services.events.emit(TeamMemberJoined( + team: 't', + agentId: agentId, + name: name, + agentType: 'coder', + paneId: '%1', + color: 'teal', + )); + await tester.pump(); + await tester.pump(); + return orch; + } + + testWidgets('fork button appears in the roster controls', (tester) async { + final semantics = tester.ensureSemantics(); + final orch = await orchWithMember(tester); + + // The fork button is an _IconButton with tooltip 'Fork session'. + expect(find.bySemanticsLabel('Fork session'), findsOneWidget); + + semantics.dispose(); + orch.dispose(); + }); + + testWidgets('tapping fork button spawns a new fork session in the orchestrator', (tester) async { + final semantics = tester.ensureSemantics(); + final orch = await orchWithMember(tester); + + expect(orch.sessions, hasLength(1)); // just the source + + await tester.tap(find.bySemanticsLabel('Fork session').first); + await tester.pump(); + await tester.pump(); + + // A second (fork) session is now registered. + expect(orch.sessions, hasLength(2)); + final forkSession = orch.sessions.last; + expect(forkSession.isFork, isTrue); + expect(forkSession.forkSourceSessionId, 'Forker-uuid'); + + semantics.dispose(); + orch.dispose(); + }); + + testWidgets('fork leaves the source session untouched', (tester) async { + final semantics = tester.ensureSemantics(); + final orch = await orchWithMember(tester); + final source = orch.byId('teammate:Forker')!; + + await tester.tap(find.bySemanticsLabel('Fork session').first); + await tester.pump(); + await tester.pump(); + + // Source session is still present and unchanged. + expect(orch.byId('teammate:Forker'), same(source)); + expect(source.visible, isTrue); + + semantics.dispose(); + orch.dispose(); + }); + }); } diff --git a/test/builtin/claude/session_naming_test.dart b/test/builtin/claude/session_naming_test.dart index b95182a4..35aa00c6 100644 --- a/test/builtin/claude/session_naming_test.dart +++ b/test/builtin/claude/session_naming_test.dart @@ -52,4 +52,29 @@ void main() { expect(resumed.last, id); }); }); + + group('forkSessionArgs (T-172)', () { + const sourceId = 'aaaa1111-1111-4111-8111-111111111111'; + + test('fork args lead with --resume then the source id', () { + final args = forkSessionArgs(sourceId); + expect(args[0], '--resume'); + expect(args[1], sourceId); + }); + + test('fork args include --fork-session to diverge without touching the original', () { + final args = forkSessionArgs(sourceId); + expect(args, contains('--fork-session')); + }); + + test('fork args contain exactly three elements', () { + // [--resume, , --fork-session] — no --session-id so claude + // assigns its own new session id (the branch). + expect(forkSessionArgs(sourceId), hasLength(3)); + }); + + test('fork args do not contain --session-id', () { + expect(forkSessionArgs(sourceId), isNot(contains('--session-id'))); + }); + }); } diff --git a/test/builtin/claude/session_orchestrator_test.dart b/test/builtin/claude/session_orchestrator_test.dart index 4a1e01d6..19f2fd1d 100644 --- a/test/builtin/claude/session_orchestrator_test.dart +++ b/test/builtin/claude/session_orchestrator_test.dart @@ -175,4 +175,95 @@ void main() { expect(managed.conversation.items, isEmpty); }); }); + + // T-172: fork-into-a-pane -------------------------------------------------- + + group('fork session (T-172)', () { + List? capturedArgs; + + setUp(() { + capturedArgs = null; + orch = ClaudeSessionOrchestrator( + processFactory: ({required sessionArgs, required cwd, env}) async { + capturedArgs = sessionArgs; + return _FakeProc(); + }, + ); + }); + + SpawnSpec forkSpec(String id, String sourceSessionId) => SpawnSpec( + id: id, + role: 'fork', + sessionId: '$id-placeholder', + cwd: '/repo', + forkSourceSessionId: sourceSessionId, + ); + + test('fork spawn passes --resume --fork-session instead of --session-id', () async { + const sourceId = 'bbbb2222-2222-4222-8222-222222222222'; + await orch.spawn(forkSpec('fork-1', sourceId)); + expect(capturedArgs, isNotNull); + expect(capturedArgs![0], '--resume'); + expect(capturedArgs![1], sourceId); + expect(capturedArgs!, contains('--fork-session')); + expect(capturedArgs!, isNot(contains('--session-id'))); + }); + + test('fork SpawnSpec.isFork is true when forkSourceSessionId is set', () { + const sourceId = 'cccc3333-3333-4333-8333-333333333333'; + final s = forkSpec('fork-1', sourceId); + expect(s.isFork, isTrue); + }); + + test('non-fork SpawnSpec.isFork is false', () { + final s = spec('primary'); + expect(s.isFork, isFalse); + }); + + test('fork registers in the orchestrator under its own id', () async { + const sourceId = 'dddd4444-4444-4444-8444-444444444444'; + final managed = await orch.spawn(forkSpec('fork-1', sourceId)); + expect(orch.byId('fork-1'), same(managed)); + expect(managed.isFork, isTrue); + expect(managed.forkSourceSessionId, sourceId); + }); + + test('fork is independent from the source — source session is unaffected', () async { + // Spawn the source session first. + final source = await orch.spawn(spec('primary')); + expect(orch.sessions, hasLength(1)); + + // Fork it. + await orch.spawn(forkSpec('fork-1', source.sessionId)); + // Both sessions exist; source is unchanged. + expect(orch.sessions, hasLength(2)); + expect(orch.byId('primary'), same(source)); + }); + + test('fork session appears in sessions list and is visible by default', () async { + const sourceId = 'eeee5555-5555-4555-8555-555555555555'; + final managed = await orch.spawn(forkSpec('fork-1', sourceId)); + expect(orch.sessions.contains(managed), isTrue); + expect(managed.visible, isTrue); + expect(orch.visibleSessions.contains(managed), isTrue); + }); + + test('fork notifies listeners when spawned', () async { + const sourceId = 'ffff6666-6666-4666-8666-666666666666'; + var notified = false; + orch.addListener(() => notified = true); + await orch.spawn(forkSpec('fork-1', sourceId)); + expect(notified, isTrue); + }); + + test('ManagedSession.cwd reflects the spec cwd', () async { + final managed = await orch.spawn(SpawnSpec( + id: 'primary', + role: 'primary', + sessionId: 'primary-uuid', + cwd: '/my/project', + )); + expect(managed.cwd, '/my/project'); + }); + }); }