diff --git a/docs/design/2026-08-10-transactional-webui-session-switching.md b/docs/design/2026-08-10-transactional-webui-session-switching.md
new file mode 100644
index 00000000000..06ab2e95949
--- /dev/null
+++ b/docs/design/2026-08-10-transactional-webui-session-switching.md
@@ -0,0 +1,37 @@
+# Transactional cross-session switching
+
+## Problem
+
+The WebUI historically detached the current session, stopped its event stream, and cleared its transcript before a target `loadSession` or `resumeSession` completed. A slow or failed restore therefore left the user without the still-healthy source session. The WebShell also keyed its main provider by the requested session, so controlled navigation remounted the provider before the target was usable.
+
+## Scope
+
+This change makes only cross-logical-session load and resume transactional. A logical target is the normalized `(sessionId, workspaceCwd)` pair. Initial bootstrap, same-logical reload, client-id replacement, full resync, memory repair, and branch adoption retain their existing behavior and are follow-up work.
+
+Modern transactional behavior requires a successful capability snapshot that advertises `client_identity` and concrete client IDs for both attachments. A daemon that explicitly lacks the feature retains the legacy destructive path. Unknown capabilities or malformed modern responses fail closed and preserve the source.
+
+## Coordinator
+
+Each provider owns one raw restore slot and one desired intent. Equivalent requests coalesce. A newer target rejects the prior public intent and replaces the queued intent, while an already-running SDK request continues to settlement because it is not cancellable. Its result is adopted only when it still matches the latest target; otherwise its attachment is detached once on a best-effort basis. The queued deadline begins when the caller requests the switch, so an expired target never starts a restore.
+
+Commit is guarded by the desired intent, absolute deadline, provider environment, local lifecycle, source logical identity, and restored target identity. Timeout, SDK failure, supersede, staging failure, and commit are explicit competing terminal states rather than an implicit `Promise.race`.
+
+## Staging and commit
+
+Replay is normalized into an unsubscribed shadow transcript store in batches of at most 512 events. The compacted replay and live journal arrays are traversed directly and are not concatenated. Only bounded summaries of notices and side-channel events are retained. Staging never writes the visible transcript, connection, prompt maps, notices, or workspace signals.
+
+After the final guard succeeds, one synchronous commit flushes the source runner's legal buffered events, stops its stream, installs the target transcript/history/session/workspace/client and connection ref, notifies the WebShell wrapper, publishes staged side effects, and settles source-local prompt waiters. The public load promise resolves only after those synchronous owners agree. Target metadata and SSE start afterward without a second restore. Source detach is asynchronous, single-attempt, and never blocks the public result or the next restore.
+
+## WebShell ownership
+
+For modern daemons, the main workspace wrapper keeps one provider instance and separates the desired target from the committed target. Workspace resolution and restore failures continue rendering the committed source. A synchronous commit callback advances wrapper ownership before the public promise resolves. Stable failed targets are latched so unrelated renders do not retry them; a controlled failure rolls the host back only while the failed desired generation is still current.
+
+Session transition state gates new prompt and mutation entry points while preserving the source event stream, existing prompt completion, cancellation, permissions, and read-only controls. UI navigation uses an invocation token plus an attachment-identity snapshot so stale completion handlers cannot clear or focus a newer request. Session-owned worktree, branch, git intent, and recap state are not cleared until ownership commits.
+
+## Compatibility and risks
+
+Legacy daemons keep the old keyed/destructive behavior. Cleanup is deliberately best effort: a failed detach can leave an invisible client reference until the existing reaper runs. Staging temporarily holds the source transcript and target replay at once, and CPU-heavy restore work in a shared ACP child can still delay source events. This change does not optimize JSONL reading, selective replay, or daemon capacity.
+
+## Verification
+
+Unit coverage exercises delayed success/failure, exact-target coalescing, latest-only serialization, controlled switching, malformed ownership, write gating, synchronous commit ownership, source events during preparation, wrapper remount compatibility, workspace resolution failure, invocation fencing, and post-commit catch-up timeout behavior. A focused JSDOM/real-daemon test delays delivery of an already-completed target restore response and verifies that the source remains usable until atomic commit; a structured 504 must leave the source intact.
diff --git a/integration-tests/cli/qwen-serve-webui-session-switching.test.ts b/integration-tests/cli/qwen-serve-webui-session-switching.test.ts
new file mode 100644
index 00000000000..1e2c8d7d5fb
--- /dev/null
+++ b/integration-tests/cli/qwen-serve-webui-session-switching.test.ts
@@ -0,0 +1,334 @@
+/**
+ * @license
+ * Copyright 2025 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { act, createElement } from 'react';
+import type { Root } from 'react-dom/client';
+import { JSDOM } from 'jsdom';
+import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
+import {
+ DaemonHttpError,
+ type DaemonTranscriptBlock,
+} from '@qwen-code/sdk/daemon';
+import {
+ makeTempWorkspace,
+ spawnDaemon,
+ type SpawnedDaemon,
+} from './_daemon-harness.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const MOCK_AGENT_PATH = path.resolve(
+ __dirname,
+ '../fixtures/mock-acp-child/agent.mjs',
+);
+
+let activeDaemon: SpawnedDaemon | undefined;
+let root: Root | undefined;
+let dom: JSDOM;
+let createRoot: typeof import('react-dom/client').createRoot;
+let DaemonSessionProvider: typeof import('@qwen-code/webui/daemon-react-sdk').DaemonSessionProvider;
+let useActions: typeof import('@qwen-code/webui/daemon-react-sdk').useActions;
+let useConnection: typeof import('@qwen-code/webui/daemon-react-sdk').useConnection;
+let useTranscriptBlocks: typeof import('@qwen-code/webui/daemon-react-sdk').useTranscriptBlocks;
+const originalGlobalDescriptors = new Map(
+ ['window', 'document', 'navigator', 'IS_REACT_ACT_ENVIRONMENT'].map(
+ (key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,
+ ),
+);
+
+beforeAll(async () => {
+ dom = new JSDOM('
', {
+ url: 'http://localhost',
+ });
+ Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ value: dom.window,
+ });
+ Object.defineProperty(globalThis, 'document', {
+ configurable: true,
+ value: dom.window.document,
+ });
+ Object.defineProperty(globalThis, 'navigator', {
+ configurable: true,
+ value: dom.window.navigator,
+ });
+ Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
+ configurable: true,
+ value: true,
+ });
+ ({ createRoot } = await import('react-dom/client'));
+ ({ DaemonSessionProvider, useActions, useConnection, useTranscriptBlocks } =
+ await import('@qwen-code/webui/daemon-react-sdk'));
+});
+
+afterAll(() => {
+ dom.window.close();
+ for (const [key, descriptor] of originalGlobalDescriptors) {
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
+ else Reflect.deleteProperty(globalThis, key);
+ }
+});
+
+afterEach(async () => {
+ if (root) {
+ await act(async () => root?.unmount());
+ root = undefined;
+ }
+ await activeDaemon?.dispose();
+ activeDaemon = undefined;
+});
+
+function deferred() {
+ let resolve!: (value: T | PromiseLike) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+async function waitFor(
+ condition: () => boolean,
+ description: string,
+ timeoutMs = 8_000,
+): Promise {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (condition()) return;
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ });
+ }
+ throw new Error(`Timed out waiting for ${description}`);
+}
+
+describe('qwen serve WebUI transactional session switching', () => {
+ async function setup() {
+ const workspace = makeTempWorkspace('webui-session-switching');
+ activeDaemon = await spawnDaemon({
+ workspaceCwd: workspace,
+ env: {
+ QWEN_CLI_ENTRY: MOCK_AGENT_PATH,
+ MOCK_ACP_MODE: 'echo',
+ },
+ });
+ const source = await activeDaemon.client.createOrAttachSession({
+ sessionScope: 'thread',
+ });
+ const resolvedWorkspace = source.workspaceCwd ?? workspace;
+ await activeDaemon.client.prompt(source.sessionId, {
+ prompt: [{ type: 'text', text: 'source transcript' }],
+ });
+ const target = await activeDaemon.client.createOrAttachSession({
+ sessionScope: 'thread',
+ });
+ await activeDaemon.client.prompt(target.sessionId, {
+ prompt: [{ type: 'text', text: 'target transcript' }],
+ });
+ let actions: ReturnType | undefined;
+ let connection: ReturnType | undefined;
+ let blocks: readonly DaemonTranscriptBlock[] = [];
+ function Harness() {
+ actions = useActions();
+ connection = useConnection();
+ blocks = useTranscriptBlocks();
+ return null;
+ }
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ await act(async () => {
+ root?.render(
+ createElement(
+ DaemonSessionProvider,
+ {
+ autoConnect: true,
+ baseUrl: activeDaemon!.base,
+ token: activeDaemon!.token,
+ sessionId: source.sessionId,
+ workspaceCwd: resolvedWorkspace,
+ },
+ createElement(Harness),
+ ),
+ );
+ });
+ await waitFor(
+ () =>
+ connection?.status === 'connected' &&
+ connection.sessionId === source.sessionId &&
+ connection.capabilities?.features.includes('client_identity') ===
+ true &&
+ JSON.stringify(blocks).includes('source transcript'),
+ 'source session bootstrap',
+ );
+ return {
+ workspace: resolvedWorkspace,
+ source,
+ target,
+ getActions: () => {
+ if (!actions) throw new Error('session actions unavailable');
+ return actions;
+ },
+ getConnection: () => connection,
+ getBlocks: () => blocks,
+ };
+ }
+
+ it('keeps the source usable until a completed target response is released', async () => {
+ const originalFetch = globalThis.fetch;
+ const state = await setup();
+ const responseReady = deferred();
+ const releaseResponse = deferred();
+ let loadOutcome: Promise | undefined;
+ try {
+ globalThis.fetch = async (input, init) => {
+ const request =
+ input instanceof Request ? input : new Request(input, init);
+ const response = await originalFetch(request);
+ if (
+ request.method === 'POST' &&
+ new URL(request.url).pathname.endsWith(
+ `/session/${encodeURIComponent(state.target.sessionId)}/load`,
+ )
+ ) {
+ responseReady.resolve();
+ await releaseResponse.promise;
+ }
+ return response;
+ };
+ act(() => {
+ loadOutcome = state
+ .getActions()
+ .loadSession(state.target.sessionId, {
+ workspaceCwd: state.workspace,
+ })
+ .then(
+ () => undefined,
+ (error: unknown) => error,
+ );
+ });
+ await responseReady.promise;
+
+ expect(state.getConnection()).toMatchObject({
+ status: 'connected',
+ sessionId: state.source.sessionId,
+ sessionTransition: { phase: 'preparing' },
+ });
+ await expect(state.getActions().cancel()).resolves.toBeUndefined();
+ await activeDaemon!.client.prompt(state.source.sessionId, {
+ prompt: [{ type: 'text', text: 'source remains live' }],
+ });
+ await waitFor(
+ () => JSON.stringify(state.getBlocks()).includes('source remains live'),
+ 'source event while target response is held',
+ );
+
+ await act(async () => {
+ releaseResponse.resolve();
+ expect(await loadOutcome).toBeUndefined();
+ });
+ expect(state.getConnection()).toMatchObject({
+ status: 'connected',
+ sessionId: state.target.sessionId,
+ });
+ expect(JSON.stringify(state.getBlocks())).toContain('target transcript');
+ expect(JSON.stringify(state.getBlocks())).not.toContain(
+ 'source remains live',
+ );
+ } finally {
+ globalThis.fetch = originalFetch;
+ releaseResponse.resolve();
+ await loadOutcome?.catch(() => undefined);
+ if (root) {
+ await act(async () => root?.unmount());
+ root = undefined;
+ }
+ await activeDaemon?.dispose();
+ activeDaemon = undefined;
+ fs.rmSync(state.workspace, { recursive: true, force: true });
+ }
+ }, 30_000);
+
+ it('preserves the source after a structured target timeout', async () => {
+ const originalFetch = globalThis.fetch;
+ const state = await setup();
+ try {
+ globalThis.fetch = async (input, init) => {
+ const request =
+ input instanceof Request ? input : new Request(input, init);
+ if (
+ request.method === 'POST' &&
+ new URL(request.url).pathname.endsWith(
+ `/session/${encodeURIComponent(state.target.sessionId)}/load`,
+ )
+ ) {
+ return new Response(
+ JSON.stringify({
+ code: 'session_restore_timeout',
+ error: 'Session restore timed out',
+ retryable: true,
+ }),
+ {
+ status: 504,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Retry-After': '5',
+ },
+ },
+ );
+ }
+ return originalFetch(request);
+ };
+ let restoreError: unknown;
+ await act(async () => {
+ try {
+ await state.getActions().loadSession(state.target.sessionId, {
+ workspaceCwd: state.workspace,
+ });
+ } catch (error) {
+ restoreError = error;
+ }
+ });
+ expect(restoreError).toBeInstanceOf(DaemonHttpError);
+ expect(restoreError).toMatchObject({
+ status: 504,
+ body: {
+ code: 'session_restore_timeout',
+ retryable: true,
+ },
+ });
+ expect(state.getConnection()).toMatchObject({
+ status: 'connected',
+ sessionId: state.source.sessionId,
+ sessionTransition: {
+ phase: 'failed',
+ error: { code: 'session_restore_timeout', status: 504 },
+ },
+ });
+ expect(JSON.stringify(state.getBlocks())).toContain('source transcript');
+ await activeDaemon!.client.prompt(state.source.sessionId, {
+ prompt: [{ type: 'text', text: 'source after timeout' }],
+ });
+ await waitFor(
+ () =>
+ JSON.stringify(state.getBlocks()).includes('source after timeout'),
+ 'source event after target timeout',
+ );
+ } finally {
+ globalThis.fetch = originalFetch;
+ if (root) {
+ await act(async () => root?.unmount());
+ root = undefined;
+ }
+ await activeDaemon?.dispose();
+ activeDaemon = undefined;
+ fs.rmSync(state.workspace, { recursive: true, force: true });
+ }
+ }, 30_000);
+});
diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx
index bd86103ff34..e740f8b24f2 100644
--- a/packages/web-shell/client/App.test.tsx
+++ b/packages/web-shell/client/App.test.tsx
@@ -274,6 +274,7 @@ const {
listScheduledTasks: vi.fn(),
updateScheduledTask: vi.fn(),
deleteScheduledTask: vi.fn(),
+ deleteModel: vi.fn().mockResolvedValue(undefined),
},
mockMcp: {
initialize: vi.fn().mockResolvedValue({ accepted: true }),
@@ -301,6 +302,7 @@ const {
onDismissFollowup: vi.fn(),
},
testState: {
+ ownerVersion: 0,
prompt: 'hello',
inputAnnotations: undefined as DaemonInputAnnotation[] | undefined,
promptImages: undefined as
@@ -315,11 +317,22 @@ const {
latestStatusBarTasks: null as DaemonSessionMonitorTaskStatus[] | null,
latestStatusBarOnOpenTasks: null as (() => void) | null,
latestMessageListProps: null as {
+ messages?: Array<{
+ role?: string;
+ content?: string;
+ answer?: string;
+ isPending?: boolean;
+ }>;
failedPromptMessageId?: string;
onRetryFailedPrompt?: () => void;
isResponding?: boolean;
activeTurnStartedAt?: number;
} | null,
+ latestBtwMessageProps: null as {
+ question: string;
+ answer: string;
+ isPending: boolean;
+ } | null,
latestAddWorkspaceDialogProps: null as AddWorkspaceDialogTestProps | null,
latestToolApprovalKeyboardActive: null as boolean | null,
toolApprovalKeyboardActiveHistory: [] as Array,
@@ -352,6 +365,15 @@ const {
latestSettingsState: null as {
settings: DaemonSettingDescriptor[];
} | null,
+ latestModelManagement: null as {
+ busy?: boolean;
+ onSelectModel?: (modelId: string) => void;
+ onDeleteModel?: (target: {
+ authType: string;
+ modelId: string;
+ baseUrl?: string;
+ }) => void;
+ } | null,
latestScheduledTasksProps: null as {
onRunPrompt?: (
prompt: string,
@@ -392,51 +414,60 @@ const {
};
});
-vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
- DAEMON_APPROVAL_MODES: ['default', 'plan', 'auto-edit', 'auto', 'yolo'],
- DaemonSessionProvider: ({ children }: { children: ReactNode }) => children,
- useActions: () => mockSessionActions,
- useConnection: () => mockConnection,
- useDaemonFollowupSuggestion: () => ({
- followupState: null,
- clear: mockFollowup.clear,
- onAcceptFollowup: mockFollowup.onAcceptFollowup,
- onDismissFollowup: mockFollowup.onDismissFollowup,
- }),
- useSessionNotices: () => ({ notices: [], dismissNotice: vi.fn() }),
- usePromptStatus: () => 'idle',
- useSettings: () => ({
- settings: testState.settings,
- setValue: settingsSetValue,
- reload: settingsReload,
- loading: false,
- }),
- useProviders: () => ({
- providers: [],
- current: undefined,
- loading: false,
- error: undefined,
- reload: vi.fn().mockResolvedValue(undefined),
- }),
- useStreamingState: () => testState.streamingState,
- useTranscriptBlocks: () => testState.blocks,
- useTranscriptHistory: () => ({
- hasMore: false,
- loading: false,
- capacityReached: false,
- paginationError: false,
- loadMore: vi.fn(),
- release: vi.fn(),
- }),
- useTranscriptStore: () => mockStore,
- useWorkspace: () => mockWorkspace,
- useWorkspaceActions: () => mockWorkspaceActions,
- useMcp: () => mockMcp,
- useWorkspaceEventSignals: () => ({
- artifactsVersion: 0,
- extensionsVersion: 0,
- }),
-}));
+vi.mock('@qwen-code/webui/daemon-react-sdk', () => {
+ const ownerGuard = {
+ capture: () => {
+ const ownerVersion = testState.ownerVersion;
+ return { isCurrent: () => testState.ownerVersion === ownerVersion };
+ },
+ };
+ return {
+ DAEMON_APPROVAL_MODES: ['default', 'plan', 'auto-edit', 'auto', 'yolo'],
+ DaemonSessionProvider: ({ children }: { children: ReactNode }) => children,
+ useActions: () => mockSessionActions,
+ useConnection: () => mockConnection,
+ useDaemonSessionOwnerGuard: () => ownerGuard,
+ useDaemonFollowupSuggestion: () => ({
+ followupState: null,
+ clear: mockFollowup.clear,
+ onAcceptFollowup: mockFollowup.onAcceptFollowup,
+ onDismissFollowup: mockFollowup.onDismissFollowup,
+ }),
+ useSessionNotices: () => ({ notices: [], dismissNotice: vi.fn() }),
+ usePromptStatus: () => 'idle',
+ useSettings: () => ({
+ settings: testState.settings,
+ setValue: settingsSetValue,
+ reload: settingsReload,
+ loading: false,
+ }),
+ useProviders: () => ({
+ providers: [],
+ current: undefined,
+ loading: false,
+ error: undefined,
+ reload: vi.fn().mockResolvedValue(undefined),
+ }),
+ useStreamingState: () => testState.streamingState,
+ useTranscriptBlocks: () => testState.blocks,
+ useTranscriptHistory: () => ({
+ hasMore: false,
+ loading: false,
+ capacityReached: false,
+ paginationError: false,
+ loadMore: vi.fn(),
+ release: vi.fn(),
+ }),
+ useTranscriptStore: () => mockStore,
+ useWorkspace: () => mockWorkspace,
+ useWorkspaceActions: () => mockWorkspaceActions,
+ useMcp: () => mockMcp,
+ useWorkspaceEventSignals: () => ({
+ artifactsVersion: 0,
+ extensionsVersion: 0,
+ }),
+ };
+});
vi.mock('@qwen-code/sdk/daemon', () => ({
DaemonHttpError: class DaemonHttpError extends Error {
@@ -636,6 +667,12 @@ vi.mock('./components/MessageList', async () => {
return {
MessageList: React.forwardRef(function MessageList(
props: {
+ messages?: Array<{
+ role?: string;
+ content?: string;
+ answer?: string;
+ isPending?: boolean;
+ }>;
showRetryHint?: boolean;
onRetryClick?: () => void;
failedPromptMessageId?: string;
@@ -695,8 +732,18 @@ vi.mock('./components/messages/SettingsMessage', async () => {
language: string,
scope: 'user' | 'workspace',
) => void;
+ modelManagement?: {
+ busy?: boolean;
+ onSelectModel?: (modelId: string) => void;
+ onDeleteModel?: (target: {
+ authType: string;
+ modelId: string;
+ baseUrl?: string;
+ }) => void;
+ };
}) => {
testState.latestSettingsState = props.settingsState;
+ testState.latestModelManagement = props.modelManagement ?? null;
return React.createElement(
'div',
{ 'data-testid': 'settings-message' },
@@ -1449,7 +1496,19 @@ vi.doMock('./monitorDetailsContext', async () => {
},
};
});
-mockComponent('./components/messages/BtwMessage', 'BtwMessage');
+vi.doMock('./components/messages/BtwMessage', async () => {
+ const React = await import('react');
+ return {
+ BtwMessage: (props: {
+ question: string;
+ answer: string;
+ isPending: boolean;
+ }) => {
+ testState.latestBtwMessageProps = props;
+ return React.createElement('div');
+ },
+ };
+});
mockComponent('./components/QueuedPromptDisplay', 'QueuedPromptDisplay');
const {
@@ -4347,6 +4406,7 @@ beforeEach(() => {
};
mockConnection.gitBranch = undefined;
mockConnection.gitStatus = undefined;
+ testState.ownerVersion = 0;
mockWorkspace.capabilities = {
workspaces: [{ id: 'primary', cwd: '/workspace', primary: true }],
};
@@ -4397,6 +4457,7 @@ beforeEach(() => {
testState.latestStatusBarTasks = null;
testState.latestStatusBarOnOpenTasks = null;
testState.latestMessageListProps = null;
+ testState.latestBtwMessageProps = null;
testState.latestAddWorkspaceDialogProps = null;
testState.latestToolApprovalKeyboardActive = null;
testState.toolApprovalKeyboardActiveHistory = [];
@@ -4412,6 +4473,7 @@ beforeEach(() => {
testState.latestMonitorDetailsOnOpen = null;
testState.settings = [];
testState.latestSettingsState = null;
+ testState.latestModelManagement = null;
testState.latestScheduledTasksProps = null;
testState.latestGoalsProps = null;
rawEnqueuePrompt.mockClear();
@@ -4515,6 +4577,8 @@ beforeEach(() => {
mockWorkspaceActions.listScheduledTasks.mockReset();
mockWorkspaceActions.updateScheduledTask.mockReset();
mockWorkspaceActions.deleteScheduledTask.mockReset();
+ mockWorkspaceActions.deleteModel.mockReset();
+ mockWorkspaceActions.deleteModel.mockResolvedValue(undefined);
mockMcp.initialize.mockClear();
mockMcp.initialize.mockResolvedValue({ accepted: true });
mockMcp.reloadConfig.mockClear();
@@ -6939,6 +7003,59 @@ describe('App session callbacks', () => {
});
});
+ it('does not expose cached metadata after a same-id workspace switch', async () => {
+ mockConnection.displayName = undefined;
+ const sourceStatus = deferred<{
+ workspaceCwd: string;
+ displayName: string;
+ }>();
+ mockWorkspace.client.sessionStatus.mockReturnValueOnce(
+ sourceStatus.promise,
+ );
+ const sourceList = deferred();
+ mockWorkspace.client.listWorkspaceSessions.mockReturnValueOnce(
+ sourceList.promise,
+ );
+ const targetStatus = deferred<{ workspaceCwd: string }>();
+ mockWorkspace.client.sessionStatus.mockReturnValueOnce(
+ targetStatus.promise,
+ );
+ const { container, rerender } = renderApp();
+
+ await act(async () => {
+ sourceStatus.resolve({
+ workspaceCwd: '/work/a',
+ displayName: 'Session A title',
+ });
+ sourceList.resolve([]);
+ await Promise.all([sourceStatus.promise, sourceList.promise]);
+ });
+
+ await vi.waitFor(() => {
+ expect(
+ container.querySelector('[data-testid="chat-context-header"]')
+ ?.textContent,
+ ).toContain('Session A title');
+ });
+
+ testState.ownerVersion += 1;
+ mockConnection.workspaceCwd = '/work/b';
+ rerender();
+ await flush();
+ rerender();
+
+ expect(
+ container.querySelector('[data-testid="chat-context-header"]')
+ ?.textContent,
+ ).not.toContain('Session A title');
+
+ await act(async () => {
+ targetStatus.resolve({ workspaceCwd: '/work/b' });
+ await targetStatus.promise;
+ });
+ await flush();
+ });
+
it('keeps the persistent chat header opt-in for existing integrations', () => {
const { container } = renderApp({ header: undefined });
@@ -9633,6 +9750,9 @@ describe('App session callbacks', () => {
it('discards an automatic recap after switching to an existing session', async () => {
const { recap, container } = await triggerAutoRecap();
+ mockSessionActions.loadSession.mockImplementationOnce(async () => {
+ testState.ownerVersion += 1;
+ });
await act(async () => {
container
.querySelector('[data-testid="load-session"]')
@@ -9652,8 +9772,36 @@ describe('App session callbacks', () => {
]);
});
+ it('keeps an automatic recap when an existing-session switch fails', async () => {
+ const { recap } = await triggerAutoRecap();
+ mockSessionActions.loadSession.mockRejectedValueOnce(
+ new Error('target restore failed'),
+ );
+
+ await act(async () => {
+ window.dispatchEvent(
+ new CustomEvent('qwen:open-session', { detail: 'session-2' }),
+ );
+ await Promise.resolve();
+ });
+ await act(async () => {
+ recap.resolve({ sessionId: 'session-1', recap: 'Current session recap' });
+ await recap.promise;
+ });
+
+ expect(mockStore.dispatch).toHaveBeenCalledWith([
+ expect.objectContaining({
+ source: 'recap',
+ text: expect.stringContaining('Current session recap'),
+ }),
+ ]);
+ });
+
it('discards an automatic recap after resuming a session by command', async () => {
const { recap } = await triggerAutoRecap();
+ mockSessionActions.loadSession.mockImplementationOnce(async () => {
+ testState.ownerVersion += 1;
+ });
await act(async () => {
testState.latestChatEditorProps?.onSubmit('/resume session-3');
recap.resolve({
@@ -9663,7 +9811,9 @@ describe('App session callbacks', () => {
await recap.promise;
});
- expect(mockSessionActions.loadSession).toHaveBeenCalledWith('session-3');
+ expect(mockSessionActions.loadSession).toHaveBeenCalledWith('session-3', {
+ workspaceCwd: undefined,
+ });
expect(mockStore.dispatch).not.toHaveBeenCalledWith([
expect.objectContaining({ source: 'recap' }),
]);
@@ -9766,6 +9916,49 @@ describe('App session callbacks', () => {
expect(editorFocus).toHaveBeenCalledOnce();
});
+ it('does not finish a same-id workspace switch before commit', async () => {
+ const load = deferred();
+ mockSessionActions.loadSession.mockImplementationOnce(() => {
+ mockConnection.sessionTransition = {
+ phase: 'preparing',
+ operation: 'load',
+ origin: 'action',
+ targetSessionId: 'session-1',
+ targetWorkspaceCwd: '/work/b',
+ };
+ return load.promise;
+ });
+ const { rerender } = renderApp();
+ await flush();
+ editorFocus.mockClear();
+
+ await act(async () => {
+ window.dispatchEvent(
+ new CustomEvent('qwen:open-session', {
+ detail: { sessionId: 'session-1', workspaceCwd: '/work/b' },
+ }),
+ );
+ await Promise.resolve();
+ });
+ await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
+ expect(editorFocus).not.toHaveBeenCalled();
+ expect(testState.latestChatEditorProps?.disabled).toBe(true);
+ expect(testState.latestChatEditorProps?.onSubmit('must stay on A')).toBe(
+ false,
+ );
+ expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
+
+ await act(async () => {
+ mockConnection.workspaceCwd = '/work/b';
+ mockConnection.sessionTransition = undefined;
+ load.resolve();
+ rerender();
+ await load.promise;
+ });
+ await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
+ expect(editorFocus).toHaveBeenCalledOnce();
+ });
+
it('opens a Live session in its owning Conversations workspace', async () => {
renderApp();
await flush();
@@ -11114,6 +11307,97 @@ describe('App session callbacks', () => {
});
});
+ it('preserves the turn-error retry while session writes are blocked', async () => {
+ const { container, rerender } = renderApp();
+ await flush();
+
+ testState.prompt = 'recover this stream';
+ await clickSubmit(container);
+ mockSessionActions.sendPrompt.mockClear();
+ act(() => {
+ testState.blocks = [
+ {
+ kind: 'error',
+ source: 'turn_error',
+ id: 'turn-error-switching',
+ errorKind: 'model_stream_interrupted',
+ text: 'terminated',
+ },
+ ];
+ rerender({ desiredSessionTargetPending: true });
+ });
+
+ const retry = container.querySelector(
+ '[data-testid="retry"]',
+ );
+ expect(retry).not.toBeNull();
+ act(() => retry?.click());
+
+ expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
+ expect(container.querySelector('[data-testid="retry"]')).not.toBeNull();
+
+ act(() => rerender({ desiredSessionTargetPending: false }));
+ await flush();
+ const unblockedRetry = container.querySelector(
+ '[data-testid="retry"]',
+ );
+ expect(unblockedRetry).not.toBeNull();
+ act(() => unblockedRetry?.click());
+ await flush();
+ expect(mockSessionActions.sendPrompt).toHaveBeenCalledOnce();
+ });
+
+ it('does not settle a turn-error retry into a different workspace', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const retrySend = deferred();
+ const { container, rerender } = renderApp();
+ await flush();
+
+ testState.prompt = 'recover this stream';
+ await clickSubmit(container);
+ mockSessionActions.sendPrompt.mockClear();
+ act(() => {
+ testState.blocks = [
+ {
+ kind: 'error',
+ source: 'turn_error',
+ id: 'turn-error-cross-workspace',
+ errorKind: 'model_stream_interrupted',
+ text: 'terminated',
+ },
+ ];
+ rerender();
+ });
+ mockSessionActions.sendPrompt.mockReturnValueOnce(retrySend.promise);
+
+ await act(async () => {
+ container
+ .querySelector('[data-testid="retry"]')
+ ?.click();
+ await Promise.resolve();
+ });
+ const retryOptions = mockSessionActions.sendPrompt.mock.calls[0]?.[1];
+ act(() => {
+ retryOptions?.onAdmissionStarted?.();
+ mockConnection.workspaceCwd = '/other-workspace';
+ testState.ownerVersion += 1;
+ rerender();
+ });
+ await act(async () => {
+ retrySend.reject(new Error('response lost'));
+ await Promise.resolve();
+ });
+
+ expect(
+ container.querySelector('[data-testid="prompt-admission-unknown"]'),
+ ).toBeNull();
+ expect(warn).not.toHaveBeenCalledWith(
+ '[WebShell] post-turn retry admission outcome is unknown',
+ expect.anything(),
+ );
+ warn.mockRestore();
+ });
+
it('locks an image retry when its admission response is lost', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const retrySend = deferred();
@@ -11335,6 +11619,65 @@ describe('App session callbacks', () => {
expect(container.querySelector('button[title="Side task"]')).toBeNull();
});
+ it('settles visible recap after a same-id attachment replacement', async () => {
+ const recap = deferred<{ sessionId: string; recap: string | null }>();
+ mockSessionActions.recapSession.mockReturnValueOnce(recap.promise);
+ const { container, rerender } = renderApp();
+ await flush();
+
+ testState.prompt = '/recap';
+ await clickSubmit(container);
+ expect(
+ testState.latestMessageListProps?.messages?.some((message) =>
+ message.content?.includes('Generating recap'),
+ ),
+ ).toBe(true);
+
+ act(() => {
+ testState.ownerVersion += 1;
+ rerender();
+ });
+ await act(async () => {
+ recap.resolve({ sessionId: 'session-1', recap: 'Reconnect-safe recap' });
+ await recap.promise;
+ });
+
+ expect(
+ testState.latestMessageListProps?.messages?.some((message) =>
+ message.content?.includes('Reconnect-safe recap'),
+ ),
+ ).toBe(true);
+ });
+
+ it('settles visible btw after a same-id attachment replacement', async () => {
+ const btw = deferred<{ answer: string }>();
+ mockSessionActions.btwSession.mockReturnValueOnce(btw.promise);
+ const { container, rerender } = renderApp();
+ await flush();
+
+ testState.prompt = '/btw keep this answer';
+ await clickSubmit(container);
+ expect(testState.latestBtwMessageProps).toMatchObject({
+ question: 'keep this answer',
+ isPending: true,
+ });
+
+ act(() => {
+ testState.ownerVersion += 1;
+ rerender();
+ });
+ await act(async () => {
+ btw.resolve({ answer: 'Reconnect-safe answer' });
+ await btw.promise;
+ });
+
+ expect(testState.latestBtwMessageProps).toMatchObject({
+ question: 'keep this answer',
+ answer: 'Reconnect-safe answer',
+ isPending: false,
+ });
+ });
+
it('opens a new side task for /btw side when the capability is available', async () => {
mockConnection.capabilities.features = ['session_side_task'];
const { container } = renderApp();
@@ -11534,6 +11877,95 @@ describe('App session callbacks', () => {
);
});
+ it('does not send a deferred plan prompt into a replacement owner', async () => {
+ const approval = deferred();
+ mockSessionActions.setApprovalMode.mockReturnValueOnce(approval.promise);
+ const { container, rerender } = renderApp();
+ await flush();
+
+ testState.prompt = '/plan explain the migration';
+ await clickSubmit(container);
+ expect(mockSessionActions.setApprovalMode).toHaveBeenCalledWith('plan');
+
+ act(() => {
+ testState.ownerVersion += 1;
+ mockConnection.sessionId = 'session-2';
+ rerender();
+ });
+ await act(async () => {
+ approval.resolve();
+ await approval.promise;
+ });
+
+ expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
+ expect(testState.latestChatEditorProps?.isPreparing).toBe(false);
+ });
+
+ it('clears deferred plan preparation after a same-session reattach', async () => {
+ const approval = deferred();
+ mockSessionActions.setApprovalMode.mockReturnValueOnce(approval.promise);
+ const { container, rerender } = renderApp();
+ await flush();
+
+ testState.prompt = '/plan explain the migration';
+ await clickSubmit(container);
+ expect(testState.latestChatEditorProps?.isPreparing).toBe(true);
+
+ act(() => {
+ testState.ownerVersion += 1;
+ rerender();
+ });
+ await act(async () => {
+ approval.resolve();
+ await approval.promise;
+ });
+
+ expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
+ expect(testState.latestChatEditorProps?.isPreparing).toBe(false);
+ });
+
+ it('does not let an A-to-B-to-A plan completion clear newer preparation', async () => {
+ const firstApproval = deferred();
+ const secondApproval = deferred();
+ mockSessionActions.setApprovalMode
+ .mockReturnValueOnce(firstApproval.promise)
+ .mockReturnValueOnce(secondApproval.promise);
+ const { container, rerender } = renderApp();
+ await flush();
+
+ testState.prompt = '/plan first';
+ await clickSubmit(container);
+
+ act(() => {
+ testState.ownerVersion += 1;
+ mockConnection.sessionId = 'session-2';
+ rerender();
+ });
+ await flush();
+ act(() => {
+ testState.ownerVersion += 1;
+ mockConnection.sessionId = 'session-1';
+ rerender();
+ });
+ await flush();
+
+ testState.prompt = '/plan second';
+ await clickSubmit(container);
+ expect(testState.latestChatEditorProps?.isPreparing).toBe(true);
+
+ await act(async () => {
+ firstApproval.resolve();
+ await firstApproval.promise;
+ });
+ expect(testState.latestChatEditorProps?.isPreparing).toBe(true);
+
+ await act(async () => {
+ secondApproval.resolve();
+ await secondApproval.promise;
+ });
+ expect(testState.latestChatEditorProps?.isPreparing).toBe(false);
+ });
+
it('dispatches turn_complete only for the session that was streaming', async () => {
const onSessionChange = vi.fn();
const { container, rerender } = renderApp({ onSessionChange });
@@ -14298,6 +14730,124 @@ describe('App session callbacks', () => {
expect(settingsReload).toHaveBeenCalled();
});
+ it('clears model selection busy state after a same-session reattach', async () => {
+ const selection = deferred();
+ mockSessionActions.setModel.mockReturnValueOnce(selection.promise);
+ const { container, rerender } = renderApp();
+ await flush();
+ testState.prompt = '/settings';
+ await clickSubmit(container);
+ await flush();
+
+ act(() => testState.latestModelManagement?.onSelectModel?.('qwen-next'));
+ expect(testState.latestModelManagement?.busy).toBe(true);
+
+ act(() => {
+ testState.ownerVersion += 1;
+ rerender();
+ });
+ await act(async () => {
+ selection.resolve();
+ await selection.promise;
+ });
+
+ expect(testState.latestModelManagement?.busy).toBe(false);
+ });
+
+ it('does not let an A-to-B-to-A model completion clear a newer selection', async () => {
+ const firstSelection = deferred();
+ const secondSelection = deferred();
+ mockSessionActions.setModel
+ .mockReturnValueOnce(firstSelection.promise)
+ .mockReturnValueOnce(secondSelection.promise);
+ const { container, rerender } = renderApp();
+ await flush();
+ testState.prompt = '/settings';
+ await clickSubmit(container);
+ await flush();
+ act(() => testState.latestModelManagement?.onSelectModel?.('model-a'));
+
+ act(() => {
+ testState.ownerVersion += 1;
+ mockConnection.sessionId = 'session-2';
+ rerender();
+ });
+ await flush();
+ act(() => {
+ testState.ownerVersion += 1;
+ mockConnection.sessionId = 'session-1';
+ rerender();
+ });
+ await flush();
+
+ testState.prompt = '/settings';
+ await clickSubmit(container);
+ await flush();
+ act(() => testState.latestModelManagement?.onSelectModel?.('model-b'));
+ expect(testState.latestModelManagement?.busy).toBe(true);
+
+ await act(async () => {
+ firstSelection.resolve();
+ await firstSelection.promise;
+ });
+ expect(testState.latestModelManagement?.busy).toBe(true);
+
+ await act(async () => {
+ secondSelection.resolve();
+ await secondSelection.promise;
+ });
+ expect(testState.latestModelManagement?.busy).toBe(false);
+ });
+
+ it('does not let an A-to-B-to-A deletion clear a newer selection', async () => {
+ const deletion = deferred();
+ const selection = deferred();
+ mockWorkspaceActions.deleteModel.mockReturnValueOnce(deletion.promise);
+ mockSessionActions.setModel.mockReturnValueOnce(selection.promise);
+ const { container, rerender } = renderApp();
+ await flush();
+ testState.prompt = '/settings';
+ await clickSubmit(container);
+ await flush();
+ act(() =>
+ testState.latestModelManagement?.onDeleteModel?.({
+ authType: 'api-key',
+ modelId: 'old-model',
+ }),
+ );
+
+ act(() => {
+ testState.ownerVersion += 1;
+ mockConnection.sessionId = 'session-2';
+ rerender();
+ });
+ await flush();
+ act(() => {
+ testState.ownerVersion += 1;
+ mockConnection.sessionId = 'session-1';
+ rerender();
+ });
+ await flush();
+
+ testState.prompt = '/settings';
+ await clickSubmit(container);
+ await flush();
+ act(() => testState.latestModelManagement?.onSelectModel?.('model-b'));
+ expect(testState.latestModelManagement?.busy).toBe(true);
+
+ await act(async () => {
+ deletion.resolve(undefined);
+ await deletion.promise;
+ });
+ expect(testState.latestModelManagement?.busy).toBe(true);
+
+ await act(async () => {
+ selection.resolve();
+ await selection.promise;
+ });
+ expect(testState.latestModelManagement?.busy).toBe(false);
+ });
+
it('sends /model --fast with --global when the fast-model picker is opened from the User tab', async () => {
const { container } = renderApp();
await flush();
@@ -14481,7 +15031,9 @@ describe('App session callbacks', () => {
await flush();
expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull();
- expect(mockSessionActions.loadSession).toHaveBeenCalledWith('session-2');
+ expect(mockSessionActions.loadSession).toHaveBeenCalledWith('session-2', {
+ workspaceCwd: undefined,
+ });
});
it('dispatches rename only after the current session name changes', async () => {
@@ -14606,6 +15158,40 @@ describe('App session callbacks', () => {
});
});
+ it('reconciles a confirmed rename after its source attachment is replaced', async () => {
+ const rename = deferred();
+ mockSessionActions.renameSession.mockReturnValueOnce(rename.promise);
+ const { container, rerender } = renderApp();
+ await flush();
+
+ testState.prompt = '/rename Delayed title';
+ await clickSubmit(container);
+ await vi.waitFor(() => {
+ expect(mockSessionActions.renameSession).toHaveBeenCalledWith(
+ 'Delayed title',
+ );
+ });
+
+ act(() => {
+ testState.ownerVersion += 1;
+ mockConnection.sessionId = 'session-2';
+ mockConnection.workspaceCwd = '/tmp/other';
+ rerender();
+ });
+ sessionCatalogController.renamed.mockClear();
+
+ await act(async () => {
+ rename.resolve();
+ await rename.promise;
+ });
+
+ expect(sessionCatalogController.renamed).toHaveBeenCalledWith(
+ '/tmp/project',
+ 'session-1',
+ 'Delayed title',
+ );
+ });
+
it('reconciles a name reused after the session loaded a different title', async () => {
const { container, rerender } = renderApp();
await flush();
@@ -14668,8 +15254,9 @@ describe('App prompt send failure retry', () => {
it('keeps an unknown lazy-session admission scoped to its allocated session', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
mockConnection.sessionId = undefined;
- mockSessionActions.createSession.mockResolvedValueOnce({
- sessionId: 'session-created',
+ mockSessionActions.createSession.mockImplementationOnce(async () => {
+ testState.ownerVersion += 1;
+ return { sessionId: 'session-created' };
});
const firstSend = deferred();
mockSessionActions.sendPrompt.mockReturnValueOnce(firstSend.promise);
@@ -14687,8 +15274,8 @@ describe('App prompt send failure retry', () => {
expect(mockSessionActions.sendPrompt).toHaveBeenCalledOnce();
});
const firstSendOptions = mockSessionActions.sendPrompt.mock.calls[0]?.[1];
- act(() => firstSendOptions?.onAdmissionStarted?.());
act(() => {
+ firstSendOptions?.onAdmissionStarted?.();
mockConnection.sessionId = 'session-created';
rerender();
});
@@ -14964,6 +15551,67 @@ describe('App prompt send failure retry', () => {
});
});
+ it('settles a prompt retry after a same-id attachment replacement', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ const firstSend = deferred();
+ const retrySend = deferred();
+ let retryAdmitted: (() => void) | undefined;
+ mockSessionActions.sendPrompt
+ .mockImplementationOnce(() => {
+ testState.blocks = [{ id: 'u1', kind: 'user' }];
+ return firstSend.promise;
+ })
+ .mockImplementationOnce(
+ (
+ _text: string,
+ options?: {
+ onAdmitted?: () => void;
+ },
+ ) => {
+ retryAdmitted = options?.onAdmitted;
+ return retrySend.promise;
+ },
+ );
+ const { container, rerender } = renderApp();
+ await flush();
+
+ act(() => {
+ testState.latestChatEditorProps?.onSubmit('hello');
+ });
+ testState.messages = [{ id: 'u1', role: 'user', content: 'hello' }];
+ await act(async () => {
+ firstSend.reject(new DaemonHttpError(413, {}, 'Prompt too large'));
+ await Promise.resolve();
+ });
+ act(() =>
+ container
+ .querySelector('[data-testid="failed-prompt-retry"]')
+ ?.click(),
+ );
+
+ act(() => {
+ testState.ownerVersion += 1;
+ rerender();
+ retryAdmitted?.();
+ });
+ await act(async () => {
+ retrySend.resolve();
+ await retrySend.promise;
+ testState.streamingState = 'idle';
+ rerender();
+ await Promise.resolve();
+ });
+ act(() => {
+ testState.streamingState = 'responding';
+ rerender();
+ });
+
+ expect(testState.latestMessageListProps?.isResponding).toBe(true);
+ expect(
+ container.querySelector('[data-testid="streaming-status"]'),
+ ).not.toBeNull();
+ });
+
it('shows processing only after retry admission and restarts its timer', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const firstSend = deferred();
@@ -15664,22 +16312,103 @@ describe('App manual-run orchestration (scheduled tasks)', () => {
void second;
});
- it('rejects a bound run when the session switch times out', async () => {
+ it('does not let an old same-target failure clear a newer bound run', async () => {
+ admitOnSend();
+ const { container, rerender } = renderApp();
+ await flush();
+ const run = await openRunHandler(container);
+ const firstRestore = deferred();
+ const secondRestore = deferred();
+ mockSessionActions.loadSession
+ .mockReturnValueOnce(firstRestore.promise)
+ .mockReturnValueOnce(secondRestore.promise);
+ let firstError: unknown;
+ let secondSettled = false;
+
+ await act(async () => {
+ void run('first', 'same-target').catch((error) => {
+ firstError = error;
+ });
+ void run('second', 'same-target').then(
+ () => {
+ secondSettled = true;
+ },
+ () => {
+ secondSettled = true;
+ },
+ );
+ await Promise.resolve();
+ });
+ expect((firstError as Error | undefined)?.message).toMatch(/superseded/);
+
+ await act(async () => {
+ firstRestore.reject(new Error('old restore failed'));
+ await Promise.resolve();
+ });
+ expect(secondSettled).toBe(false);
+
+ mockConnection.sessionId = 'same-target';
+ await act(async () => {
+ secondRestore.resolve();
+ rerender();
+ await Promise.resolve();
+ });
+ await vi.waitFor(() => expect(secondSettled).toBe(true));
+ expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
+ 'second',
+ expect.any(Object),
+ );
+ });
+
+ it('does not apply the catch-up timeout while restore is pending', async () => {
const { container } = renderApp();
await flush();
const run = await openRunHandler(container);
+ const restore = deferred();
+ mockSessionActions.loadSession.mockReturnValueOnce(restore.promise);
vi.useFakeTimers();
let err: unknown;
await act(async () => {
void run('do the thing', 'never-active').catch((e) => {
err = e;
});
- await Promise.resolve(); // loadSidebarSession resolves; no fire (not current)
+ await Promise.resolve();
});
await act(async () => {
vi.advanceTimersByTime(30_000);
});
- expect((err as Error | undefined)?.message).toMatch(/Timed out switching/);
+ expect(err).toBeUndefined();
+ await act(async () => {
+ restore.reject(new Error('restore timed out'));
+ await Promise.resolve();
+ });
+ expect((err as Error | undefined)?.message).toBe('restore timed out');
+ });
+
+ it('starts the 30 second timeout only after commit while catching up', async () => {
+ const { container } = renderApp();
+ await flush();
+ const run = await openRunHandler(container);
+ mockSessionActions.loadSession.mockImplementationOnce(async () => {
+ mockConnection.sessionId = 'bound-session';
+ mockConnection.catchingUp = true;
+ });
+ vi.useFakeTimers();
+ let err: unknown;
+ await act(async () => {
+ void run('do the thing', 'bound-session').catch((error) => {
+ err = error;
+ });
+ await Promise.resolve();
+ });
+ await act(async () => {
+ vi.advanceTimersByTime(29_999);
+ });
+ expect(err).toBeUndefined();
+ await act(async () => {
+ vi.advanceTimersByTime(1);
+ });
+ expect((err as Error | undefined)?.message).toMatch(/session replay/);
});
it('"create via chat" starts a fresh session and primes the composer', async () => {
diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx
index 0ba3bcac005..78a2d240b8e 100644
--- a/packages/web-shell/client/App.tsx
+++ b/packages/web-shell/client/App.tsx
@@ -21,6 +21,7 @@ import {
useSettings,
useProviders,
useSessionNotices,
+ useDaemonSessionOwnerGuard,
useStreamingState,
useTranscriptHistory,
useTranscriptStore,
@@ -29,6 +30,7 @@ import {
useWorkspaceEventSignals,
type DaemonSessionActions,
type DaemonSessionNotice,
+ type DaemonSessionOwnerSnapshot,
type DaemonStreamingState,
} from '@qwen-code/webui/daemon-react-sdk';
import { DaemonHttpError, isDaemonTurnError } from '@qwen-code/sdk/daemon';
@@ -442,9 +444,6 @@ interface ArtifactPanelSessionState {
interface PaneArtifactSnapshot {
artifacts: readonly DaemonSessionArtifact[];
}
-// Cap on how long a manual "run now" waits for its bound session to become
-// active before giving up, so the scheduled-tasks UI can't stay stuck disabled
-// if the switch never completes.
const BOUND_RUN_SWITCH_TIMEOUT_MS = 30_000;
function availableSkillInfos(status: {
@@ -645,6 +644,7 @@ export type WebShellSlashCommandHandler = (
) => boolean | void;
export interface WebShellProps {
+ desiredSessionTargetPending?: boolean;
/** Called whenever the attached daemon session or workspace changes. */
onSessionIdChange?: (
sessionId: string | undefined,
@@ -1566,6 +1566,7 @@ function readScopedModelSetting(
}
export function App({
+ desiredSessionTargetPending = false,
onSessionIdChange,
onSessionCreated,
theme: providedTheme,
@@ -1872,6 +1873,16 @@ export function App({
const store = useTranscriptStore();
const blocks = useAnimationFrameTranscriptBlocks();
const connection = useConnection();
+ const logicalSessionKey = connection.sessionId
+ ? `${connection.workspaceCwd ?? ''}\0${connection.sessionId}`
+ : undefined;
+ const sessionWriteBlocked =
+ desiredSessionTargetPending ||
+ connection.sessionTransition?.phase === 'queued' ||
+ connection.sessionTransition?.phase === 'preparing';
+ const sessionWriteBlockedRef = useRef(sessionWriteBlocked);
+ sessionWriteBlockedRef.current = sessionWriteBlocked;
+ const sessionOwnerGuard = useDaemonSessionOwnerGuard();
const transcriptHistory = useTranscriptHistory();
const workspace = useWorkspace();
const sessionCatalogController = useSessionCatalogController(
@@ -2030,12 +2041,17 @@ export function App({
const [sessionStatusDisplayName, setSessionStatusDisplayName] = useState<
string | undefined
>(undefined);
- // Tracks the session id from the latest effect run. In-flight fetches
- // compare their captured sid against this ref on resolve: a match means
+ // Tracks the logical session from the latest effect run. In-flight fetches
+ // compare their captured key against this ref on resolve: a match means
// the response is still relevant and may set OR clear the worktree state;
// a mismatch means connection.sessionId moved on (reconnect cycling or a
// user-initiated switch) and the stale response is dropped.
- const worktreeSessionIdRef = useRef(undefined);
+ const worktreeSessionKeyRef = useRef(undefined);
+ useLayoutEffect(() => {
+ setSessionWorktree(undefined);
+ setSessionBranch(undefined);
+ setSessionStatusDisplayName(undefined);
+ }, [logicalSessionKey]);
// Restore worktree info from the server when switching to an existing
// session. The effect intentionally does NOT cancel in-flight fetches on
// cleanup: connection.sessionId can cycle through several sessions during
@@ -2043,19 +2059,15 @@ export function App({
// discard the one response we actually need.
useEffect(() => {
const sid = connection.sessionId;
- const previousSid = worktreeSessionIdRef.current;
- worktreeSessionIdRef.current = sid;
+ const sessionKey = logicalSessionKey;
+ const owner = sessionOwnerGuard.capture();
+ worktreeSessionKeyRef.current = sessionKey;
if (!sid) {
setSessionWorktree(undefined);
setSessionBranch(undefined);
setSessionStatusDisplayName(undefined);
return;
}
- if (previousSid !== sid) {
- setSessionWorktree(undefined);
- setSessionBranch(undefined);
- setSessionStatusDisplayName(undefined);
- }
if (
connection.status !== 'connected' ||
connection.loadingTranscript ||
@@ -2066,7 +2078,7 @@ export function App({
workspace.client
.sessionStatus(sid)
.then((summary) => {
- if (worktreeSessionIdRef.current === sid) {
+ if (worktreeSessionKeyRef.current === sessionKey && owner.isCurrent()) {
setSessionWorktree(summary.worktree);
setSessionBranch(summary.branch);
setSessionStatusDisplayName(summary.displayName);
@@ -2081,7 +2093,12 @@ export function App({
{ fresh: true },
)
.then((page) => {
- if (worktreeSessionIdRef.current !== sid) return;
+ if (
+ worktreeSessionKeyRef.current !== sessionKey ||
+ !owner.isCurrent()
+ ) {
+ return;
+ }
const listedSession = page.sessions.find(
(session) => session.sessionId === sid,
);
@@ -2092,7 +2109,7 @@ export function App({
.catch(() => undefined);
})
.catch(() => {
- if (worktreeSessionIdRef.current === sid) {
+ if (worktreeSessionKeyRef.current === sessionKey && owner.isCurrent()) {
setSessionWorktree(undefined);
setSessionBranch(undefined);
setSessionStatusDisplayName(undefined);
@@ -2100,9 +2117,13 @@ export function App({
});
}, [
connection.catchingUp,
+ connection.clientId,
connection.loadingTranscript,
connection.sessionId,
connection.status,
+ connection.workspaceCwd,
+ logicalSessionKey,
+ sessionOwnerGuard,
workspace.client,
]);
// Active workspace: the connected session's workspace, else the workspace
@@ -2260,6 +2281,11 @@ export function App({
failedPromptRef.current = next;
setFailedPrompt(next);
}, []);
+ useLayoutEffect(() => {
+ updateFailedPrompt(null);
+ setFailedPromptRetry(null);
+ updateUnknownPromptAdmission(null);
+ }, [logicalSessionKey, updateFailedPrompt, updateUnknownPromptAdmission]);
const [recapMessage, setRecapMessage] = useState(
null,
);
@@ -2274,7 +2300,6 @@ export function App({
const lastNotifiedSessionIdRef = useRef(undefined);
const lastNotifiedWorkspaceIdRef = useRef(undefined);
const lastNotifiedWorkspaceCwdRef = useRef(undefined);
- const lastGoalSessionIdRef = useRef(connection.sessionId);
const displayMessages = useMemo(() => {
const localMessages = [recapMessage].filter(
(message): message is LocalAnchoredMessage => message !== null,
@@ -2482,7 +2507,7 @@ export function App({
useLayoutEffect(() => {
preserveEnvironmentPanelOnArtifactOpenRef.current = false;
setEnvironmentPanelOpen(false);
- }, [connection.sessionId]);
+ }, [logicalSessionKey]);
const artifactPanelOpenRef = useRef(artifactPanelOpen);
artifactPanelOpenRef.current = artifactPanelOpen;
const [activeArtifactPanelTabId, setActiveArtifactPanelTabId] = useState<
@@ -2525,7 +2550,7 @@ export function App({
const artifactPanelStateBySessionRef = useRef(
new Map(),
);
- const artifactPanelSessionIdRef = useRef(connection.sessionId);
+ const artifactPanelSessionIdRef = useRef(logicalSessionKey);
artifactPanelSessionStateRef.current = {
open: artifactPanelOpen,
tabs: artifactPanelTabs,
@@ -2558,7 +2583,7 @@ export function App({
}
}
- const nextSessionId = connection.sessionId;
+ const nextSessionId = logicalSessionKey;
artifactPanelSessionIdRef.current = nextSessionId;
const savedState = nextSessionId
? artifactPanelStateBySessionRef.current.get(nextSessionId)
@@ -2585,7 +2610,7 @@ export function App({
setPaneArtifactSnapshots(new Map());
setArtifactPanelWidth(savedState.width);
setArtifactPanelFullscreen(false);
- }, [connection.sessionId]);
+ }, [logicalSessionKey]);
const sideTasksAvailable =
Boolean(connection.sessionId && connection.workspaceCwd) &&
connection.capabilities?.features.includes(SESSION_SIDE_TASK_FEATURE) ===
@@ -2594,6 +2619,9 @@ export function App({
items: [],
loaded: false,
});
+ useLayoutEffect(() => {
+ setSideTaskCatalog({ items: [], loaded: false });
+ }, [logicalSessionKey]);
const optimisticSideTaskIdsRef = useRef(new Set());
const visibleSideTasks =
sideTaskCatalog.parentSessionId === connection.sessionId
@@ -3550,9 +3578,11 @@ export function App({
async (tool: ACPToolCall): Promise => {
const sessionId = monitorDetailsSessionIdRef.current;
if (!sessionId) return false;
+ const owner = sessionOwnerGuard.capture();
try {
const snapshot = await sessionActions.getTasks();
if (
+ !owner.isCurrent() ||
monitorDetailsSessionIdRef.current !== sessionId ||
snapshot.sessionId !== sessionId
) {
@@ -3567,7 +3597,7 @@ export function App({
return false;
}
},
- [openMonitorPanel, sessionActions],
+ [openMonitorPanel, sessionActions, sessionOwnerGuard],
);
useEffect(() => {
const monitors = new Map(
@@ -3672,6 +3702,7 @@ export function App({
assignComposerRef(composerRef, editorRef.current ?? emptyComposerApi);
}, [composerRef]);
const [activeGoal, setActiveGoal] = useState(null);
+ useLayoutEffect(() => setActiveGoal(null), [logicalSessionKey]);
const [isCreatingMissingSession, setIsCreatingMissingSession] =
useState(false);
const creatingMissingSessionRef = useRef(false);
@@ -4552,6 +4583,7 @@ export function App({
const refreshActiveSessionDisplayName = useCallback(async () => {
const activeConnection = connectionRef.current;
if (!activeConnection.sessionId || !activeConnection.workspaceCwd) return;
+ const owner = sessionOwnerGuard.capture();
try {
const page = await loadSessionCatalogOnce(
workspace.client,
@@ -4563,6 +4595,7 @@ export function App({
{ fresh: true },
);
if (
+ !owner.isCurrent() ||
connectionRef.current.sessionId !== activeConnection.sessionId ||
connectionRef.current.workspaceCwd !== activeConnection.workspaceCwd ||
connectionRef.current.displayName
@@ -4576,7 +4609,7 @@ export function App({
} catch {
// The live session_metadata_updated event remains the primary path.
}
- }, [workspace.client]);
+ }, [sessionOwnerGuard, workspace.client]);
const refreshActiveSessionDisplayNameRef = useRef(
refreshActiveSessionDisplayName,
);
@@ -4648,6 +4681,11 @@ export function App({
setCurrentMode(modeId);
}, []);
const [isPreparingPrompt, setIsPreparingPrompt] = useState(false);
+ const planPreparationTokenRef = useRef(0);
+ useLayoutEffect(() => {
+ planPreparationTokenRef.current += 1;
+ setIsPreparingPrompt(false);
+ }, [logicalSessionKey]);
const createSessionPromiseRef = useRef | null>(
null,
);
@@ -4844,8 +4882,15 @@ export function App({
onAdmissionStarted?: (sessionId: string | undefined) => void;
onAdmitted?: () => void;
onOptimisticUserMessage?: (message: OptimisticUserMessage) => void;
+ ownerRef?: { current: DaemonSessionOwnerSnapshot };
},
) => {
+ if (sessionWriteBlockedRef.current) {
+ throw new DOMException(
+ 'Session switch is still preparing',
+ 'InvalidStateError',
+ );
+ }
const isUserPrompt = !text.trimStart().startsWith('/');
const previousLastSubmittedPrompt = lastSubmittedPromptRef.current;
const previousLastSubmittedImages = lastSubmittedImagesRef.current;
@@ -4918,6 +4963,7 @@ export function App({
let allocatedSessionId: string | undefined;
try {
allocatedSessionId = await ensureSessionForPrompt();
+ if (opts?.ownerRef) opts.ownerRef.current = sessionOwnerGuard.capture();
} finally {
if (shouldShowPreparing) {
setIsPreparingPrompt(false);
@@ -5010,6 +5056,7 @@ export function App({
getComposerWorkspaceCwd,
sessionCatalogController,
sessionActions,
+ sessionOwnerGuard,
store,
],
);
@@ -5308,7 +5355,9 @@ export function App({
discardUnknownQueuedPrompt,
} = useQueuedPrompts({
connected,
+ writeBlocked: sessionWriteBlocked,
sessionId: connection.sessionId,
+ workspaceCwd: connection.workspaceCwd,
clientId: connection.clientId,
canMutateMidTurn,
canQueryMidTurn,
@@ -5426,14 +5475,16 @@ export function App({
setBtwMessage(null);
setTasksDialogMessage(null);
lastRecapBlockCountRef.current = 0;
- }, [connection.sessionId]);
+ }, [connection.sessionId, connection.workspaceCwd]);
const runVisibleRecap = useCallback(() => {
+ if (sessionWriteBlocked) return;
if (!requireActiveSessionForLocalCommand()) return;
const messageId = `local-recap-${nextRecapMessageIdRef.current++}`;
const anchorIndex = messages.length;
const anchorAfterId = messages.at(-1)?.id;
const sessionId = connection.sessionId;
+ const workspaceCwd = connection.workspaceCwd;
setRecapMessage({
anchorAfterId,
anchorIndex,
@@ -5447,7 +5498,11 @@ export function App({
});
sessionActions.recapSession().then(
(result) => {
- if (currentSessionIdRef.current !== sessionId) return;
+ if (
+ currentSessionIdRef.current !== sessionId ||
+ connectionRef.current.workspaceCwd !== workspaceCwd
+ )
+ return;
setRecapMessage({
anchorAfterId,
anchorIndex,
@@ -5463,7 +5518,11 @@ export function App({
});
},
(error: unknown) => {
- if (currentSessionIdRef.current !== sessionId) return;
+ if (
+ currentSessionIdRef.current !== sessionId ||
+ connectionRef.current.workspaceCwd !== workspaceCwd
+ )
+ return;
setRecapMessage(null);
if (!isAbortError(error) && !isAlreadyDispatched(error)) {
console.warn('[web-shell] unhandled recap failure', error);
@@ -5472,14 +5531,17 @@ export function App({
);
}, [
connection.sessionId,
+ connection.workspaceCwd,
messages,
requireActiveSessionForLocalCommand,
+ sessionWriteBlocked,
sessionActions,
t,
]);
const runVisibleBtw = useCallback(
(rawQuestion: string) => {
+ if (sessionWriteBlocked) return;
const question = rawQuestion.trim();
if (!question) {
pushToast('error', t('btw.empty'));
@@ -5489,6 +5551,7 @@ export function App({
const messageId = `local-btw-${nextBtwMessageIdRef.current++}`;
const sessionId = connection.sessionId;
+ const workspaceCwd = connection.workspaceCwd;
btwAbortControllerRef.current?.abort();
const abortController = new AbortController();
btwAbortControllerRef.current = abortController;
@@ -5504,7 +5567,11 @@ export function App({
.btwSession(question, { signal: abortController.signal })
.then(
(result) => {
- if (currentSessionIdRef.current !== sessionId) return;
+ if (
+ currentSessionIdRef.current !== sessionId ||
+ connectionRef.current.workspaceCwd !== workspaceCwd
+ )
+ return;
if (btwAbortControllerRef.current !== abortController) return;
btwAbortControllerRef.current = null;
setBtwMessage({
@@ -5516,7 +5583,11 @@ export function App({
});
},
(error: unknown) => {
- if (currentSessionIdRef.current !== sessionId) return;
+ if (
+ currentSessionIdRef.current !== sessionId ||
+ connectionRef.current.workspaceCwd !== workspaceCwd
+ )
+ return;
if (btwAbortControllerRef.current !== abortController) return;
btwAbortControllerRef.current = null;
setBtwMessage(null);
@@ -5528,8 +5599,10 @@ export function App({
},
[
connection.sessionId,
+ connection.workspaceCwd,
pushToast,
requireActiveSessionForLocalCommand,
+ sessionWriteBlocked,
sessionActions,
t,
],
@@ -5661,6 +5734,11 @@ export function App({
// re-creating on every render (and without an exhaustive-deps warning).
const reloadProviders = providersState.reload;
const [modelActionBusy, setModelActionBusy] = useState(false);
+ const modelActionTokenRef = useRef(0);
+ useLayoutEffect(() => {
+ modelActionTokenRef.current += 1;
+ setModelActionBusy(false);
+ }, [logicalSessionKey]);
const {
settings: workspaceSettings,
setValue: setWorkspaceSetting,
@@ -6017,6 +6095,8 @@ export function App({
const handleSettingsLanguageChange = useCallback(
(nextLanguage: WebShellLanguage, scope: 'user' | 'workspace' = 'user') => {
+ if (sessionWriteBlocked) return;
+ const owner = { current: sessionOwnerGuard.capture() };
const previousLanguage = selectedLanguage;
// Forward the settings tab's scope to the command so a Workspace-tab edit
// persists to workspace settings instead of always writing user scope
@@ -6026,8 +6106,9 @@ export function App({
const scopeFlag = scope === 'workspace' ? ' --project' : ' --global';
const command = `/language ui ${nextLanguage}${scopeFlag}`;
handleLanguageChange(nextLanguage);
- const refreshSettings = () => {
- return Promise.all([
+ const refreshSettings = async () => {
+ if (!owner.current.isCurrent()) return;
+ await Promise.all([
sessionActions.refreshCommands(),
reloadWorkspaceSettings(),
]);
@@ -6037,9 +6118,10 @@ export function App({
blockLocalCommandDuringTurn();
return;
}
- sendPrompt(command, undefined)
+ sendPrompt(command, undefined, { ownerRef: owner })
.then(refreshSettings)
.catch((error: unknown) => {
+ if (!owner.current.isCurrent()) return;
handleLanguageChange(previousLanguage);
reportError(error, 'Failed to sync /language command');
});
@@ -6049,9 +6131,11 @@ export function App({
handleLanguageChange,
reloadWorkspaceSettings,
reportError,
+ sessionWriteBlocked,
sendPrompt,
selectedLanguage,
sessionActions,
+ sessionOwnerGuard,
],
);
@@ -6079,6 +6163,7 @@ export function App({
const handleSetMode = useCallback(
(modeId: string) => {
+ if (sessionWriteBlocked) return;
if (!isDaemonApprovalMode(modeId)) {
reportError(
new Error(`Unsupported approval mode: ${modeId}`),
@@ -6090,9 +6175,11 @@ export function App({
setPendingMode(modeId);
return;
}
+ const owner = sessionOwnerGuard.capture();
sessionActions
.setApprovalMode(modeId)
.then((result) => {
+ if (!owner.isCurrent()) return;
const effectiveMode = result.mode || modeId;
setCurrentMode(effectiveMode);
const approval = pendingApprovalRef.current;
@@ -6121,10 +6208,19 @@ export function App({
}
})
.catch((error: unknown) => {
+ if (!owner.isCurrent()) return;
reportError(error, t('local.approvalMode'));
});
},
- [sessionActions, reportError, store, t, setPendingMode],
+ [
+ sessionWriteBlocked,
+ reportError,
+ sessionActions,
+ sessionOwnerGuard,
+ setPendingMode,
+ store,
+ t,
+ ],
);
useEffect(() => {
@@ -6133,10 +6229,10 @@ export function App({
// Drop queued commands on a session switch so the drain never runs a
// command against a different workspace's daemon (mirrors useQueuedPrompts).
- const prevQueueSessionIdRef = useRef(connection.sessionId);
+ const prevQueueSessionIdRef = useRef(logicalSessionKey);
useEffect(() => {
- if (prevQueueSessionIdRef.current === connection.sessionId) return;
- prevQueueSessionIdRef.current = connection.sessionId;
+ if (prevQueueSessionIdRef.current === logicalSessionKey) return;
+ prevQueueSessionIdRef.current = logicalSessionKey;
const dropped = queuedShellCommandsRef.current.length;
queuedShellCommandsRef.current = [];
// Skip the bump when the transition is into the session that
@@ -6149,7 +6245,7 @@ export function App({
if (dropped > 0) {
pushToast('warning', t('queue.shellDropped', { count: dropped }));
}
- }, [connection.sessionId, pushToast, t]);
+ }, [connection.sessionId, logicalSessionKey, pushToast, t]);
// Declared after the session-switch wipe effect above: React runs effects in
// declaration order, so the queue is already cleared before this drain sees it.
@@ -6172,6 +6268,7 @@ export function App({
const generation = ++drainGenerationRef.current;
const drainSessionId = connectionRef.current.sessionId;
const drainWorkspaceCwd = getComposerWorkspaceCwd();
+ const drainOwner = sessionOwnerGuard.capture();
void (async () => {
try {
let batch = cmds;
@@ -6180,6 +6277,7 @@ export function App({
const generationChanged = drainGenerationRef.current !== generation;
if (
generationChanged ||
+ !drainOwner.isCurrent() ||
connectionRef.current.sessionId !== drainSessionId ||
connectionRef.current.status !== 'connected'
) {
@@ -6231,6 +6329,7 @@ export function App({
reportError,
sessionActions,
sessionCatalogController,
+ sessionOwnerGuard,
streamingState,
t,
]);
@@ -6345,23 +6444,15 @@ export function App({
}
}, [connection.error, onError]);
- useEffect(() => {
+ useLayoutEffect(() => {
setCurrentModel(connection.currentModel ?? '');
- }, [connection.currentModel, connection.sessionId]);
+ }, [connection.currentModel, logicalSessionKey]);
- useEffect(() => {
+ useLayoutEffect(() => {
setCurrentMode(connection.currentMode ?? 'default');
- }, [connection.currentMode, connection.sessionId]);
+ }, [connection.currentMode, logicalSessionKey]);
useEffect(() => {
- const previousGoalSessionId = lastGoalSessionIdRef.current;
- if (
- connection.sessionId &&
- connection.sessionId !== previousGoalSessionId
- ) {
- setActiveGoal(null);
- }
- lastGoalSessionIdRef.current = connection.sessionId;
if (!connection.sessionId && connection.missingSession) {
// Keep the dead-session route visible until the user explicitly starts a
// new chat; clearing it here would immediately hide the recovery state.
@@ -6370,12 +6461,9 @@ export function App({
lastNotifiedWorkspaceCwdRef.current = undefined;
return;
}
- // After a session is cleared the connection's workspaceCwd is a leftover
- // from the previous session; reporting it would misroute the host back to
- // the old workspace. activeWorkspaceCwd resolves the workspace picked for
- // the next session (locked / selected / primary) and is what the composer
- // chip reports, so the host and the chip stay in agreement.
- const reportedWorkspaceCwd = activeWorkspaceCwd ?? connection.workspaceCwd;
+ const reportedWorkspaceCwd = connection.sessionId
+ ? connection.workspaceCwd
+ : activeWorkspaceCwd;
const activeWorkspace = workspaces.find(
(entry) => entry.cwd === reportedWorkspaceCwd,
);
@@ -6410,7 +6498,6 @@ export function App({
]);
const lastRenameSessionRef = useRef(undefined);
- const lastRenameWorkspaceCwdRef = useRef(undefined);
const lastRenameNameRef = useRef(undefined);
const lastReconciledRenameRef = useRef<
| {
@@ -6441,12 +6528,8 @@ export function App({
const sessionId = connection.sessionId;
const displayName = connection.displayName;
if (!sessionId || !displayName) return;
- if (
- sessionId !== lastRenameSessionRef.current ||
- connection.workspaceCwd !== lastRenameWorkspaceCwdRef.current
- ) {
- lastRenameSessionRef.current = sessionId;
- lastRenameWorkspaceCwdRef.current = connection.workspaceCwd;
+ if (logicalSessionKey !== lastRenameSessionRef.current) {
+ lastRenameSessionRef.current = logicalSessionKey;
lastRenameNameRef.current = displayName;
lastReconciledRenameRef.current = undefined;
return;
@@ -6478,6 +6561,7 @@ export function App({
connection.displayName,
connection.sessionId,
connection.workspaceCwd,
+ logicalSessionKey,
sessionCatalogController,
]);
@@ -6527,7 +6611,7 @@ export function App({
useEffect(() => {
lastRecapBlockCountRef.current = 0;
autoRecapVersionRef.current += 1;
- }, [connection.sessionId]);
+ }, [logicalSessionKey]);
useEffect(() => {
const AWAY_THRESHOLD_MS = 3 * 60 * 1000;
const MIN_NEW_BLOCKS = 4;
@@ -6540,6 +6624,7 @@ export function App({
hiddenAtRef.current = null;
if (hiddenAt === null) return;
if (Date.now() - hiddenAt < AWAY_THRESHOLD_MS) return;
+ if (sessionWriteBlocked) return;
if (streamingStateRef.current !== 'idle') return;
if (!connection.sessionId) return;
const currentCount = store.getSnapshot().blocks.length;
@@ -6548,6 +6633,7 @@ export function App({
lastRecapBlockCountRef.current = currentCount;
const sessionId = connection.sessionId;
const version = autoRecapVersionRef.current;
+ const owner = sessionOwnerGuard.capture();
// Local-only commands also append user blocks. Treat any new visible user
// activity as invalidating the recap rather than risk placing it too late.
const userBlockId = getLatestUserBlockId(store.getSnapshot().blocks);
@@ -6561,6 +6647,7 @@ export function App({
// catch those. Kept so it is not simplified away as redundant.
if (
autoRecapVersionRef.current !== version ||
+ !owner.isCurrent() ||
connectionRef.current.sessionId !== sessionId ||
result.sessionId !== sessionId ||
currentUserBlockId !== userBlockId ||
@@ -6596,7 +6683,14 @@ export function App({
document.addEventListener('visibilitychange', onVisibilityChange);
return () =>
document.removeEventListener('visibilitychange', onVisibilityChange);
- }, [connection.sessionId, sessionActions, store, t]);
+ }, [
+ connection.sessionId,
+ sessionActions,
+ sessionOwnerGuard,
+ sessionWriteBlocked,
+ store,
+ t,
+ ]);
const handleCycleMode = useCallback(() => {
const idx = isDaemonApprovalMode(currentMode)
@@ -6617,13 +6711,16 @@ export function App({
// "context detail" click) runs immediately, even mid-turn — only the
// echo is skipped while streaming so the active turn is not split.
if (!requireActiveSessionForLocalCommand()) return;
+ const owner = sessionOwnerGuard.capture();
echoLocalCommandIfIdle(commandText);
sessionActions
.getContextUsage({ detail })
.then((result) => {
+ if (!owner.isCurrent()) return;
dispatchReadOnlyStatus(serializeContextUsageMessage(result));
})
.catch((error: unknown) => {
+ if (!owner.isCurrent()) return;
reportError(error, 'Failed to load context usage');
});
},
@@ -6632,6 +6729,7 @@ export function App({
dispatchReadOnlyStatus,
requireActiveSessionForLocalCommand,
sessionActions,
+ sessionOwnerGuard,
reportError,
],
);
@@ -6650,6 +6748,7 @@ export function App({
const branchCurrentSession = useCallback(
(name?: string) => {
+ if (sessionWriteBlocked) return;
if (!requireActiveSessionForLocalCommand()) return;
sessionActions
.branchSession(name || undefined)
@@ -6670,6 +6769,7 @@ export function App({
[
reportError,
requireActiveSessionForLocalCommand,
+ sessionWriteBlocked,
sessionActions,
store,
t,
@@ -6963,6 +7063,13 @@ export function App({
},
[],
);
+ const generateSuggestionContent = useCallback(
+ (prompt: string, options?: { signal?: AbortSignal }) => {
+ void logicalSessionKey;
+ return sessionActions.generateSessionContent(prompt, options);
+ },
+ [logicalSessionKey, sessionActions],
+ );
const {
suggestion: newSessionSuggestion,
@@ -6981,7 +7088,7 @@ export function App({
isRunning: streamingState !== 'idle',
dialogOpen: interactionBlocked || approvalOverlayActive,
hasAttachments: hasComposerAttachments,
- generateContent: sessionActions.generateSessionContent,
+ generateContent: generateSuggestionContent,
});
const handleComposerTextChange = useCallback(
@@ -7161,27 +7268,25 @@ export function App({
}
}, [createNewSession, onSessionIdChange]);
+ const sessionOpenInvocationRef = useRef(0);
const loadSidebarSession = useCallback(
async (sessionId: string, workspaceCwd?: string) => {
- composerSourceVersionRef.current += 1;
+ const invocation = ++sessionOpenInvocationRef.current;
composerFocusRequestRef.current += 1;
setSidebarSwitchingSessionId(sessionId);
- setGitModeIntent({ mode: 'current' });
- setSessionWorktree(undefined);
- setSessionBranch(undefined);
- // Close the drawer before awaiting the load; the transcript clears
- // immediately and shows its loading skeleton for the selected session.
closeMobileDrawer();
// Loading another session should reveal its chat, not stay on the
// Settings/Status panel (no-op when the panel is closed).
closePanel();
try {
- autoRecapVersionRef.current += 1;
await sessionActions.loadSession(sessionId, { workspaceCwd });
+ if (sessionOpenInvocationRef.current === invocation) {
+ composerSourceVersionRef.current += 1;
+ }
} catch (error) {
- setSidebarSwitchingSessionId((current) =>
- current === sessionId ? null : current,
- );
+ if (sessionOpenInvocationRef.current === invocation) {
+ setSidebarSwitchingSessionId(null);
+ }
throw error;
}
},
@@ -7232,7 +7337,8 @@ export function App({
sidebarSwitchingSessionId !== null &&
connection.sessionId === sidebarSwitchingSessionId &&
!connection.loadingTranscript &&
- !connection.catchingUp
+ !connection.catchingUp &&
+ !sessionWriteBlocked
) {
setSidebarSwitchingSessionId(null);
scheduleComposerFocus(sidebarSwitchingSessionId);
@@ -7242,6 +7348,7 @@ export function App({
connection.loadingTranscript,
connection.sessionId,
scheduleComposerFocus,
+ sessionWriteBlocked,
sidebarSwitchingSessionId,
]);
@@ -7259,12 +7366,13 @@ export function App({
prompt: string;
resolve: () => void;
reject: (err: unknown) => void;
- timer: ReturnType;
+ timer?: ReturnType;
+ owner?: { isCurrent(): boolean };
} | null>(null);
const clearPendingBoundRun = useCallback((sessionId: string) => {
const cur = pendingBoundRunRef.current;
if (cur && cur.sessionId === sessionId) {
- clearTimeout(cur.timer);
+ if (cur.timer !== undefined) clearTimeout(cur.timer);
pendingBoundRunRef.current = null;
}
}, []);
@@ -7319,12 +7427,27 @@ export function App({
if (
!pending ||
conn.sessionId !== pending.sessionId ||
- conn.loadingTranscript ||
- conn.catchingUp
+ conn.loadingTranscript
) {
return;
}
- clearTimeout(pending.timer);
+ if (conn.catchingUp) {
+ if (pending.timer === undefined) {
+ pending.timer = setTimeout(() => {
+ clearPendingBoundRun(pending.sessionId);
+ pending.reject(new Error('Timed out waiting for session replay'));
+ }, BOUND_RUN_SWITCH_TIMEOUT_MS);
+ }
+ return;
+ }
+ if (pending.owner && !pending.owner.isCurrent()) {
+ clearPendingBoundRun(pending.sessionId);
+ pending.reject(
+ new DOMException('Bound run session was replaced', 'AbortError'),
+ );
+ return;
+ }
+ if (pending.timer !== undefined) clearTimeout(pending.timer);
pendingBoundRunRef.current = null;
// Resolves at prompt admission (see enqueueManualRun); the switch-timeout was
// cleared above, so a long turn can't trip it. Recording happens in the
@@ -7333,7 +7456,7 @@ export function App({
() => pending.resolve(),
(error: unknown) => pending.reject(error),
);
- }, [enqueueManualRun]);
+ }, [clearPendingBoundRun, enqueueManualRun]);
const runTaskManually = useCallback(
(prompt: string, sessionId: string | null): Promise => {
setMainView('chat');
@@ -7345,28 +7468,29 @@ export function App({
// reject the old promise so its caller doesn't record a dropped run.
const prev = pendingBoundRunRef.current;
if (prev) {
- clearTimeout(prev.timer);
+ if (prev.timer !== undefined) clearTimeout(prev.timer);
pendingBoundRunRef.current = null;
prev.reject(new Error('superseded by another run'));
}
return new Promise((resolve, reject) => {
- const timer = setTimeout(() => {
- clearPendingBoundRun(sessionId);
- reject(new Error('Timed out switching to the task session'));
- }, BOUND_RUN_SWITCH_TIMEOUT_MS);
- pendingBoundRunRef.current = {
+ const pending: NonNullable = {
sessionId,
prompt,
resolve,
reject,
- timer,
};
+ pendingBoundRunRef.current = pending;
loadSidebarSession(sessionId)
// Fire immediately when the session was already active (no dep change
// to trigger the effect); a no-op if the load is still settling, in
// which case the effect picks it up.
- .then(() => tryFireBoundRun())
+ .then(() => {
+ if (pendingBoundRunRef.current !== pending) return;
+ pending.owner = sessionOwnerGuard.capture();
+ tryFireBoundRun();
+ })
.catch((error: unknown) => {
+ if (pendingBoundRunRef.current !== pending) return;
clearPendingBoundRun(sessionId);
reject(error);
});
@@ -7376,6 +7500,7 @@ export function App({
enqueueManualRun,
loadSidebarSession,
clearPendingBoundRun,
+ sessionOwnerGuard,
tryFireBoundRun,
],
);
@@ -7390,16 +7515,24 @@ export function App({
const openTasksPanel = useCallback(() => {
if (!requireActiveSessionForLocalCommand()) return;
+ const owner = sessionOwnerGuard.capture();
sessionActions
.getTasks()
.then((snapshot) => {
+ if (!owner.isCurrent()) return;
setTasksDialogMessage({ snapshot });
})
.catch((error: unknown) => {
+ if (!owner.isCurrent()) return;
if (isSessionDisconnectedError(error)) return;
reportError(error, 'Failed to load tasks');
});
- }, [reportError, requireActiveSessionForLocalCommand, sessionActions]);
+ }, [
+ reportError,
+ requireActiveSessionForLocalCommand,
+ sessionActions,
+ sessionOwnerGuard,
+ ]);
const openEnvironmentTasksPanel = useCallback(() => {
if (!requireActiveSessionForLocalCommand()) return;
setEnvironmentPanelOpen(true);
@@ -7460,14 +7593,24 @@ export function App({
const handleBusyGoalClear = useCallback(
(text: string) => {
+ if (sessionWriteBlocked) return false;
if (!requireActiveSessionForLocalCommand()) return false;
+ const owner = sessionOwnerGuard.capture();
store.appendLocalUserMessage(text);
sessionActions.clearGoal().catch((error: unknown) => {
+ if (!owner.isCurrent()) return;
reportError(error, 'Failed to clear /goal');
});
return true;
},
- [reportError, requireActiveSessionForLocalCommand, sessionActions, store],
+ [
+ reportError,
+ requireActiveSessionForLocalCommand,
+ sessionWriteBlocked,
+ sessionActions,
+ sessionOwnerGuard,
+ store,
+ ],
);
const loadRewindSnapshots = useCallback(
@@ -7504,15 +7647,18 @@ export function App({
const goalArg = goalArgOf(text);
const sendToDaemon = opts?.sendToDaemon ?? true;
const sendGoalPrompt = () => {
+ const owner = { current: sessionOwnerGuard.capture() };
const deferComposerCommit = Boolean(onSubmitBeforeRef.current);
const clearComposerOnPromptStart =
!connectionRef.current.sessionId || deferComposerCommit;
sendPrompt(text, images, {
+ ownerRef: owner,
clearComposerOnPromptStart,
commitComposerAccepted: clearComposerOnPromptStart
? opts?.commitComposerAccepted
: undefined,
}).catch((error: unknown) => {
+ if (!owner.current.isCurrent()) return;
reportError(error, 'Failed to send /goal command');
});
return clearComposerOnPromptStart ? false : true;
@@ -7547,6 +7693,7 @@ export function App({
openGoals,
reportError,
sendPrompt,
+ sessionOwnerGuard,
store,
connectionRef,
],
@@ -7568,6 +7715,7 @@ export function App({
commitComposerAccepted?: ComposerSubmitCommit,
metadata?: { inputAnnotations?: DaemonInputAnnotation[] },
) => {
+ if (sessionWriteBlockedRef.current) return false;
if (
unknownPromptAdmissionRef.current?.payloadAvailable &&
unknownPromptAdmissionRef.current.sessionId ===
@@ -7606,12 +7754,16 @@ export function App({
trackSendFailure?: boolean;
},
) => {
+ const admissionAttachment = {
+ current: sessionOwnerGuard.capture(),
+ };
const admissionOwner = {
sourceVersion: composerSourceVersionRef.current,
sessionId: connectionRef.current.sessionId,
workspaceCwd: getComposerWorkspaceCwd(),
};
const admissionOwnerIsCurrent = () =>
+ admissionAttachment.current.isCurrent() &&
composerSourceVersionRef.current === admissionOwner.sourceVersion &&
(admissionOwner.sessionId === undefined ||
(connectionRef.current.sessionId === admissionOwner.sessionId &&
@@ -7625,6 +7777,7 @@ export function App({
let admissionStarted = false;
let admissionSessionId: string | undefined;
sendPrompt(promptText, promptImages, {
+ ownerRef: admissionAttachment,
...sendOptions,
clearComposerOnPromptStart,
commitComposerAccepted: clearComposerOnPromptStart
@@ -7834,19 +7987,25 @@ export function App({
return true;
}
const nextLanguage = normalizeLanguage(languageArg);
+ const owner = { current: sessionOwnerGuard.capture() };
handleLanguageChange(nextLanguage);
if (!promptBlocked) {
const deferComposerCommit = Boolean(onSubmitBeforeRef.current);
const clearComposerOnPromptStart =
!connectionRef.current.sessionId || deferComposerCommit;
sendPrompt(`/language ui ${nextLanguage}`, undefined, {
+ ownerRef: owner,
clearComposerOnPromptStart,
commitComposerAccepted: clearComposerOnPromptStart
? commitComposerAccepted
: undefined,
})
- .then(() => sessionActions.refreshCommands())
+ .then(() => {
+ if (!owner.current.isCurrent()) return;
+ return sessionActions.refreshCommands();
+ })
.catch((error: unknown) => {
+ if (!owner.current.isCurrent()) return;
reportError(error, 'Failed to sync /language command');
});
return clearComposerOnPromptStart ? false : true;
@@ -7897,9 +8056,11 @@ export function App({
pushToast('error', t('fork.empty'));
return true;
}
+ const owner = sessionOwnerGuard.capture();
sessionActions
.forkSession(directive)
.then((result) => {
+ if (!owner.isCurrent()) return;
if (!result.launched) {
pushToast('warning', t('fork.notStarted'));
return;
@@ -7911,6 +8072,7 @@ export function App({
);
})
.catch((error: unknown) => {
+ if (!owner.isCurrent()) return;
const reason =
error instanceof Error ? error.message : String(error);
reportError(error, t('fork.failed', { reason }));
@@ -7976,12 +8138,15 @@ export function App({
setPendingModel(modelArg);
return true;
}
+ const owner = sessionOwnerGuard.capture();
sessionActions
.setModel(modelArg)
.then(() => {
+ if (!owner.isCurrent()) return;
setPendingModel(modelArg);
})
.catch((error: unknown) => {
+ if (!owner.isCurrent()) return;
reportError(error, t('model.switch'));
});
} else {
@@ -8004,10 +8169,15 @@ export function App({
}
return true;
}
+ const planPreparationToken = prompt
+ ? ++planPreparationTokenRef.current
+ : undefined;
if (prompt) setIsPreparingPrompt(true);
+ const owner = sessionOwnerGuard.capture();
sessionActions
.setApprovalMode('plan')
.then(() => {
+ if (!owner.isCurrent()) return;
setPendingMode('plan');
if (prompt) {
return sendPrompt(prompt, images, {
@@ -8019,10 +8189,16 @@ export function App({
}
})
.catch((error: unknown) => {
+ if (!owner.isCurrent()) return;
reportError(error, t('mode.plan'));
})
.finally(() => {
- if (prompt) setIsPreparingPrompt(false);
+ if (
+ prompt &&
+ planPreparationTokenRef.current === planPreparationToken
+ ) {
+ setIsPreparingPrompt(false);
+ }
});
return prompt ? false : true;
}
@@ -8313,6 +8489,7 @@ export function App({
if (!requireActiveSessionForLocalCommand()) return false;
const renamedSessionId = connectionRef.current.sessionId;
const renamedWorkspaceCwd = connectionRef.current.workspaceCwd;
+ const owner = sessionOwnerGuard.capture();
sessionActions
.renameSession(displayName)
.then(() => {
@@ -8323,6 +8500,7 @@ export function App({
displayName,
);
}
+ if (!owner.isCurrent()) return;
store.dispatch([
{
type: 'status',
@@ -8336,6 +8514,7 @@ export function App({
renamedWorkspaceCwd,
);
}
+ if (!owner.isCurrent()) return;
reportError(error, 'Failed to rename session');
});
return true;
@@ -8343,13 +8522,7 @@ export function App({
if (cmd === 'resume') {
const sessionId = text.slice(match[0].length).trim();
if (sessionId) {
- closeMobileDrawer();
- // Resuming a session means the user wants to see that chat, so
- // close any open Settings/Status panel (no-op when already closed),
- // consistent with createNewSession / loadSidebarSession.
- closePanel();
- autoRecapVersionRef.current += 1;
- sessionActions.loadSession(sessionId).catch((error: unknown) => {
+ loadSidebarSession(sessionId).catch((error: unknown) => {
reportError(error, 'Failed to load session');
});
} else {
@@ -8385,15 +8558,18 @@ export function App({
if (statsArg === 'model') statsView = 'model';
else if (statsArg === 'tools') statsView = 'tools';
if (!requireActiveSessionForLocalCommand()) return false;
+ const owner = sessionOwnerGuard.capture();
echoLocalCommandIfIdle(text);
sessionActions
.getStats()
.then((result) => {
+ if (!owner.isCurrent()) return;
dispatchReadOnlyStatus(
serializeStatsMessage(result, statsView),
);
})
.catch((error: unknown) => {
+ if (!owner.isCurrent()) return;
reportError(error, 'Failed to load stats');
});
return true;
@@ -8610,6 +8786,7 @@ export function App({
[
sendPrompt,
sessionActions,
+ sessionOwnerGuard,
store,
enqueuePrompt,
echoOrDeferLocalCommand,
@@ -8617,7 +8794,6 @@ export function App({
dispatchReadOnlyStatus,
branchCurrentSession,
closeMobileDrawer,
- closePanel,
openPanel,
openScheduledTasks,
openGoals,
@@ -8638,6 +8814,7 @@ export function App({
sideTasksAvailable,
openEnvironmentTasksPanel,
hiddenCommands,
+ loadSidebarSession,
pushToast,
reportError,
runVisibleRecap,
@@ -8685,13 +8862,15 @@ export function App({
const handleConfirm = useCallback(
(id: string, selectedOption: string, answers?: Record) => {
+ const owner = sessionOwnerGuard.capture();
sessionActions
.submitPermission(id, selectedOption, answers)
.catch((error: unknown) => {
+ if (!owner.isCurrent()) return;
reportError(error, 'Failed to submit permission choice');
});
},
- [sessionActions, reportError],
+ [sessionActions, reportError, sessionOwnerGuard],
);
const handleAskUserConfirm = useCallback(
(id: string, selectedOption: string, answers?: Record) =>
@@ -8700,6 +8879,7 @@ export function App({
);
const handleCancel = useCallback(() => {
+ const owner = sessionOwnerGuard.capture();
const dropped = queuedShellCommandsRef.current.length;
queuedShellCommandsRef.current = [];
drainGenerationRef.current++;
@@ -8709,9 +8889,10 @@ export function App({
pushToast('warning', t('queue.shellDropped', { count: dropped }));
}
sessionActions.cancel().catch((error: unknown) => {
+ if (!owner.isCurrent()) return;
reportError(error, 'Failed to cancel request');
});
- }, [sessionActions, reportError, pushToast, t]);
+ }, [sessionActions, reportError, pushToast, sessionOwnerGuard, t]);
const handleFocusTaskPill = useCallback((): boolean => {
if (interactionBlocked) return false;
@@ -8776,6 +8957,7 @@ export function App({
);
const handleRetry = useCallback(() => {
+ if (sessionWriteBlockedRef.current) return;
if (
showRetryHintRef.current &&
connected &&
@@ -8787,13 +8969,15 @@ export function App({
) {
const retryErrorId = retryableTurnErrorIdRef.current;
const retrySessionId = connectionRef.current.sessionId;
+ const retryWorkspaceCwd = getComposerWorkspaceCwd();
const retrySourceVersion = composerSourceVersionRef.current;
const retryText = lastSubmittedPromptRef.current;
const retryImages = lastSubmittedImagesRef.current;
const retryInputAnnotations = lastSubmittedInputAnnotationsRef.current;
const retryOwnerIsCurrent = () =>
composerSourceVersionRef.current === retrySourceVersion &&
- connectionRef.current.sessionId === retrySessionId;
+ connectionRef.current.sessionId === retrySessionId &&
+ getComposerWorkspaceCwd() === retryWorkspaceCwd;
retriedTurnErrorIdRef.current = retryErrorId;
setShowRetryHint(false);
setFailedPromptRetry({
@@ -8858,6 +9042,7 @@ export function App({
}
}, [
connected,
+ getComposerWorkspaceCwd,
pushToast,
reportError,
sendPrompt,
@@ -9042,11 +9227,13 @@ export function App({
};
}, [resetEscapeState]);
- const isDisabled = shouldDisableComposerInput({
- catchingUp: Boolean(connection.catchingUp),
- pendingApproval: pendingApproval !== null,
- isPreparingPrompt,
- });
+ const isDisabled =
+ sessionWriteBlocked ||
+ shouldDisableComposerInput({
+ catchingUp: Boolean(connection.catchingUp),
+ pendingApproval: pendingApproval !== null,
+ isPreparingPrompt,
+ });
const composerPlaceholderInputState = {
catchingUp: Boolean(connection.catchingUp),
isPreparingPrompt,
@@ -9063,6 +9250,7 @@ export function App({
const handleModelSelect = useCallback(
(modelId: string) => {
+ if (sessionWriteBlocked) return;
if (!connectionRef.current.sessionId) {
setPendingModel(modelId);
return;
@@ -9071,10 +9259,13 @@ export function App({
// selection is in flight — rapid Set current clicks would otherwise launch
// concurrent setModel calls that can resolve out of order and leave a
// model other than the user's last click active.
+ const owner = sessionOwnerGuard.capture();
+ const modelActionToken = ++modelActionTokenRef.current;
setModelActionBusy(true);
sessionActions
.setModel(modelId)
.then((result) => {
+ if (!owner.isCurrent()) return;
const summary = getModelSwitchSummary(result);
setPendingModel(summary?.modelId ?? modelId);
if (summary) {
@@ -9087,15 +9278,29 @@ export function App({
}
})
.catch((error: unknown) => {
+ if (!owner.isCurrent()) return;
reportError(error, t('model.switch'));
})
- .finally(() => setModelActionBusy(false));
+ .finally(() => {
+ if (modelActionTokenRef.current === modelActionToken) {
+ setModelActionBusy(false);
+ }
+ });
},
- [sessionActions, store, reportError, t, setPendingModel],
+ [
+ sessionWriteBlocked,
+ reportError,
+ sessionActions,
+ sessionOwnerGuard,
+ setPendingModel,
+ store,
+ t,
+ ],
);
const handleDeleteModel = useCallback(
(target: { authType: string; modelId: string; baseUrl?: string }) => {
+ const modelActionToken = ++modelActionTokenRef.current;
setModelActionBusy(true);
workspaceActions
.deleteModel(target)
@@ -9126,7 +9331,11 @@ export function App({
.catch((error: unknown) => {
reportError(error, t('settings.models.deleteFailed'));
})
- .finally(() => setModelActionBusy(false));
+ .finally(() => {
+ if (modelActionTokenRef.current === modelActionToken) {
+ setModelActionBusy(false);
+ }
+ });
},
// Depend on the stable `reload` fn, not the whole providersState object,
// which useProviders returns fresh each render (would defeat the memo).
@@ -9215,8 +9424,12 @@ export function App({
// and ignore the user's User-vs-Workspace choice.
const scopeFlag =
modelSettingScope === 'user' ? ' --global' : ' --project';
- sendPrompt(`/model --fast ${modelId}${scopeFlag}`)
+ const owner = { current: sessionOwnerGuard.capture() };
+ sendPrompt(`/model --fast ${modelId}${scopeFlag}`, undefined, {
+ ownerRef: owner,
+ })
.then(() => {
+ if (!owner.current.isCurrent()) return;
// sendPrompt resolves only after the `/model --fast` turn *completes*
// (actions.ts → waitForAcceptedPromptCompletion), so the change is
// already applied here — this reload reads the new value, not a stale
@@ -9233,6 +9446,7 @@ export function App({
});
})
.catch((error: unknown) => {
+ if (!owner.current.isCurrent()) return;
reportError(error, 'Failed to switch fast model');
});
},
@@ -9244,6 +9458,7 @@ export function App({
reportError,
reloadWorkspaceSettings,
modelSettingScope,
+ sessionOwnerGuard,
],
);
@@ -9715,14 +9930,9 @@ export function App({
{
- closeMobileDrawer();
- closePanel();
- autoRecapVersionRef.current += 1;
- sessionActions
- .loadSession(sessionId)
- .catch((error: unknown) => {
- reportError(error, 'Failed to load session');
- });
+ loadSidebarSession(sessionId).catch((error: unknown) => {
+ reportError(error, 'Failed to load session');
+ });
}}
onClose={() => setShowResumeDialog(false)}
/>
@@ -10629,17 +10839,24 @@ export function App({
// would land in an empty session with no explanation.
// Letting this reject keeps the error in the form the
// user is looking at.
+ const owner = {
+ current: sessionOwnerGuard.capture(),
+ };
try {
await sendPrompt(`/goal ${condition}`, undefined, {
clearComposerOnPromptStart: true,
+ ownerRef: owner,
});
+ if (!owner.current.isCurrent()) return false;
} catch (error) {
// `sendPrompt` creates the session lazily, so by now
// one may exist even though the prompt never landed.
// Remember it so the retry reuses it rather than
// stranding it.
- strandedGoalSessionRef.current =
- connectionRef.current.sessionId;
+ if (owner.current.isCurrent()) {
+ strandedGoalSessionRef.current =
+ connectionRef.current.sessionId;
+ }
throw error;
}
strandedGoalSessionRef.current = undefined;
diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx
index 1625c5bf737..c1de7b08f9c 100644
--- a/packages/web-shell/client/components/ChatPane.test.tsx
+++ b/packages/web-shell/client/components/ChatPane.test.tsx
@@ -110,6 +110,9 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
workspaceCwd: '/primary',
}),
useWorkspaceEventSignals: () => ({ artifactsVersion: 0 }),
+ useDaemonSessionOwnerGuard: () => ({
+ capture: () => ({ isCurrent: () => true }),
+ }),
}));
vi.mock('../session-catalog/session-catalog-hooks', () => ({
diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx
index ac197c5052a..509ebcb5c23 100644
--- a/packages/web-shell/client/components/ChatPane.tsx
+++ b/packages/web-shell/client/components/ChatPane.tsx
@@ -538,6 +538,7 @@ export function ChatPane({
} = useQueuedPrompts({
connected: connection.status === 'connected',
sessionId: connection.sessionId,
+ workspaceCwd: connection.workspaceCwd,
clientId: connection.clientId,
canMutateMidTurn,
canQueryMidTurn,
diff --git a/packages/web-shell/client/components/WorkspaceSessionProvider.test.tsx b/packages/web-shell/client/components/WorkspaceSessionProvider.test.tsx
new file mode 100644
index 00000000000..d33392e209d
--- /dev/null
+++ b/packages/web-shell/client/components/WorkspaceSessionProvider.test.tsx
@@ -0,0 +1,360 @@
+// @vitest-environment jsdom
+
+import { act, type ReactNode, useEffect } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => ({
+ connection: {
+ status: 'connected',
+ sessionId: 'session-a',
+ workspaceCwd: '/work/a',
+ } as Record,
+ workspace: {
+ status: 'connected',
+ capabilities: {
+ workspaceCwd: '/work/a',
+ features: ['client_identity'],
+ workspaces: [
+ { id: 'a', cwd: '/work/a', primary: true, trusted: true },
+ { id: 'b', cwd: '/work/b', primary: false, trusted: true },
+ ],
+ },
+ refreshCapabilities: vi.fn(async () => undefined),
+ } as Record,
+ addWorkspace: vi.fn(),
+ providerMounts: 0,
+ providerUnmounts: 0,
+ providerProps: [] as Array>,
+ appProps: [] as Array>,
+}));
+
+vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
+ DaemonSessionProvider: ({
+ children,
+ ...props
+ }: Record & { children: ReactNode }) => {
+ mocks.providerProps.push(props);
+ useEffect(() => {
+ mocks.providerMounts += 1;
+ return () => {
+ mocks.providerUnmounts += 1;
+ };
+ }, []);
+ return children;
+ },
+ useWorkspace: () => mocks.workspace,
+ useConnection: () => mocks.connection,
+ useWorkspaceActions: () => ({ addWorkspace: mocks.addWorkspace }),
+}));
+
+vi.mock('../App', () => ({
+ App: (props: Record) => {
+ mocks.appProps.push(props);
+ return (
+
+ );
+ },
+}));
+
+import { WorkspaceSessionProvider } from './WorkspaceSessionProvider';
+
+describe('WorkspaceSessionProvider transactional targets', () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(() => {
+ mocks.connection = {
+ status: 'connected',
+ sessionId: 'session-a',
+ workspaceCwd: '/work/a',
+ };
+ mocks.workspace = {
+ status: 'connected',
+ capabilities: {
+ workspaceCwd: '/work/a',
+ features: ['client_identity'],
+ workspaces: [
+ { id: 'a', cwd: '/work/a', primary: true, trusted: true },
+ { id: 'b', cwd: '/work/b', primary: false, trusted: true },
+ ],
+ },
+ refreshCapabilities: vi.fn(async () => undefined),
+ };
+ mocks.addWorkspace.mockReset();
+ mocks.providerMounts = 0;
+ mocks.providerUnmounts = 0;
+ mocks.providerProps = [];
+ mocks.appProps = [];
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ });
+
+ async function renderTarget(
+ sessionId: string,
+ workspaceCwd: string,
+ onSessionIdChange = vi.fn(),
+ ) {
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+ return onSessionIdChange;
+ }
+
+ it('keeps the modern provider mounted until the desired target commits', async () => {
+ const onSessionIdChange = await renderTarget('session-a', '/work/a');
+ expect(mocks.providerMounts).toBe(1);
+ expect(container.textContent).toBe('/work/a');
+
+ await renderTarget('session-b', '/work/b', onSessionIdChange);
+ expect(mocks.providerMounts).toBe(1);
+ expect(mocks.providerUnmounts).toBe(0);
+ expect(mocks.providerProps.at(-1)).toMatchObject({
+ sessionId: 'session-b',
+ workspaceCwd: '/work/b',
+ });
+ expect(mocks.appProps.at(-1)).toMatchObject({
+ desiredSessionTargetPending: true,
+ initialSelectedWorkspaceCwd: '/work/a',
+ });
+
+ await act(async () => {
+ const commit = mocks.providerProps.at(-1)?.[
+ 'onSessionTransitionCommit'
+ ] as (target: { sessionId: string; workspaceCwd: string }) => void;
+ commit({ sessionId: 'session-b', workspaceCwd: '/work/b' });
+ });
+ expect(container.textContent).toBe('/work/b');
+ expect(mocks.providerProps.at(-1)).toMatchObject({
+ sessionId: 'session-b',
+ workspaceCwd: '/work/b',
+ });
+ expect(mocks.appProps.at(-1)).toMatchObject({
+ desiredSessionTargetPending: false,
+ });
+ const appReport = mocks.appProps.at(-1)?.['onSessionIdChange'] as (
+ sessionId: string,
+ workspaceId: string,
+ workspaceCwd: string,
+ ) => void;
+ appReport('session-b', 'b', '/work/b');
+ expect(onSessionIdChange).toHaveBeenCalledTimes(1);
+ expect(onSessionIdChange).toHaveBeenCalledWith('session-b', 'b', '/work/b');
+ });
+
+ it('does not feed stale host props back after an action-driven commit', async () => {
+ const onSessionIdChange = await renderTarget('session-a', '/work/a');
+
+ await act(async () => {
+ const commit = mocks.providerProps.at(-1)?.[
+ 'onSessionTransitionCommit'
+ ] as (target: { sessionId: string; workspaceCwd: string }) => void;
+ commit({ sessionId: 'session-b', workspaceCwd: '/work/b' });
+ });
+
+ expect(mocks.providerProps.at(-1)).toMatchObject({
+ sessionId: 'session-b',
+ workspaceCwd: '/work/b',
+ });
+ expect(onSessionIdChange).not.toHaveBeenCalled();
+ const appReport = mocks.appProps.at(-1)?.['onSessionIdChange'] as (
+ sessionId: string,
+ workspaceId: string,
+ workspaceCwd: string,
+ ) => void;
+ appReport('session-b', 'b', '/work/b');
+ expect(onSessionIdChange).toHaveBeenCalledWith('session-b', 'b', '/work/b');
+ });
+
+ it('keeps the committed app visible while a workspace target is unresolved', async () => {
+ const onSessionIdChange = await renderTarget('session-a', '/work/a');
+ mocks.workspace = {
+ ...mocks.workspace,
+ capabilities: undefined,
+ };
+
+ await renderTarget('session-b', '/work/missing', onSessionIdChange);
+ expect(mocks.providerMounts).toBe(1);
+ expect(container.textContent).toBe('/work/a');
+ expect(mocks.providerProps.at(-1)).toMatchObject({
+ sessionId: 'session-a',
+ workspaceCwd: '/work/a',
+ });
+ expect(mocks.appProps.at(-1)).toMatchObject({
+ desiredSessionTargetPending: true,
+ });
+ });
+
+ it('unblocks the committed session after workspace resolution fails', async () => {
+ const onSessionIdChange = await renderTarget('session-a', '/work/a');
+ onSessionIdChange.mockClear();
+ mocks.workspace = {
+ ...mocks.workspace,
+ status: 'error',
+ capabilities: {
+ workspaceCwd: '/work/a',
+ features: ['client_identity'],
+ workspaces: [{ id: 'a', cwd: '/work/a', primary: true, trusted: true }],
+ },
+ };
+
+ await renderTarget('session-b', '/work/missing', onSessionIdChange);
+
+ expect(mocks.providerMounts).toBe(1);
+ expect(container.textContent).toBe('/work/a');
+ expect(mocks.appProps.at(-1)).toMatchObject({
+ desiredSessionTargetPending: false,
+ });
+ expect(onSessionIdChange).toHaveBeenCalledTimes(1);
+ expect(onSessionIdChange).toHaveBeenCalledWith('session-a', 'a', '/work/a');
+
+ await renderTarget('session-b', '/work/missing', onSessionIdChange);
+ expect(onSessionIdChange).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not preserve a target that never connected', async () => {
+ mocks.connection = { status: 'error' };
+ const onSessionIdChange = await renderTarget('session-a', '/work/a');
+ mocks.workspace = {
+ ...mocks.workspace,
+ capabilities: {
+ workspaceCwd: '/work/a',
+ features: ['client_identity'],
+ workspaces: [{ id: 'a', cwd: '/work/a', primary: true, trusted: true }],
+ },
+ };
+
+ await renderTarget('session-b', '/work/missing', onSessionIdChange);
+
+ expect(mocks.providerUnmounts).toBe(1);
+ expect(container.textContent).not.toContain('/work/a');
+ });
+
+ it('rolls a still-current controlled target back after restore failure', async () => {
+ const onSessionIdChange = await renderTarget('session-a', '/work/a');
+ onSessionIdChange.mockClear();
+ await renderTarget('session-b', '/work/b', onSessionIdChange);
+ mocks.connection = {
+ status: 'connected',
+ sessionId: 'session-a',
+ workspaceCwd: '/work/a',
+ sessionTransition: {
+ phase: 'failed',
+ operation: 'load',
+ origin: 'controlled',
+ targetSessionId: 'session-b',
+ targetWorkspaceCwd: '/work/b',
+ },
+ };
+ await renderTarget('session-b', '/work/b', onSessionIdChange);
+ expect(onSessionIdChange).toHaveBeenCalledTimes(1);
+ expect(onSessionIdChange).toHaveBeenCalledWith('session-a', 'a', '/work/a');
+ expect(mocks.appProps.at(-1)).toMatchObject({
+ desiredSessionTargetPending: false,
+ });
+ });
+
+ it('rolls back a primary-workspace target when workspace props are omitted', async () => {
+ const onSessionIdChange = vi.fn();
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+ onSessionIdChange.mockClear();
+
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+ mocks.connection = {
+ status: 'connected',
+ sessionId: 'session-a',
+ workspaceCwd: '/work/a',
+ sessionTransition: {
+ phase: 'failed',
+ operation: 'load',
+ origin: 'controlled',
+ targetSessionId: 'session-b',
+ targetWorkspaceCwd: '/work/a',
+ },
+ };
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+
+ expect(onSessionIdChange).toHaveBeenCalledWith('session-a', 'a', '/work/a');
+ });
+
+ it('preserves keyed remounts for legacy daemons', async () => {
+ mocks.workspace = {
+ ...mocks.workspace,
+ capabilities: {
+ workspaceCwd: '/work/a',
+ features: [],
+ workspaces: [
+ { id: 'a', cwd: '/work/a', primary: true, trusted: true },
+ { id: 'b', cwd: '/work/b', primary: false, trusted: true },
+ ],
+ },
+ };
+ const onSessionIdChange = await renderTarget('session-a', '/work/a');
+ await renderTarget('session-b', '/work/b', onSessionIdChange);
+ expect(mocks.providerMounts).toBe(2);
+ expect(mocks.providerUnmounts).toBe(1);
+ expect(mocks.appProps.at(-1)).toMatchObject({
+ initialSelectedWorkspaceCwd: '/work/b',
+ });
+ });
+
+ it('does not remount when an unknown daemon resolves as modern', async () => {
+ mocks.workspace = { ...mocks.workspace, capabilities: undefined };
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+ expect(mocks.providerMounts).toBe(1);
+
+ mocks.workspace = {
+ ...mocks.workspace,
+ capabilities: {
+ workspaceCwd: '/work/a',
+ features: ['client_identity'],
+ workspaces: [{ id: 'a', cwd: '/work/a', primary: true, trusted: true }],
+ },
+ };
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+
+ expect(mocks.providerMounts).toBe(1);
+ expect(mocks.providerUnmounts).toBe(0);
+ });
+});
diff --git a/packages/web-shell/client/components/WorkspaceSessionProvider.tsx b/packages/web-shell/client/components/WorkspaceSessionProvider.tsx
index c080852f3c1..696f3913383 100644
--- a/packages/web-shell/client/components/WorkspaceSessionProvider.tsx
+++ b/packages/web-shell/client/components/WorkspaceSessionProvider.tsx
@@ -1,10 +1,12 @@
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { WifiOffIcon } from 'lucide-react';
import {
DaemonSessionProvider,
+ useConnection,
useWorkspace,
useWorkspaceActions,
} from '@qwen-code/webui/daemon-react-sdk';
+import type { DaemonConnectionState } from '@qwen-code/webui/daemon-react-sdk';
import type { DaemonWorkspaceCapability } from '@qwen-code/sdk/daemon';
import { App, type WebShellProps } from '../App';
import {
@@ -14,6 +16,17 @@ import {
import { getTranslator, normalizeLanguage } from '../i18n';
import { Spinner } from './ui/spinner';
import { WorkspaceUnavailableState } from './WorkspaceUnavailableState';
+const CLIENT_IDENTITY_FEATURE = 'client_identity';
+type CommittedSessionTarget = { sessionId: string; workspaceCwd?: string };
+function SessionStateObserver({
+ onChange,
+}: {
+ onChange: (connection: DaemonConnectionState) => void;
+}) {
+ const connection = useConnection();
+ useEffect(() => onChange(connection), [connection, onChange]);
+ return null;
+}
interface WorkspaceSessionProviderProps {
sessionId?: string;
@@ -91,10 +104,168 @@ export function WorkspaceSessionProvider({
: workspace.capabilities?.workspaces?.find(
(entry) => entry.id === effectiveWorkspaceId,
);
+ const desiredWorkspace =
+ targetWorkspace ??
+ (!effectiveWorkspaceCwd && !effectiveWorkspaceId
+ ? workspace.capabilities?.workspaces?.find(
+ (entry) =>
+ entry.primary || entry.cwd === workspace.capabilities?.workspaceCwd,
+ )
+ : undefined);
const t = useMemo(
() => getTranslator(normalizeLanguage(webShellProps.language)),
[webShellProps.language],
);
+ const onSessionIdChange = webShellProps.onSessionIdChange;
+ const transactionalRef = useRef(undefined);
+ if (workspace.capabilities) {
+ transactionalRef.current = workspace.capabilities.features.includes(
+ CLIENT_IDENTITY_FEATURE,
+ );
+ }
+ const transactional = transactionalRef.current === true;
+ const desiredKey = `${effectiveSessionId ?? ''}\0${effectiveWorkspaceCwd ?? effectiveWorkspaceId ?? ''}`;
+ const [, setCommittedTarget] = useState();
+ const committedTargetRef = useRef(
+ undefined,
+ );
+ const pendingHostCommitKeyRef = useRef(undefined);
+ if (
+ pendingHostCommitKeyRef.current !== undefined &&
+ pendingHostCommitKeyRef.current !== desiredKey
+ ) {
+ pendingHostCommitKeyRef.current = undefined;
+ }
+ const installCommittedTarget = useCallback(
+ (target: CommittedSessionTarget) => {
+ committedTargetRef.current = target;
+ setCommittedTarget(target);
+ },
+ [],
+ );
+ const commitTarget = useCallback(
+ (target: CommittedSessionTarget) => {
+ pendingHostCommitKeyRef.current =
+ target.sessionId !== effectiveSessionId ||
+ target.workspaceCwd !== desiredWorkspace?.cwd
+ ? desiredKey
+ : undefined;
+ installCommittedTarget(target);
+ },
+ [
+ desiredKey,
+ desiredWorkspace?.cwd,
+ effectiveSessionId,
+ installCommittedTarget,
+ ],
+ );
+ const canKeepCommitted =
+ transactional && committedTargetRef.current !== undefined;
+ const desiredTargetResolved =
+ (!effectiveWorkspaceCwd && !effectiveWorkspaceId) ||
+ targetWorkspace !== undefined;
+ const failureLatchRef = useRef(undefined);
+ const desiredTargetFailed =
+ workspace.status === 'error' ||
+ (!desiredTargetResolved &&
+ ((lockWorkspaceCwd !== undefined &&
+ registrationErrorCwd === lockWorkspaceCwd) ||
+ (workspace.capabilities !== undefined && !lockWorkspaceCwd)));
+ const desiredTargetReady = desiredTargetResolved && !desiredTargetFailed;
+ const controlledTargetUncommitted =
+ effectiveSessionId !== undefined &&
+ pendingHostCommitKeyRef.current !== desiredKey &&
+ (effectiveSessionId !== committedTargetRef.current?.sessionId ||
+ desiredWorkspace?.cwd !== committedTargetRef.current?.workspaceCwd);
+ const desiredTargetPending =
+ canKeepCommitted &&
+ failureLatchRef.current !== desiredKey &&
+ !desiredTargetFailed &&
+ (!desiredTargetReady || controlledTargetUncommitted);
+ const reportCommittedTarget = useCallback(() => {
+ const committed = committedTargetRef.current;
+ if (!committed) return;
+ const workspaceId = workspace.capabilities?.workspaces?.find(
+ (entry) => entry.cwd === committed.workspaceCwd,
+ )?.id;
+ onSessionIdChange?.(
+ committed.sessionId,
+ workspaceId,
+ committed.workspaceCwd,
+ );
+ }, [onSessionIdChange, workspace.capabilities?.workspaces]);
+ const observeSessionState = useCallback(
+ (connection: DaemonConnectionState) => {
+ if (
+ transactional &&
+ connection.status === 'connected' &&
+ connection.sessionId
+ ) {
+ installCommittedTarget({
+ sessionId: connection.sessionId,
+ workspaceCwd: connection.workspaceCwd,
+ });
+ }
+ const transition = connection.sessionTransition;
+ if (
+ transition?.phase === 'failed' &&
+ transition.targetSessionId === effectiveSessionId &&
+ transition.targetWorkspaceCwd === desiredWorkspace?.cwd &&
+ failureLatchRef.current !== desiredKey
+ ) {
+ failureLatchRef.current = desiredKey;
+ reportCommittedTarget();
+ }
+ },
+ [
+ desiredKey,
+ effectiveSessionId,
+ installCommittedTarget,
+ reportCommittedTarget,
+ desiredWorkspace?.cwd,
+ transactional,
+ ],
+ );
+
+ useEffect(() => {
+ if (!canKeepCommitted || desiredTargetReady) {
+ if (failureLatchRef.current !== desiredKey) {
+ failureLatchRef.current = undefined;
+ }
+ return;
+ }
+ if (!desiredTargetFailed || failureLatchRef.current === desiredKey) return;
+ failureLatchRef.current = desiredKey;
+ reportCommittedTarget();
+ }, [
+ canKeepCommitted,
+ desiredKey,
+ desiredTargetFailed,
+ desiredTargetReady,
+ reportCommittedTarget,
+ ]);
+ const keepCommittedTarget =
+ canKeepCommitted &&
+ (!desiredTargetReady || pendingHostCommitKeyRef.current === desiredKey);
+ const providerSessionId = keepCommittedTarget
+ ? committedTargetRef.current!.sessionId
+ : effectiveSessionId;
+ const providerWorkspaceCwd = keepCommittedTarget
+ ? committedTargetRef.current!.workspaceCwd
+ : desiredWorkspace?.cwd;
+ const visibleWorkspaceCwd = canKeepCommitted
+ ? committedTargetRef.current!.workspaceCwd
+ : desiredWorkspace?.cwd;
+ const visibleWorkspace =
+ (desiredWorkspace?.cwd === visibleWorkspaceCwd
+ ? desiredWorkspace
+ : undefined) ??
+ workspace.capabilities?.workspaces?.find(
+ (entry) => entry.cwd === visibleWorkspaceCwd,
+ ) ??
+ (registeredLockedWorkspace?.cwd === visibleWorkspaceCwd
+ ? registeredLockedWorkspace
+ : undefined);
useEffect(() => {
if (!lockWorkspaceCwd || !workspace.capabilities || pathWorkspace) return;
@@ -151,7 +322,8 @@ export function WorkspaceSessionProvider({
if (
(effectiveWorkspaceCwd || effectiveWorkspaceId) &&
- workspace.status === 'error'
+ workspace.status === 'error' &&
+ !canKeepCommitted
) {
return (
);
}
- if (lockWorkspaceCwd && registrationErrorCwd === lockWorkspaceCwd) {
+ if (
+ lockWorkspaceCwd &&
+ registrationErrorCwd === lockWorkspaceCwd &&
+ !canKeepCommitted
+ ) {
return (
);
}
- if (lockWorkspaceCwd && !targetWorkspace) {
+ if (lockWorkspaceCwd && !targetWorkspace && !canKeepCommitted) {
return (
);
}
- if ((effectiveWorkspaceCwd || effectiveWorkspaceId) && !targetWorkspace) {
+ if (
+ (effectiveWorkspaceCwd || effectiveWorkspaceId) &&
+ !targetWorkspace &&
+ !canKeepCommitted
+ ) {
return (
+
diff --git a/packages/web-shell/client/hooks/useBackgroundTasks.test.tsx b/packages/web-shell/client/hooks/useBackgroundTasks.test.tsx
index e010a87f868..e7bd5d7d68e 100644
--- a/packages/web-shell/client/hooks/useBackgroundTasks.test.tsx
+++ b/packages/web-shell/client/hooks/useBackgroundTasks.test.tsx
@@ -22,6 +22,8 @@ interface Deferred {
}
const sdkMock = vi.hoisted(() => ({
+ ownerVersion: 0,
+ ownerGuard: { capture: vi.fn() },
actions: {
getTasks: vi.fn(),
},
@@ -29,6 +31,7 @@ const sdkMock = vi.hoisted(() => ({
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
useActions: () => sdkMock.actions,
+ useDaemonSessionOwnerGuard: () => sdkMock.ownerGuard,
}));
let root: Root | null = null;
@@ -109,6 +112,11 @@ beforeEach(() => {
refreshTrigger = 0;
latestTasks = [];
sdkMock.actions.getTasks.mockReset();
+ sdkMock.ownerVersion = 0;
+ sdkMock.ownerGuard.capture.mockImplementation(() => {
+ const version = sdkMock.ownerVersion;
+ return { isCurrent: () => sdkMock.ownerVersion === version };
+ });
});
afterEach(async () => {
@@ -205,6 +213,7 @@ describe('useBackgroundTasks', () => {
});
sessionId = 'session-b';
+ sdkMock.ownerVersion += 1;
await rerenderHarness();
expect(sdkMock.actions.getTasks).toHaveBeenCalledTimes(2);
expect(sdkMock.actions.getTasks).toHaveBeenLastCalledWith({
@@ -234,4 +243,45 @@ describe('useBackgroundTasks', () => {
silent: true,
});
});
+
+ it('ignores an old attachment response when the session id is unchanged', async () => {
+ const request = deferred();
+ sdkMock.actions.getTasks.mockReturnValueOnce(request.promise);
+ await renderHarness();
+
+ sdkMock.ownerVersion += 1;
+ await act(async () => {
+ request.resolve(
+ snapshot('session-a', [monitor('stale-monitor', 'running')]),
+ );
+ await request.promise;
+ });
+
+ expect(latestTasks).toEqual([]);
+ });
+
+ it('starts polling a replacement attachment while the old request hangs', async () => {
+ const oldRequest = deferred();
+ const runningMonitor = monitor('replacement-monitor', 'running');
+ sdkMock.actions.getTasks
+ .mockReturnValueOnce(oldRequest.promise)
+ .mockResolvedValueOnce(snapshot('session-a', [runningMonitor]));
+
+ await renderHarness();
+ expect(sdkMock.actions.getTasks).toHaveBeenCalledTimes(1);
+
+ sdkMock.ownerVersion += 1;
+ await rerenderHarness();
+
+ expect(sdkMock.actions.getTasks).toHaveBeenCalledTimes(2);
+ expect(latestTasks).toEqual([runningMonitor]);
+
+ await act(async () => {
+ oldRequest.resolve(
+ snapshot('session-a', [monitor('stale-monitor', 'completed')]),
+ );
+ await oldRequest.promise;
+ });
+ expect(latestTasks).toEqual([runningMonitor]);
+ });
});
diff --git a/packages/web-shell/client/hooks/useBackgroundTasks.ts b/packages/web-shell/client/hooks/useBackgroundTasks.ts
index 253495fcaed..33485f39f62 100644
--- a/packages/web-shell/client/hooks/useBackgroundTasks.ts
+++ b/packages/web-shell/client/hooks/useBackgroundTasks.ts
@@ -1,6 +1,9 @@
import { useEffect, useRef, useState } from 'react';
import type { DaemonSessionTaskStatus } from '@qwen-code/sdk/daemon';
-import { useActions } from '@qwen-code/webui/daemon-react-sdk';
+import {
+ useActions,
+ useDaemonSessionOwnerGuard,
+} from '@qwen-code/webui/daemon-react-sdk';
import { TASKS_STATUS_ACTIVE_EVENT } from '../components/messages/TasksStatusMessage';
import { isSessionDisconnectedError } from '../utils/sessionErrors';
@@ -20,32 +23,30 @@ export function useBackgroundTasks(
refreshTrigger = 0,
): DaemonSessionTaskStatus[] {
const actions = useActions();
+ const ownerGuard = useDaemonSessionOwnerGuard();
+ const ownerRef = useRef(ownerGuard.capture());
+ if (!ownerRef.current?.isCurrent()) ownerRef.current = ownerGuard.capture();
+ const owner = ownerRef.current;
const [tasks, setTasks] = useState([]);
+ const tasksOwnerRef = useRef(owner);
const [pollingActive, setPollingActive] = useState(false);
const [tasksPanelActive, setTasksPanelActive] = useState(false);
const emptyPollsRef = useRef(0);
- const tasksRefreshInFlightRef = useRef<{
- sessionId: string;
- request: object;
- } | null>(null);
+ const tasksRefreshInFlightRef = useRef(null);
useEffect(() => {
+ tasksOwnerRef.current = owner;
setTasks([]);
setPollingActive(false);
emptyPollsRef.current = 0;
- }, [connected, sessionId]);
+ }, [connected, owner, sessionId]);
useEffect(() => {
- if (!connected || !sessionId || !taskActivityKey) return;
+ if (!connected || !sessionId || (!taskActivityKey && refreshTrigger === 0))
+ return;
emptyPollsRef.current = 0;
setPollingActive(true);
- }, [connected, sessionId, taskActivityKey]);
-
- useEffect(() => {
- if (!connected || !sessionId || refreshTrigger === 0) return;
- emptyPollsRef.current = 0;
- setPollingActive(true);
- }, [connected, refreshTrigger, sessionId]);
+ }, [connected, owner, refreshTrigger, sessionId, taskActivityKey]);
useEffect(() => {
if (tasksPanelActive) return;
@@ -53,13 +54,17 @@ export function useBackgroundTasks(
let disposed = false;
const refresh = () => {
- if (tasksRefreshInFlightRef.current?.sessionId === sessionId) return;
- const request = {};
- tasksRefreshInFlightRef.current = { sessionId, request };
+ if (tasksRefreshInFlightRef.current === owner) return;
+ tasksRefreshInFlightRef.current = owner;
actions
.getTasks({ silent: true })
.then((snapshot) => {
- if (disposed || snapshot.sessionId !== sessionId) return;
+ if (
+ disposed ||
+ !owner.isCurrent() ||
+ snapshot.sessionId !== sessionId
+ )
+ return;
setTasks(snapshot.tasks);
if (snapshot.tasks.length === 0) {
emptyPollsRef.current += 1;
@@ -74,7 +79,7 @@ export function useBackgroundTasks(
}
})
.catch((error: unknown) => {
- if (disposed) return;
+ if (disposed || !owner.isCurrent()) return;
if (isSessionDisconnectedError(error)) {
setPollingActive(false);
return;
@@ -82,7 +87,7 @@ export function useBackgroundTasks(
console.warn('[web-shell] failed to refresh tasks:', error);
})
.finally(() => {
- if (tasksRefreshInFlightRef.current?.request === request) {
+ if (tasksRefreshInFlightRef.current === owner) {
tasksRefreshInFlightRef.current = null;
}
});
@@ -94,7 +99,7 @@ export function useBackgroundTasks(
disposed = true;
clearInterval(id);
};
- }, [actions, connected, pollingActive, sessionId, tasksPanelActive]);
+ }, [actions, connected, owner, pollingActive, sessionId, tasksPanelActive]);
const tasksRef = useRef(tasks);
tasksRef.current = tasks;
@@ -113,5 +118,5 @@ export function useBackgroundTasks(
window.removeEventListener(TASKS_STATUS_ACTIVE_EVENT, onTasksPanelActive);
}, []);
- return tasks;
+ return tasksOwnerRef.current === owner ? tasks : [];
}
diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx b/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx
index 6b47901ad52..ac27515d7c1 100644
--- a/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx
+++ b/packages/web-shell/client/hooks/useQueuedPrompts.dom.test.tsx
@@ -37,6 +37,7 @@ const sdk = vi.hoisted(() => ({
originatorClientId?: string;
}>,
consume: vi.fn(),
+ ownerVersion: 0,
}));
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
@@ -49,6 +50,12 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
batches: sdk.batches,
consume: sdk.consume,
}),
+ useDaemonSessionOwnerGuard: () => ({
+ capture: () => {
+ const ownerVersion = sdk.ownerVersion;
+ return { isCurrent: () => sdk.ownerVersion === ownerVersion };
+ },
+ }),
}));
(
@@ -75,6 +82,7 @@ function mount(
sessionActions: DaemonSessionActions,
canMutateMidTurn = true,
connected = false,
+ writeBlocked = false,
) {
const editor = {
getText: vi.fn(() => ''),
@@ -92,13 +100,17 @@ function mount(
function Harness({
state,
activeSessionId,
+ blocked,
}: {
state: typeof streamingState;
activeSessionId: string;
+ blocked: boolean;
}) {
latest = useQueuedPrompts({
connected,
+ writeBlocked: blocked,
sessionId: activeSessionId,
+ workspaceCwd: '/workspace',
clientId: 'client-1',
canMutateMidTurn,
// This suite pins the legacy local-fallback lifecycle.
@@ -114,13 +126,24 @@ function mount(
}
let activeSessionId = 'session-1';
+ let blocked = writeBlocked;
const render = (
state: typeof streamingState,
nextSessionId = activeSessionId,
+ replaceOwner = false,
+ nextWriteBlocked = blocked,
) => {
+ if (replaceOwner) sdk.ownerVersion += 1;
activeSessionId = nextSessionId;
+ blocked = nextWriteBlocked;
act(() =>
- root.render(),
+ root.render(
+ ,
+ ),
);
};
render(streamingState);
@@ -151,6 +174,7 @@ beforeEach(() => {
sdk.batches = [];
sdk.pendingEvents = [];
sdk.consume.mockReset();
+ sdk.ownerVersion = 0;
});
afterEach(() => {
@@ -159,6 +183,27 @@ afterEach(() => {
});
describe('useQueuedPrompts default mid-turn insertion', () => {
+ it('restores an unaccepted mid-turn prompt when its owner is replaced', () => {
+ const { actions } = createActions();
+ vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(
+ new Promise(() => undefined),
+ );
+ const { editor, render } = mount('responding', actions);
+
+ act(() => latest.enqueuePrompt('belongs to the source attachment'));
+ expect(latest.queuedPrompts).toHaveLength(1);
+
+ render('responding', 'session-1', true);
+
+ expect(latest.queuedPrompts).toEqual([]);
+ expect(editor.setText).toHaveBeenCalledWith(
+ 'belongs to the source attachment',
+ );
+ const signal = vi.mocked(actions.enqueueMidTurnMessage).mock.calls[0]?.[1]
+ ?.signal;
+ expect(signal?.aborted).toBe(true);
+ });
+
it('queues and submits an image-only prompt without using mid-turn text insertion', () => {
const { actions } = createActions();
mount('responding', actions);
@@ -515,6 +560,24 @@ describe('useQueuedPrompts default mid-turn insertion', () => {
expect(store.appendLocalUserMessage).not.toHaveBeenCalled();
});
+ it('fences an old submit before the replacement owner rerenders', async () => {
+ const { actions, pendingSubmit } = createActions();
+ const { render, store } = mount('responding', actions);
+
+ act(() =>
+ latest.enqueuePrompt('', [{ data: 'b2xk', media_type: 'image/png' }]),
+ );
+ sdk.ownerVersion += 1;
+ await act(async () => {
+ pendingSubmit.resolve({ promptId: 'old-server-prompt' });
+ await Promise.resolve();
+ });
+
+ expect(store.appendLocalUserMessage).not.toHaveBeenCalled();
+ render('responding', 'session-1');
+ expect(latest.queuedPrompts).toEqual([]);
+ });
+
it('ignores an old refresh after an S1 to S2 to S1 owner change', async () => {
const { actions } = createActions();
const oldRefresh = deferred<{
@@ -804,6 +867,26 @@ describe('useQueuedPrompts default mid-turn insertion', () => {
]);
});
+ it('freezes mid-turn fallback while a session switch is preparing', async () => {
+ const { actions } = createActions();
+ const admission = deferred<{ accepted: boolean }>();
+ vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise);
+ const { render } = mount('responding', actions);
+
+ act(() => latest.enqueuePrompt('留在当前会话'));
+ render('responding', 'session-1', false, true);
+ await act(async () => admission.resolve({ accepted: false }));
+ render('idle', 'session-1', false, true);
+
+ expect(actions.submitPrompt).not.toHaveBeenCalled();
+ expect(latest.queuedPrompts).toMatchObject([
+ { text: '留在当前会话', midTurnState: 'submitting' },
+ ]);
+
+ render('idle', 'session-1', false, false);
+ expect(actions.submitPrompt).toHaveBeenCalledOnce();
+ });
+
it('falls back once when the running turn ends before injection', async () => {
const { actions } = createActions();
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
@@ -907,6 +990,87 @@ describe('useQueuedPrompts default mid-turn insertion', () => {
expect(editor.focus).toHaveBeenCalled();
});
+ it('restores an edited prompt after a same-id attachment replacement', async () => {
+ const { actions } = createActions();
+ const removal = deferred<{ removed: boolean }>();
+ vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
+ accepted: true,
+ messageId: 'mid-edit',
+ });
+ vi.mocked(actions.removeMidTurnMessage).mockReturnValue(removal.promise);
+ const { editor, render } = mount('responding', actions);
+
+ act(() => latest.enqueuePrompt('修改后保留'));
+ await act(async () => {});
+ let editPromise!: Promise;
+ act(() => {
+ editPromise = latest.editQueuedPrompt(1);
+ });
+ render('responding', 'session-1', true);
+ await act(async () => {
+ removal.resolve({ removed: true });
+ await editPromise;
+ });
+
+ expect(editor.setText).toHaveBeenCalledWith('修改后保留');
+ expect(editor.setText).toHaveBeenCalledOnce();
+ expect(editor.focus).toHaveBeenCalled();
+ });
+
+ it('restores an edited prompt when switching to a different session', async () => {
+ const { actions } = createActions();
+ const removal = deferred<{ removed: boolean }>();
+ vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
+ accepted: true,
+ messageId: 'mid-cross-session-edit',
+ });
+ vi.mocked(actions.removeMidTurnMessage).mockReturnValue(removal.promise);
+ const { editor, render } = mount('responding', actions);
+
+ act(() => latest.enqueuePrompt('切换后保留'));
+ await act(async () => {});
+ let editPromise!: Promise;
+ act(() => {
+ editPromise = latest.editQueuedPrompt(1);
+ });
+ render('responding', 'session-2', true);
+ await act(async () => {
+ removal.resolve({ removed: true });
+ await editPromise;
+ });
+
+ expect(editor.setText).toHaveBeenCalledWith('切换后保留');
+ expect(editor.setText).toHaveBeenCalledOnce();
+ expect(latest.queuedPrompts).toEqual([]);
+ });
+
+ it('does not restore an edited prompt when cross-session removal loses', async () => {
+ const { actions } = createActions();
+ const removal = deferred<{ removed: boolean }>();
+ vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
+ accepted: true,
+ messageId: 'mid-cross-session-edit-lost',
+ });
+ vi.mocked(actions.removeMidTurnMessage).mockReturnValue(removal.promise);
+ const { editor, render } = mount('responding', actions);
+
+ act(() => latest.enqueuePrompt('仍在服务端'));
+ await act(async () => {});
+ let editPromise!: Promise;
+ act(() => {
+ editPromise = latest.editQueuedPrompt(1);
+ });
+ render('responding', 'session-2', true);
+ expect(editor.setText).not.toHaveBeenCalled();
+ await act(async () => {
+ removal.resolve({ removed: false });
+ await editPromise;
+ });
+
+ expect(editor.setText).not.toHaveBeenCalled();
+ expect(latest.queuedPrompts).toEqual([]);
+ });
+
it('keeps the row when removal loses the race with drain or idle', async () => {
const { actions } = createActions();
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx b/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx
index 6c0102e780c..16b18c0b620 100644
--- a/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx
+++ b/packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx
@@ -35,6 +35,7 @@ const sdkMock = vi.hoisted(() => {
}>,
consumeInjected: vi.fn(),
pendingEvents: [] as Array>,
+ ownerVersion: 0,
pendingEventListeners,
publishPendingEvents: (events: Array>) => {
mock.pendingEvents = events;
@@ -58,6 +59,12 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', async () => {
batches: sdkMock.injectedBatches,
consume: sdkMock.consumeInjected,
}),
+ useDaemonSessionOwnerGuard: () => ({
+ capture: () => {
+ const version = sdkMock.ownerVersion;
+ return { isCurrent: () => sdkMock.ownerVersion === version };
+ },
+ }),
subscribePendingPromptEvents: (listener: () => void) => {
sdkMock.pendingEventListeners.add(listener);
return () => {
@@ -83,7 +90,9 @@ const CLIENT_ID = 'client-self';
interface HarnessOptions {
connected?: boolean;
+ writeBlocked?: boolean;
sessionId?: string;
+ workspaceCwd?: string;
clientId?: string;
canMutateMidTurn?: boolean;
canQueryMidTurn?: boolean;
@@ -115,7 +124,9 @@ function createHarness() {
function TestComponent(opts: HarnessOptions) {
latest = useQueuedPrompts({
connected: opts.connected ?? true,
+ writeBlocked: opts.writeBlocked ?? false,
sessionId: opts.sessionId ?? 'session-a',
+ workspaceCwd: opts.workspaceCwd ?? '/workspace',
clientId: opts.clientId ?? CLIENT_ID,
canMutateMidTurn: opts.canMutateMidTurn ?? true,
canQueryMidTurn: opts.canQueryMidTurn ?? true,
@@ -165,6 +176,7 @@ function createHarness() {
describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_query)', () => {
beforeEach(() => {
vi.clearAllMocks();
+ sdkMock.ownerVersion = 0;
sdkMock.actions.enqueueMidTurnMessage.mockImplementation(
(_message: string, opts?: { messageId?: string }) =>
Promise.resolve({ accepted: true, messageId: opts?.messageId }),
@@ -620,7 +632,7 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que
}
});
- it('reports an admission failure after the user switches sessions', async () => {
+ it('does not report an admission failure after the user switches sessions', async () => {
let rejectAdmission: ((error: Error) => void) | undefined;
sdkMock.actions.enqueueMidTurnMessage.mockReturnValueOnce(
new Promise((_resolve, reject) => {
@@ -638,7 +650,7 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que
rejectAdmission?.(new Error('daemon unavailable'));
});
- expect(harness.reportError).toHaveBeenCalledTimes(1);
+ expect(harness.reportError).not.toHaveBeenCalled();
expect(harness.editor.setText).not.toHaveBeenCalled();
expect(sdkMock.actions.submitPrompt).not.toHaveBeenCalled();
} finally {
@@ -879,6 +891,249 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que
}
});
+ it('preserves a stable-id admission across same-session owner replacement', async () => {
+ sdkMock.actions.enqueueMidTurnMessage.mockReturnValue(
+ new Promise(() => {}),
+ );
+ const harness = createHarness();
+ try {
+ await harness.render({ streamingState: 'responding' });
+ await act(async () => {
+ harness.result().enqueuePrompt('survive reattach');
+ });
+ expect(harness.result().queuedPrompts).toEqual([]);
+
+ sdkMock.ownerVersion += 1;
+ await harness.render({ streamingState: 'responding' });
+
+ expect(harness.result().queuedPrompts).toEqual([
+ expect.objectContaining({
+ sessionId: 'session-a',
+ text: 'survive reattach',
+ admissionOutcome: 'unknown',
+ payloadCompleteness: 'complete',
+ }),
+ ]);
+ expect(harness.editor.setText).not.toHaveBeenCalled();
+ } finally {
+ await harness.dispose();
+ }
+ });
+
+ it('preserves an ambiguous stable-id admission across later reattachment', async () => {
+ let rejectAdmission: ((error: Error) => void) | undefined;
+ sdkMock.actions.enqueueMidTurnMessage.mockReturnValue(
+ new Promise((_resolve, reject) => {
+ rejectAdmission = reject;
+ }),
+ );
+ const harness = createHarness();
+ try {
+ await harness.render({ streamingState: 'responding' });
+ sdkMock.actions.getMidTurnMessages.mockResolvedValue(undefined);
+ await act(async () => {
+ harness.result().enqueuePrompt('ambiguous input');
+ rejectAdmission?.(new Error('response lost'));
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(harness.result().queuedPrompts).toEqual([
+ expect.objectContaining({
+ text: 'ambiguous input',
+ admissionOutcome: 'unknown',
+ }),
+ ]);
+
+ sdkMock.ownerVersion += 1;
+ await harness.render({ streamingState: 'responding' });
+
+ expect(harness.result().queuedPrompts).toEqual([
+ expect.objectContaining({
+ text: 'ambiguous input',
+ admissionOutcome: 'unknown',
+ }),
+ ]);
+ } finally {
+ await harness.dispose();
+ }
+ });
+
+ it('does not resurrect an admission after authoritative settlement', async () => {
+ let rejectAdmission: ((error: Error) => void) | undefined;
+ sdkMock.actions.enqueueMidTurnMessage.mockReturnValue(
+ new Promise((_resolve, reject) => {
+ rejectAdmission = reject;
+ }),
+ );
+ const harness = createHarness();
+ try {
+ await harness.render({ streamingState: 'responding' });
+ sdkMock.actions.getMidTurnMessages.mockResolvedValue(undefined);
+ await act(async () => {
+ harness.result().enqueuePrompt('settled input');
+ rejectAdmission?.(new Error('response lost'));
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ const messageId = harness.result().queuedPrompts[0]?.midTurnMessageId;
+ if (!messageId) throw new Error('missing stable message id');
+
+ sdkMock.actions.getMidTurnMessages.mockResolvedValue({
+ messages: [],
+ settledMessageIds: [messageId],
+ promotedMessageIds: [],
+ });
+ await harness.render({ streamingState: 'idle' });
+ expect(harness.result().queuedPrompts).toEqual([]);
+
+ sdkMock.ownerVersion += 1;
+ await harness.render({ streamingState: 'idle' });
+
+ expect(harness.result().queuedPrompts).toEqual([]);
+ } finally {
+ await harness.dispose();
+ }
+ });
+
+ it('does not carry a stable-id admission into another workspace', async () => {
+ sdkMock.actions.enqueueMidTurnMessage.mockReturnValue(
+ new Promise(() => {}),
+ );
+ const harness = createHarness();
+ try {
+ await harness.render({
+ streamingState: 'responding',
+ workspaceCwd: '/workspace-a',
+ });
+ await act(async () => {
+ harness.result().enqueuePrompt('workspace-a input');
+ });
+
+ await harness.render({
+ streamingState: 'responding',
+ workspaceCwd: '/workspace-b',
+ });
+
+ expect(harness.result().queuedPrompts).toEqual([]);
+ expect(harness.editor.setText).not.toHaveBeenCalled();
+
+ await harness.render({
+ streamingState: 'responding',
+ workspaceCwd: '/workspace-a',
+ });
+ expect(harness.result().queuedPrompts).toEqual([
+ expect.objectContaining({
+ text: 'workspace-a input',
+ admissionOutcome: 'unknown',
+ }),
+ ]);
+ } finally {
+ await harness.dispose();
+ }
+ });
+
+ it('restores a rejected stable-id admission after returning to its workspace', async () => {
+ let resolveAdmission:
+ | ((value: { accepted: boolean; messageId?: string }) => void)
+ | undefined;
+ sdkMock.actions.enqueueMidTurnMessage.mockReturnValue(
+ new Promise((resolve) => {
+ resolveAdmission = resolve;
+ }),
+ );
+ const harness = createHarness();
+ try {
+ await harness.render({
+ streamingState: 'responding',
+ workspaceCwd: '/workspace-a',
+ });
+ await act(async () => {
+ harness.result().enqueuePrompt('rejected in workspace-a');
+ });
+ await harness.render({
+ streamingState: 'responding',
+ workspaceCwd: '/workspace-b',
+ });
+ await act(async () => {
+ resolveAdmission?.({ accepted: false });
+ await Promise.resolve();
+ });
+
+ await harness.render({
+ streamingState: 'responding',
+ workspaceCwd: '/workspace-a',
+ });
+
+ expect(harness.result().queuedPrompts).toEqual([
+ expect.objectContaining({
+ text: 'rejected in workspace-a',
+ admissionOutcome: 'unknown',
+ }),
+ ]);
+ } finally {
+ await harness.dispose();
+ }
+ });
+
+ it('does not apply an old-owner reconcile after same-id reattachment', async () => {
+ const harness = createHarness();
+ try {
+ await harness.render({ streamingState: 'responding' });
+ let resolveSnapshot: ((value: unknown) => void) | undefined;
+ sdkMock.actions.getMidTurnMessages.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveSnapshot = resolve;
+ }),
+ );
+ await harness.render({ streamingState: 'idle' });
+
+ sdkMock.ownerVersion += 1;
+ await harness.render({ streamingState: 'idle' });
+ resolveSnapshot?.({
+ messages: [{ messageId: 'stale', text: 'old owner payload' }],
+ settledMessageIds: [],
+ promotedMessageIds: [],
+ });
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(harness.result().queuedPrompts).toEqual([]);
+ } finally {
+ await harness.dispose();
+ }
+ });
+
+ it('does not fall back after an idle reconciliation is blocked', async () => {
+ const harness = createHarness();
+ try {
+ await harness.render({ streamingState: 'responding' });
+ sdkMock.actions.getPendingPrompts.mockClear();
+ sdkMock.actions.getMidTurnMessages.mockImplementationOnce(
+ (opts?: { signal?: AbortSignal }) =>
+ new Promise((resolve) => {
+ opts?.signal?.addEventListener('abort', () => resolve(undefined), {
+ once: true,
+ });
+ }),
+ );
+
+ await harness.render({ streamingState: 'idle', writeBlocked: false });
+ await harness.render({ streamingState: 'idle', writeBlocked: true });
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(sdkMock.actions.getPendingPrompts).not.toHaveBeenCalled();
+ expect(harness.result().queuedPrompts).toEqual([]);
+ } finally {
+ await harness.dispose();
+ }
+ });
+
it('drops a connect snapshot after the streaming phase changes', async () => {
const resolveSnapshots: Array<(value: unknown) => void> = [];
sdkMock.actions.getMidTurnMessages.mockImplementation(
diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.ts b/packages/web-shell/client/hooks/useQueuedPrompts.ts
index fded8976577..cc3b3f789f8 100644
--- a/packages/web-shell/client/hooks/useQueuedPrompts.ts
+++ b/packages/web-shell/client/hooks/useQueuedPrompts.ts
@@ -8,7 +8,6 @@ import {
useCallback,
useEffect,
useLayoutEffect,
- useMemo,
useRef,
useState,
useSyncExternalStore,
@@ -20,6 +19,7 @@ import {
subscribePendingPromptEvents,
subscribePendingPromptVersion,
useDaemonMidTurnInjected,
+ useDaemonSessionOwnerGuard,
type DaemonSessionActions,
type DaemonStreamingState,
} from '@qwen-code/webui/daemon-react-sdk';
@@ -43,7 +43,9 @@ interface RefBox {
interface UseQueuedPromptsArgs {
connected: boolean;
+ writeBlocked?: boolean;
sessionId?: string;
+ workspaceCwd?: string;
clientId?: string;
/**
* Whether the daemon advertises `session_mid_turn_message_mutation`. Gates the
@@ -149,7 +151,9 @@ export interface UseQueuedPromptsResult {
export function useQueuedPrompts({
connected,
+ writeBlocked = false,
sessionId,
+ workspaceCwd,
clientId,
canMutateMidTurn,
canQueryMidTurn,
@@ -160,14 +164,36 @@ export function useQueuedPrompts({
reportError,
t,
}: UseQueuedPromptsArgs): UseQueuedPromptsResult {
+ const writeBlockedRef = useRef(writeBlocked);
+ writeBlockedRef.current = writeBlocked;
+ const sessionOwnerGuard = useDaemonSessionOwnerGuard();
const [queuedPrompts, setQueuedPrompts] = useState([]);
const queuedPromptsRef = useRef([]);
- const ownerTokenRef = useRef({ sessionId });
- if (ownerTokenRef.current.sessionId !== sessionId) {
- ownerTokenRef.current = { sessionId };
+ const ownerTokenRef = useRef({
+ sessionId,
+ workspaceCwd,
+ snapshot: sessionOwnerGuard.capture(),
+ });
+ if (
+ ownerTokenRef.current.sessionId !== sessionId ||
+ ownerTokenRef.current.workspaceCwd !== workspaceCwd ||
+ !ownerTokenRef.current.snapshot.isCurrent()
+ ) {
+ ownerTokenRef.current = {
+ sessionId,
+ workspaceCwd,
+ snapshot: sessionOwnerGuard.capture(),
+ };
}
+ const ownerToken = ownerTokenRef.current;
+ const isCurrentOwnerTokenRef = useRef(
+ (token: typeof ownerToken) =>
+ ownerTokenRef.current === token && token.snapshot.isCurrent(),
+ );
+ const queuedPromptsOwnerRef = useRef(ownerToken);
const nextQueuedPromptIdRef = useRef(1);
const latestSessionIdRef = useRef(sessionId);
+ const latestWorkspaceCwdRef = useRef(workspaceCwd);
const midTurnEnqueueAbortRef = useRef(null);
const submitAbortControllersRef = useRef>(new Set());
const removingServerPromptIdsRef = useRef>(new Set());
@@ -175,6 +201,9 @@ export function useQueuedPrompts({
const completionCallbacksRef = useRef