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:
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user