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:
2026-05-30 23:34:50 +02:00
co-authored by Claude
parent 2888efbe5f
commit e3c0b0146b
13 changed files with 412 additions and 40 deletions
@@ -203,6 +203,27 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
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 {
final stats = await _load();
if (mounted) setState(() => _stats = stats);
@@ -318,6 +339,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
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,
+75 -25
View File
@@ -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 <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
/// pane publishes its status to the status-bar context slot (T-145).
final bool active;
@@ -159,16 +171,6 @@ class _ClaudePaneState extends State<ClaudePane> {
}
_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,
// so the session (and its accumulating conversation) outlives this pane.
final orch = activeSessionOrchestrator;
@@ -176,21 +178,55 @@ class _ClaudePaneState extends State<ClaudePane> {
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 <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<ClaudePane> {
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<ClaudePane> {
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<void> _clearSession() async {
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
Widget build(BuildContext context) {
return MultitabPane<_Session>(
@@ -66,6 +80,8 @@ class ClaudeSessionHostState extends State<ClaudeSessionHost> {
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<ClaudeSessionHost> {
}
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;
}
+32 -10
View File
@@ -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 <sourceSessionId> [<cwd>]
// <sourceSessionId>: the clide-internal id of the session to fork.
// <cwd>: 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 <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
// 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].
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
/// always clean sessions, never resumed.
String freshSessionId() {
@@ -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 <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
@@ -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 = <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) {
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();
+2 -1
View File
@@ -30,7 +30,8 @@ bool isKnownSlashCommand(String text, Iterable<String> 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<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
/// [kClideOwnedCommands]), or null.