From bbc6899df2da639596f54ac9ba35f205effc62ec Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 12 Jun 2026 01:11:17 +0200 Subject: [PATCH] consume the fork source on first bind (T-375) widget.forkSourceId took precedence over the fresh/resume logic on EVERY (re)bind, so /clear in a fork pane re-forked the original conversation instead of clearing, and /resume re-forked the same way. The source is now copied into one-shot pane state and cleared after the first successful fork spawn; later respawns operate on the pane's own session. Co-Authored-By: Claude Fable 5 --- .pql/changelog/ticket_history/2026-06.sql | 2 ++ .pql/changelog/tickets/2026-06.sql | 10 ++++++++++ CHANGELOG.md | 5 +++++ lib/builtin/claude/src/claude_pane.dart | 10 +++++++++- test/builtin/claude/claude_pane_test.dart | 17 +++++++++++++++++ 5 files changed, 43 insertions(+), 1 deletion(-) diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 66d7c54e..203e917a 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -4001,3 +4001,5 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCP03EJ9CDBGZGRPD19N8W', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:06:26', '2026-06-11 23:06:26', '2026-06-11 23:06:26', NULL, '4e1cc82f703d717f7592890e230caf00', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC90B72A270CAKA7AP1ZX8', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:07:01', '2026-06-11 23:07:01', '2026-06-11 23:07:01', NULL, '795c139edb5acd0d2a187dac6a3066c7', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC90B72A270CAKA7AP1ZX8', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:08:13', '2026-06-11 23:08:13', '2026-06-11 23:08:13', NULL, 'f83a2fc13f3839070957bea6ac7fdc5f', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCAFKK334YNJXZJQG4J6AW', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 23:08:40', '2026-06-11 23:08:40', '2026-06-11 23:08:40', NULL, 'd61ecda5f46b3ad0e15ea566bc83a52c', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCAFKK334YNJXZJQG4J6AW', 'status', 'in_progress', 'done', NULL, '2026-06-11 23:10:56', '2026-06-11 23:10:56', '2026-06-11 23:10:56', NULL, '4b505b885dc39785651e259370fbc97b', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index d3c8f52f..2188e2e2 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -4109,3 +4109,13 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat Fix: hold a Map> — first caller installs the future synchronously, later callers await the same future; remove the entry on failure. Acceptance: test issuing two concurrent spawn() calls for one id yields the same ManagedSession instance and exactly one process spawn (count via injected spawner); failure path clears the in-flight entry so a retry can proceed.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:57:56', '2026-06-11 23:08:13', NULL, 'fde3f9e912ea1d25256003ef56dcf491', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCAFKK334YNJXZJQG4J6AW', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Fork panes: widget.forkSourceId wins forever — /clear re-forks the original conversation', 'lib/builtin/claude/src/claude_pane.dart:266-281 — when (re)binding a session, widget.forkSourceId takes precedence forever, so /clear in a fork pane re-forks the original conversation instead of clearing, and /resume and /fork misbehave the same way. Related context: clide owns /clear, /resume, /compact interception (T-156); panes pin a session id. + +Fix: treat forkSourceId as a one-shot spawn parameter — consume it on first bind (clear it into pane state), so subsequent session-mutating commands operate on the pane''s live session. + +Acceptance: test that a fork pane after /clear starts an empty session (no fork source passed to the orchestrator on respawn); first bind still forks from the source.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:08', '2026-06-11 23:08:40', NULL, '8e8a63e0617c3707db1cf5621f181b63', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCAFKK334YNJXZJQG4J6AW', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Fork panes: widget.forkSourceId wins forever — /clear re-forks the original conversation', 'lib/builtin/claude/src/claude_pane.dart:266-281 — when (re)binding a session, widget.forkSourceId takes precedence forever, so /clear in a fork pane re-forks the original conversation instead of clearing, and /resume and /fork misbehave the same way. Related context: clide owns /clear, /resume, /compact interception (T-156); panes pin a session id. + +Fix: treat forkSourceId as a one-shot spawn parameter — consume it on first bind (clear it into pane state), so subsequent session-mutating commands operate on the pane''s live session. + +Acceptance: test that a fork pane after /clear starts an empty session (no fork source passed to the orchestrator on respawn); first bind still forks from the source.', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 21:58:08', '2026-06-11 23:10:56', NULL, '1cd70120bb752815eeaebb58e57b1a1c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 42833ffb..7c6efefd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. check and the loser's live process was orphaned; spawns for an id are now coalesced onto one in-flight future. (T-374) +- **`/clear` in a fork pane clears instead of re-forking.** The fork source + took precedence on every respawn, so clearing a fork tab silently branched + the original conversation again; the source now seeds only the first + bind. (T-375) + - **Accepting ExitPlanMode now leaves plan mode in the conversation panel.** Approving Claude's plan (the ExitPlanMode tool) transitioned the underlying session out of plan mode, but clide's tracked permission mode didn't follow, diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 9cef3bed..b07be507 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -81,6 +81,10 @@ class _ClaudePaneState extends State { String? _error; String _statusLine = 'starting…'; + /// One-shot fork source: seeds the first bind, then cleared so /clear, + /// /resume, and respawns operate on this pane's own session (T-375). + late String? _forkSource = widget.forkSourceId; + bool _spawned = false; /// Per-session composer draft (text + caret), held here so an unsent @@ -272,7 +276,7 @@ class _ClaudePaneState extends State { } final ManagedSession managed; - final forkSource = widget.forkSourceId; + final forkSource = _forkSource; 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 @@ -286,6 +290,10 @@ class _ClaudePaneState extends State { if (mounted) setState(() => _error = 'Could not start fork: $e'); return; } + // One-shot: the fork source seeds only the FIRST bind. Leaving it set + // made /clear re-fork the original conversation instead of clearing — + // every later respawn must operate on this pane's own session (T-375). + _forkSource = null; if (!mounted) return; setState(() => _statusLine = 'fork of $forkSource'); } else { diff --git a/test/builtin/claude/claude_pane_test.dart b/test/builtin/claude/claude_pane_test.dart index dc52f28d..085450bd 100644 --- a/test/builtin/claude/claude_pane_test.dart +++ b/test/builtin/claude/claude_pane_test.dart @@ -52,15 +52,18 @@ void main() { late ClaudeSessionOrchestrator orch; late String root; final created = <_FakeProc>[]; + final spawnArgs = >[]; setUp(() async { f = await KernelFixture.create(); created.clear(); + spawnArgs.clear(); root = '/repo-a'; orch = ClaudeSessionOrchestrator( processFactory: ({required sessionArgs, required cwd, env}) async { final p = _FakeProc(); created.add(p); + spawnArgs.add(sessionArgs); return p; }, ); @@ -175,6 +178,20 @@ void main() { expect(orch.byId('primary')!.sessionId, id); }); + testWidgets('/clear in a fork pane clears instead of re-forking (T-375)', (tester) async { + await mount(tester, const ClaudePane(showChrome: false, isPrimary: false, secondaryIndex: 1, forkSourceId: 'source-session-uuid')); + // First bind forks from the source. + expect(spawnArgs.single, containsAll(['--fork-session', 'source-session-uuid'])); + + await act(tester, () => composer(tester).onSubmit('/clear')); + + // The respawn must NOT fork the original again — the fork source is a + // one-shot spawn parameter consumed by the first bind. + expect(spawnArgs, hasLength(2)); + expect(spawnArgs.last, isNot(contains('--fork-session'))); + expect(spawnArgs.last, isNot(contains('source-session-uuid'))); + }); + testWidgets('/fork delegates to the onFork callback with the session id', (tester) async { String? forkedWith; await mount(tester, ClaudePane(showChrome: false, onFork: (sid) => forkedWith = sid));