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