fork a Claude conversation into a new pane
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 <source> --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 <noreply@anthropic.com>
This commit is contained in:
@@ -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-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-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', '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;
|
||||||
|
|||||||
@@ -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);
|
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-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).', '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'', <sourceSessionId>, ''--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 <source> --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);
|
||||||
|
|||||||
@@ -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
|
safe trio default → acceptEdits → plan and sends `set_permission_mode` to
|
||||||
that session; Shift-click reaches `bypassPermissions` behind a confirm.
|
that session; Shift-click reaches `bypassPermissions` behind a confirm.
|
||||||
The badge reflects the live mode. (T-181, D-77)
|
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
|
- Team coordination broker (T-170, D-77) — clide hosts an in-process MCP
|
||||||
server (`clide-team`) for managed sessions over the stream-json control
|
server (`clide-team`) for managed sessions over the stream-json control
|
||||||
channel, giving agents tools to message each other, broadcast, see the
|
channel, giving agents tools to message each other, broadcast, see the
|
||||||
|
|||||||
@@ -203,6 +203,27 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
|||||||
if (mounted) setState(() {});
|
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<void> _refreshStats() async {
|
Future<void> _refreshStats() async {
|
||||||
final stats = await _load();
|
final stats = await _load();
|
||||||
if (mounted) setState(() => _stats = stats);
|
if (mounted) setState(() => _stats = stats);
|
||||||
@@ -318,6 +339,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
|||||||
final managed = _orchestrator?.byMemberName(name);
|
final managed = _orchestrator?.byMemberName(name);
|
||||||
managed?.session.setPermissionMode(mode);
|
managed?.session.setPermissionMode(mode);
|
||||||
},
|
},
|
||||||
|
onFork: (name) => _forkMember(name),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -450,9 +472,8 @@ class _MetaRow {
|
|||||||
/// - eye / eye-slash — show / hide the session pane
|
/// - eye / eye-slash — show / hide the session pane
|
||||||
/// - speaker / speaker-slash — mute / unmute broker delivery
|
/// - speaker / speaker-slash — mute / unmute broker delivery
|
||||||
/// - inject (chat icon) — expand the inline message input
|
/// - 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
|
/// - close (×) — kill the session
|
||||||
///
|
|
||||||
/// Seam for T-172: add a fork button to the _buildControls row.
|
|
||||||
class _AgentRosterRow extends StatefulWidget {
|
class _AgentRosterRow extends StatefulWidget {
|
||||||
const _AgentRosterRow({
|
const _AgentRosterRow({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -465,6 +486,7 @@ class _AgentRosterRow extends StatefulWidget {
|
|||||||
required this.onInjectSubmit,
|
required this.onInjectSubmit,
|
||||||
required this.onClose,
|
required this.onClose,
|
||||||
required this.onSetPermissionMode,
|
required this.onSetPermissionMode,
|
||||||
|
required this.onFork,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TeamMemberJoined member;
|
final TeamMemberJoined member;
|
||||||
@@ -486,6 +508,10 @@ class _AgentRosterRow extends StatefulWidget {
|
|||||||
/// the mode to the session via [StreamJsonSession.setPermissionMode].
|
/// the mode to the session via [StreamJsonSession.setPermissionMode].
|
||||||
final void Function(String memberName, String mode) onSetPermissionMode;
|
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
|
@override
|
||||||
State<_AgentRosterRow> createState() => _AgentRosterRowState();
|
State<_AgentRosterRow> createState() => _AgentRosterRowState();
|
||||||
}
|
}
|
||||||
@@ -660,6 +686,13 @@ class _AgentRosterRowState extends State<_AgentRosterRow> {
|
|||||||
color: isInjecting ? tokens.globalFocus : tokens.globalTextMuted,
|
color: isInjecting ? tokens.globalFocus : tokens.globalTextMuted,
|
||||||
onTap: () => widget.onToggleInject(widget.member.name),
|
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
|
// Close session
|
||||||
_IconButton(
|
_IconButton(
|
||||||
painter: PhosphorIcons.xMark,
|
painter: PhosphorIcons.xMark,
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ class ClaudePane extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
this.isPrimary = true,
|
this.isPrimary = true,
|
||||||
this.secondaryIndex,
|
this.secondaryIndex,
|
||||||
|
this.forkSourceId,
|
||||||
|
this.onFork,
|
||||||
this.showChrome = true,
|
this.showChrome = true,
|
||||||
this.active = true,
|
this.active = true,
|
||||||
this.contributionId = 'claude.primary',
|
this.contributionId = 'claude.primary',
|
||||||
@@ -40,6 +42,16 @@ class ClaudePane extends StatefulWidget {
|
|||||||
final bool showChrome;
|
final bool showChrome;
|
||||||
final int? secondaryIndex;
|
final int? secondaryIndex;
|
||||||
|
|
||||||
|
/// When non-null, spawn this pane as a fork of the given claude session id
|
||||||
|
/// using `--resume <forkSourceId> --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
|
/// Whether this pane is the visible/focused sub-tab. Only the active
|
||||||
/// pane publishes its status to the status-bar context slot (T-145).
|
/// pane publishes its status to the status-bar context slot (T-145).
|
||||||
final bool active;
|
final bool active;
|
||||||
@@ -159,16 +171,6 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
}
|
}
|
||||||
_repoRoot = repoRoot;
|
_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 <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,
|
// The orchestrator owns the session (T-169): spawn-or-bind by our pane key,
|
||||||
// so the session (and its accumulating conversation) outlives this pane.
|
// so the session (and its accumulating conversation) outlives this pane.
|
||||||
final orch = activeSessionOrchestrator;
|
final orch = activeSessionOrchestrator;
|
||||||
@@ -176,21 +178,55 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
setState(() => _error = 'Session orchestrator unavailable.');
|
setState(() => _error = 'Session orchestrator unavailable.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final ManagedSession managed;
|
final ManagedSession managed;
|
||||||
try {
|
final forkSource = widget.forkSourceId;
|
||||||
managed = await orch.spawn(SpawnSpec(
|
if (forkSource != null) {
|
||||||
id: _orchId,
|
// Fork pane: branch source session into a new clide-managed session.
|
||||||
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
|
// The clide-internal id is a fresh UUID; the real claude session id is
|
||||||
sessionId: _sessionId!,
|
// assigned by `--fork-session` and arrives in the init event (T-172).
|
||||||
cwd: repoRoot,
|
_sessionId ??= freshSessionId();
|
||||||
resume: resume,
|
try {
|
||||||
transcriptPath: resume ? transcriptFile : null,
|
managed = await orch.spawn(SpawnSpec(
|
||||||
));
|
id: _orchId,
|
||||||
} catch (e) {
|
role: 'fork ${widget.secondaryIndex}',
|
||||||
if (mounted) setState(() => _error = 'Could not start claude: $e');
|
sessionId: _sessionId!,
|
||||||
return;
|
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 <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;
|
_session = managed.session;
|
||||||
_conversation = managed.conversation;
|
_conversation = managed.conversation;
|
||||||
@@ -198,13 +234,13 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _status = s);
|
setState(() => _status = s);
|
||||||
});
|
});
|
||||||
setState(() => _statusLine = resume ? 'resumed · $_sessionId' : 'new session · $_sessionId');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send composed text to Claude over the stream-json channel. Commands clide
|
// Send composed text to Claude over the stream-json channel. Commands clide
|
||||||
// owns (T-156) are handled here, never forwarded — /clear and /resume fork
|
// 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,
|
// 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) {
|
void _send(String text) {
|
||||||
switch (clideOwnedCommand(text)) {
|
switch (clideOwnedCommand(text)) {
|
||||||
case 'clear':
|
case 'clear':
|
||||||
@@ -213,10 +249,24 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
case 'resume':
|
case 'resume':
|
||||||
unawaited(_resumeFlow());
|
unawaited(_resumeFlow());
|
||||||
return;
|
return;
|
||||||
|
case 'fork':
|
||||||
|
_forkSession();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
_session?.send(text);
|
_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.
|
/// clide-owned `/clear` (T-156): respawn on a brand-new, empty session.
|
||||||
Future<void> _clearSession() async {
|
Future<void> _clearSession() async {
|
||||||
if (mounted) setState(() => _statusLine = 'clearing…');
|
if (mounted) setState(() => _statusLine = 'clearing…');
|
||||||
|
|||||||
@@ -55,6 +55,20 @@ class ClaudeSessionHostState extends State<ClaudeSessionHost> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open a new pane as a fork of [sourceClaudeSessionId] (T-172).
|
||||||
|
///
|
||||||
|
/// The fork pane is a secondary tab seeded with `--resume <source>
|
||||||
|
/// --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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MultitabPane<_Session>(
|
return MultitabPane<_Session>(
|
||||||
@@ -66,6 +80,8 @@ class ClaudeSessionHostState extends State<ClaudeSessionHost> {
|
|||||||
return ClaudePane(
|
return ClaudePane(
|
||||||
isPrimary: s.isPrimary,
|
isPrimary: s.isPrimary,
|
||||||
secondaryIndex: s.secondaryIndex,
|
secondaryIndex: s.secondaryIndex,
|
||||||
|
forkSourceId: s.forkSourceId,
|
||||||
|
onFork: addFork,
|
||||||
// The MultitabPane already provides the tab strip header;
|
// The MultitabPane already provides the tab strip header;
|
||||||
// suppressing the ClaudePane's own chrome avoids a double row.
|
// suppressing the ClaudePane's own chrome avoids a double row.
|
||||||
showChrome: false,
|
showChrome: false,
|
||||||
@@ -78,7 +94,11 @@ class ClaudeSessionHostState extends State<ClaudeSessionHost> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _Session {
|
class _Session {
|
||||||
const _Session({required this.isPrimary, this.secondaryIndex});
|
const _Session({required this.isPrimary, this.secondaryIndex, this.forkSourceId});
|
||||||
final bool isPrimary;
|
final bool isPrimary;
|
||||||
final int? secondaryIndex;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,17 +174,39 @@ class ClaudeExtension extends ClideExtension {
|
|||||||
return IpcResponse.ok(id: '', data: {'taskId': taskId, 'toId': toId, 'ok': ok});
|
return IpcResponse.ok(id: '', data: {'taskId': taskId, 'toId': toId, 'ok': ok});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// claude.agent.spawn: spawning a new agent session programmatically.
|
// claude.agent.fork: branch a managed session into a new fork session
|
||||||
// Full implementation deferred — requires the caller to supply
|
// (T-172, D-6 CLI/UI parity for the roster fork button).
|
||||||
// SpawnSpec fields (sessionId, cwd, role, team flag, etc.) which are
|
// Usage: clide claude.agent.fork <sourceSessionId> [<cwd>]
|
||||||
// non-trivial to serialize over a flat CLI arg list. The UI affordance
|
// <sourceSessionId>: the clide-internal id of the session to fork.
|
||||||
// (TeamPanelHost spawn) is the primary surface for now; this stub
|
// <cwd>: optional working directory; defaults to the source session's cwd.
|
||||||
// satisfies D-6 parity and will be fleshed out in T-172.
|
|
||||||
CommandContribution(
|
CommandContribution(
|
||||||
id: 'claude.agent.spawn',
|
id: 'claude.agent.fork',
|
||||||
command: 'claude.agent.spawn',
|
command: 'claude.agent.fork',
|
||||||
title: 'Claude: spawn a new agent session (stub — T-172)',
|
title: 'Claude: fork a managed session into a new branch session',
|
||||||
run: (_) async => IpcResponse.ok(id: '', data: const {'status': 'not-implemented', 'ticket': 'T-172'}),
|
run: (args) async {
|
||||||
|
final sourceId = args.firstOrNull;
|
||||||
|
if (sourceId == null) {
|
||||||
|
return IpcResponse.ok(id: '', data: const {'error': 'usage: claude.agent.fork <sourceSessionId> [<cwd>]'});
|
||||||
|
}
|
||||||
|
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
|
// Always-pickable left-panel tab: Claude activity (from
|
||||||
// stats-cache.json) + the team roster when a team is running (T-141).
|
// stats-cache.json) + the team roster when a team is running (T-141).
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ String primarySessionId(String repoRoot) => _deterministicUuid(_primarySessionSe
|
|||||||
/// (T-161). Appended after the stream-json flags by [ClaudeStreamJsonProcess].
|
/// (T-161). Appended after the stream-json flags by [ClaudeStreamJsonProcess].
|
||||||
List<String> claudeLaunchArgs(String sessionId, {required bool resume}) => resume ? ['--resume', sessionId] : ['--session-id', sessionId];
|
List<String> 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 <sourceSessionId> --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<String> forkSessionArgs(String sourceSessionId) => ['--resume', sourceSessionId, '--fork-session'];
|
||||||
|
|
||||||
/// A fresh random session id for a secondary pane — secondaries are
|
/// A fresh random session id for a secondary pane — secondaries are
|
||||||
/// always clean sessions, never resumed.
|
/// always clean sessions, never resumed.
|
||||||
String freshSessionId() {
|
String freshSessionId() {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class SpawnSpec {
|
|||||||
this.visible = true,
|
this.visible = true,
|
||||||
this.team = false,
|
this.team = false,
|
||||||
this.memberName,
|
this.memberName,
|
||||||
|
this.forkSourceSessionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
@@ -75,6 +76,15 @@ class SpawnSpec {
|
|||||||
/// Name teammates address this session by (`send_message(to: …)`); defaults
|
/// Name teammates address this session by (`send_message(to: …)`); defaults
|
||||||
/// to [role] when omitted. Only meaningful when [team] is true.
|
/// to [role] when omitted. Only meaningful when [team] is true.
|
||||||
final String? memberName;
|
final String? memberName;
|
||||||
|
|
||||||
|
/// When non-null, spawn a forked branch of this claude session id (T-172).
|
||||||
|
/// Uses `--resume <forkSourceSessionId> --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
|
/// One clide-managed session: the process wrapper plus the conversation it
|
||||||
@@ -85,16 +95,27 @@ class ManagedSession {
|
|||||||
required this.id,
|
required this.id,
|
||||||
required this.role,
|
required this.role,
|
||||||
required this.sessionId,
|
required this.sessionId,
|
||||||
|
required this.cwd,
|
||||||
required this.session,
|
required this.session,
|
||||||
required this.conversation,
|
required this.conversation,
|
||||||
this.memberName,
|
this.memberName,
|
||||||
this.visible = true,
|
this.visible = true,
|
||||||
this.muted = false,
|
this.muted = false,
|
||||||
|
this.forkSourceSessionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
final String role;
|
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;
|
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 StreamJsonSession session;
|
||||||
final ConversationController conversation;
|
final ConversationController conversation;
|
||||||
|
|
||||||
@@ -110,6 +131,13 @@ class ManagedSession {
|
|||||||
/// The session process still runs; teammates' messages accumulate in its
|
/// The session process still runs; teammates' messages accumulate in its
|
||||||
/// inbox but are not injected into stdin until unmuted (T-171).
|
/// inbox but are not injected into stdin until unmuted (T-171).
|
||||||
bool muted;
|
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
|
/// 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
|
// injected into their system prompt (T-170). Register the member before
|
||||||
// spawning so a peer that messages it immediately resolves.
|
// spawning so a peer that messages it immediately resolves.
|
||||||
final mcpServers = <McpServer>[];
|
final mcpServers = <McpServer>[];
|
||||||
var sessionArgs = claudeLaunchArgs(spec.sessionId, resume: spec.resume);
|
// Fork sessions use --resume <source> --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) {
|
if (spec.team) {
|
||||||
final name = spec.memberName ?? spec.role;
|
final name = spec.memberName ?? spec.role;
|
||||||
broker.addMember(TeamMemberRef(id: spec.id, name: name, role: spec.role));
|
broker.addMember(TeamMemberRef(id: spec.id, name: name, role: spec.role));
|
||||||
@@ -174,10 +205,12 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
|||||||
id: spec.id,
|
id: spec.id,
|
||||||
role: spec.role,
|
role: spec.role,
|
||||||
sessionId: spec.sessionId,
|
sessionId: spec.sessionId,
|
||||||
|
cwd: spec.cwd,
|
||||||
session: session,
|
session: session,
|
||||||
conversation: conversation,
|
conversation: conversation,
|
||||||
memberName: spec.memberName,
|
memberName: spec.memberName,
|
||||||
visible: spec.visible,
|
visible: spec.visible,
|
||||||
|
forkSourceSessionId: spec.forkSourceSessionId,
|
||||||
);
|
);
|
||||||
_sessions[spec.id] = managed;
|
_sessions[spec.id] = managed;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ bool isKnownSlashCommand(String text, Iterable<String> known) {
|
|||||||
/// Slash commands clide handles itself instead of forwarding to Claude:
|
/// 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
|
/// 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).
|
/// transcript reader can't follow, so clide owns the semantics (T-156).
|
||||||
const Set<String> kClideOwnedCommands = {'clear', 'resume'};
|
/// `/fork` branches the current session into a new pane (T-172).
|
||||||
|
const Set<String> kClideOwnedCommands = {'clear', 'resume', 'fork'};
|
||||||
|
|
||||||
/// The clide-owned command in [text] (a single-line leading-slash token in
|
/// The clide-owned command in [text] (a single-line leading-slash token in
|
||||||
/// [kClideOwnedCommands]), or null.
|
/// [kClideOwnedCommands]), or null.
|
||||||
|
|||||||
@@ -624,4 +624,82 @@ void main() {
|
|||||||
orch.dispose();
|
orch.dispose();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// T-172: fork button in the roster -------------------------------------------
|
||||||
|
|
||||||
|
group('T-172 fork session button', () {
|
||||||
|
Future<ClaudeSessionOrchestrator> 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,4 +52,29 @@ void main() {
|
|||||||
expect(resumed.last, id);
|
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, <sourceId>, --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')));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,4 +175,95 @@ void main() {
|
|||||||
expect(managed.conversation.items, isEmpty);
|
expect(managed.conversation.items, isEmpty);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// T-172: fork-into-a-pane --------------------------------------------------
|
||||||
|
|
||||||
|
group('fork session (T-172)', () {
|
||||||
|
List<String>? 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 <source> --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');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user