diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 0eac9edd4f..c76da2c435 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -450,6 +450,7 @@ export interface ServeWorkspaceProvidersStatus { initialized: boolean; acpChannelLive?: boolean; current?: ServeWorkspaceProviderCurrent; + approvalMode?: string; providers: ServeWorkspaceProviderStatus[]; errors?: ServeStatusCell[]; } diff --git a/packages/cli/src/serve/workspace-providers-status.test.ts b/packages/cli/src/serve/workspace-providers-status.test.ts index 289501a51c..799a960e11 100644 --- a/packages/cli/src/serve/workspace-providers-status.test.ts +++ b/packages/cli/src/serve/workspace-providers-status.test.ts @@ -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({ diff --git a/packages/cli/src/serve/workspace-providers-status.ts b/packages/cli/src/serve/workspace-providers-status.ts index c01e23af1d..e8f2109322 100644 --- a/packages/cli/src/serve/workspace-providers-status.ts +++ b/packages/cli/src/serve/workspace-providers-status.ts @@ -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(); 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; diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 9587804d41..04db97792b 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -2717,6 +2717,28 @@ export class DaemonClient { ); } + async detachSession(sessionId: string, clientId?: string): Promise { + 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, diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index 9e451b6ed1..5b1aaf9f7b 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -590,6 +590,10 @@ export class DaemonSessionClient { return await this.client.closeSession(this.sessionId, this.clientId); } + async detach(): Promise { + return await this.client.detachSession(this.sessionId, this.clientId); + } + async updateMetadata(metadata: { displayName?: string; }): Promise { diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 5fe1ea7ef8..d1b4464a4d 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -501,6 +501,7 @@ export interface DaemonWorkspaceProvidersStatus { initialized: boolean; acpChannelLive?: boolean; current?: DaemonWorkspaceProviderCurrent; + approvalMode?: DaemonApprovalMode; providers: DaemonWorkspaceProviderStatus[]; errors?: DaemonStatusCell[]; } diff --git a/packages/web-shell/README.md b/packages/web-shell/README.md index deb7db0830..310696c0b5 100644 --- a/packages/web-shell/README.md +++ b/packages/web-shell/README.md @@ -43,7 +43,7 @@ export function QwenCodePanel() { { console.log('current session:', sessionId); }} @@ -69,7 +69,7 @@ import { WebShell } from '@qwen-code/web-shell'; export function App() { return ( - + @@ -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 语言后触发 | ## 架构说明 diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 39f57d8df6..38cc1eb125 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -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; + closeSession: () => Promise; + clearSession: () => Promise; }; const emptyComposerApi: WebShellComposerApi = { @@ -501,17 +506,6 @@ function assignComposerRef( (ref as React.MutableRefObject).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(null); - const activeSessionIdRef = useRef(connection.sessionId); + const currentSessionIdRef = useRef(connection.sessionId); + const lastNotifiedSessionIdRef = useRef(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 - )(text, promptOptions); - }, - [clearFollowup, sessionActions], - ); const streamingState = useStreamingState(); const streamingStateRef = useRef(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 | 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 + )(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') } diff --git a/packages/web-shell/client/components/ChatEditor.module.css b/packages/web-shell/client/components/ChatEditor.module.css index 20efebfd7a..0891125d39 100644 --- a/packages/web-shell/client/components/ChatEditor.module.css +++ b/packages/web-shell/client/components/ChatEditor.module.css @@ -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; diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index 250fac362e..6fa6d13b1d 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -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