mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
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:
parent
487fb45510
commit
1467ed3100
24 changed files with 2328 additions and 450 deletions
|
|
@ -450,6 +450,7 @@ export interface ServeWorkspaceProvidersStatus {
|
|||
initialized: boolean;
|
||||
acpChannelLive?: boolean;
|
||||
current?: ServeWorkspaceProviderCurrent;
|
||||
approvalMode?: string;
|
||||
providers: ServeWorkspaceProviderStatus[];
|
||||
errors?: ServeStatusCell[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -501,6 +501,7 @@ export interface DaemonWorkspaceProvidersStatus {
|
|||
initialized: boolean;
|
||||
acpChannelLive?: boolean;
|
||||
current?: DaemonWorkspaceProviderCurrent;
|
||||
approvalMode?: DaemonApprovalMode;
|
||||
providers: DaemonWorkspaceProviderStatus[];
|
||||
errors?: DaemonStatusCell[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 语言后触发 |
|
||||
|
||||
## 架构说明
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
|
|
|
|||
186
packages/web-shell/client/utils/sessionPreparation.test.ts
Normal file
186
packages/web-shell/client/utils/sessionPreparation.test.ts
Normal 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 };
|
||||
}
|
||||
58
packages/web-shell/client/utils/sessionPreparation.ts
Normal file
58
packages/web-shell/client/utils/sessionPreparation.ts
Normal 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(),
|
||||
]);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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}>
|
||||
|
|
|
|||
380
packages/webui/src/daemon/session/actions.test.ts
Normal file
380
packages/webui/src/daemon/session/actions.test.ts
Normal 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: {},
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue