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
@@ -624,4 +624,82 @@ void main() {
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);
});
});
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);
});
});
// 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');
});
});
}