rebind the Claude pane on an in-place workspace switch (T-269)

Separate clide windows are isolated (own process, per-root IPC socket,
per-repo deterministic session id), so parallel repos in separate windows
were already fine. But switching the workspace in place (Open Project /
Open Folder) only emitted ProjectOpened — nothing rebound the Claude
session, so the primary pane kept the PREVIOUS repo's conversation.

Two compounding causes, fixed in layers:
- ClaudeSessionOrchestrator.spawn() was idempotent on the literal key
  'primary' without checking cwd, so it handed the old repo's session to
  the new repo. It now reuses a cached session only when its cwd matches
  the spec; a mismatch tears the stale one down and spawns fresh.
- The primary ClaudePane is built once behind a GlobalKey and spawns once,
  so it never re-resolved. It now listens for ProjectOpened and rebinds:
  close its orchestrator entry, drop the cached session id + repo root, and
  respawn against the now-active repo. Secondaries don't self-rebind.
- ClaudeSessionHost drops the old repo's secondary/fork tabs on a switch,
  so a switched workspace starts like a fresh launch (lone primary).
- The extension closes any remaining sessions whose cwd != the new root,
  catching team/non-pane sessions no pane owns.

Tested at the orchestrator: cwd-aware idempotency (reuse on cwd match,
teardown + respawn on mismatch). Pane/host widget coverage is intentionally
deferred — claude_pane.dart has no widget-test harness yet and pulling it
into coverage piecemeal would drop the gate; tracked separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 12:37:53 +02:00
co-authored by Claude Opus 4.8
parent 892240b3a6
commit f813fd4d5a
8 changed files with 176 additions and 5 deletions
@@ -764,3 +764,5 @@ Refs: D-78 (interaction zone / display-only conversation widgets). Built on T-16
SEQUENCING UPDATE (after T-266 refinement): the shared container/holder primitive now lives in T-266 and is CONSUMED by T-264 (nested agent run), so T-266 is no longer "last" — its primitive lands before/with T-264. T-264 is now blocked by T-266. Revised order: T-262 / T-263 (fold success result, fold agent prompt) → T-266 (shared holder/container primitive + activity-card restyle) → T-264 (nest the whole agent run on that primitive) → T-265 (relabel sidechain prose) can land anytime alongside.', NULL, '2026-06-07 08:49:16', '2026-06-07 08:49:16', '2026-06-07 08:49:16', NULL, '7795b980e57c7bf096b495b3f61c8289', 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-255', 'status', 'in_progress', 'done', NULL, '2026-06-07 08:51:08', '2026-06-07 08:51:08', '2026-06-07 08:51:08', NULL, '68cf2ebb539ca57c1c8165acecf3c111', 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-268', 'status', 'backlog', 'done', NULL, '2026-06-07 09:36:59', '2026-06-07 09:36:59', '2026-06-07 09:36:59', NULL, 'dcce34ebf831a8836fe31bcad6bab92c', 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-269', 'status', 'backlog', 'done', NULL, '2026-06-07 09:51:32', '2026-06-07 09:51:32', '2026-06-07 09:51:32', NULL, '3fec60e8de272979ad791c313eac76f8', 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-48', 'status', 'backlog', 'ready', NULL, '2026-06-07 10:03:36', '2026-06-07 10:03:36', '2026-06-07 10:03:36', NULL, '643716825d71326f6066fb04cbb67077', 1) ON CONFLICT(hash) DO NOTHING;
+24
View File
@@ -1597,3 +1597,27 @@ Fix: /clear in the PRIMARY pane should empty the deterministic session in place
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-268', 'bug', NULL, 'primary /clear must clear the deterministic session in place, not orphan to a fresh id', 'The primary Claude pane is anchored to a deterministic session id derived from the repo path (primarySessionId), so it resumes the same transcript across restarts (D-77/T-146). But clide-owned /clear (T-156) respawns on a fresh RANDOM id (freshSessionId). Next clide launch recomputes the deterministic id, finds its old transcript on disk, and resumes the PRE-clear conversation — the cleared session is orphaned. Continuity appears broken.
Fix: /clear in the PRIMARY pane should empty the deterministic session in place delete its <id>.jsonl (and sidecar <id>/ dir) so ''claude --session-id'' re-creates it empty, then respawn on the SAME deterministic id. Secondary panes are throwaway and keep the fresh-random behavior. Factor the transcript path/dir construction (duplicated in claude_pane._spawn and _resumeFlow) into session_naming helpers (claudeProjectDir/claudeTranscriptPath/clearSessionTranscript) so the clear logic is DRY and unit-testable.', 'done', 'high', NULL, NULL, 'D-77', '2026-06-07 09:35:03', '2026-06-07 09:36:59', NULL, '824de390dc6da465575d998291976377', 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-269', 'bug', NULL, 'primary Claude pane keeps the previous repo''s session after an in-place workspace switch', 'Separate clide WINDOWS are isolated (separate process, per-root IPC socket, per-repo deterministic session id), so parallel repos in separate windows are fine. The bug is the in-place workspace switch (Open Project / Open Folder -> kernel.project.open, app.dart:456/474): it only emits ProjectOpened and updates settings (project.dart:147) — nothing tears down Claude sessions.
Two compounding causes:
1. ClaudeSessionOrchestrator.spawn() is idempotent on the literal key ''primary'' WITHOUT checking cwd (session_orchestrator.dart:192-194) it returns the old repo''s session for the new repo.
2. ClaudePane spawns once behind a _spawned guard, under a GlobalKey host (claude_pane.dart:140, extension.dart:33/52) so after an in-place switch it never re-resolves; it keeps the previous repo''s session/conversation.
Fix (T-269):
- Orchestrator safety net: spawn() must reuse an existing session only when its cwd matches spec.cwd; a cwd mismatch is a stale cross-workspace binding -> close + spawn fresh.
- ClaudePane (primary) listens for ProjectOpened and rebinds: close its orch entry, null out _sessionId/_repoRoot, respawn for the new repo. Secondaries do not self-rebind.
- ClaudeSessionHost listens for ProjectOpened and drops secondary/fork tabs (the old repo''s), keeping just the primary a switched workspace starts like a fresh launch.
- Extension tears down all sessions whose cwd != new root on ProjectOpened (catches team/non-pane sessions).', 'backlog', 'high', NULL, NULL, 'D-77', '2026-06-07 09:44:17', '2026-06-07 09:44:17', NULL, 'e2da4b11d8f64cfeddeab64f6407004a', 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-269', 'bug', NULL, 'primary Claude pane keeps the previous repo''s session after an in-place workspace switch', 'Separate clide WINDOWS are isolated (separate process, per-root IPC socket, per-repo deterministic session id), so parallel repos in separate windows are fine. The bug is the in-place workspace switch (Open Project / Open Folder -> kernel.project.open, app.dart:456/474): it only emits ProjectOpened and updates settings (project.dart:147) — nothing tears down Claude sessions.
Two compounding causes:
1. ClaudeSessionOrchestrator.spawn() is idempotent on the literal key ''primary'' WITHOUT checking cwd (session_orchestrator.dart:192-194) it returns the old repo''s session for the new repo.
2. ClaudePane spawns once behind a _spawned guard, under a GlobalKey host (claude_pane.dart:140, extension.dart:33/52) so after an in-place switch it never re-resolves; it keeps the previous repo''s session/conversation.
Fix (T-269):
- Orchestrator safety net: spawn() must reuse an existing session only when its cwd matches spec.cwd; a cwd mismatch is a stale cross-workspace binding -> close + spawn fresh.
- ClaudePane (primary) listens for ProjectOpened and rebinds: close its orch entry, null out _sessionId/_repoRoot, respawn for the new repo. Secondaries do not self-rebind.
- ClaudeSessionHost listens for ProjectOpened and drops secondary/fork tabs (the old repo''s), keeping just the primary a switched workspace starts like a fresh launch.
- Extension tears down all sessions whose cwd != new root on ProjectOpened (catches team/non-pane sessions).', 'done', 'high', NULL, NULL, 'D-77', '2026-06-07 09:44:17', '2026-06-07 09:51:32', NULL, '9b92ed68f5c352d5461c01d7e98c0b50', 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-48', 'story', NULL, 'application menu bar (Zed-style)', 'Add a Zed-style application menu integrated into the hat bar: File, Edit, Selection, View, Help/About. Rendered as custom Flutter widgets (no native menu bar — we own the chrome per D-7). Menus expose the same commands registered in the command palette with their keybindings shown inline. Submenus for View should include panel toggles, zoom, focus mode. About opens a modal with version, license, and links.', 'ready', 'medium', NULL, NULL, NULL, '2026-04-23 20:30:09', '2026-06-07 10:03:36', NULL, 'e09873c62cfab523b2b4048d9681c9ae', 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-270', 'story', NULL, 'make sidebar filter boxes addressable via MessageBus (clide ui filter)', NULL, 'backlog', 'medium', NULL, NULL, 'D-6', '2026-06-07 10:23:13', '2026-06-07 10:23:13', NULL, '0d79acd088da9d0b240ee5473c3a21c4', 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);
+5
View File
@@ -154,6 +154,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Fixed
- Switching the workspace in place (Open Project/Folder) now rebinds the Claude
pane to the new repo's session instead of keeping the previous repo's
conversation, and drops the old repo's secondary tabs. Separate windows were
already isolated — this only affected reusing one window for another repo.
(T-269)
- `/clear` in the primary Claude pane now clears that session **in place** —
it empties the pane's deterministic, restart-stable session instead of
starting a throwaway random one. Previously a cleared primary was orphaned:
+39
View File
@@ -67,6 +67,7 @@ class ClaudePane extends StatefulWidget {
class _ClaudePaneState extends State<ClaudePane> {
StreamSubscription<SessionStatus>? _statusSub;
StreamSubscription<ProjectOpened>? _projectSub;
ConversationController? _conversation;
StreamJsonSession? _session;
SessionStatus _status = const SessionStatus();
@@ -145,12 +146,19 @@ class _ClaudePaneState extends State<ClaudePane> {
unawaited(activeClaudeConfig?.ensureProbe());
// Reflect skills/config changes in the status line (T-154).
activeClaudeConfig?.addListener(_onConfigChanged);
// Rebind to the new repo's session when the workspace is switched in
// place (Open Project/Folder). This pane is built once behind a
// GlobalKey and spawns once, so without this it would keep the previous
// repo's session after a switch (T-269).
_projectSub = ClideKernel.of(context).events.on<ProjectOpened>().listen(_onProjectChanged);
}
}
@override
void dispose() {
activeClaudeConfig?.removeListener(_onConfigChanged);
_projectSub?.cancel();
_projectSub = null;
_statusSub?.cancel();
_statusSub = null;
// The orchestrator owns the session, so disposing this pane does NOT kill
@@ -185,6 +193,37 @@ class _ClaudePaneState extends State<ClaudePane> {
return _spawn();
}
/// Rebind to the active workspace when the project is switched in place
/// (T-269). Only the primary rebinds — secondaries/forks belong to the old
/// repo and are dropped by the host. A no-op when the path is unchanged or
/// the session hasn't resolved its repo yet.
void _onProjectChanged(ProjectOpened e) {
if (!mounted || !widget.isPrimary) return;
if (_repoRoot == null || e.path == _repoRoot) return;
unawaited(_rebindToActiveProject());
}
/// Tear down the current session and respawn against the now-active
/// workspace: drop the cached session id and repo root so [_spawn]
/// re-resolves both for the new repo (T-269).
Future<void> _rebindToActiveProject() async {
_statusSub?.cancel();
_statusSub = null;
await activeSessionOrchestrator?.close(_orchId); // kills the old repo's session
_conversation = null;
_session = null;
_sessionId = null;
_repoRoot = null;
if (mounted) {
setState(() {
_status = const SessionStatus();
_error = null;
_statusLine = 'starting…';
});
}
await _spawn();
}
Future<void> _spawn() async {
if (!mounted) return;
final ipc = _ipc();
@@ -1,3 +1,6 @@
import 'dart:async';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
@@ -21,6 +24,9 @@ class ClaudeSessionHostState extends State<ClaudeSessionHost> {
late final MultitabController<_Session> _controller;
int _nextSecondary = 1;
StreamSubscription<ProjectOpened>? _projectSub;
String? _projectRoot;
@override
void initState() {
super.initState();
@@ -39,8 +45,34 @@ class ClaudeSessionHostState extends State<ClaudeSessionHost> {
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_projectSub ??= ClideKernel.of(context).events.on<ProjectOpened>().listen(_onProjectChanged);
}
/// Reset to a lone primary tab when the workspace is switched in place
/// (T-269): the old repo's secondaries/forks don't belong in the new
/// workspace. Removing them disposes their panes, which close their sessions
/// through the orchestrator. The primary tab stays and rebinds itself.
void _onProjectChanged(ProjectOpened e) {
final prev = _projectRoot;
_projectRoot = e.path;
if (prev == null || prev == e.path) return; // initial open / no change
if (!mounted) return;
final stale = _controller.entries.where((x) => x.id != _primaryId).map((x) => x.id).toList();
if (stale.isEmpty) return;
setState(() {
for (final id in stale) {
_controller.remove(id);
}
_nextSecondary = 1;
});
}
@override
void dispose() {
_projectSub?.cancel();
_controller.dispose();
super.dispose();
}
+26
View File
@@ -36,6 +36,10 @@ class ClaudeExtension extends ClideExtension {
ClaudeSessionOrchestrator? _orchestrator;
final List<StreamSubscription<dynamic>> _subs = [];
/// Active workspace root, tracked so an in-place project switch can tear
/// down the previous repo's sessions (T-269).
String? _projectRoot;
/// App-wide Claude environment (skills, commands, settings, permissions,
/// slash list). Built and loaded at activation (D-76, T-151).
ClaudeConfig? get config => _config;
@@ -324,12 +328,34 @@ class ClaudeExtension extends ClideExtension {
_orchestrator = ClaudeSessionOrchestrator();
activeSessionOrchestrator = _orchestrator;
// An in-place workspace switch (Open Project/Folder) must not leave the
// previous repo's sessions running — including team/non-pane sessions the
// panes don't own. Close every session that doesn't belong to the new
// root; panes rebind themselves to the new repo (T-269).
_subs.add(ctx.events.on<ProjectOpened>().listen(_onProjectChanged));
// `clide image show <path>` (T-249): the dispatcher resolves + publishes an
// 'image' message; we inject the matching card into the conversation the
// user is looking at (the primary lead, else the first visible session).
_subs.add(ctx.messages.subscribe(channel: imageShowChannel).listen(_onImageShow));
}
/// Close every session that doesn't belong to the newly-active workspace
/// after an in-place project switch (T-269). The initial open (no previous
/// root) and a no-op re-open are skipped. Panes rebind to the new repo on
/// their own; this catches team/orphan sessions no pane owns.
void _onProjectChanged(ProjectOpened e) {
final prev = _projectRoot;
_projectRoot = e.path;
if (prev == null || prev == e.path) return;
final orch = _orchestrator;
if (orch == null) return;
final stale = orch.sessions.where((m) => m.cwd != e.path).map((m) => m.id).toList();
for (final id in stale) {
unawaited(orch.close(id));
}
}
/// Inject an [ImageMessage] from a published `image` bus message (T-249).
/// Dropped silently if no live conversation is available — the CLI already
/// reported success at publish time, and a missing pane is transient.
@@ -187,11 +187,20 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
ManagedSession? byId(String id) => _sessions[id];
/// Spawn and register a session. Idempotent on [SpawnSpec.id] — a repeat
/// call returns the existing session rather than starting a second process.
/// Spawn and register a session. Idempotent on [SpawnSpec.id] *within a
/// workspace* — a repeat call for the same [SpawnSpec.cwd] returns the
/// existing session rather than starting a second process (the fast path that
/// lets a hidden/kept-alive pane keep its session). A repeat call with the
/// SAME id but a DIFFERENT cwd means the workspace was switched in place
/// (T-269): the existing session belongs to the old repo, so it is torn down
/// and a fresh one spawned for the new repo — a pane must never inherit
/// another workspace's conversation.
Future<ManagedSession> spawn(SpawnSpec spec) async {
final existing = _sessions[spec.id];
if (existing != null) return existing;
if (existing != null) {
if (existing.cwd == spec.cwd) return existing;
await close(spec.id);
}
// Team sessions host the clide-team MCP server and get a roster + role
// injected into their system prompt (T-170). Register the member before
@@ -47,11 +47,11 @@ ClaudeSessionOrchestrator _orch(List<_FakeProc> created) {
);
}
SpawnSpec _spec(String id, {bool resume = false, String? transcriptPath}) => SpawnSpec(
SpawnSpec _spec(String id, {bool resume = false, String? transcriptPath, String cwd = '/repo'}) => SpawnSpec(
id: id,
role: id,
sessionId: '$id-uuid',
cwd: '/repo',
cwd: cwd,
resume: resume,
transcriptPath: transcriptPath,
);
@@ -140,6 +140,40 @@ void main() {
});
});
// ---- in-place workspace switch (T-269) ---------------------------------
group('spawn — cwd-aware idempotency (T-269)', () {
late List<_FakeProc> created;
late ClaudeSessionOrchestrator orch;
setUp(() {
created = [];
orch = _orch(created);
});
tearDown(() => orch.dispose());
test('same id + same cwd reuses the session (no second process)', () async {
final a = await orch.spawn(_spec('primary', cwd: '/repo-a'));
final b = await orch.spawn(_spec('primary', cwd: '/repo-a'));
expect(identical(a, b), isTrue);
expect(created, hasLength(1));
});
test('same id + different cwd tears down the old session and spawns fresh', () async {
// Workspace switched in place: the new repo must NOT inherit the old
// repo's 'primary' session.
final a = await orch.spawn(_spec('primary', cwd: '/repo-a'));
final b = await orch.spawn(_spec('primary', cwd: '/repo-b'));
expect(identical(a, b), isFalse);
expect(b.cwd, '/repo-b');
expect(created, hasLength(2));
expect(created.first.killed, isTrue, reason: 'old repo session is killed');
expect(orch.sessions, hasLength(1));
expect(orch.byId('primary')!.cwd, '/repo-b');
});
});
// ---- /resume — bind to an existing session via orchestrator -------------
group('/resume — resume an existing session via orchestrator', () {