fix(web-shell): defer session creation until first prompt (#6066)

* fix(web-shell): defer session creation until first prompt

* fix(web-shell): stabilize deferred session attach

* docs(web-shell): document optional session change callback

* test(web-shell): cover deferred session setup failures

* fix(webui): preserve concurrent session load on clear

* fix(web-shell): keep session prep helper in utils

* fix(web-shell): harden deferred session lifecycle

* fix(web-shell): guard deferred session races

* fix(web-shell): simplify controlled session selection

* fix(web-shell): tighten empty session edge cases

* fix(web-shell): tighten deferred session lifecycle

* test(webui): align session action mocks with daemon types

* fix(web-shell): notify cleared session ids

* fix(webui): guard session cleanup races

* fix(web-shell): preserve blocked local commands

---------

Co-authored-by: ytahdn <ytahdn@gmail.com>
Co-authored-by: 易良 <1204183885@qq.com>
This commit is contained in:
ytahdn 2026-07-01 22:04:37 +08:00 committed by GitHub
parent 487fb45510
commit 1467ed3100
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2328 additions and 450 deletions

View file

@ -450,6 +450,7 @@ export interface ServeWorkspaceProvidersStatus {
initialized: boolean;
acpChannelLive?: boolean;
current?: ServeWorkspaceProviderCurrent;
approvalMode?: string;
providers: ServeWorkspaceProviderStatus[];
errors?: ServeStatusCell[];
}

View file

@ -15,6 +15,13 @@ const coreMock = vi.hoisted(() => ({
throwModelsConfigError: false,
modelsConfigErrorMessage:
'Failed loading provider https://user:secret@broken.example/v1',
debugLogger: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
isEnabled: vi.fn(() => false),
warn: vi.fn(),
},
}));
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
@ -30,6 +37,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
}
return {
...actual,
createDebugLogger: () => coreMock.debugLogger,
ModelsConfig: TestModelsConfig,
};
});
@ -62,6 +70,7 @@ describe('createWorkspaceProvidersStatusProvider', () => {
coreMock.throwModelsConfigError = false;
coreMock.modelsConfigErrorMessage =
'Failed loading provider https://user:secret@broken.example/v1';
coreMock.debugLogger.warn.mockClear();
resetHomeEnvBootstrapForTesting();
});
@ -122,6 +131,42 @@ describe('createWorkspaceProvidersStatusProvider', () => {
expect(second.current?.modelId).toBe('model-b(openai)');
});
it('returns the workspace approval mode', async () => {
const provider = createWorkspaceProvidersStatusProvider({ env: {} });
await writeUserSettings({
tools: { approvalMode: 'yolo' },
});
const result = await provider(workspace, false);
expect(result.approvalMode).toBe('yolo');
});
it('normalizes legacy workspace approval mode spelling', async () => {
const provider = createWorkspaceProvidersStatusProvider({ env: {} });
await writeUserSettings({
tools: { approvalMode: 'auto_edit' },
});
const result = await provider(workspace, false);
expect(result.approvalMode).toBe('auto-edit');
});
it('warns and falls back for an unknown workspace approval mode', async () => {
const provider = createWorkspaceProvidersStatusProvider({ env: {} });
await writeUserSettings({
tools: { approvalMode: 'auto-edt' },
});
const result = await provider(workspace, false);
expect(result.approvalMode).toBe('default');
expect(coreMock.debugLogger.warn).toHaveBeenCalledWith(
'[workspace-providers-status] unrecognized approvalMode "auto-edt", falling back to default',
);
});
it('marks only the model matching persisted model.baseUrl as current', async () => {
const provider = createWorkspaceProvidersStatusProvider({ env: {} });
await writeUserSettings({

View file

@ -5,6 +5,9 @@
*/
import {
ApprovalMode,
APPROVAL_MODES,
createDebugLogger,
ModelsConfig,
resolveProviderProtocol,
tokenLimit,
@ -30,6 +33,8 @@ import {
sanitizeProviderBaseUrl,
} from '../utils/acpModelUtils.js';
const debugLogger = createDebugLogger('WORKSPACE_PROVIDERS_STATUS');
export type WorkspaceProvidersStatusProvider = (
workspaceCwd: string,
acpChannelLive: boolean,
@ -97,6 +102,7 @@ function buildWorkspaceProvidersStatus(
typeof settings.fastModel === 'string' && settings.fastModel.length > 0
? settings.fastModel
: undefined;
const approvalMode = resolveApprovalMode(settings);
const providers = new Map<string, ServeWorkspaceProviderStatus>();
const explicitModelBaseUrls = buildExplicitModelBaseUrls(
settings.modelProviders,
@ -169,6 +175,7 @@ function buildWorkspaceProvidersStatus(
initialized: true,
acpChannelLive,
...(current ? { current } : {}),
approvalMode,
providers: [...providers.values()],
...(resolvedCliConfig.warnings.length > 0
? {
@ -200,6 +207,24 @@ function buildWorkspaceProvidersStatus(
}
}
function resolveApprovalMode(settings: Settings): ApprovalMode {
const value = settings.tools?.approvalMode;
if (typeof value !== 'string') return ApprovalMode.DEFAULT;
const normalized = value.trim().toLowerCase().replaceAll('_', '-');
const mode = normalized === 'autoedit' ? ApprovalMode.AUTO_EDIT : normalized;
if ((APPROVAL_MODES as readonly string[]).includes(mode)) {
return mode as ApprovalMode;
}
if (value.trim().length > 0) {
debugLogger.warn(
`[workspace-providers-status] unrecognized approvalMode "${value}", falling back to default`,
);
}
return ApprovalMode.DEFAULT;
}
function isMainSelectableModel(model: {
fastOnly?: boolean;
voiceOnly?: boolean;

View file

@ -2717,6 +2717,28 @@ export class DaemonClient {
);
}
async detachSession(sessionId: string, clientId?: string): Promise<void> {
if (!clientId) return;
return await this.fetchWithTimeout(
`${this.baseUrl}/session/${encodeURIComponent(sessionId)}/detach`,
{
method: 'POST',
headers: this.headers({}, clientId),
},
async (res) => {
if (res.status === 204 || res.status === 404) {
try {
await res.body?.cancel();
} catch {
/* body already consumed or no body */
}
return;
}
throw await this.failOnError(res, 'POST /session/:id/detach');
},
);
}
async deleteSessionsData(
sessionIds: string[],
clientId?: string,

View file

@ -590,6 +590,10 @@ export class DaemonSessionClient {
return await this.client.closeSession(this.sessionId, this.clientId);
}
async detach(): Promise<void> {
return await this.client.detachSession(this.sessionId, this.clientId);
}
async updateMetadata(metadata: {
displayName?: string;
}): Promise<SessionMetadataResult> {

View file

@ -501,6 +501,7 @@ export interface DaemonWorkspaceProvidersStatus {
initialized: boolean;
acpChannelLive?: boolean;
current?: DaemonWorkspaceProviderCurrent;
approvalMode?: DaemonApprovalMode;
providers: DaemonWorkspaceProviderStatus[];
errors?: DaemonStatusCell[];
}

View file

@ -43,7 +43,7 @@ export function QwenCodePanel() {
<WebShellWithProviders
baseUrl="http://127.0.0.1:4170"
token="your-bearer-token"
initialSessionId="838e1811-9f84-4848-9915-d9a7f01ff5c6"
sessionId="838e1811-9f84-4848-9915-d9a7f01ff5c6"
onSessionIdChange={(sessionId) => {
console.log('current session:', sessionId);
}}
@ -69,7 +69,7 @@ import { WebShell } from '@qwen-code/web-shell';
export function App() {
return (
<DaemonWorkspaceProvider baseUrl="http://127.0.0.1:4170" token="...">
<DaemonSessionProvider initialSessionId="...">
<DaemonSessionProvider sessionId="...">
<ChatPanel />
<WebShell theme="dark" language="zh-CN" />
</DaemonSessionProvider>
@ -87,21 +87,21 @@ export function App() {
包含 `WebShell` 的所有 Props加上 Provider 配置:
| 属性 | 类型 | 说明 |
| ------------------ | -------- | ---------------------------------------------------- |
| `baseUrl` | `string` | daemon API 地址,未传时使用 `window.location.origin` |
| `token` | `string` | daemon API Bearer token |
| `initialSessionId` | `string` | 初始要连接的 session id |
| 属性 | 类型 | 说明 |
| ----------- | -------- | ---------------------------------------------------- |
| `baseUrl` | `string` | daemon API 地址,未传时使用 `window.location.origin` |
| `token` | `string` | daemon API Bearer token |
| `sessionId` | `string` | 要连接的 session id未传或 `undefined` 时保持空页面 |
### WebShell
| 属性 | 类型 | 说明 |
| ------------------- | -------------------------------------- | --------------------------------- |
| `onSessionIdChange` | `(sessionId: string) => void` | 当前 session id 变化时触发 |
| `theme` | `'dark' \| 'light'` | UI 主题,默认 `dark` |
| `onThemeChange` | `(theme: WebShellTheme) => void` | `/theme` 命令切换主题后触发 |
| `language` | `'en' \| 'zh-CN' \| 'zh' \| 'zh-cn'` | UI 语言 |
| `onLanguageChange` | `(language: WebShellLanguage) => void` | `/language ui` 切换 UI 语言后触发 |
| 属性 | 类型 | 说明 |
| ------------------- | ------------------------------------------ | --------------------------------- |
| `onSessionIdChange` | `(sessionId: string \| undefined) => void` | 当前 session id 变化或清空时触发 |
| `theme` | `'dark' \| 'light'` | UI 主题,默认 `dark` |
| `onThemeChange` | `(theme: WebShellTheme) => void` | `/theme` 命令切换主题后触发 |
| `language` | `'en' \| 'zh-CN' \| 'zh' \| 'zh-cn'` | UI 语言 |
| `onLanguageChange` | `(language: WebShellLanguage) => void` | `/language ui` 切换 UI 语言后触发 |
## 架构说明

View file

@ -10,6 +10,7 @@ import {
type ReactNode,
} from 'react';
import {
DAEMON_APPROVAL_MODES,
useActions,
useConnection,
useDaemonFollowupSuggestion,
@ -104,10 +105,6 @@ import {
type SerializedTasksMessage,
} from './components/messages/TasksStatusMessage';
import { isBackgroundSubAgentToolCall } from './adapters/toolClassification';
import {
DAEMON_APPROVAL_MODES,
type DaemonApprovalMode,
} from '@qwen-code/webui/daemon-react-sdk';
import { serializeContextUsageMessage } from './components/messages/ContextUsageMessage';
import {
serializeStatsMessage,
@ -125,6 +122,10 @@ import {
serializeGoalStatusMessage,
} from './components/messages/GoalStatusMessage';
import { BtwMessage } from './components/messages/BtwMessage';
import {
createAndAttachSessionForPrompt,
isDaemonApprovalMode,
} from './utils/sessionPreparation';
import type { ACPToolCall, Message, PermissionRequest } from './adapters/types';
import {
computeTodoDetails,
@ -247,6 +248,7 @@ interface SendPromptOptionsWithRetry {
optimisticUserMessage?: boolean;
images?: PromptImage[];
retry?: boolean;
clearComposerOnPromptStart?: boolean;
}
type GoalStatusTranscriptBlock = DaemonTranscriptBlock & {
@ -309,7 +311,7 @@ export interface WebShellSidebarOptions {
export interface WebShellProps {
/** Called whenever the attached daemon session id changes. */
onSessionIdChange?: (sessionId: string) => void;
onSessionIdChange?: (sessionId: string | undefined) => void;
/** Visual theme for the embedded shell. */
theme?: WebShellTheme;
/** Called when `/theme` changes the web-shell theme. */
@ -390,6 +392,9 @@ export interface WebShellProps {
type SessionActionsWithCreate = {
createSession: () => Promise<{ sessionId: string }>;
attachSession: () => Promise<void>;
closeSession: () => Promise<void>;
clearSession: () => Promise<void>;
};
const emptyComposerApi: WebShellComposerApi = {
@ -501,17 +506,6 @@ function assignComposerRef(
(ref as React.MutableRefObject<WebShellComposerApi | null>).current = value;
}
function replaceSessionUrl(sessionId: string): void {
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
url.pathname = `/session/${encodeURIComponent(sessionId)}`;
if (!import.meta.env.DEV) {
url.searchParams.delete('token');
url.searchParams.delete('daemon');
}
window.history.replaceState(null, '', url);
}
function getInitialLanguage(): WebShellLanguage {
if (typeof window === 'undefined') return 'en';
const params = new URLSearchParams(window.location.search);
@ -600,10 +594,6 @@ function serializeModelSwitchSummary(summary: ModelSwitchSummary): string {
return `Using ${summary.isRuntime ? 'runtime ' : ''}model: ${summary.modelId}`;
}
function isDaemonApprovalMode(mode: string): mode is DaemonApprovalMode {
return DAEMON_APPROVAL_MODES.includes(mode as DaemonApprovalMode);
}
function isEditToolPermission(request: PermissionRequest): boolean {
return request.toolKind === 'edit';
}
@ -924,7 +914,8 @@ export function App({
const nextRecapMessageIdRef = useRef(1);
const nextBtwMessageIdRef = useRef(1);
const btwAbortControllerRef = useRef<AbortController | null>(null);
const activeSessionIdRef = useRef(connection.sessionId);
const currentSessionIdRef = useRef(connection.sessionId);
const lastNotifiedSessionIdRef = useRef<string | undefined>(undefined);
const displayMessages = useMemo(() => {
const localMessages = [recapMessage].filter(
(message): message is LocalAnchoredMessage => message !== null,
@ -1091,34 +1082,6 @@ export function App({
editorRef.current?.insertText(suggestion);
},
});
const sendPrompt = useCallback(
(
text: string,
images?: PromptImage[],
opts?: { optimisticUserMessage?: boolean; retry?: boolean },
) => {
clearFollowup();
const isUserPrompt = !text.trimStart().startsWith('/');
if (!opts?.retry && isUserPrompt) {
lastSubmittedPromptRef.current = text;
lastSubmittedImagesRef.current = images;
retriedTurnErrorIdRef.current = null;
}
setShowRetryHint(false);
const promptOptions: SendPromptOptionsWithRetry = {
images,
optimisticUserMessage: opts?.optimisticUserMessage,
retry: opts?.retry,
};
return (
sessionActions.sendPrompt as (
promptText: string,
options?: SendPromptOptionsWithRetry,
) => ReturnType<typeof sessionActions.sendPrompt>
)(text, promptOptions);
},
[clearFollowup, sessionActions],
);
const streamingState = useStreamingState();
const streamingStateRef = useRef<DaemonStreamingState>(streamingState);
const localStreamingStartedAtRef = useRef(Date.now());
@ -1269,10 +1232,100 @@ export function App({
const [currentModel, setCurrentModel] = useState('');
const currentModelRef = useRef(currentModel);
currentModelRef.current = currentModel;
const setPendingModel = useCallback((modelId: string) => {
currentModelRef.current = modelId;
setCurrentModel(modelId);
}, []);
const connectionRef = useRef(connection);
connectionRef.current = connection;
const requireActiveSessionForLocalCommand = useCallback((): boolean => {
if (connectionRef.current.sessionId) return true;
pushToast('info', t('localCommand.noSession'));
return false;
}, [pushToast, t]);
const sessionDisplayName = connection.displayName;
const [currentMode, setCurrentMode] = useState('default');
const currentModeRef = useRef(currentMode);
currentModeRef.current = currentMode;
const setPendingMode = useCallback((modeId: string) => {
currentModeRef.current = modeId;
setCurrentMode(modeId);
}, []);
const [isPreparingPrompt, setIsPreparingPrompt] = useState(false);
const createSessionPromiseRef = useRef<Promise<void> | null>(null);
useEffect(() => {
if (connection.sessionId) {
createSessionPromiseRef.current = null;
}
}, [connection.sessionId]);
const ensureSessionForPrompt = useCallback(() => {
if (connectionRef.current.sessionId) return Promise.resolve();
if (!createSessionPromiseRef.current) {
createSessionPromiseRef.current = (async () => {
const modelId =
currentModelRef.current || connectionRef.current.currentModel;
const modeId =
currentModeRef.current || connectionRef.current.currentMode;
await createAndAttachSessionForPrompt({
sessionActions: sessionActions as typeof sessionActions &
SessionActionsWithCreate,
modelId,
modeId,
});
})().catch((error: unknown) => {
createSessionPromiseRef.current = null;
throw error;
});
}
return createSessionPromiseRef.current;
}, [sessionActions]);
const sendPrompt = useCallback(
async (
text: string,
images?: PromptImage[],
opts?: {
optimisticUserMessage?: boolean;
retry?: boolean;
clearComposerOnPromptStart?: boolean;
},
) => {
clearFollowup();
const isUserPrompt = !text.trimStart().startsWith('/');
if (!opts?.retry && isUserPrompt) {
lastSubmittedPromptRef.current = text;
lastSubmittedImagesRef.current = images;
retriedTurnErrorIdRef.current = null;
}
setShowRetryHint(false);
const shouldShowPreparing = !connectionRef.current.sessionId;
if (shouldShowPreparing) {
setIsPreparingPrompt(true);
}
try {
await ensureSessionForPrompt();
} finally {
if (shouldShowPreparing) {
setIsPreparingPrompt(false);
}
}
const promptOptions: SendPromptOptionsWithRetry = {
images,
optimisticUserMessage: opts?.optimisticUserMessage,
retry: opts?.retry,
};
if (opts?.clearComposerOnPromptStart) {
editorRef.current?.clear();
}
const result = await (
sessionActions.sendPrompt as (
promptText: string,
options?: SendPromptOptionsWithRetry,
) => ReturnType<typeof sessionActions.sendPrompt>
)(text, promptOptions);
return result;
},
[clearFollowup, ensureSessionForPrompt, sessionActions],
);
const availableModels = useMemo(
() =>
(connection.models ?? []).filter(isVisibleComposerModel).map((m) => ({
@ -1362,7 +1415,7 @@ export function App({
onBugReportRef.current = onBugReport;
useEffect(() => {
activeSessionIdRef.current = connection.sessionId;
currentSessionIdRef.current = connection.sessionId;
btwAbortControllerRef.current?.abort();
btwAbortControllerRef.current = null;
setRecapMessage(null);
@ -1372,6 +1425,7 @@ export function App({
}, [connection.sessionId]);
const runVisibleRecap = useCallback(() => {
if (!requireActiveSessionForLocalCommand()) return;
const messageId = `local-recap-${nextRecapMessageIdRef.current++}`;
const anchorIndex = messages.length;
const anchorAfterId = messages.at(-1)?.id;
@ -1389,7 +1443,7 @@ export function App({
});
sessionActions.recapSession().then(
(result) => {
if (activeSessionIdRef.current !== sessionId) return;
if (currentSessionIdRef.current !== sessionId) return;
setRecapMessage({
anchorAfterId,
anchorIndex,
@ -1405,14 +1459,20 @@ export function App({
});
},
(error: unknown) => {
if (activeSessionIdRef.current !== sessionId) return;
if (currentSessionIdRef.current !== sessionId) return;
setRecapMessage(null);
if (!isAbortError(error) && !isAlreadyDispatched(error)) {
console.warn('[web-shell] unhandled recap failure', error);
}
},
);
}, [connection.sessionId, messages, sessionActions, t]);
}, [
connection.sessionId,
messages,
requireActiveSessionForLocalCommand,
sessionActions,
t,
]);
const runVisibleBtw = useCallback(
(rawQuestion: string) => {
@ -1421,6 +1481,7 @@ export function App({
pushToast('error', t('btw.empty'));
return;
}
if (!requireActiveSessionForLocalCommand()) return;
const messageId = `local-btw-${nextBtwMessageIdRef.current++}`;
const sessionId = connection.sessionId;
@ -1439,7 +1500,7 @@ export function App({
.btwSession(question, { signal: abortController.signal })
.then(
(result) => {
if (activeSessionIdRef.current !== sessionId) return;
if (currentSessionIdRef.current !== sessionId) return;
if (btwAbortControllerRef.current !== abortController) return;
btwAbortControllerRef.current = null;
setBtwMessage({
@ -1451,7 +1512,7 @@ export function App({
});
},
(error: unknown) => {
if (activeSessionIdRef.current !== sessionId) return;
if (currentSessionIdRef.current !== sessionId) return;
if (btwAbortControllerRef.current !== abortController) return;
btwAbortControllerRef.current = null;
setBtwMessage(null);
@ -1461,7 +1522,13 @@ export function App({
},
);
},
[connection.sessionId, pushToast, sessionActions, t],
[
connection.sessionId,
pushToast,
requireActiveSessionForLocalCommand,
sessionActions,
t,
],
);
const dismissBtwMessage = useCallback(() => {
@ -1672,6 +1739,10 @@ export function App({
);
return;
}
if (!connectionRef.current.sessionId) {
setPendingMode(modeId);
return;
}
sessionActions
.setApprovalMode(modeId)
.then((result) => {
@ -1706,7 +1777,7 @@ export function App({
reportError(error, t('local.approvalMode'));
});
},
[sessionActions, reportError, store, t],
[sessionActions, reportError, store, t, setPendingMode],
);
useEffect(() => {
@ -1763,11 +1834,10 @@ export function App({
useEffect(() => {
if (connection.sessionId) {
setActiveGoal(null);
onSessionIdChange?.(connection.sessionId);
if (!onSessionIdChange) {
replaceSessionUrl(connection.sessionId);
}
}
if (lastNotifiedSessionIdRef.current === connection.sessionId) return;
lastNotifiedSessionIdRef.current = connection.sessionId;
onSessionIdChange?.(connection.sessionId);
}, [connection.sessionId, onSessionIdChange]);
useEffect(() => {
@ -1871,6 +1941,7 @@ export function App({
(commandText: string, detail: boolean) => {
// Self-guard so every entry point (keyboard, status-bar button, in-chat
// "context detail" click) defers mid-turn instead of splitting the turn.
if (!requireActiveSessionForLocalCommand()) return;
if (echoOrDeferLocalCommand(commandText)) return;
sessionActions
.getContextUsage({ detail })
@ -1890,6 +1961,7 @@ export function App({
[
echoOrDeferLocalCommand,
store,
requireActiveSessionForLocalCommand,
sessionActions,
reportError,
resumeChatBottomFollow,
@ -1904,6 +1976,7 @@ export function App({
const branchCurrentSession = useCallback(
(name?: string) => {
if (!requireActiveSessionForLocalCommand()) return;
sessionActions
.branchSession(name || undefined)
.then((result) => {
@ -1920,7 +1993,13 @@ export function App({
reportError(error, t('branch.failed'));
});
},
[reportError, sessionActions, store, t],
[
reportError,
requireActiveSessionForLocalCommand,
sessionActions,
store,
t,
],
);
const handleBranchCurrentSession = useCallback(() => {
branchCurrentSession();
@ -1931,24 +2010,15 @@ export function App({
// it stuck open with the page scroll still locked, matching loadSidebarSession.
closeMobileDrawer();
try {
const session = await (
await (
sessionActions as typeof sessionActions & SessionActionsWithCreate
).createSession();
if (onSessionIdChange) {
onSessionIdChange(session.sessionId);
return true;
}
void sessionActions
.loadSession(session.sessionId)
.catch((error: unknown) =>
reportError(error, 'Failed to switch session'),
);
).clearSession();
return true;
} catch (error) {
reportError(error, 'Failed to create a new session');
reportError(error, 'Failed to start a new chat');
return false;
}
}, [closeMobileDrawer, onSessionIdChange, reportError, sessionActions]);
}, [closeMobileDrawer, reportError, sessionActions]);
const loadSidebarSession = useCallback(
async (sessionId: string) => {
@ -1982,6 +2052,7 @@ export function App({
}, [connection.catchingUp, connection.sessionId, sidebarSwitchingSessionId]);
const openTasksPanel = useCallback(() => {
if (!requireActiveSessionForLocalCommand()) return;
sessionActions
.getTasks()
.then((snapshot) => {
@ -1990,7 +2061,7 @@ export function App({
.catch((error: unknown) => {
reportError(error, 'Failed to load tasks');
});
}, [reportError, sessionActions]);
}, [reportError, requireActiveSessionForLocalCommand, sessionActions]);
const dispatchGoalSet = useCallback(
(condition: string, setAt: number) => {
@ -2029,13 +2100,14 @@ export function App({
const handleBusyGoalClear = useCallback(
(text: string) => {
if (!requireActiveSessionForLocalCommand()) return false;
store.appendLocalUserMessage(text);
sessionActions.clearGoal().catch((error: unknown) => {
reportError(error, 'Failed to clear /goal');
});
return true;
},
[reportError, sessionActions, store],
[reportError, requireActiveSessionForLocalCommand, sessionActions, store],
);
const loadRewindSnapshots = useCallback(
@ -2069,6 +2141,15 @@ export function App({
const goalArg = text.replace(/^\/goal\b/i, '').trim();
const lowerGoalArg = goalArg.toLowerCase();
const sendToDaemon = opts?.sendToDaemon ?? true;
const sendGoalPrompt = () => {
const clearComposerOnPromptStart = !connectionRef.current.sessionId;
sendPrompt(text, images, {
clearComposerOnPromptStart,
}).catch((error: unknown) => {
reportError(error, 'Failed to send /goal command');
});
return clearComposerOnPromptStart ? false : true;
};
if (goalArg && GOAL_CLEAR_KEYWORDS.has(lowerGoalArg)) {
if (!sendToDaemon) {
@ -2078,26 +2159,18 @@ export function App({
}
return handleBusyGoalClear(text);
} else if (goalArg) {
store.appendLocalUserMessage(text);
if (!sendToDaemon) {
store.appendLocalUserMessage(text);
dispatchGoalSet(goalArg, Date.now());
return true;
}
sendPrompt(text, images, { optimisticUserMessage: false }).catch(
(error: unknown) => {
reportError(error, 'Failed to send /goal command');
},
);
return true;
return sendGoalPrompt();
}
store.appendLocalUserMessage(text);
if (sendToDaemon) {
sendPrompt(text, images, { optimisticUserMessage: false }).catch(
(error: unknown) =>
reportError(error, 'Failed to send /goal command'),
);
return sendGoalPrompt();
}
store.appendLocalUserMessage(text);
return true;
},
[
@ -2107,6 +2180,7 @@ export function App({
reportError,
sendPrompt,
store,
connectionRef,
],
);
@ -2122,16 +2196,30 @@ export function App({
const handleSubmit = useCallback(
(text: string, images?: PromptImage[]) => {
const promptBlocked = streamingStateRef.current !== 'idle';
const submitPromptFromEditor = (
promptText: string,
promptImages: PromptImage[] | undefined,
errorMessage: string,
opts?: { optimisticUserMessage?: boolean; retry?: boolean },
) => {
const clearComposerOnPromptStart = !connectionRef.current.sessionId;
sendPrompt(promptText, promptImages, {
...opts,
clearComposerOnPromptStart,
}).catch((error: unknown) => reportError(error, errorMessage));
return clearComposerOnPromptStart ? false : true;
};
if (text.startsWith('/')) {
const match = text.match(/^\/([\w-]+)/);
if (match) {
const cmd = match[1];
if (hiddenCommands.has(normalizeHiddenCommand(cmd))) {
if (promptBlocked) return blockLocalCommandDuringTurn();
sendPrompt(text, images).catch((error: unknown) =>
reportError(error, 'Failed to send hidden slash command'),
if (promptBlocked) return enqueuePrompt(text, images);
return submitPromptFromEditor(
text,
images,
'Failed to send hidden slash command',
);
return true;
}
if (cmd === 'help') {
setShowHelpDialog(true);
@ -2210,11 +2298,16 @@ export function App({
const nextLanguage = normalizeLanguage(languageArg);
handleLanguageChange(nextLanguage);
if (!promptBlocked) {
sendPrompt(`/language ui ${nextLanguage}`)
const clearComposerOnPromptStart =
!connectionRef.current.sessionId;
sendPrompt(`/language ui ${nextLanguage}`, undefined, {
clearComposerOnPromptStart,
})
.then(() => sessionActions.refreshCommands())
.catch((error: unknown) => {
reportError(error, 'Failed to sync /language command');
});
return clearComposerOnPromptStart ? false : true;
}
return true;
}
@ -2244,6 +2337,7 @@ export function App({
return true;
}
if (cmd === 'rewind') {
if (!requireActiveSessionForLocalCommand()) return false;
setShowRewindDialog(true);
return true;
}
@ -2255,6 +2349,7 @@ export function App({
}
if (cmd === 'fork') {
if (promptBlocked) return blockLocalCommandDuringTurn();
if (!requireActiveSessionForLocalCommand()) return false;
const directive = text.slice(match[0].length).trim();
if (!directive) {
pushToast('error', t('fork.empty'));
@ -2291,11 +2386,12 @@ export function App({
return true;
}
if (modelArg.startsWith('--fast ')) {
if (promptBlocked) return blockLocalCommandDuringTurn();
sendPrompt(text, images).catch((error: unknown) =>
reportError(error, 'Failed to send /model --fast'),
if (promptBlocked) return enqueuePrompt(text, images);
return submitPromptFromEditor(
text,
images,
'Failed to send /model --fast',
);
return true;
}
if (modelArg === '--voice') {
if (echoOrDeferLocalCommand(text, images)) return true;
@ -2311,17 +2407,25 @@ export function App({
return true;
}
if (modelArg.startsWith('--voice ')) {
if (promptBlocked) return blockLocalCommandDuringTurn();
sendPrompt(text, images).catch((error: unknown) =>
reportError(error, 'Failed to send /model --voice'),
const voiceModelId = modelArg.replace(/^--voice\s+/, '');
setWorkspaceSetting(
'workspace',
'voiceModel',
voiceModelId,
).catch((error: unknown) =>
reportError(error, t('model.setVoice')),
);
return true;
}
if (modelArg) {
if (!connectionRef.current.sessionId) {
setPendingModel(modelArg);
return true;
}
sessionActions
.setModel(modelArg)
.then(() => {
setCurrentModel(modelArg);
setPendingModel(modelArg);
})
.catch((error: unknown) => {
reportError(error, t('model.switch'));
@ -2334,20 +2438,37 @@ export function App({
if (cmd === 'plan') {
if (promptBlocked) return blockLocalCommandDuringTurn();
const prompt = text.slice(match[0].length).trim();
if (!connectionRef.current.sessionId) {
setPendingMode('plan');
if (prompt) {
return submitPromptFromEditor(
prompt,
images,
'Failed to send plan prompt',
);
}
return true;
}
if (prompt) setIsPreparingPrompt(true);
sessionActions
.setApprovalMode('plan')
.then(() => {
setCurrentMode('plan');
setPendingMode('plan');
if (prompt) {
sendPrompt(prompt, images).catch((error: unknown) =>
return sendPrompt(prompt, images, {
clearComposerOnPromptStart: true,
}).catch((error: unknown) =>
reportError(error, 'Failed to send plan prompt'),
);
}
})
.catch((error: unknown) => {
reportError(error, t('mode.plan'));
})
.finally(() => {
if (prompt) setIsPreparingPrompt(false);
});
return true;
return prompt ? false : true;
}
if (cmd === 'approval-mode') {
const modeArg = text.slice(match[0].length).trim();
@ -2418,9 +2539,11 @@ export function App({
if (cmd === 'skills') {
const skillArg = text.slice(match[0].length).trim();
if (skillArg) {
if (promptBlocked) return blockLocalCommandDuringTurn();
sendPrompt(text, images).catch((error: unknown) =>
reportError(error, 'Failed to send /skills command'),
if (promptBlocked) return enqueuePrompt(text, images);
return submitPromptFromEditor(
text,
images,
'Failed to send /skills command',
);
} else {
if (echoOrDeferLocalCommand(text, images)) return true;
@ -2612,13 +2735,7 @@ export function App({
}
const clientId = connectionRef.current.clientId;
if (!clientId) {
store.appendLocalUserMessage(text);
store.dispatch([
{
type: 'error',
text: t('extensions.install.waitForSession'),
},
]);
pushToast('warning', t('extensions.install.waitForSession'));
return true;
}
store.appendLocalUserMessage(text);
@ -2668,17 +2785,19 @@ export function App({
if (cmd === 'rename') {
const renameArg = parseRenameArgument(text.slice(match[0].length));
if (renameArg.type === 'auto' || renameArg.type === 'delegate') {
if (promptBlocked) return blockLocalCommandDuringTurn();
sendPrompt(text, images).catch((error: unknown) =>
reportError(error, 'Failed to send /rename command'),
if (promptBlocked) return enqueuePrompt(text, images);
return submitPromptFromEditor(
text,
images,
'Failed to send /rename command',
);
return true;
}
const displayName = renameArg.displayName;
if (!displayName) {
pushToast('error', t('rename.empty'));
return true;
}
if (!requireActiveSessionForLocalCommand()) return false;
sessionActions
.renameSession(displayName)
.then(() => {
@ -2720,6 +2839,7 @@ export function App({
let statsView: StatsView = 'overview';
if (statsArg === 'model') statsView = 'model';
else if (statsArg === 'tools') statsView = 'tools';
if (!requireActiveSessionForLocalCommand()) return false;
if (echoOrDeferLocalCommand(text, images)) return true;
sessionActions
.getStats()
@ -2855,10 +2975,7 @@ export function App({
}
// Forward slash commands as prompts
if (promptBlocked) return enqueuePrompt(text, images);
sendPrompt(text, images).catch((error: unknown) =>
reportError(error, 'Failed to send command'),
);
return true;
return submitPromptFromEditor(text, images, 'Failed to send command');
} else if (text.startsWith('!')) {
if (promptBlocked) {
pushToast('error', t('queue.shellBlocked'));
@ -2866,16 +2983,14 @@ export function App({
}
const cmd = text.slice(1).trim();
if (!cmd) return false;
if (!requireActiveSessionForLocalCommand()) return false;
sessionActions.sendShellCommand(cmd).catch((error: unknown) => {
reportError(error, 'Failed to execute shell command');
});
return true;
} else {
if (promptBlocked) return enqueuePrompt(text, images);
sendPrompt(text, images).catch((error: unknown) =>
reportError(error, 'Failed to send message'),
);
return true;
return submitPromptFromEditor(text, images, 'Failed to send message');
}
},
[
@ -2899,8 +3014,12 @@ export function App({
reportError,
runVisibleRecap,
runVisibleBtw,
requireActiveSessionForLocalCommand,
resumeChatBottomFollow,
selectedLanguage,
setPendingModel,
setPendingMode,
setWorkspaceSetting,
showContextUsage,
t,
workspaceActions,
@ -3112,11 +3231,15 @@ export function App({
const handleModelSelect = useCallback(
(modelId: string) => {
if (!connectionRef.current.sessionId) {
setPendingModel(modelId);
return;
}
sessionActions
.setModel(modelId)
.then((result) => {
const summary = getModelSwitchSummary(result);
setCurrentModel(summary?.modelId ?? modelId);
setPendingModel(summary?.modelId ?? modelId);
if (summary) {
store.dispatch({
type: 'debug',
@ -3130,7 +3253,7 @@ export function App({
reportError(error, t('model.switch'));
});
},
[sessionActions, store, reportError, t],
[sessionActions, store, reportError, t, setPendingModel],
);
const handleFastModelSelect = useCallback(
@ -3146,21 +3269,13 @@ export function App({
[blockLocalCommandDuringTurn, sendPrompt, streamingState, reportError],
);
// Persist via the prompt channel (like `/model --fast`): the daemon's command
// processor writes `voiceModel` to settings. The `/workspace/settings` route
// is token-gated, but browser voice runs on loopback-no-token — so this is
// the path that actually works there. The daemon's /voice/stream reads it back.
const handleVoiceModelSelect = useCallback(
(modelId: string) => {
if (streamingState !== 'idle') {
blockLocalCommandDuringTurn();
return;
}
sendPrompt(`/model --voice ${modelId}`).catch((error: unknown) => {
reportError(error, t('model.setVoice'));
});
setWorkspaceSetting('workspace', 'voiceModel', modelId).catch(
(error: unknown) => reportError(error, t('model.setVoice')),
);
},
[blockLocalCommandDuringTurn, sendPrompt, streamingState, reportError, t],
[reportError, setWorkspaceSetting, t],
);
const commands = useMemo(() => {
@ -3210,6 +3325,7 @@ export function App({
[renderWelcomeFooter, welcomeHeaderProps],
);
const isChatEmptyState =
!connection.sessionId &&
displayMessages.length === 0 &&
!showFloatingTodos &&
!pendingApproval &&
@ -3739,8 +3855,13 @@ export function App({
onToggleShortcuts={handleToggleShortcuts}
onCancel={handleCancel}
isRunning={streamingState !== 'idle'}
isPreparing={isPreparingPrompt}
cancelArmed={cancelArmed}
disabled={isDisabled || pendingApproval !== null}
disabled={
isDisabled ||
pendingApproval !== null ||
isPreparingPrompt
}
commands={commands}
skills={loadedSkills}
slashCommandCategoryOrder={slashCommandCategoryOrder}
@ -3768,7 +3889,7 @@ export function App({
placeholderText={
!connected || connection.catchingUp
? t('common.loading')
: streamingState !== 'idle'
: isPreparingPrompt || streamingState !== 'idle'
? t('editor.processing')
: t('editor.placeholder')
}

View file

@ -974,6 +974,15 @@
background: currentColor;
}
.loadingIcon {
width: 14px;
height: 14px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: sendBtnLoadingSpin 0.8s linear infinite;
}
/* Armed-to-cancel state: first Esc primes the stop, the button shows an "Esc"
hint, a subtle pulse, and a depleting ring counting down the confirm window
until the second press confirms or the window lapses. */
@ -1024,6 +1033,10 @@
}
@media (prefers-reduced-motion: reduce) {
.loadingIcon {
animation: none;
}
.sendBtnArmed,
.sendBtnArmed:not(:disabled) {
animation: none;
@ -1035,6 +1048,12 @@
}
}
@keyframes sendBtnLoadingSpin {
to {
transform: rotate(360deg);
}
}
.escLabel {
font-size: 11px;
font-weight: 600;

View file

@ -62,6 +62,7 @@ interface ChatEditorProps {
onToggleShortcuts?: () => void;
onCancel?: () => void;
isRunning?: boolean;
isPreparing?: boolean;
/** First Esc armed a cancel — the send button shows an "Esc to stop" hint. */
cancelArmed?: boolean;
disabled?: boolean;
@ -193,6 +194,10 @@ function StopIcon() {
return <span className={styles.stopIcon} aria-hidden="true" />;
}
function LoadingIcon() {
return <span className={styles.loadingIcon} aria-hidden="true" />;
}
function QuickActionsIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
@ -870,6 +875,7 @@ export const ChatEditor = memo(
onToggleShortcuts,
onCancel,
isRunning = false,
isPreparing = false,
cancelArmed = false,
disabled = false,
placeholderText = 'Type a message...',
@ -1569,17 +1575,24 @@ export const ChatEditor = memo(
)}
<button
className={
showCancelButton
? `${styles.sendBtn} ${styles.sendBtnRunning}`
isPreparing || showCancelButton
? `${styles.sendBtn} ${styles.sendBtnRunning}${
cancelArmed ? ` ${styles.sendBtnArmed}` : ''
}`
: styles.sendBtn
}
disabled={
showCancelButton
? !onCancel
: core.disabled || !core.hasContent
isPreparing
? true
: showCancelButton
? !onCancel
: core.disabled || !core.hasContent
}
onClick={(e) => {
e.stopPropagation();
if (isPreparing) {
return;
}
if (showCancelButton) {
onCancel?.();
return;
@ -1587,10 +1600,33 @@ export const ChatEditor = memo(
core.submitText();
}}
aria-label={
showCancelButton ? t('stream.cancel') : t('editor.send')
isPreparing
? t('common.loading')
: showCancelButton
? cancelArmed
? t('stream.cancelArmed')
: t('stream.cancel')
: t('editor.send')
}
title={
isRunning && cancelArmed
? t('stream.cancelArmed')
: undefined
}
>
{showCancelButton ? <StopIcon /> : <SendIcon />}
{isPreparing ? (
<LoadingIcon />
) : showCancelButton ? (
cancelArmed ? (
<span className={styles.escLabel} aria-hidden="true">
Esc
</span>
) : (
<StopIcon />
)
) : (
<SendIcon />
)}
</button>
<span
role="status"

View file

@ -316,6 +316,32 @@ describe('getDisplayItemVirtualKey', () => {
}),
).toBe('group:header');
});
it('keys live turn rows by their start time', () => {
expect(
getDisplayItemVirtualKey({
type: 'turn_collapse',
key: 'u1',
turnCollapse: {
turnId: 'u1',
collapsed: false,
hiddenCount: 0,
liveStartedAt: 1_000,
},
}),
).toBe('tc:u1:1000');
expect(
getDisplayItemVirtualKey({
type: 'turn_collapse',
key: 'u1',
turnCollapse: {
turnId: 'u1',
collapsed: true,
hiddenCount: 1,
},
}),
).toBe('tc:u1');
});
});
describe('shouldUseVirtualScroll', () => {

View file

@ -282,7 +282,12 @@ export function groupParallelAgents(messages: Message[]): DisplayItem[] {
export function getDisplayItemVirtualKey(item: DisplayItem): string {
if (item.type === 'parallel_agents') return `group:${item.key}`;
if (item.type === 'turn_collapse') return `tc:${item.key}`;
if (item.type === 'turn_collapse') {
const liveKey = item.turnCollapse.liveStartedAt;
return liveKey === undefined
? `tc:${item.key}`
: `tc:${item.key}:${liveKey}`;
}
if (item.type === 'turn_content') return `turn-content:${item.key}`;
return `msg:${item.key}`;
}
@ -925,6 +930,11 @@ const TurnCollapseRow = memo(function TurnCollapseRow({
const now = useSharedNow(liveStartedAt !== undefined && showMetadataRow);
const elapsedSeenRef = useRef(0);
const previousLiveStartedAtRef = useRef<number | undefined>(liveStartedAt);
if (previousLiveStartedAtRef.current !== liveStartedAt) {
previousLiveStartedAtRef.current = liveStartedAt;
elapsedSeenRef.current = 0;
}
let displayElapsedMs: number | undefined;
if (liveStartedAt !== undefined && showMetadataRow) {
elapsedSeenRef.current = Math.max(

View file

@ -297,6 +297,8 @@ const EN: Messages = {
'contextUsage.messages': 'Messages',
'contextUsage.mcpTools': 'MCP tools',
'contextUsage.model': 'Model',
'contextUsage.noSession':
'No active session yet. Send your first message before viewing context usage.',
'contextUsage.noApiResponse':
'No API response yet. Send a message to see actual usage.',
'contextUsage.overLimit':
@ -540,6 +542,8 @@ const EN: Messages = {
'language.options': 'Available options:',
'language.set': 'Set UI language',
'language.usage': 'Usage: /language ui [en|zh-CN]',
'localCommand.noSession':
'No active session yet. Send your first message before using this command.',
'local.agents': 'Manage subagents',
'local.approvalMode': 'Change approval mode',
'local.auth': 'Connect an LLM provider',
@ -1515,6 +1519,8 @@ const ZH: Messages = {
'contextUsage.messages': '消息',
'contextUsage.mcpTools': 'MCP 工具',
'contextUsage.model': '模型',
'contextUsage.noSession':
'当前还没有会话。请先发送第一条消息,再查看上下文使用情况。',
'contextUsage.noApiResponse':
'尚无 API 响应。发送一条消息后可查看实际使用量。',
'contextUsage.overLimit':
@ -1743,6 +1749,8 @@ const ZH: Messages = {
'language.options': '可用选项:',
'language.set': '设置 UI 语言',
'language.usage': '用法:/language ui [en|zh-CN]',
'localCommand.noSession':
'当前还没有会话。请先发送第一条消息,再使用这个命令。',
'local.agents': '管理智能体',
'local.approvalMode': '切换审批模式',
'local.auth': '连接 LLM provider',

View file

@ -7,6 +7,7 @@ import { createRoot, type Root } from 'react-dom/client';
// top-level boundary sits *outside* the daemon providers (a boundary nested
// under them couldn't catch their own throw).
let workspaceShouldThrow = false;
const sessionProviderProps: Array<Record<string, unknown>> = [];
vi.mock('@qwen-code/webui/daemon-react-sdk', async () => {
const React = await import('react');
return {
@ -14,8 +15,15 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', async () => {
if (workspaceShouldThrow) throw new Error('provider boom');
return React.createElement(React.Fragment, null, children);
},
DaemonSessionProvider: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
DaemonSessionProvider: ({
children,
...props
}: {
children: React.ReactNode;
}) => {
sessionProviderProps.push(props);
return React.createElement(React.Fragment, null, children);
},
};
});
vi.mock('./App', async () => {
@ -52,6 +60,7 @@ afterEach(() => {
container.remove();
}
workspaceShouldThrow = false;
sessionProviderProps.length = 0;
vi.restoreAllMocks();
});
@ -62,6 +71,26 @@ describe('WebShellWithProviders top-level boundary', () => {
expect(container.querySelector('[role="alert"]')).toBeNull();
});
it('starts on an empty session by default', () => {
render(<WebShellWithProviders />);
expect(sessionProviderProps[0]).toMatchObject({
sessionId: undefined,
});
expect(sessionProviderProps[0]).not.toHaveProperty('deferSessionCreation');
});
it('passes controlled sessionId to the daemon session provider', () => {
render(<WebShellWithProviders sessionId="session-2" />);
expect(sessionProviderProps[0]).toMatchObject({
sessionId: 'session-2',
});
});
it('passes explicit undefined sessionId to the daemon session provider', () => {
render(<WebShellWithProviders sessionId={undefined} />);
expect(sessionProviderProps[0]).toHaveProperty('sessionId', undefined);
});
it('catches a daemon-provider render crash instead of white-screening', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
workspaceShouldThrow = true;

View file

@ -1,4 +1,4 @@
import type { ReactNode } from 'react';
import { type ReactNode } from 'react';
import {
DaemonSessionProvider,
DaemonWorkspaceProvider,
@ -13,8 +13,8 @@ export interface WebShellWithProvidersProps extends WebShellProps {
baseUrl?: string;
/** Bearer token passed to daemon requests. */
token?: string;
/** Initial daemon session id to load. Omit to create/attach automatically. */
initialSessionId?: string;
/** Session id to load. Undefined starts on an empty page. */
sessionId?: string;
/** Client identity to reuse when attaching to an externally created session. */
clientId?: string;
}
@ -71,13 +71,8 @@ export function WebShell(props: WebShellProps) {
* with both daemon providers, so MCP/tools/skills/memory/agents/session APIs
* are available without extra setup.
*/
export function WebShellWithProviders({
baseUrl,
token,
initialSessionId,
clientId,
...webShellProps
}: WebShellWithProvidersProps) {
export function WebShellWithProviders(props: WebShellWithProvidersProps) {
const { baseUrl, token, sessionId, clientId, ...webShellProps } = props;
const resolvedBaseUrl = resolveBaseUrl(baseUrl);
return (
@ -90,7 +85,7 @@ export function WebShellWithProviders({
>
<DaemonWorkspaceProvider baseUrl={resolvedBaseUrl} token={token}>
<DaemonSessionProvider
initialSessionId={initialSessionId}
sessionId={sessionId}
clientId={clientId}
suppressOwnUserEcho
>

View file

@ -90,6 +90,16 @@ function getSessionIdFromUrl(): string | undefined {
}
}
function replaceStandaloneSessionUrl(sessionId: string | undefined): void {
const url = new URL(window.location.href);
url.pathname = sessionId ? `/session/${encodeURIComponent(sessionId)}` : '/';
if (!import.meta.env.DEV) {
url.searchParams.delete('token');
url.searchParams.delete('daemon');
}
window.history.replaceState(null, '', url);
}
function StandaloneApp({ daemonToken }: { daemonToken?: string }) {
const [theme, setTheme] = useState<WebShellTheme>(() => getInitialTheme());
const [language, setLanguage] = useState<WebShellLanguage>(() =>
@ -105,6 +115,9 @@ function StandaloneApp({ daemonToken }: { daemonToken?: string }) {
setLanguage(nextLanguage);
storeLanguage(nextLanguage);
}, []);
const handleSessionIdChange = useCallback((nextSessionId?: string) => {
replaceStandaloneSessionUrl(nextSessionId);
}, []);
return (
<ErrorBoundary
@ -116,7 +129,7 @@ function StandaloneApp({ daemonToken }: { daemonToken?: string }) {
<DaemonWorkspaceProvider baseUrl={baseUrl} token={daemonToken}>
<DaemonSessionProvider
key={sessionId ?? 'new'}
initialSessionId={sessionId}
sessionId={sessionId}
suppressOwnUserEcho
>
<App
@ -124,6 +137,7 @@ function StandaloneApp({ daemonToken }: { daemonToken?: string }) {
onThemeChange={handleThemeChange}
language={language}
onLanguageChange={handleLanguageChange}
onSessionIdChange={handleSessionIdChange}
sidebar
compactThinking
/>

View file

@ -0,0 +1,186 @@
import { describe, expect, it, vi } from 'vitest';
import { createAndAttachSessionForPrompt } from './sessionPreparation';
type CreateSessionArgs = Parameters<typeof createAndAttachSessionForPrompt>[0];
const sessionResult = { sessionId: 'session-1' };
const modelResult = { model: 'qwen3' };
const approvalModeResult = { mode: 'yolo' };
function createActions(
overrides: Partial<CreateSessionArgs['sessionActions']> = {},
): CreateSessionArgs['sessionActions'] {
return {
createSession: vi.fn(async () => sessionResult),
attachSession: vi.fn(async () => {}),
closeSession: vi.fn(async () => {}),
clearSession: vi.fn(async () => {}),
setModel: vi.fn(async () => modelResult),
setApprovalMode: vi.fn(async () => approvalModeResult),
...overrides,
};
}
describe('createAndAttachSessionForPrompt', () => {
it('attaches the session before a model switch failure can abort setup', async () => {
const order: string[] = [];
const error = new Error('model failed');
const warn = vi.fn();
const waitForModel = createDeferred<void>();
const approvalStarted = createDeferred<void>();
const actions = createActions({
createSession: vi.fn(async () => {
order.push('create');
return sessionResult;
}),
attachSession: vi.fn(async () => {
order.push('attach');
}),
setModel: vi.fn(async () => {
order.push('model');
await waitForModel.promise;
throw error;
}),
setApprovalMode: vi.fn(async () => {
order.push('approval');
approvalStarted.resolve();
return approvalModeResult;
}),
});
const result = createAndAttachSessionForPrompt({
sessionActions: actions,
modelId: 'qwen3',
modeId: 'yolo',
warn,
});
await approvalStarted.promise;
waitForModel.resolve();
await result;
expect(order.slice(0, 2)).toEqual(['create', 'attach']);
expect(order.slice(2).sort()).toEqual(['approval', 'model']);
expect(warn).toHaveBeenCalledWith(
'[WebShell] failed to set model for new session:',
error,
);
});
it('keeps the attached session when approval mode setup fails', async () => {
const order: string[] = [];
const error = new Error('mode failed');
const warn = vi.fn();
const waitForModel = createDeferred<void>();
const approvalStarted = createDeferred<void>();
const actions = createActions({
createSession: vi.fn(async () => {
order.push('create');
return sessionResult;
}),
attachSession: vi.fn(async () => {
order.push('attach');
}),
setModel: vi.fn(async () => {
order.push('model');
await waitForModel.promise;
return modelResult;
}),
setApprovalMode: vi.fn(async () => {
order.push('approval');
approvalStarted.resolve();
throw error;
}),
});
const result = createAndAttachSessionForPrompt({
sessionActions: actions,
modelId: 'qwen3',
modeId: 'yolo',
warn,
});
await approvalStarted.promise;
waitForModel.resolve();
await result;
expect(order.slice(0, 2)).toEqual(['create', 'attach']);
expect(order.slice(2).sort()).toEqual(['approval', 'model']);
expect(warn).toHaveBeenCalledWith(
'[WebShell] failed to set approval mode for new session:',
error,
);
});
it('closes and clears the created session when attach fails', async () => {
const order: string[] = [];
const error = new Error('attach failed');
const warn = vi.fn();
const actions = createActions({
closeSession: vi.fn(async () => {
order.push('close');
}),
clearSession: vi.fn(async () => {
order.push('clear');
}),
attachSession: vi.fn(async () => {
throw error;
}),
});
await expect(
createAndAttachSessionForPrompt({
sessionActions: actions,
modelId: 'qwen3',
modeId: 'yolo',
warn,
}),
).rejects.toThrow(error);
expect(actions.closeSession).toHaveBeenCalledOnce();
expect(actions.clearSession).toHaveBeenCalledOnce();
expect(order).toEqual(['close', 'clear']);
expect(actions.setModel).not.toHaveBeenCalled();
expect(actions.setApprovalMode).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(
'[WebShell] failed to attach new session:',
error,
);
});
it('still clears the created session when close after attach failure fails', async () => {
const attachError = new Error('attach failed');
const closeError = new Error('close failed');
const warn = vi.fn();
const actions = createActions({
attachSession: vi.fn(async () => {
throw attachError;
}),
closeSession: vi.fn(async () => {
throw closeError;
}),
});
await expect(
createAndAttachSessionForPrompt({
sessionActions: actions,
warn,
}),
).rejects.toThrow(attachError);
expect(actions.closeSession).toHaveBeenCalledOnce();
expect(actions.clearSession).toHaveBeenCalledOnce();
expect(warn).toHaveBeenCalledWith(
'[WebShell] failed to close unattached session:',
closeError,
);
});
});
function createDeferred<T>(): {
promise: Promise<T>;
resolve: (value?: T | PromiseLike<T>) => void;
} {
let resolve!: (value?: T | PromiseLike<T>) => void;
const promise = new Promise<T>((res) => {
resolve = (value) => res(value as T | PromiseLike<T>);
});
return { promise, resolve };
}

View file

@ -0,0 +1,58 @@
import {
DAEMON_APPROVAL_MODES,
type DaemonApprovalMode,
} from '@qwen-code/webui/daemon-react-sdk';
type PromptSessionActions = {
createSession: () => Promise<unknown>;
attachSession: () => Promise<void>;
closeSession: () => Promise<void>;
clearSession: () => Promise<void>;
setModel: (modelId: string) => Promise<unknown>;
setApprovalMode: (mode: DaemonApprovalMode) => Promise<unknown>;
};
export function isDaemonApprovalMode(mode: string): mode is DaemonApprovalMode {
return DAEMON_APPROVAL_MODES.includes(mode as DaemonApprovalMode);
}
export async function createAndAttachSessionForPrompt({
sessionActions,
modelId,
modeId,
warn = console.warn,
}: {
sessionActions: PromptSessionActions;
modelId?: string;
modeId?: string;
warn?: (message?: unknown, ...optionalParams: unknown[]) => void;
}): Promise<void> {
await sessionActions.createSession();
try {
await sessionActions.attachSession();
} catch (error) {
warn('[WebShell] failed to attach new session:', error);
await sessionActions.closeSession().catch((closeError: unknown) => {
warn('[WebShell] failed to close unattached session:', closeError);
});
await sessionActions.clearSession().catch((clearError: unknown) => {
warn('[WebShell] failed to clear unattached session:', clearError);
});
throw error;
}
await Promise.all([
modelId
? sessionActions.setModel(modelId).catch((error: unknown) => {
warn('[WebShell] failed to set model for new session:', error);
})
: Promise.resolve(),
modeId && isDaemonApprovalMode(modeId)
? sessionActions.setApprovalMode(modeId).catch((error: unknown) => {
warn(
'[WebShell] failed to set approval mode for new session:',
error,
);
})
: Promise.resolve(),
]);
}

View file

@ -174,32 +174,33 @@ const INITIAL_WORKSPACE_EVENT_SIGNALS: DaemonWorkspaceEventSignals = {
* and risking transcript wipes if reconnect later attaches a different
* session and hits the sessionId-change `store.reset()` branch.
*
* 404/410 (session-not-found) normally keep the reconnect-then-recreate
* behavior, unless the caller opts into leaving missing sessions disconnected.
* 404/410 (session-not-found) leave the requested session disconnected instead
* of silently creating a replacement empty session.
*/
const AUTH_FAILURE_HTTP_STATUSES = new Set([401, 403]);
const UNHANDLED_SESSION = Symbol('unhandled session');
export function DaemonSessionProvider({
baseUrl,
token,
workspaceCwd,
initialSessionId,
clientId,
createSessionRequest,
maxQueued = 1024,
maxBlocks = DEFAULT_MAX_BLOCKS,
suppressOwnUserEcho = true,
includeRawEvent = false,
autoConnect = true,
autoReconnect = true,
missingSessionBehavior = 'create',
reconnectDelayMs = 1_000,
maxReconnectDelayMs = 10_000,
heartbeatIntervalMs = 30_000,
heartbeatFailureThreshold = 3,
loadWarnings,
children,
}: DaemonSessionProviderProps) {
export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
const {
baseUrl,
token,
workspaceCwd,
sessionId,
clientId,
createSessionRequest,
maxQueued = 1024,
maxBlocks = DEFAULT_MAX_BLOCKS,
suppressOwnUserEcho = true,
includeRawEvent = false,
autoConnect = true,
autoReconnect = true,
reconnectDelayMs = 1_000,
maxReconnectDelayMs = 10_000,
heartbeatIntervalMs = 30_000,
heartbeatFailureThreshold = 3,
loadWarnings,
children,
} = props;
const workspace = useOptionalDaemonWorkspace();
const resolvedBaseUrl = baseUrl ?? workspace?.baseUrl;
const resolvedToken = token ?? workspace?.token;
@ -210,8 +211,19 @@ export function DaemonSessionProvider({
workspaceCapabilitiesRef.current = workspace?.capabilities;
const workspaceGetCapabilitiesRef = useRef(workspace?.getCapabilities);
workspaceGetCapabilitiesRef.current = workspace?.getCapabilities;
const initialRestoreSessionIdRef = useRef(sessionId);
const initialRestoreSessionId = initialRestoreSessionIdRef.current;
// Captured once at mount: if the host did not provide an initial session,
// keep the provider empty until the first prompt creates one. Later
// sessionId prop changes are handled by the controlled-session effect below.
const shouldDeferInitialSessionCreation =
initialRestoreSessionId === undefined;
const resolvedWorkspaceCwdRef = useRef(resolvedWorkspaceCwd);
resolvedWorkspaceCwdRef.current = resolvedWorkspaceCwd;
const activeWorkspaceCwdRef = useRef(resolvedWorkspaceCwd);
if (resolvedWorkspaceCwd) {
activeWorkspaceCwdRef.current = resolvedWorkspaceCwd;
}
const store = useMemo(
() => createDaemonTranscriptStore({ maxBlocks }),
@ -229,6 +241,10 @@ export function DaemonSessionProvider({
ReturnType<typeof setTimeout> | undefined
>(undefined);
const heartbeatSupportedRef = useRef(false);
const manualSessionClearRef = useRef(false);
const skipNextCleanupDetachSessionIdRef = useRef<string | undefined>(
undefined,
);
const settledRestoredActivePromptSessionsRef = useRef<
WeakSet<DaemonSessionClient>
>(new WeakSet());
@ -248,14 +264,18 @@ export function DaemonSessionProvider({
createSessionRequestRef.current = createSessionRequest;
const [promptStatus, setPromptStatus] = useState<DaemonPromptStatus>('idle');
const [restoreSessionId, setRestoreSessionId] = useState<string | undefined>(
initialSessionId,
initialRestoreSessionId,
);
const [restoreMode, setRestoreMode] = useState<'load' | 'resume'>('load');
const [restoreSessionNonce, setRestoreSessionNonce] = useState(0);
const [attachSessionNonce, setAttachSessionNonce] = useState(0);
const [newSessionNonce, setNewSessionNonce] = useState(0);
const [connection, setConnection] = useState<DaemonConnectionState>({
status: autoConnect ? 'connecting' : 'idle',
...(initialRestoreSessionId ? { sessionId: initialRestoreSessionId } : {}),
});
const connectionRef = useRef(connection);
connectionRef.current = connection;
const noticeIdRef = useRef(0);
const [notices, setNotices] = useState<DaemonSessionNotice[]>([]);
const addNotice = useCallback<AddDaemonSessionNotice>((input) => {
@ -284,6 +304,14 @@ export function DaemonSessionProvider({
const [workspaceEventSignals, setWorkspaceEventSignals] =
useState<DaemonWorkspaceEventSignals>(INITIAL_WORKSPACE_EVENT_SIGNALS);
const hasCurrentSessionActivePromptRef = useRef<() => boolean>(() => false);
const mountedRef = useRef(false);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
if (!autoConnect) return undefined;
@ -345,11 +373,26 @@ export function DaemonSessionProvider({
let isSameSessionReconnect = false;
let shouldInjectReplaySnapshot = false;
let needsStoreReset = false;
let attachedExistingSession = false;
// Only populated when this attempt (re)loads the session: a reused
// session object carries the snapshot from its original load, whose
// usage may be older than the in-memory count.
let replayTokenUsage: DaemonConnectionState['tokenUsage'];
let replayTokenCount: number | undefined;
if (!session) {
const existingSession = sessionRef.current;
if (
existingSession &&
!restoreSessionId &&
!reconnectSessionId &&
!shouldCreateFreshSession
) {
session = existingSession;
reconnectSessionId = existingSession.sessionId;
lastSessionIdRef.current = existingSession.sessionId;
attachedExistingSession = true;
}
}
if (!session) {
setConnection((current) => ({
...current,
@ -370,6 +413,41 @@ export function DaemonSessionProvider({
caps.features.includes('client_heartbeat');
const effectWorkspaceCwd =
resolvedWorkspaceCwdRef.current ?? caps.workspaceCwd;
activeWorkspaceCwdRef.current = effectWorkspaceCwd;
if (
(shouldDeferInitialSessionCreation ||
manualSessionClearRef.current) &&
!restoreSessionId &&
!reconnectSessionId &&
!shouldCreateFreshSession
) {
const providerResult = await Promise.allSettled([
client.workspaceProviders(),
]);
if (providerResult[0].status === 'rejected') {
console.warn(
'[DaemonSessionProvider] workspaceProviders failed in deferred connect:',
providerResult[0].reason,
);
}
const providers =
providerResult[0].status === 'fulfilled'
? providerResult[0].value
: undefined;
const providerModelStatus = mapProviderStatus(providers);
setConnection((current) => ({
...current,
status: 'connected',
workspaceCwd: effectWorkspaceCwd,
models: providerModelStatus.models,
currentModel: providerModelStatus.currentModel,
currentMode: providerModelStatus.currentMode,
contextWindow: providerModelStatus.contextWindow,
providers,
capabilities: caps,
}));
return;
}
const restoreMethod =
restoreSessionId && restoreMode === 'resume'
? DaemonSessionClient.resume
@ -510,32 +588,40 @@ export function DaemonSessionProvider({
hasCurrentSessionActivePromptRef.current = hasSessionActivePrompt;
setPromptStatus(hasSessionActivePrompt() ? 'streaming' : 'idle');
const canReuseSessionMetadata =
attachedExistingSession &&
connectionRef.current.commands !== undefined &&
connectionRef.current.skills !== undefined &&
connectionRef.current.supportedCommands !== undefined &&
connectionRef.current.context !== undefined;
const [providerResult, commandResult, contextResult] =
await Promise.allSettled([
client.workspaceProviders(),
activeSession.supportedCommands(),
activeSession.context(),
]);
canReuseSessionMetadata
? [undefined, undefined, undefined]
: await Promise.allSettled([
client.workspaceProviders(),
activeSession.supportedCommands(),
activeSession.context(),
]);
const providers =
providerResult.status === 'fulfilled'
providerResult?.status === 'fulfilled'
? providerResult.value
: undefined;
const supportedCommands =
commandResult.status === 'fulfilled'
commandResult?.status === 'fulfilled'
? commandResult.value
: undefined;
const context =
contextResult.status === 'fulfilled'
contextResult?.status === 'fulfilled'
? contextResult.value
: undefined;
const loadWarningTexts = [
providerResult.status === 'rejected'
providerResult?.status === 'rejected'
? loadWarningsRef.current?.models
: undefined,
commandResult.status === 'rejected'
commandResult?.status === 'rejected'
? loadWarningsRef.current?.commands
: undefined,
contextResult.status === 'rejected'
contextResult?.status === 'rejected'
? loadWarningsRef.current?.context
: undefined,
].filter((warning): warning is string => Boolean(warning));
@ -560,7 +646,8 @@ export function DaemonSessionProvider({
?.contextWindow ??
providerContextWindow;
const { commands, skills } = mapSupportedCommands(supportedCommands);
const currentMode = getCurrentMode(context);
const currentMode =
getCurrentMode(context) ?? providerModelStatus.currentMode;
setConnection((current) => ({
status: 'connected',
@ -571,11 +658,11 @@ export function DaemonSessionProvider({
? { clientId: activeSession.clientId }
: {}),
workspaceCwd: activeSession.workspaceCwd,
commands,
skills,
models: sessionModels,
currentModel: sessionCurrentModel,
currentMode,
commands: commands.length > 0 ? commands : current.commands,
skills: skills.length > 0 ? skills : current.skills,
models: sessionModels.length > 0 ? sessionModels : current.models,
currentModel: sessionCurrentModel ?? current.currentModel,
currentMode: currentMode ?? current.currentMode,
displayName:
getSessionDisplayName(activeSession.state) ??
(current.sessionId === activeSession.sessionId
@ -600,11 +687,11 @@ export function DaemonSessionProvider({
: current.sessionId === activeSession.sessionId
? (current.tokenCount ?? 0)
: 0,
contextWindow: sessionContextWindow,
providers,
supportedCommands,
context,
capabilities,
contextWindow: sessionContextWindow ?? current.contextWindow,
providers: providers ?? current.providers,
supportedCommands: supportedCommands ?? current.supportedCommands,
context: context ?? current.context,
capabilities: capabilities ?? current.capabilities,
catchingUp:
isSameSessionReconnect ||
activeSession.lastEventId != null ||
@ -718,9 +805,14 @@ export function DaemonSessionProvider({
if (pendingLoadToResolve) {
pendingSessionLoadRef.current = undefined;
clearTimeout(pendingLoadToResolve.timeout);
if (
skipNextCleanupDetachSessionIdRef.current ===
activeSession.sessionId
) {
skipNextCleanupDetachSessionIdRef.current = undefined;
}
pendingLoadToResolve.resolve();
}
let sawEvent = false;
let resyncRequested = false;
const requestEpochResetReload = () => {
@ -755,6 +847,9 @@ export function DaemonSessionProvider({
signal: abort.signal,
maxQueued,
})) {
if (sessionRef.current?.sessionId !== activeSession.sessionId) {
break;
}
if (!sawEvent) {
sawEvent = true;
reconnectAttempt = 0;
@ -1023,6 +1118,12 @@ export function DaemonSessionProvider({
}));
return;
}
if (manualSessionClearRef.current) {
session = undefined;
sessionRef.current = undefined;
hasCurrentSessionActivePromptRef.current = () => false;
return;
}
if (!disposed && !abort.signal.aborted && !resyncRequested) {
// Keep the session handle after a normal SSE close so the next
// subscription can resume from DaemonSessionClient.lastEventId.
@ -1055,10 +1156,6 @@ export function DaemonSessionProvider({
const failedSessionId = session?.sessionId;
const isAuthFailure = isAuthFailureHttpError(error);
const isTerminal = isTerminalSessionHttpError(error);
const shouldDisconnectMissingSession =
isTerminal &&
!isAuthFailure &&
missingSessionBehavior === 'disconnect';
if (failedSessionId && (isAuthFailure || isTerminal)) {
const active = activePromptsRef.current.get(failedSessionId);
active?.controller.abort();
@ -1077,6 +1174,12 @@ export function DaemonSessionProvider({
(pendingLoad.sessionId === restoreSessionId ||
pendingLoad.sessionId === reconnectSessionId)
) {
if (
skipNextCleanupDetachSessionIdRef.current ===
pendingLoad.sessionId
) {
skipNextCleanupDetachSessionIdRef.current = undefined;
}
pendingSessionLoadRef.current = undefined;
clearTimeout(pendingLoad.timeout);
pendingLoad.reject(error);
@ -1091,20 +1194,14 @@ export function DaemonSessionProvider({
setConnection({ status: 'error', error: message });
return;
}
if (shouldDisconnectMissingSession) {
setConnection((current) => ({
...current,
status: 'disconnected',
sessionId: undefined,
error: message,
catchingUp: undefined,
}));
return;
}
reconnectSessionId = undefined;
if (restoreSessionId) {
setRestoreSessionId(undefined);
}
setConnection((current) => ({
...current,
status: 'disconnected',
sessionId: undefined,
error: message,
catchingUp: undefined,
}));
return;
} else {
// Retriable error (network failure, timeout, etc.) — preserve
// the session so the next iteration skips the full load() and
@ -1168,14 +1265,20 @@ export function DaemonSessionProvider({
hasCurrentSessionActivePromptRef.current = () => false;
setPromptStatus('idle');
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
if (pendingSessionLoadRef.current) {
const keepSessionForNextEffect =
session?.sessionId === skipNextCleanupDetachSessionIdRef.current;
const isUnmounting = !mountedRef.current;
if (
pendingSessionLoadRef.current &&
(!keepSessionForNextEffect || isUnmounting)
) {
clearTimeout(pendingSessionLoadRef.current.timeout);
pendingSessionLoadRef.current.reject(
new DOMException('Session load interrupted by cleanup', 'AbortError'),
);
pendingSessionLoadRef.current = undefined;
}
if (session?.clientId) {
if ((!keepSessionForNextEffect || isUnmounting) && session?.clientId) {
void detachDaemonClient({
baseUrl: resolvedBaseUrl!,
token: resolvedToken,
@ -1185,12 +1288,13 @@ export function DaemonSessionProvider({
console.warn('[DaemonSessionProvider] detach failed:', err),
);
}
sessionRef.current = undefined;
if (!keepSessionForNextEffect || isUnmounting) {
sessionRef.current = undefined;
}
};
}, [
autoConnect,
autoReconnect,
missingSessionBehavior,
resolvedBaseUrl,
resolvedToken,
workspaceCwd,
@ -1201,8 +1305,10 @@ export function DaemonSessionProvider({
restoreSessionId,
restoreMode,
restoreSessionNonce,
attachSessionNonce,
newSessionNonce,
clientId,
shouldDeferInitialSessionCreation,
clearNotices,
addNotice,
]);
@ -1210,6 +1316,7 @@ export function DaemonSessionProvider({
useEffect(() => {
if (
!heartbeatSupportedRef.current ||
connection.status !== 'connected' ||
heartbeatIntervalMs <= 0 ||
heartbeatFailureThreshold <= 0 ||
!connection.sessionId
@ -1251,7 +1358,12 @@ export function DaemonSessionProvider({
disposed = true;
clearInterval(timer);
};
}, [connection.sessionId, heartbeatFailureThreshold, heartbeatIntervalMs]);
}, [
connection.sessionId,
connection.status,
heartbeatFailureThreshold,
heartbeatIntervalMs,
]);
const actions = useMemo<DaemonSessionActions>(
() =>
@ -1263,6 +1375,8 @@ export function DaemonSessionProvider({
pendingSessionLoadRef,
pendingSessionLoadIdRef,
heartbeatSupportedRef,
manualSessionClearRef,
skipNextCleanupDetachSessionIdRef,
passiveAssistantDoneTimerRef,
hasSessionActivePrompt: () =>
hasCurrentSessionActivePromptRef.current(),
@ -1273,18 +1387,69 @@ export function DaemonSessionProvider({
...createSessionRequestRef.current,
sessionScope: 'thread',
workspaceCwd:
resolvedWorkspaceCwdRef.current ?? sessionRef.current?.workspaceCwd,
activeWorkspaceCwdRef.current ?? sessionRef.current?.workspaceCwd,
}),
createDetachedSession: () => {
const client =
workspaceClientRef.current ??
new DaemonClient({
baseUrl: resolvedBaseUrl!,
token: resolvedToken,
});
const request = {
...createSessionRequestRef.current,
sessionScope: 'thread' as const,
workspaceCwd:
activeWorkspaceCwdRef.current ?? sessionRef.current?.workspaceCwd,
};
const requestClientId = clientId
? clientIdRef.current
: getStableClientId(undefined);
return DaemonSessionClient.createOrAttach(
client,
request,
requestClientId,
);
},
getConnection: () => connectionRef.current,
addNotice,
setConnection,
setPromptStatus,
setRestoreSessionId,
setRestoreMode,
setRestoreSessionNonce,
setAttachSessionNonce,
setNewSessionNonce,
}),
[addNotice, store],
[addNotice, clientId, resolvedBaseUrl, resolvedToken, store],
);
const lastHandledSessionIdRef = useRef<
string | undefined | typeof UNHANDLED_SESSION
>(UNHANDLED_SESSION);
useEffect(() => {
if (lastHandledSessionIdRef.current === sessionId) return;
lastHandledSessionIdRef.current = sessionId;
const currentSessionId = connectionRef.current.sessionId;
if (sessionId === currentSessionId) return;
const request = sessionId
? actions.loadSession(sessionId, { deferTranscriptReset: true })
: currentSessionId
? actions.clearSession()
: undefined;
if (!request) return;
void request.catch((error: unknown) => {
console.warn(
'[DaemonSessionProvider] controlled session transition failed:',
error,
);
});
}, [actions, sessionId]);
return (
<DaemonStoreContext.Provider value={store}>
<DaemonConnectionContext.Provider value={connection}>

View file

@ -0,0 +1,380 @@
import { describe, expect, it, vi } from 'vitest';
import type { DaemonSessionClient } from '@qwen-code/sdk/daemon';
import {
createDaemonSessionActions,
getConnectionAfterSessionClear,
} from './actions';
import type {
ActivePrompt,
DaemonConnectionState,
PendingSessionLoad,
SettledPrompt,
} from './types';
describe('getConnectionAfterSessionClear', () => {
it('clears session fields for the session being detached', () => {
const next = getConnectionAfterSessionClear(
{
status: 'disconnected',
workspaceCwd: '/workspace',
sessionId: 'session-a',
clientId: 'client-a',
displayName: 'Session A',
tokenCount: 42,
commands: [commandInfo('old-command')],
skills: ['old-skill'],
supportedCommands: supportedCommandsStatus('session-a'),
context: contextStatus('session-a'),
catchingUp: true,
error: 'old error',
} as DaemonConnectionState,
'session-a',
);
expect(next).toMatchObject({
status: 'connected',
workspaceCwd: '/workspace',
catchingUp: undefined,
error: undefined,
});
expect(next).not.toHaveProperty('sessionId');
expect(next).not.toHaveProperty('clientId');
expect(next).not.toHaveProperty('displayName');
expect(next).not.toHaveProperty('tokenCount');
expect(next).not.toHaveProperty('commands');
expect(next).not.toHaveProperty('skills');
expect(next).not.toHaveProperty('supportedCommands');
expect(next).not.toHaveProperty('context');
});
it('preserves a concurrently loaded session', () => {
const next = getConnectionAfterSessionClear(
{
status: 'connecting',
workspaceCwd: '/workspace',
sessionId: 'session-b',
clientId: 'client-b',
displayName: 'Session B',
tokenCount: 7,
commands: [commandInfo('new-command')],
skills: ['new-skill'],
supportedCommands: supportedCommandsStatus('session-b', 'new-command'),
context: contextStatus('session-b'),
catchingUp: true,
error: 'old error',
} as DaemonConnectionState,
'session-a',
);
expect(next).toMatchObject({
status: 'connected',
workspaceCwd: '/workspace',
sessionId: 'session-b',
clientId: 'client-b',
displayName: 'Session B',
tokenCount: 7,
commands: [commandInfo('new-command')],
skills: ['new-skill'],
supportedCommands: supportedCommandsStatus('session-b', 'new-command'),
context: contextStatus('session-b'),
catchingUp: undefined,
error: undefined,
});
});
});
describe('createDaemonSessionActions', () => {
it('creates from the active session client when the connection matches', async () => {
const existingSession = createMockSession('session-a');
const nextSession = createMockSession('session-b');
existingSession.client.createOrAttachSession.mockResolvedValue(nextSession);
const createDetachedSession = vi.fn();
const { actions } = createActionsHarness({
connection: { status: 'connected', sessionId: 'session-a' },
createDetachedSession,
session: existingSession,
});
await expect(actions.createSession()).resolves.toBe(nextSession);
expect(existingSession.client.createOrAttachSession).toHaveBeenCalledOnce();
expect(createDetachedSession).not.toHaveBeenCalled();
});
it('creates a detached session when no active session exists', async () => {
const nextSession = createMockSession('session-b');
const createDetachedSession = vi.fn(async () => nextSession);
const { actions, sessionRef, getConnection } = createActionsHarness({
connection: { status: 'connected' },
createDetachedSession,
});
await expect(actions.createSession()).resolves.toBe(nextSession);
expect(createDetachedSession).toHaveBeenCalledOnce();
expect(sessionRef.current).toBe(nextSession);
expect(getConnection()).toMatchObject({ sessionId: 'session-b' });
});
it('does not restore a detached session after the session was cleared', async () => {
const nextSession = createMockSession('session-b');
const deferred = createDeferred<DaemonSessionClient>();
const manualSessionClearRef = { current: false };
const createDetachedSession = vi.fn(() => deferred.promise);
const { actions, sessionRef, getConnection } = createActionsHarness({
connection: { status: 'connected' },
createDetachedSession,
manualSessionClearRef,
});
const createPromise = actions.createSession();
manualSessionClearRef.current = true;
deferred.resolve(nextSession as unknown as DaemonSessionClient);
await expect(createPromise).rejects.toMatchObject({
name: 'AbortError',
message: 'Session creation interrupted',
});
expect(nextSession.detach).toHaveBeenCalledOnce();
expect(sessionRef.current).toBeUndefined();
expect(getConnection()).not.toHaveProperty('sessionId');
});
it('creates a detached session when the ref and connection do not match', async () => {
const existingSession = createMockSession('session-a');
const nextSession = createMockSession('session-b');
const createDetachedSession = vi.fn(async () => nextSession);
const { actions } = createActionsHarness({
connection: { status: 'connected', sessionId: 'session-other' },
createDetachedSession,
session: existingSession,
});
await expect(actions.createSession()).resolves.toBe(nextSession);
expect(existingSession.client.createOrAttachSession).not.toHaveBeenCalled();
expect(createDetachedSession).toHaveBeenCalledOnce();
});
it('starts an attach session load and bumps the attach nonce', async () => {
const session = createMockSession('session-a');
const setAttachSessionNonce = vi.fn();
const { actions, pendingSessionLoadRef } = createActionsHarness({
session,
setAttachSessionNonce,
});
const attachPromise = actions.attachSession();
expect(pendingSessionLoadRef.current).toMatchObject({
id: 1,
sessionId: 'session-a',
mode: 'attach',
});
expect(setAttachSessionNonce).toHaveBeenCalledOnce();
const nonceUpdater = setAttachSessionNonce.mock.calls[0]?.[0];
expect(typeof nonceUpdater).toBe('function');
expect(nonceUpdater?.(1)).toBe(2);
clearTimeout(pendingSessionLoadRef.current?.timeout);
pendingSessionLoadRef.current?.resolve();
await expect(attachPromise).resolves.toBeUndefined();
});
it('reports attach timeouts as attach session failures', async () => {
vi.useFakeTimers();
try {
const session = createMockSession('session-a');
const addNotice = vi.fn((notice) => notice);
const { actions } = createActionsHarness({
addNotice,
session,
});
const attachPromise = actions.attachSession();
vi.advanceTimersByTime(30_000);
await expect(attachPromise).rejects.toThrow('Session attach timed out');
expect(addNotice).toHaveBeenCalledWith(
expect.objectContaining({
code: 'daemon.attach_session.failed',
operation: 'attach_session',
}),
);
} finally {
vi.useRealTimers();
}
});
it('rejects attachSession when no session exists', async () => {
const { actions } = createActionsHarness();
await expect(actions.attachSession()).rejects.toThrow(
'Daemon session is not connected',
);
});
it('aborts active prompts and rejects pending session loads when clearing', async () => {
const controller = new AbortController();
const session = createMockSession('session-a');
const pendingReject = vi.fn();
const pendingSessionLoadRef = {
current: {
id: 1,
sessionId: 'session-a',
mode: 'attach' as const,
timeout: setTimeout(() => undefined, 30_000),
resolve: vi.fn(),
reject: pendingReject,
},
};
const { actions } = createActionsHarness({
activePrompts: new Map([['session-a', { controller } as ActivePrompt]]),
pendingSessionLoadRef,
session,
});
await actions.clearSession();
expect(controller.signal.aborted).toBe(true);
expect(pendingReject).toHaveBeenCalledWith(
expect.objectContaining({
name: 'AbortError',
message: 'Session cleared',
}),
);
expect(pendingSessionLoadRef.current).toBeUndefined();
});
});
function createActionsHarness(
opts: {
activePrompts?: Map<string, ActivePrompt>;
addNotice?: ReturnType<typeof vi.fn>;
connection?: DaemonConnectionState;
createDetachedSession?: ReturnType<typeof vi.fn>;
manualSessionClearRef?: { current: boolean };
pendingSessionLoadRef?: { current: PendingSessionLoad | undefined };
session?: ReturnType<typeof createMockSession>;
setAttachSessionNonce?: ReturnType<typeof vi.fn>;
} = {},
) {
let connection: DaemonConnectionState = opts.connection ?? {
status: 'connected',
workspaceCwd: '/workspace',
};
const sessionRef = {
current: opts.session as unknown as DaemonSessionClient | undefined,
};
const activePromptsRef = {
current: opts.activePrompts ?? new Map<string, ActivePrompt>(),
};
const pendingSessionLoadRef =
opts.pendingSessionLoadRef ??
({ current: undefined } as {
current: PendingSessionLoad | undefined;
});
const actions = createDaemonSessionActions({
store: {
reset: vi.fn(),
appendLocalUserMessage: vi.fn(),
dispatch: vi.fn(),
} as never,
sessionRef,
activePromptsRef,
settledPromptsRef: { current: new Map<string, SettledPrompt>() },
pendingSessionLoadRef,
pendingSessionLoadIdRef: { current: 0 },
heartbeatSupportedRef: { current: false },
manualSessionClearRef: opts.manualSessionClearRef ?? { current: false },
skipNextCleanupDetachSessionIdRef: { current: undefined },
passiveAssistantDoneTimerRef: { current: undefined },
getCreateSessionRequest: () => ({ workspaceCwd: '/workspace' }),
createDetachedSession: (opts.createDetachedSession ??
vi.fn(
async () =>
createMockSession(
'detached-session',
) as unknown as DaemonSessionClient,
)) as () => Promise<DaemonSessionClient>,
getConnection: () => connection,
hasSessionActivePrompt: () => false,
resetCurrentSessionActivePrompt: vi.fn(),
addNotice: opts.addNotice ?? vi.fn(),
setConnection: (update) => {
connection = typeof update === 'function' ? update(connection) : update;
},
setPromptStatus: vi.fn(),
setRestoreSessionId: vi.fn(),
setRestoreMode: vi.fn(),
setRestoreSessionNonce: vi.fn(),
setAttachSessionNonce: opts.setAttachSessionNonce ?? vi.fn(),
setNewSessionNonce: vi.fn(),
});
return {
actions,
getConnection: () => connection,
pendingSessionLoadRef,
sessionRef,
};
}
function createMockSession(sessionId: string) {
return {
sessionId,
workspaceCwd: '/workspace',
clientId: `client-${sessionId}`,
client: {
createOrAttachSession: vi.fn(),
setSessionApprovalMode: vi.fn(),
listWorkspaceSessions: vi.fn(),
closeSession: vi.fn(),
},
detach: vi.fn(async () => undefined),
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
function commandInfo(name: string) {
const raw = commandRaw(name);
return {
name,
description: '',
raw,
};
}
function commandRaw(name: string) {
return {
name,
description: '',
input: null,
};
}
function supportedCommandsStatus(sessionId: string, ...names: string[]) {
return {
v: 1 as const,
sessionId,
availableCommands: names.map(commandRaw),
availableSkills: [],
};
}
function contextStatus(sessionId: string) {
return {
v: 1 as const,
sessionId,
workspaceCwd: '/workspace',
state: {},
};
}

View file

@ -53,8 +53,12 @@ export interface CreateDaemonSessionActionsArgs {
pendingSessionLoadRef: RefBox<PendingSessionLoad | undefined>;
pendingSessionLoadIdRef: RefBox<number>;
heartbeatSupportedRef: RefBox<boolean>;
manualSessionClearRef: RefBox<boolean>;
skipNextCleanupDetachSessionIdRef: RefBox<string | undefined>;
passiveAssistantDoneTimerRef: TimerRef;
getCreateSessionRequest: () => CreateSessionRequest;
createDetachedSession: () => Promise<DaemonSessionClient>;
getConnection: () => DaemonConnectionState;
hasSessionActivePrompt: () => boolean;
resetCurrentSessionActivePrompt: () => void;
addNotice: AddDaemonSessionNotice;
@ -63,9 +67,34 @@ export interface CreateDaemonSessionActionsArgs {
setRestoreSessionId: Dispatch<SetStateAction<string | undefined>>;
setRestoreMode: Dispatch<SetStateAction<'load' | 'resume'>>;
setRestoreSessionNonce: Dispatch<SetStateAction<number>>;
setAttachSessionNonce: Dispatch<SetStateAction<number>>;
setNewSessionNonce: Dispatch<SetStateAction<number>>;
}
export function getConnectionAfterSessionClear(
current: DaemonConnectionState,
clearedSessionId: string | undefined,
): DaemonConnectionState {
const next = { ...current };
if (!clearedSessionId || current.sessionId === clearedSessionId) {
delete next.sessionId;
delete next.clientId;
delete next.displayName;
delete next.tokenUsage;
delete next.tokenCount;
delete next.commands;
delete next.skills;
delete next.supportedCommands;
delete next.context;
}
return {
...next,
status: 'connected',
catchingUp: undefined,
error: undefined,
};
}
export function createDaemonSessionActions({
store,
sessionRef,
@ -74,8 +103,12 @@ export function createDaemonSessionActions({
pendingSessionLoadRef,
pendingSessionLoadIdRef,
heartbeatSupportedRef,
manualSessionClearRef,
skipNextCleanupDetachSessionIdRef,
passiveAssistantDoneTimerRef,
getCreateSessionRequest,
createDetachedSession,
getConnection,
hasSessionActivePrompt,
resetCurrentSessionActivePrompt,
addNotice,
@ -84,12 +117,37 @@ export function createDaemonSessionActions({
setRestoreSessionId,
setRestoreMode,
setRestoreSessionNonce,
setAttachSessionNonce,
setNewSessionNonce,
}: CreateDaemonSessionActionsArgs): DaemonSessionActions {
function startSessionSwitch(
function clearActiveSessionState() {
for (const [, active] of activePromptsRef.current) {
active.controller.abort();
}
activePromptsRef.current.clear();
settledPromptsRef.current.clear();
setPromptStatus('idle');
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
if (pendingSessionLoadRef.current) {
if (
skipNextCleanupDetachSessionIdRef.current ===
pendingSessionLoadRef.current.sessionId
) {
skipNextCleanupDetachSessionIdRef.current = undefined;
}
clearTimeout(pendingSessionLoadRef.current.timeout);
pendingSessionLoadRef.current.reject(
new DOMException('Session cleared', 'AbortError'),
);
pendingSessionLoadRef.current = undefined;
}
store.reset();
setRestoreSessionId(undefined);
}
function startPendingSessionLoad(
sessionId: string,
mode: 'load' | 'resume',
opts?: SessionSwitchOptions,
mode: PendingSessionLoad['mode'],
): Promise<void> {
const loadId = pendingSessionLoadIdRef.current + 1;
pendingSessionLoadIdRef.current = loadId;
@ -111,7 +169,7 @@ export function createDaemonSessionActions({
addNotice,
`${capitalize(mode)} session failed`,
new Error(`Session ${mode} timed out`),
mode === 'load' ? 'load_session' : 'resume_session',
getSessionLoadNoticeOperation(mode),
),
);
}
@ -125,6 +183,16 @@ export function createDaemonSessionActions({
reject,
};
});
return loadPromise;
}
function startSessionSwitch(
sessionId: string,
mode: 'load' | 'resume',
opts?: SessionSwitchOptions,
): Promise<void> {
manualSessionClearRef.current = false;
const loadPromise = startPendingSessionLoad(sessionId, mode);
const currentSessionId = sessionRef.current?.sessionId;
const activePrompt = currentSessionId
? activePromptsRef.current.get(currentSessionId)
@ -489,37 +557,96 @@ export function createDaemonSessionActions({
},
async createSession() {
try {
manualSessionClearRef.current = false;
const session = sessionRef.current;
const activeSession =
session && getConnection().sessionId === session.sessionId
? session
: undefined;
if (activeSession) {
const nextSession = await withActionTimeout(
activeSession.client.createOrAttachSession(
getCreateSessionRequest(),
),
'Create session timed out',
);
persistStableClientId(nextSession.clientId, nextSession.sessionId);
return nextSession;
}
const nextSession = await withActionTimeout(
createDetachedSession(),
'Create session timed out',
);
if (manualSessionClearRef.current) {
try {
await withActionTimeout(
nextSession.detach(),
'Detach cleared session timed out',
);
} catch (error) {
console.warn(
'[DaemonSessionActions] detach after interrupted create failed:',
error,
);
}
throw new DOMException('Session creation interrupted', 'AbortError');
}
persistStableClientId(nextSession.clientId, nextSession.sessionId);
sessionRef.current = nextSession;
skipNextCleanupDetachSessionIdRef.current = nextSession.sessionId;
setConnection((current) => ({
...current,
status: 'connected',
sessionId: nextSession.sessionId,
...(nextSession.clientId ? { clientId: nextSession.clientId } : {}),
workspaceCwd: nextSession.workspaceCwd,
error: undefined,
}));
return nextSession;
} catch (error) {
throw dispatchActionError(
addNotice,
'Create session failed',
error,
'create_session',
);
}
},
async attachSession() {
const session = requireSessionForAction(
addNotice,
sessionRef.current,
'Create session failed',
'create_session',
'Attach session failed',
'attach_session',
);
const nextSession = await withActionTimeout(
session.client.createOrAttachSession(getCreateSessionRequest()),
'Create session timed out',
const loadPromise = startPendingSessionLoad(session.sessionId, 'attach');
setAttachSessionNonce((nonce) => nonce + 1);
return loadPromise;
},
async clearSession() {
const session = sessionRef.current;
manualSessionClearRef.current = true;
clearActiveSessionState();
sessionRef.current = undefined;
setConnection((current) =>
getConnectionAfterSessionClear(current, session?.sessionId),
);
persistStableClientId(nextSession.clientId, nextSession.sessionId);
return nextSession;
if (session) {
try {
await withActionTimeout(session.detach(), 'Clear session timed out');
} catch (error) {
console.warn('[DaemonSessionActions] detach on clear failed:', error);
}
}
},
async newSession() {
for (const [, active] of activePromptsRef.current) {
active.controller.abort();
}
activePromptsRef.current.clear();
settledPromptsRef.current.clear();
setPromptStatus('idle');
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
if (pendingSessionLoadRef.current) {
clearTimeout(pendingSessionLoadRef.current.timeout);
pendingSessionLoadRef.current.reject(
new DOMException('New session requested', 'AbortError'),
);
pendingSessionLoadRef.current = undefined;
}
store.reset();
setRestoreSessionId(undefined);
manualSessionClearRef.current = false;
clearActiveSessionState();
setNewSessionNonce((nonce) => nonce + 1);
},
@ -1183,3 +1310,11 @@ function markNoticeDispatched(error: Error): Error {
function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}
function getSessionLoadNoticeOperation(
mode: PendingSessionLoad['mode'],
): DaemonNoticeOperation {
if (mode === 'resume') return 'resume_session';
if (mode === 'attach') return 'attach_session';
return 'load_session';
}

View file

@ -25,12 +25,14 @@ export function mapProviderStatus(
): {
models: DaemonModelInfo[];
currentModel?: string;
currentMode?: string;
contextWindow?: number;
} {
if (!status) return { models: [] };
const seen = new Set<string>();
const models: DaemonModelInfo[] = [];
let currentModel = preferredCurrentModel ?? status.current?.modelId;
const currentMode = status.approvalMode;
let contextWindow: number | undefined;
for (const provider of status.providers) {
@ -68,7 +70,7 @@ export function mapProviderStatus(
}
}
return { models, currentModel, contextWindow };
return { models, currentModel, currentMode, contextWindow };
}
export function mapSessionContextModels(

View file

@ -92,8 +92,8 @@ export interface DaemonSessionProviderProps {
token?: string;
/** Workspace cwd used when creating, loading, or resuming daemon sessions. */
workspaceCwd?: string;
/** Session id to load on mount instead of creating or attaching automatically. */
initialSessionId?: string;
/** Session id to load. Undefined keeps the page empty until a prompt creates one. */
sessionId?: string;
/** Stable client identity to reuse for session-scoped daemon requests. */
clientId?: string;
/** Extra create-session options, excluding workspaceCwd which is owned by the provider. */
@ -110,8 +110,6 @@ export interface DaemonSessionProviderProps {
autoConnect?: boolean;
/** Reconnect automatically after recoverable daemon/session failures. */
autoReconnect?: boolean;
/** Behavior when the active session is missing (404/410). Defaults to create. */
missingSessionBehavior?: 'create' | 'disconnect';
/** Initial reconnect delay in milliseconds. */
reconnectDelayMs?: number;
/** Maximum reconnect delay in milliseconds after backoff. */
@ -152,6 +150,7 @@ export type DaemonNoticeOperation =
| 'set_approval_mode'
| 'submit_permission'
| 'cancel_prompt'
| 'attach_session'
| 'load_session'
| 'resume_session'
| 'create_session'
@ -311,7 +310,13 @@ export interface DaemonSessionActions {
}): Promise<DaemonSessionSummary[]>;
loadSession(sessionId: string, opts?: SessionSwitchOptions): Promise<void>;
resumeSession(sessionId: string, opts?: SessionSwitchOptions): Promise<void>;
/**
* Create a daemon session and update local session state. Callers that need
* transcript/event streaming must follow with `attachSession()`.
*/
createSession(): Promise<DaemonSession>;
attachSession(): Promise<void>;
clearSession(): Promise<void>;
newSession(): Promise<void>;
releaseSession(sessionId: string): Promise<void>;
closeSession(): Promise<void>;
@ -413,7 +418,7 @@ export type SettledPrompt =
export interface PendingSessionLoad {
id: number;
sessionId: string;
mode: 'load' | 'resume';
mode: 'load' | 'resume' | 'attach';
timeout: ReturnType<typeof setTimeout>;
resolve: () => void;
reject: (error: unknown) => void;