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
+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