diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index d8f77bd155..61a9616112 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -1984,6 +1984,97 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('ignores client retry when no turn_error made the session retryable', async () => { + const handle = makeChannel(); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await bridge.sendPrompt(session.sessionId, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'spoof retry' }], + retry: true, + } as PromptRequest); + + expect(handle.agent.promptCalls[0]).not.toHaveProperty('retry'); + expect(handle.agent.promptCalls[0]?._meta?.['qwen.daemon.retry']).toBe( + undefined, + ); + await bridge.shutdown(); + }); + + it('strips client-spoofed retry metadata without a turn_error', async () => { + const handle = makeChannel(); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await bridge.sendPrompt(session.sessionId, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'spoof retry meta' }], + _meta: { 'qwen.daemon.retry': true }, + } as PromptRequest); + + expect(handle.agent.promptCalls[0]?._meta?.['qwen.daemon.retry']).toBe( + undefined, + ); + await bridge.shutdown(); + }); + + it('honors retry once after a turn_error', async () => { + let calls = 0; + const handle = makeChannel({ + promptImpl: () => { + calls += 1; + if (calls === 1) throw new Error('temporary failure'); + return { stopReason: 'end_turn' }; + }, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + const turnError = (async () => { + for await (const event of iter) { + if (event.type === 'turn_error') return event; + } + throw new Error('turn_error was not published'); + })(); + + await expect( + bridge.sendPrompt(session.sessionId, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'first' }], + }), + ).rejects.toThrow(); + await turnError; + + await bridge.sendPrompt(session.sessionId, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'retry' }], + retry: true, + } as PromptRequest); + + expect(handle.agent.promptCalls[1]?._meta).toHaveProperty( + 'qwen.daemon.retry', + true, + ); + + await bridge.sendPrompt(session.sessionId, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'second spoof' }], + retry: true, + } as PromptRequest); + + expect(handle.agent.promptCalls[2]).not.toHaveProperty('retry'); + expect(handle.agent.promptCalls[2]?._meta?.['qwen.daemon.retry']).toBe( + undefined, + ); + + abort.abort(); + await bridge.shutdown(); + }); + it('echoes user_message_chunk to ALL session subscribers (cross-client sync)', async () => { // Cross-client sync fix: a prompt sent by client A must be visible // to every SSE subscriber of the same session — not just the diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 2576bd87de..91cb27a0d9 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -299,6 +299,7 @@ interface SessionEntry { * an originator clientId is known. Used by the session reaper to avoid * killing sessions mid-prompt. */ promptActive: boolean; + retryAllowed: boolean; /** * Per-prompt "already broadcast `prompt_cancelled`" latch. The explicit * `cancelSession` route and the `sendPrompt` abort path (originator SSE @@ -605,6 +606,7 @@ function broadcastTurnError( ): void { const message = extractErrorMessage(err); const code = extractErrorCode(err); + entry.retryAllowed = true; entry.events.publish({ type: 'turn_error', data: { @@ -631,6 +633,7 @@ const DEFAULT_INIT_TIMEOUT_MS = 10_000; const PERSIST_TIMEOUT_MS = 5_000; const MCP_RESTART_TIMEOUT_MS = 300_000; const MCP_OAUTH_TIMEOUT_MS = 600_000; +const DAEMON_RETRY_META_KEY = 'qwen.daemon.retry'; /** * Backstop timeout for `qwen/control/session/recap`. The underlying * side-query is single-attempt with `maxOutputTokens: 300`, so a @@ -1984,6 +1987,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { attachCount: 0, spawnOwnerWantedKill: false, promptActive: false, + retryAllowed: false, }; ci.sessionIds.add(entry.sessionId); byId.set(entry.sessionId, entry); @@ -2702,6 +2706,30 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { if (signal?.aborted) { throw new DOMException('Prompt aborted', 'AbortError'); } + const requestedRetry = + (req as unknown as { retry?: unknown }).retry === true; + const isRetry = requestedRetry && entry.retryAllowed; + entry.retryAllowed = false; + const promptRequest = (() => { + const copy = { + ...normalized, + } as PromptRequest & { retry?: unknown }; + delete copy.retry; + const meta = + copy._meta && typeof copy._meta === 'object' + ? { ...copy._meta } + : {}; + delete meta[DAEMON_RETRY_META_KEY]; + if (isRetry) { + meta[DAEMON_RETRY_META_KEY] = true; + } + if (Object.keys(meta).length > 0) { + copy._meta = meta; + } else { + delete copy._meta; + } + return copy; + })(); entry.promptActive = true; entry.sessionLastSeenAt = Date.now(); if (originatorClientId === undefined) { @@ -2728,15 +2756,24 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // Multi-modal: one envelope per content block. Non-text blocks // pass through verbatim (the agent's Core multimodal echo is a // for now the common text path is the immediate fix. + // + // Retry: skip echo — the original user_message_chunk is already + // in the transcript from the first attempt. entry.cancelBroadcast = false; - echoPromptToSessionBus(entry, normalized, originatorClientId); + if (!isRetry) { + echoPromptToSessionBus( + entry, + promptRequest, + originatorClientId, + ); + } } catch (echoErr) { entry.promptActive = false; delete entry.activePromptOriginatorClientId; throw echoErr; } const promptPromise = entry.connection - .prompt(normalized) + .prompt(promptRequest) .finally(() => { entry.promptActive = false; entry.sessionLastSeenAt = Date.now(); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index f6320902a2..1fa2963bbc 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -151,6 +151,8 @@ import { } from './rewrite/index.js'; const debugLogger = createDebugLogger('SESSION'); +const USER_CANCEL_ABORT_REASON = 'qwen:user-cancel'; +const DAEMON_RETRY_META_KEY = 'qwen.daemon.retry'; function maskApiKeyForDisplay(apiKey: string | undefined): string { const trimmed = apiKey?.trim() ?? ''; @@ -718,7 +720,7 @@ export class Session implements SessionContext { } if (this.pendingPrompt) { - this.pendingPrompt.abort(); + this.pendingPrompt.abort(USER_CANCEL_ABORT_REASON); this.pendingPrompt = null; } @@ -979,10 +981,23 @@ export class Session implements SessionContext { ), ); - // record user message for session management - this.config - .getChatRecordingService() - ?.recordUserMessage(promptText); + // Retry: strip orphaned user entries so the model sees a clean + // history (no dangling user message from the failed attempt). + // Also skip recordUserMessage to avoid duplicating the user + // turn in the JSONL transcript. + const isRetry = + (params as { retry?: boolean }).retry === true || + (params as { _meta?: Record })._meta?.[ + DAEMON_RETRY_META_KEY + ] === true; + if (isRetry) { + this.#getCurrentChat().stripOrphanedUserEntriesFromHistory(); + } else { + // record user message for session management + this.config + .getChatRecordingService() + ?.recordUserMessage(promptText); + } // Check if the input contains a slash command // Extract text from the first text block if present @@ -1191,6 +1206,17 @@ export class Session implements SessionContext { } } } catch (error) { + // Only explicit user cancellation maps to a normal + // cancelled turn. Other aborts/errors should surface so + // infra failures are not hidden as successful cancels. + if ( + pendingSend.signal.aborted && + pendingSend.signal.reason === USER_CANCEL_ABORT_REASON && + this.#isAbortError(error) + ) { + return { stopReason: 'cancelled' }; + } + // Fire StopFailure hook (fire-and-forget, replaces Stop event for API errors) // Aligned with useGeminiStream.ts handleFinishedWithErrorEvent const errorStatus = getErrorStatus(error); diff --git a/packages/cli/src/serve/routes/workspaceSettings.ts b/packages/cli/src/serve/routes/workspaceSettings.ts index 03f06baa6f..af5fdc4e89 100644 --- a/packages/cli/src/serve/routes/workspaceSettings.ts +++ b/packages/cli/src/serve/routes/workspaceSettings.ts @@ -27,12 +27,14 @@ const TUI_ONLY_SETTINGS = new Set([ 'ide.enabled', 'ui.showLineNumbers', 'ui.renderMode', - 'ui.compactMode', 'ui.useTerminalBuffer', 'ui.hideBanner', 'ui.accessibility.enableLoadingPhrases', + 'ui.enableWelcomeBack', ]); +const WEB_SHELL_SETTINGS = new Set(['ui.compactMode']); + const VALID_WRITE_SCOPES = new Set(['workspace']); const MAX_STRING_VALUE_LENGTH = 1024; @@ -65,11 +67,15 @@ interface SettingsResponse { const SECURITY_SENSITIVE_SETTINGS = new Set(['tools.approvalMode']); function getAllowedKeys(): Set { - return new Set( + const keys = new Set( getDialogSettingKeys().filter( (k) => !TUI_ONLY_SETTINGS.has(k) && !SECURITY_SENSITIVE_SETTINGS.has(k), ), ); + for (const key of WEB_SHELL_SETTINGS) { + keys.add(key); + } + return keys; } function buildSettingsResponse( diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 005581e30e..8d94c4fd64 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -10,6 +10,7 @@ import { useActions, useConnection, useDaemonFollowupSuggestion, + useSettings, useSessionNotices, useStreamingState, useTranscriptBlocks, @@ -61,11 +62,9 @@ import { SETTINGS_ACTIVE_EVENT, SettingsMessage, } from './components/messages/SettingsMessage'; +import { resolveShellOutputMaxLines } from './components/messages/ToolGroup'; import { HelpDialog } from './components/dialogs/HelpDialog'; -import { - ThemeDialog, - type WebShellTheme, -} from './components/dialogs/ThemeDialog'; +import { ThemeDialog } from './components/dialogs/ThemeDialog'; import { DeleteSessionDialog } from './components/dialogs/DeleteSessionDialog'; import { ReleaseSessionDialog } from './components/dialogs/ReleaseSessionDialog'; import { getLocalCommands } from './constants/localCommands'; @@ -77,6 +76,7 @@ import { useShallowMemo, useStableArray } from './hooks/useShallowMemo'; import { I18nProvider, getTranslator, + languageSettingToWebShellLanguage, languageLabel, normalizeLanguage, type WebShellLanguage, @@ -125,6 +125,13 @@ import type { } from './adapters/types'; import { extractTodosFromToolCall, hasActiveTodos } from './utils/todos'; import { ThemeProvider } from './themeContext'; +import { + WebShellThemeId, + THEME_SETTING_KEY, + LANGUAGE_SETTING_KEY, + themeSettingToWebShellTheme, + type WebShellTheme, +} from './themeContext'; import { WebShellCustomizationProvider, type WebShellMarkdownCustomization, @@ -140,26 +147,8 @@ const MODES_CYCLE = DAEMON_APPROVAL_MODES; const MAX_DISPLAYED_QUEUED_PROMPTS = 3; const MAX_QUEUED_PROMPT_PREVIEW_CHARS = 240; const MAX_TOASTS = 4; -const COMPACT_MODE_STORAGE_KEY = 'web-shell:compact-mode'; - -function loadCompactMode(): boolean { - try { - return window.localStorage.getItem(COMPACT_MODE_STORAGE_KEY) === 'true'; - } catch { - return false; - } -} - -function saveCompactMode(enabled: boolean) { - try { - window.localStorage.setItem( - COMPACT_MODE_STORAGE_KEY, - enabled ? 'true' : 'false', - ); - } catch { - // Ignore storage failures in private browsing or restricted contexts. - } -} +const COMPACT_MODE_SETTING_KEY = 'ui.compactMode'; +const HIDE_TIPS_SETTING_KEY = 'ui.hideTips'; function normalizeHiddenCommand(command: string): string { return command.trim().replace(/^\/+/, '').toLowerCase(); @@ -187,6 +176,7 @@ interface QueuedPrompt { id: number; text: string; images?: PromptImage[]; + onComplete?: () => void; } interface ActiveGoalStatus { @@ -217,7 +207,7 @@ export interface WebShellProps { /** Called whenever the attached daemon session id changes. */ onSessionIdChange?: (sessionId: string) => void; /** Visual theme for the embedded shell. Defaults to the dark terminal skin. */ - theme?: 'dark' | 'light'; + theme?: WebShellTheme; /** Called when `/theme` changes the web-shell theme. */ onThemeChange?: (theme: WebShellTheme) => void; /** UI language for the Web terminal. Defaults to `?language=` or browser language. */ @@ -548,7 +538,7 @@ function QueuedPromptDisplay({ export function App({ onSessionIdChange, - theme: providedTheme = 'dark', + theme: providedTheme, onThemeChange, language: providedLanguage, onLanguageChange, @@ -702,18 +692,33 @@ export function App({ ( text: string, images?: PromptImage[], - opts?: { optimisticUserMessage?: boolean }, + 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); return sessionActions.sendPrompt(text, { images, optimisticUserMessage: opts?.optimisticUserMessage, + retry: opts?.retry, }); }, [clearFollowup, sessionActions], ); const streamingState = useStreamingState(); const streamingStateRef = useRef(streamingState); + const lastSubmittedPromptRef = useRef(''); + const lastSubmittedImagesRef = useRef(undefined); + const retryableTurnErrorIdRef = useRef(null); + const retriedTurnErrorIdRef = useRef(null); + const [showRetryHint, setShowRetryHint] = useState(false); + const showRetryHintRef = useRef(showRetryHint); + showRetryHintRef.current = showRetryHint; const connected = connection.status === 'connected'; const [loadedSkills, setLoadedSkills] = useState([]); useEffect(() => { @@ -765,8 +770,9 @@ export function App({ const modelPanelActive = usePanelActive(MODEL_ACTIVE_EVENT); const settingsPanelActive = usePanelActive(SETTINGS_ACTIVE_EVENT); const authPanelActive = usePanelActive(AUTH_ACTIVE_EVENT); - const [selectedTheme, setSelectedTheme] = - useState(providedTheme); + const [selectedTheme, setSelectedTheme] = useState( + providedTheme ?? WebShellThemeId.Dark, + ); const [currentModel, setCurrentModel] = useState(''); const currentModelRef = useRef(currentModel); currentModelRef.current = currentModel; @@ -785,8 +791,16 @@ export function App({ showHelpDialog || showThemeDialog || showToolsDialog; + const inlinePanelOpen = + approvalModeInlineOpen || + authInlineOpen || + agentsInlineMode !== null || + memoryInlineOpen || + modelInlineMode !== null || + settingsInlineOpen; const bottomHidden = dialogOpen || + inlinePanelOpen || approvalModePanelActive || mcpPanelActive || tasksPanelActive || @@ -986,18 +1000,22 @@ export function App({ queuedPromptsRef.current = queuedPrompts; }, [queuedPrompts]); - const enqueuePrompt = useCallback((text: string, images?: PromptImage[]) => { - const trimmed = text.trim(); - if (!trimmed) return true; - const nextPrompt: QueuedPrompt = { - id: nextQueuedPromptIdRef.current++, - text: trimmed, - images: images ? [...images] : undefined, - }; - queuedPromptsRef.current = [...queuedPromptsRef.current, nextPrompt]; - setQueuedPrompts(queuedPromptsRef.current); - return true; - }, []); + const enqueuePrompt = useCallback( + (text: string, images?: PromptImage[], onComplete?: () => void) => { + const trimmed = text.trim(); + if (!trimmed) return true; + const nextPrompt: QueuedPrompt = { + id: nextQueuedPromptIdRef.current++, + text: trimmed, + images: images ? [...images] : undefined, + onComplete, + }; + queuedPromptsRef.current = [...queuedPromptsRef.current, nextPrompt]; + setQueuedPrompts(queuedPromptsRef.current); + return true; + }, + [], + ); const popNextQueuedPrompt = useCallback((): QueuedPrompt | null => { const [nextPrompt, ...rest] = queuedPromptsRef.current; @@ -1023,10 +1041,6 @@ export function App({ return true; }, [store, t]); - useEffect(() => { - setSelectedTheme(providedTheme); - }, [providedTheme]); - const handleThemeChange = useCallback( (nextTheme: WebShellTheme) => { setSelectedTheme(nextTheme); @@ -1035,20 +1049,109 @@ export function App({ [onThemeChange], ); - useEffect(() => { - if (providedLanguage !== undefined) { - setSelectedLanguage(normalizeLanguage(providedLanguage)); - } - }, [providedLanguage]); + const handleLanguageChange = useCallback( + (nextLanguage: WebShellLanguage) => { + setSelectedLanguage(nextLanguage); + onLanguageChange?.(nextLanguage); + }, + [onLanguageChange], + ); const handleToggleShortcuts = useCallback(() => { setShowShortcuts((prev) => !prev); }, []); - const [compactMode, setCompactMode] = useState(loadCompactMode); + const workspaceSettingsState = useSettings({ + autoLoad: true, + }); + const { + settings: workspaceSettings, + setValue: setWorkspaceSetting, + reload: reloadWorkspaceSettings, + } = workspaceSettingsState; + const compactModeSetting = workspaceSettings.find( + (setting) => setting.key === COMPACT_MODE_SETTING_KEY, + ); + const themeSetting = workspaceSettings.find( + (setting) => setting.key === THEME_SETTING_KEY, + ); + const hideTipsSetting = workspaceSettings.find( + (setting) => setting.key === HIDE_TIPS_SETTING_KEY, + ); + const languageSetting = workspaceSettings.find( + (setting) => setting.key === LANGUAGE_SETTING_KEY, + ); + const shellOutputMaxLines = resolveShellOutputMaxLines(workspaceSettings); + const [compactMode, setCompactMode] = useState(false); const compactModeRef = useRef(compactMode); compactModeRef.current = compactMode; + useEffect(() => { + const value = compactModeSetting?.values.effective; + if (typeof value === 'boolean') { + setCompactMode(value); + } + }, [compactModeSetting?.values.effective]); + + useEffect(() => { + if (providedTheme) { + setSelectedTheme(providedTheme); + return; + } + const settingTheme = themeSettingToWebShellTheme( + themeSetting?.values.effective, + ); + if (settingTheme) { + setSelectedTheme(settingTheme); + } + }, [providedTheme, themeSetting?.values.effective]); + + useEffect(() => { + if (providedLanguage !== undefined) { + setSelectedLanguage(normalizeLanguage(providedLanguage)); + return; + } + const settingLanguage = languageSettingToWebShellLanguage( + languageSetting?.values.effective, + ); + if (settingLanguage) { + setSelectedLanguage(settingLanguage); + } + }, [providedLanguage, languageSetting?.values.effective]); + + const handleSettingsLanguageChange = useCallback( + (nextLanguage: WebShellLanguage) => { + const previousLanguage = selectedLanguage; + const command = `/language ui ${nextLanguage}`; + handleLanguageChange(nextLanguage); + const refreshSettings = () => { + return Promise.all([ + sessionActions.refreshCommands(), + reloadWorkspaceSettings(), + ]); + }; + if (streamingStateRef.current !== 'idle') { + enqueuePrompt(command, undefined, refreshSettings); + return; + } + sendPrompt(command) + .then(refreshSettings) + .catch((error: unknown) => { + handleLanguageChange(previousLanguage); + reportError(error, 'Failed to sync /language command'); + }); + }, + [ + enqueuePrompt, + handleLanguageChange, + reloadWorkspaceSettings, + reportError, + sendPrompt, + selectedLanguage, + sessionActions, + ], + ); + const handleClearScreen = useCallback(() => { if (streamingStateRef.current !== 'idle') { store.dispatch([{ type: 'status', text: t('clear.blocked') }]); @@ -1058,10 +1161,16 @@ export function App({ }, [store, t]); const handleToggleCompact = useCallback(() => { + const previous = compactModeRef.current; const next = !compactModeRef.current; setCompactMode(next); - saveCompactMode(next); - }, []); + setWorkspaceSetting('workspace', COMPACT_MODE_SETTING_KEY, next).catch( + (error: unknown) => { + setCompactMode(previous); + reportError(error, t('compact.saveFailed')); + }, + ); + }, [reportError, setWorkspaceSetting, t]); const handleSetMode = useCallback( (modeId: string) => { @@ -1121,6 +1230,26 @@ export function App({ streamingStateRef.current = streamingState; }, [streamingState]); + useEffect(() => { + let retryableTurnErrorId: string | null = null; + for (let i = blocks.length - 1; i >= 0; i--) { + const block = blocks[i]; + if (block?.kind === 'user') break; + if (block?.kind === 'error' && block.source === 'turn_error') { + retryableTurnErrorId = block.id; + break; + } + if (block?.kind !== 'debug') break; + } + const canRetry = + connected && + retryableTurnErrorId !== null && + retryableTurnErrorId !== retriedTurnErrorIdRef.current && + lastSubmittedPromptRef.current.length > 0; + retryableTurnErrorIdRef.current = canRetry ? retryableTurnErrorId : null; + setShowRetryHint(canRetry); + }, [blocks, connected]); + useEffect(() => { onStreamingStateChange?.(streamingState); }, [streamingState, onStreamingStateChange]); @@ -1376,6 +1505,15 @@ export function App({ ], ); + const hiddenCommands = useMemo( + () => + new Set( + (hiddenSlashCommands ?? []).map(normalizeHiddenCommand).filter(Boolean), + ), + [hiddenSlashCommands], + ); + const hideSettings = hiddenCommands.has('settings'); + const handleSubmit = useCallback( (text: string, images?: PromptImage[]) => { const promptBlocked = streamingStateRef.current !== 'idle'; @@ -1468,8 +1606,7 @@ export function App({ return true; } const nextLanguage = normalizeLanguage(languageArg); - setSelectedLanguage(nextLanguage); - onLanguageChange?.(nextLanguage); + handleLanguageChange(nextLanguage); if (!promptBlocked) { sendPrompt(`/language ui ${nextLanguage}`) .then(() => sessionActions.refreshCommands()) @@ -1674,6 +1811,11 @@ export function App({ return true; } if (cmd === 'settings') { + if (hideSettings) { + store.appendLocalUserMessage(text); + store.dispatch([{ type: 'status', text: t('command.hidden') }]); + return true; + } store.appendLocalUserMessage(text); setSettingsInlineOpen(true); return true; @@ -1958,7 +2100,8 @@ export function App({ handleGoalSlashCommand, handleThemeChange, handleSetMode, - onLanguageChange, + handleLanguageChange, + hideSettings, pushToast, reportError, runVisibleRecap, @@ -1982,14 +2125,25 @@ export function App({ if (!nextPrompt) return; drainingQueueRef.current = true; + let sent = false; const timer = window.setTimeout(() => { + sent = true; try { handleSubmit(nextPrompt.text, nextPrompt.images); + nextPrompt.onComplete?.(); } finally { drainingQueueRef.current = false; } }, 0); return () => { + if (!sent) { + // Cleanup ran before timeout fired — put the prompt back at the + // front of the queue so it's not lost. This can happen when any + // dependency (e.g. handleSubmit, streamingState) changes between + // popNextQueuedPrompt() and the setTimeout firing. + queuedPromptsRef.current = [nextPrompt, ...queuedPromptsRef.current]; + setQueuedPrompts(queuedPromptsRef.current); + } window.clearTimeout(timer); drainingQueueRef.current = false; }; @@ -2032,6 +2186,30 @@ export function App({ } editorRef.current?.focus(); }, []); + + const handleRetry = useCallback(() => { + if ( + showRetryHintRef.current && + connected && + streamingStateRef.current === 'idle' && + retryableTurnErrorIdRef.current && + lastSubmittedPromptRef.current + ) { + retriedTurnErrorIdRef.current = retryableTurnErrorIdRef.current; + setShowRetryHint(false); + sendPrompt( + lastSubmittedPromptRef.current, + lastSubmittedImagesRef.current, + { + optimisticUserMessage: false, + retry: true, + }, + ).catch((error: unknown) => reportError(error, 'Failed to retry prompt')); + } else { + store.dispatch([{ type: 'status', text: t('retry.none') }]); + } + }, [connected, sendPrompt, reportError, store, t]); + useEffect(() => { const onGlobalShortcut = (e: KeyboardEvent) => { if (bottomHidden) return; @@ -2048,14 +2226,21 @@ export function App({ } if (e.key === 'y') { e.preventDefault(); - editorRef.current?.retryLast(); + handleRetry(); return; } } }; window.addEventListener('keydown', onGlobalShortcut, true); return () => window.removeEventListener('keydown', onGlobalShortcut, true); - }, [bottomHidden, handleClearScreen, handleToggleCompact]); + }, [ + bottomHidden, + handleClearScreen, + handleToggleCompact, + handleRetry, + store, + t, + ]); useEffect(() => { const resetEscapeState = () => { @@ -2180,11 +2365,10 @@ export function App({ const commands = useMemo(() => { const skillNames = new Set(connection.skills ?? []); - const hidden = new Set( - (hiddenSlashCommands ?? []).map(normalizeHiddenCommand).filter(Boolean), - ); return mergeCommands(connection.commands ?? [], getLocalCommands(t)) - .filter((command) => !hidden.has(normalizeHiddenCommand(command.name))) + .filter( + (command) => !hiddenCommands.has(normalizeHiddenCommand(command.name)), + ) .map((command) => { if (!skillNames.has(command.name)) return command; return { @@ -2193,7 +2377,7 @@ export function App({ description: command.description || t('skills.run'), }; }); - }, [connection.commands, connection.skills, hiddenSlashCommands, t]); + }, [connection.commands, connection.skills, hiddenCommands, t]); const welcomeHeaderProps = useMemo( () => ({ @@ -2201,12 +2385,14 @@ export function App({ cwd: connection.workspaceCwd || '', currentModel, currentMode, + hideTips: hideTipsSetting?.values.effective === true, }), [ connection.capabilities?.qwenCodeVersion, connection.workspaceCwd, currentModel, currentMode, + hideTipsSetting?.values.effective, ], ); @@ -2222,7 +2408,9 @@ export function App({ const appClassName = [ styles.app, - selectedTheme === 'light' ? styles.themeLight : styles.themeDark, + selectedTheme === WebShellThemeId.Light + ? styles.themeLight + : styles.themeDark, externalClassName, ] .filter(Boolean) @@ -2326,6 +2514,9 @@ export function App({ onShowContextDetail={handleShowContextDetail} catchingUp={connection.catchingUp} workspaceCwd={connection.workspaceCwd || ''} + shellOutputMaxLines={shellOutputMaxLines} + showRetryHint={showRetryHint} + onRetryClick={handleRetry} welcomeHeader={welcomeHeader} tailContent={ agentsInlineMode || @@ -2388,11 +2579,13 @@ export function App({ )} {settingsInlineOpen && ( setSettingsInlineOpen(false)} + onLanguageChange={handleSettingsLanguageChange} + onThemeChange={handleThemeChange} onSubDialog={(key) => { setSettingsInlineOpen(false); - if (key === 'ui.theme') setShowThemeDialog(true); - else if (key === 'fastModel') + if (key === 'fastModel') setModelInlineMode('fast'); else if (key === 'tools.approvalMode') setApprovalModeInlineOpen(true); @@ -2514,6 +2707,7 @@ export function App({ onReturnToInput={handleReturnToEditor} taskActivityKey={backgroundTaskActivityKey} activeGoal={activeGoal} + hideSettings={hideSettings} /> ))} diff --git a/packages/web-shell/client/adapters/messageTypes.ts b/packages/web-shell/client/adapters/messageTypes.ts index be61eaf9ea..77404d94b0 100644 --- a/packages/web-shell/client/adapters/messageTypes.ts +++ b/packages/web-shell/client/adapters/messageTypes.ts @@ -92,6 +92,7 @@ export interface DaemonSystemMessage { role: 'system'; content: string; variant: 'info' | 'error' | 'warning'; + retryable?: boolean; } export interface DaemonUserShellMessage { diff --git a/packages/web-shell/client/adapters/transcriptToMessages.test.ts b/packages/web-shell/client/adapters/transcriptToMessages.test.ts index d612fcb638..af7d816383 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.test.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.test.ts @@ -1875,6 +1875,31 @@ describe('transcriptBlocksToDaemonMessages', () => { role: 'system', content: 'Connection lost', variant: 'error', + retryable: false, + }, + ]); + }); + + it('marks turn_error blocks as retryable system errors', () => { + const messages = transcriptBlocksToDaemonMessages([ + { + id: 'err-1', + kind: 'error' as const, + source: 'turn_error' as const, + text: 'Request failed', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }, + ]); + + expect(messages).toEqual([ + { + id: 'err-1', + role: 'system', + content: 'Request failed', + variant: 'error', + retryable: true, }, ]); }); diff --git a/packages/web-shell/client/adapters/transcriptToMessages.ts b/packages/web-shell/client/adapters/transcriptToMessages.ts index 78fdab7c7b..3ac2456776 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.ts @@ -410,15 +410,18 @@ export function transcriptBlocksToDaemonMessages( break; } - case 'error': + case 'error': { + const errorBlock = block as DaemonStatusTranscriptBlock; messages.push({ id: block.id, role: 'system', - content: (block as DaemonStatusTranscriptBlock).text, + content: errorBlock.text, variant: 'error', + retryable: errorBlock.source === 'turn_error', }); needsNewContentMessage = true; break; + } case 'prompt_cancelled': messages.push({ diff --git a/packages/web-shell/client/components/Editor.module.css b/packages/web-shell/client/components/Editor.module.css index 1e2fe1a978..155445c241 100644 --- a/packages/web-shell/client/components/Editor.module.css +++ b/packages/web-shell/client/components/Editor.module.css @@ -285,3 +285,9 @@ line-height: 1.35; max-height: 2.7em; } + +@media (max-width: 700px) { + .borderTopLabel { + display: none; + } +} diff --git a/packages/web-shell/client/components/MessageItem.tsx b/packages/web-shell/client/components/MessageItem.tsx index d7f0ac26ae..b0763ce7dc 100644 --- a/packages/web-shell/client/components/MessageItem.tsx +++ b/packages/web-shell/client/components/MessageItem.tsx @@ -27,6 +27,9 @@ interface MessageItemProps { onShowContextDetail?: () => void; workspaceCwd?: string; isLatest?: boolean; + showRetryHint?: boolean; + onRetryClick?: () => void; + shellOutputMaxLines: number; } export const MessageItem = memo(function MessageItem({ @@ -36,6 +39,9 @@ export const MessageItem = memo(function MessageItem({ onShowContextDetail, workspaceCwd, isLatest = false, + showRetryHint = false, + onRetryClick, + shellOutputMaxLines, }: MessageItemProps) { switch (message.role) { case 'user': @@ -55,6 +61,7 @@ export const MessageItem = memo(function MessageItem({ pendingApproval={pendingApproval} onConfirm={onConfirm} workspaceCwd={workspaceCwd} + shellOutputMaxLines={shellOutputMaxLines} /> ); case 'plan': @@ -66,6 +73,8 @@ export const MessageItem = memo(function MessageItem({ variant={message.variant} onShowContextDetail={onShowContextDetail} isLatest={isLatest} + showRetryHint={showRetryHint && message.retryable === true} + onRetryClick={onRetryClick} /> ); case 'user_shell': @@ -112,6 +121,9 @@ function areMessageItemPropsEqual( if (prev.onShowContextDetail !== next.onShowContextDetail) return false; if (prev.workspaceCwd !== next.workspaceCwd) return false; if (prev.isLatest !== next.isLatest) return false; + if (prev.showRetryHint !== next.showRetryHint) return false; + if (prev.onRetryClick !== next.onRetryClick) return false; + if (prev.shellOutputMaxLines !== next.shellOutputMaxLines) return false; return areMessagesEqual(prev.message, next.message); } @@ -136,7 +148,8 @@ function areMessagesEqual(prev: Message, next: Message): boolean { return ( next.role === 'system' && prev.content === next.content && - prev.variant === next.variant + prev.variant === next.variant && + prev.retryable === next.retryable ); case 'user_shell': return ( diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index e213a80c73..1af2bc8492 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -39,12 +39,15 @@ interface MessageListProps { tailContent?: ReactNode; tailKey?: string; virtualScrollThreshold?: number; + shellOutputMaxLines: number; /** * When true, scroll the tail content into view the moment it first appears * even if the user had scrolled up. Opt-in per caller so unrelated inline * panels don't yank the reader to the bottom. Defaults to false. */ autoScrollTailIntoView?: boolean; + showRetryHint?: boolean; + onRetryClick?: () => void; } function isAskUserQuestion(request: PermissionRequest): boolean { @@ -289,7 +292,10 @@ export function MessageList({ tailContent, tailKey = 'tail', virtualScrollThreshold = VIRTUAL_SCROLL_THRESHOLD, + shellOutputMaxLines, autoScrollTailIntoView = false, + showRetryHint = false, + onRetryClick, }: MessageListProps) { const compactMode = useContext(CompactModeContext); const mergedMessages = useMemo( @@ -588,6 +594,9 @@ export function MessageList({ onShowContextDetail={onShowContextDetail} workspaceCwd={workspaceCwd} isLatest={itemIndex === displayItems.length - 1} + showRetryHint={showRetryHint} + onRetryClick={onRetryClick} + shellOutputMaxLines={shellOutputMaxLines} /> ); }, @@ -605,6 +614,9 @@ export function MessageList({ headerOffset, displayItems, workspaceCwd, + showRetryHint, + onRetryClick, + shellOutputMaxLines, ], ); diff --git a/packages/web-shell/client/components/StatusBar.module.css b/packages/web-shell/client/components/StatusBar.module.css index bbe5b3ca71..62844007c2 100644 --- a/packages/web-shell/client/components/StatusBar.module.css +++ b/packages/web-shell/client/components/StatusBar.module.css @@ -2,7 +2,7 @@ display: flex; align-items: center; justify-content: space-between; - padding: 2px 2ch; + padding: 2px 12px; font-size: 13px; color: var(--text-dimmed); flex-shrink: 0; @@ -27,10 +27,7 @@ instead of the two touching. font: inherit so the -2ch resolves against the same font as the bar's 2ch padding; the 2px padding is hit area, free since we're out of flow. */ - position: absolute; left: calc(-2ch - 6px); - top: 50%; - transform: translateY(-50%); display: inline-flex; align-items: center; justify-content: center; @@ -182,3 +179,11 @@ .taskPill:disabled { cursor: default; } + +@media (max-width: 700px) { + .settingsButton, + .modelButton, + .modeHint { + display: none; + } +} diff --git a/packages/web-shell/client/components/StatusBar.tsx b/packages/web-shell/client/components/StatusBar.tsx index 841aa74ba3..bca6fd5e76 100644 --- a/packages/web-shell/client/components/StatusBar.tsx +++ b/packages/web-shell/client/components/StatusBar.tsx @@ -58,6 +58,8 @@ interface StatusBarProps { condition: string; setAt: number; } | null; + /** Hide the settings gear button (e.g. when /settings is in hiddenSlashCommands). */ + hideSettings?: boolean; } // Feather "settings" gear, stroke-based like PromptChevron so it inherits @@ -171,6 +173,7 @@ export const StatusBar = forwardRef( onReturnToInput, taskActivityKey, activeGoal, + hideSettings, }, ref, ) { @@ -332,7 +335,7 @@ export const StatusBar = forwardRef( return (
- {connected && ( + {connected && !hideSettings && (
); } + +function formatDuration(milliseconds: number): string { + if (milliseconds <= 0) return '0s'; + + const totalSeconds = Math.floor(milliseconds / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const parts: string[] = []; + + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0) parts.push(`${seconds}s`); + + return parts.length > 0 ? parts.join(' ') : '0s'; +} diff --git a/packages/web-shell/client/components/WelcomeHeader.tsx b/packages/web-shell/client/components/WelcomeHeader.tsx index 3083622a4e..795fae4b6d 100644 --- a/packages/web-shell/client/components/WelcomeHeader.tsx +++ b/packages/web-shell/client/components/WelcomeHeader.tsx @@ -63,6 +63,7 @@ export interface WelcomeHeaderProps { cwd: string; currentModel: string; currentMode: string; + hideTips?: boolean; } export function WelcomeHeader({ @@ -70,6 +71,7 @@ export function WelcomeHeader({ cwd, currentModel, currentMode, + hideTips = false, }: WelcomeHeaderProps) { const { language, t } = useI18n(); const tip = useMemo(() => pickTip(language), [language]); @@ -114,10 +116,12 @@ export function WelcomeHeader({
-
- {t('welcome.tipLabel')} - {tip} -
+ {!hideTips && ( +
+ {t('welcome.tipLabel')} + {tip} +
+ )} ); } diff --git a/packages/web-shell/client/components/dialogs/DialogPrimitives.module.css b/packages/web-shell/client/components/dialogs/DialogPrimitives.module.css index 1152a5a7e7..c5275473de 100644 --- a/packages/web-shell/client/components/dialogs/DialogPrimitives.module.css +++ b/packages/web-shell/client/components/dialogs/DialogPrimitives.module.css @@ -8,6 +8,7 @@ border: 1px solid var(--border-color); border-radius: var(--radius); overflow: hidden; + margin: 24px; } .resume-picker-header { diff --git a/packages/web-shell/client/components/dialogs/ThemeDialog.tsx b/packages/web-shell/client/components/dialogs/ThemeDialog.tsx index 46e121db1e..2bad6d6b71 100644 --- a/packages/web-shell/client/components/dialogs/ThemeDialog.tsx +++ b/packages/web-shell/client/components/dialogs/ThemeDialog.tsx @@ -2,8 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { dp } from './dialogStyles'; import { useDelayedGlobalKeyDown } from '../../hooks/useDelayedGlobalKeyDown'; import { useI18n } from '../../i18n'; - -export type WebShellTheme = 'dark' | 'light'; +import { WEB_SHELL_THEMES, type WebShellTheme } from '../../themeContext'; interface ThemeDialogProps { currentTheme: WebShellTheme; @@ -11,15 +10,13 @@ interface ThemeDialogProps { onClose: () => void; } -const THEME_IDS: WebShellTheme[] = ['dark', 'light']; - export function ThemeDialog({ currentTheme, onSelect, onClose, }: ThemeDialogProps) { const { t } = useI18n(); - const themes = THEME_IDS.map((id) => ({ + const themes = WEB_SHELL_THEMES.map((id) => ({ id, label: t(`theme.${id}`), description: t(`theme.${id}.desc`), diff --git a/packages/web-shell/client/components/messages/ContextUsageMessage.module.css b/packages/web-shell/client/components/messages/ContextUsageMessage.module.css index f647071210..30a643d756 100644 --- a/packages/web-shell/client/components/messages/ContextUsageMessage.module.css +++ b/packages/web-shell/client/components/messages/ContextUsageMessage.module.css @@ -11,6 +11,7 @@ font-family: var(--font-mono); font-size: 13px; line-height: 1.45; + margin-left: 14px; } .title { diff --git a/packages/web-shell/client/components/messages/ModelMessage.tsx b/packages/web-shell/client/components/messages/ModelMessage.tsx index edea2cac62..cc2adf0e0a 100644 --- a/packages/web-shell/client/components/messages/ModelMessage.tsx +++ b/packages/web-shell/client/components/messages/ModelMessage.tsx @@ -74,6 +74,14 @@ function getModelKey(model: ModelMessageModel): string { ].join('\0'); } +function getModelSelectId( + model: ModelMessageModel, + isFastMode: boolean, +): string { + if (!isFastMode) return model.id; + return model.baseModelId ?? model.id.replace(/\([^()]+\)$/, ''); +} + function DetailRow({ label, value }: { label: string; value: string }) { return (
@@ -165,9 +173,9 @@ export function ModelMessage({ const handleSelect = useCallback(() => { const model = availableModels[selectedIdx]; if (!model) return; - onSelect(model.id); + onSelect(getModelSelectId(model, isFastMode)); onClose(); - }, [availableModels, onClose, onSelect, selectedIdx]); + }, [availableModels, isFastMode, onClose, onSelect, selectedIdx]); useDelayedGlobalKeyDown( (e: KeyboardEvent) => { @@ -234,7 +242,7 @@ export function ModelMessage({ aria-selected={selected} className={`${styles.row} ${selected ? styles.selected : ''}`} onClick={() => { - onSelect(model.id); + onSelect(getModelSelectId(model, isFastMode)); onClose(); }} // Hover feedback is pure CSS (.row:hover) and deliberately does diff --git a/packages/web-shell/client/components/messages/SettingsMessage.module.css b/packages/web-shell/client/components/messages/SettingsMessage.module.css index 13c35f91a2..961a1525b9 100644 --- a/packages/web-shell/client/components/messages/SettingsMessage.module.css +++ b/packages/web-shell/client/components/messages/SettingsMessage.module.css @@ -37,6 +37,8 @@ gap: 2px; max-height: min(360px, calc(100vh - 260px)); overflow-y: auto; + padding-right: 14px; + scrollbar-gutter: stable; } .category { diff --git a/packages/web-shell/client/components/messages/SettingsMessage.tsx b/packages/web-shell/client/components/messages/SettingsMessage.tsx index 87db3b6367..8443df79ac 100644 --- a/packages/web-shell/client/components/messages/SettingsMessage.tsx +++ b/packages/web-shell/client/components/messages/SettingsMessage.tsx @@ -7,22 +7,54 @@ import { useState, type CSSProperties, } from 'react'; -import { - useSettings, - type DaemonSettingDescriptor, +import type { + DaemonSettingDescriptor, + DaemonSettingUpdateResult, + DaemonWorkspaceSettingsStatus, } from '@qwen-code/webui/daemon-react-sdk'; import { useDelayedGlobalKeyDown } from '../../hooks/useDelayedGlobalKeyDown'; -import { useI18n } from '../../i18n'; +import { + WEB_SHELL_LANGUAGES, + languageLabel, + languageSettingToWebShellLanguage, + useI18n, + type WebShellLanguage, +} from '../../i18n'; +import { + WEB_SHELL_THEMES, + WebShellThemeId, + THEME_SETTING_KEY, + LANGUAGE_SETTING_KEY, + themeSettingToWebShellTheme, + webShellThemeToSettingValue, + type WebShellTheme, +} from '../../themeContext'; import styles from './SettingsMessage.module.css'; export const SETTINGS_ACTIVE_EVENT = 'web-shell:settings-panel-active'; interface SettingsMessageProps { + settingsState: SettingsMessageSettingsState; onClose: () => void; + onLanguageChange: (language: WebShellLanguage) => void; onSubDialog: (settingKey: string) => void; + onThemeChange: (theme: WebShellTheme) => void; } -const SUB_DIALOG_KEYS = new Set(['ui.theme', 'fastModel']); +export interface SettingsMessageSettingsState { + status: DaemonWorkspaceSettingsStatus | undefined; + settings: DaemonSettingDescriptor[]; + loading: boolean; + error: Error | undefined; + reload: () => Promise; + setValue: ( + scope: 'workspace', + key: string, + value: unknown, + ) => Promise; +} + +const SUB_DIALOG_KEYS = new Set(['fastModel']); type Scope = 'user' | 'workspace'; @@ -31,6 +63,55 @@ type Translator = ( vars?: Record, ) => string; +function translateSettingText( + t: Translator, + key: string, + fallback: string, +): string { + const translated = t(key); + return translated === key ? fallback : translated; +} + +function formatSettingCategory(category: string, t: Translator): string { + return translateSettingText(t, `settings.category.${category}`, category); +} + +export function formatSettingLabel( + setting: DaemonSettingDescriptor, + t: Translator, +): string { + return translateSettingText( + t, + `settings.label.${setting.key}`, + setting.label, + ); +} + +function formatSettingDescription( + setting: DaemonSettingDescriptor, + t: Translator, +): string | undefined { + if (!setting.description) return undefined; + return translateSettingText( + t, + `settings.description.${setting.key}`, + setting.description, + ); +} + +function formatSettingOption( + setting: DaemonSettingDescriptor, + value: unknown, + label: string, + t: Translator, +): string { + return translateSettingText( + t, + `settings.option.${setting.key}.${String(value)}`, + label, + ); +} + function formatValue( setting: DaemonSettingDescriptor, scope: Scope, @@ -38,13 +119,23 @@ function formatValue( ): string { const effective = resolveValue(setting, scope); if (effective === undefined || effective === null) return ''; + if (setting.key === THEME_SETTING_KEY) { + const theme = themeSettingToWebShellTheme(effective, WebShellThemeId.Dark); + return t(`theme.${theme}`); + } + if (setting.key === LANGUAGE_SETTING_KEY) { + const language = languageSettingToWebShellLanguage(effective); + return language ? languageLabel(language) : String(effective); + } if (setting.type === 'boolean') return effective === true ? t('settings.value.on') : t('settings.value.off'); if (setting.type === 'enum' && setting.options) { const opt = setting.options.find((o) => o.value === effective); - return opt?.label ?? String(effective); + return opt + ? formatSettingOption(setting, opt.value, opt.label, t) + : String(effective); } const s = String(effective); return s.length > 24 ? s.slice(0, 21) + '...' : s; @@ -104,7 +195,10 @@ interface CategoryGroup { items: DaemonSettingDescriptor[]; } -function groupByCategory(settings: DaemonSettingDescriptor[]): CategoryGroup[] { +function groupByCategory( + settings: DaemonSettingDescriptor[], + t: Translator, +): CategoryGroup[] { const map = new Map(); for (const s of settings) { let group = map.get(s.category); @@ -115,7 +209,7 @@ function groupByCategory(settings: DaemonSettingDescriptor[]): CategoryGroup[] { group.push(s); } return Array.from(map.entries()).map(([category, items]) => ({ - category, + category: formatSettingCategory(category, t), items, })); } @@ -155,13 +249,14 @@ export function nextSettingIdx( } export function SettingsMessage({ + settingsState, onClose, + onLanguageChange, onSubDialog, + onThemeChange, }: SettingsMessageProps) { const { t } = useI18n(); - const { status, settings, loading, error, reload, setValue } = useSettings({ - autoLoad: true, - }); + const { status, settings, loading, error, reload, setValue } = settingsState; const [scope, setScope] = useState('workspace'); const [selectedIdx, setSelectedIdx] = useState(0); const [busyKey, setBusyKey] = useState(null); @@ -170,6 +265,10 @@ export function SettingsMessage({ key: string; draft: string; } | null>(null); + type SubPanel = null | 'theme' | 'language'; + const [subPanel, setSubPanel] = useState(null); + const [selectedThemeIdx, setSelectedThemeIdx] = useState(0); + const [selectedLanguageIdx, setSelectedLanguageIdx] = useState(0); const panelIdRef = useRef(`settings-${Math.random().toString(36).slice(2)}`); const panelRef = useRef(null); const listRef = useRef(null); @@ -181,16 +280,25 @@ export function SettingsMessage({ }, [onClose]); const rows = useMemo( - () => flattenGroups(groupByCategory(settings)), - [settings], + () => flattenGroups(groupByCategory(settings, t)), + [settings, t], ); const [restartPending, setRestartPending] = useState(false); const selectedRow = rows[selectedIdx]; const selectedDescription = - selectedRow?.type === 'setting' - ? selectedRow.setting?.description + selectedRow?.type === 'setting' && selectedRow.setting + ? formatSettingDescription(selectedRow.setting, t) : undefined; + const showInitialLoading = loading && !status; + const themeSetting = settings.find((s) => s.key === THEME_SETTING_KEY); + const themeValue = themeSettingToWebShellTheme( + themeSetting?.values.effective, + ); + const languageSetting = settings.find((s) => s.key === LANGUAGE_SETTING_KEY); + const languageValue = languageSettingToWebShellLanguage( + languageSetting?.values.effective, + ); // Marquee state for an overflowing description: distance to travel and a // duration that keeps the glide speed constant regardless of text length. @@ -297,19 +405,52 @@ export function SettingsMessage({ el?.scrollIntoView({ block: 'nearest' }); }, [selectedIdx]); + useEffect(() => { + if (subPanel !== 'theme') return; + const el = listRef.current?.children[selectedThemeIdx] as + | HTMLElement + | undefined; + el?.scrollIntoView({ block: 'nearest' }); + }, [selectedThemeIdx, subPanel]); + + useEffect(() => { + if (subPanel !== 'language') return; + const el = listRef.current?.children[selectedLanguageIdx] as + | HTMLElement + | undefined; + el?.scrollIntoView({ block: 'nearest' }); + }, [selectedLanguageIdx, subPanel]); + useEffect(() => { if (editMode) { setTimeout(() => inputRef.current?.focus(), 0); } }, [editMode]); + useEffect(() => { + if (subPanel !== 'theme') return; + const idx = themeValue ? WEB_SHELL_THEMES.indexOf(themeValue) : -1; + setSelectedThemeIdx(idx >= 0 ? idx : 0); + }, [subPanel, themeValue]); + + useEffect(() => { + if (subPanel !== 'language') return; + const idx = languageValue ? WEB_SHELL_LANGUAGES.indexOf(languageValue) : -1; + setSelectedLanguageIdx(idx >= 0 ? idx : 0); + }, [subPanel, languageValue]); + const handleSetValue = useCallback( (key: string, value: unknown) => { if (!restartPending) setMessage(null); setBusyKey(key); setValue('workspace', key, value) - .then((result) => { - if (result?.requiresRestart) { + .then(async (result) => { + try { + await reload(); + } catch { + // reload failure is non-fatal — the value was already saved + } + if (result?.requiresRestart && key !== LANGUAGE_SETTING_KEY) { setRestartPending(true); setMessage(t('settings.requiresRestart')); } @@ -319,7 +460,7 @@ export function SettingsMessage({ }) .finally(() => setBusyKey(null)); }, - [restartPending, setValue, t], + [reload, restartPending, setValue, t], ); const handleAction = useCallback( @@ -330,6 +471,14 @@ export function SettingsMessage({ setMessage(t('settings.readOnly')); return; } + if (setting.key === THEME_SETTING_KEY) { + setSubPanel('theme'); + return; + } + if (setting.key === LANGUAGE_SETTING_KEY) { + setSubPanel('language'); + return; + } if (SUB_DIALOG_KEYS.has(setting.key)) { onSubDialog(setting.key); return; @@ -397,6 +546,71 @@ export function SettingsMessage({ return; } + if (subPanel === 'theme') { + if (e.key === 'Escape') { + claim(); + setSubPanel(null); + return; + } + if (e.key === 'ArrowDown' || e.key === 'j') { + claim(); + setSelectedThemeIdx((i) => + Math.min(i + 1, WEB_SHELL_THEMES.length - 1), + ); + return; + } + if (e.key === 'ArrowUp' || e.key === 'k') { + claim(); + setSelectedThemeIdx((i) => Math.max(i - 1, 0)); + return; + } + if ((e.key === 'Enter' || e.key === ' ') && !busyKey) { + claim(); + const value = WEB_SHELL_THEMES[selectedThemeIdx]; + if (value) { + setSubPanel(null); + onThemeChange(value); + handleSetValue( + THEME_SETTING_KEY, + webShellThemeToSettingValue(value), + ); + } + return; + } + return; + } + + if (subPanel === 'language') { + if (e.key === 'Escape') { + claim(); + setSubPanel(null); + return; + } + if (e.key === 'ArrowDown' || e.key === 'j') { + claim(); + setSelectedLanguageIdx((i) => + Math.min(i + 1, WEB_SHELL_LANGUAGES.length - 1), + ); + return; + } + if (e.key === 'ArrowUp' || e.key === 'k') { + claim(); + setSelectedLanguageIdx((i) => Math.max(i - 1, 0)); + return; + } + if ((e.key === 'Enter' || e.key === ' ') && !busyKey) { + claim(); + const value = WEB_SHELL_LANGUAGES[selectedLanguageIdx]; + if (value) { + setSubPanel(null); + onLanguageChange(value); + onClose(); + } + return; + } + return; + } + if (e.key === 'Escape') { claim(); onClose(); @@ -430,7 +644,21 @@ export function SettingsMessage({ } } }, - [busyKey, editMode, handleAction, handleEditSubmit, onClose, reload, rows], + [ + busyKey, + editMode, + handleAction, + handleEditSubmit, + handleSetValue, + subPanel, + onClose, + onLanguageChange, + reload, + rows, + selectedLanguageIdx, + selectedThemeIdx, + onThemeChange, + ], ); const scopeLabel = @@ -441,111 +669,185 @@ export function SettingsMessage({ return (
- {t('settings.title')} + + {subPanel === 'theme' + ? `${t('settings.title')} / ${t('theme.title')}` + : subPanel === 'language' + ? `${t('settings.title')} / ${t('language.set')}` + : t('settings.title')} + {scopeLabel}
- {(message || loading) && ( + {(message || showInitialLoading) && (
{message || t('settings.loading')}
)} -
- {!loading && rows.length === 0 && ( -
{t('settings.empty')}
- )} - {rows.map((row, i) => { - if (row.type === 'header') { - return ( -
- {row.category} -
- ); - } - - const setting = row.setting!; - const isSelected = i === selectedIdx; - const isEditing = editMode?.key === setting.key; - const isSubDialog = SUB_DIALOG_KEYS.has(setting.key); - const hasScopeValue = scopeHasValue(setting, scope); - const hintKey = scopeHintKey(setting, scope); - - return ( +
+ {subPanel === 'theme' ? ( + WEB_SHELL_THEMES.map((themeName, index) => (
{ if (busyKey) return; - setSelectedIdx(i); - handleAction(setting); + setSelectedThemeIdx(index); + setSubPanel(null); + onThemeChange(themeName); + handleSetValue( + THEME_SETTING_KEY, + webShellThemeToSettingValue(themeName), + ); }} - // Hover feedback is pure CSS (.item:hover) and deliberately does - // NOT move the selection: arrow keys own the pointer + accent - // label, the mouse only adds a background highlight. This keeps - // mouse and keyboard from fighting when the list scrolls under a - // resting cursor. >
- {isSelected ? '›' : ' '} - - {setting.label} - {/* Cross-scope hint inline after the label, same as the - native CLI — never a separate row. */} - {hintKey && ( - - {' '} - {t(hintKey, { - scope: t( - scope === 'workspace' - ? 'settings.scope.user' - : 'settings.scope.workspace', - ), - })} - - )} + + {index === selectedThemeIdx ? '›' : ' '} + {t(`theme.${themeName}`)} - {busyKey === setting.key - ? '...' - : `${formatValue(setting, scope, t)}${hasScopeValue ? '*' : ''}${setting.requiresRestart ? ' ⟳' : ''}${isSubDialog ? ' ▸' : ''}`} + {themeName === themeValue ? '✓' : ''}
- {isEditing && editMode && ( -
- - setEditMode({ key: editMode.key, draft: e.target.value }) - } - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleEditSubmit(); - } - if (e.key === 'Escape') { - e.preventDefault(); - setEditMode(null); - } - }} - /> -
- )}
- ); - })} + )) + ) : subPanel === 'language' ? ( + WEB_SHELL_LANGUAGES.map((languageName, index) => ( +
{ + if (busyKey) return; + setSelectedLanguageIdx(index); + setSubPanel(null); + onLanguageChange(languageName); + onClose(); + }} + > +
+ + {index === selectedLanguageIdx ? '›' : ' '} + + + {languageLabel(languageName)} + + + {languageName === languageValue ? '✓' : ''} + +
+
+ )) + ) : ( + <> + {!loading && rows.length === 0 && ( +
{t('settings.empty')}
+ )} + {rows.map((row, i) => { + if (row.type === 'header') { + return ( +
+ {row.category} +
+ ); + } + + const setting = row.setting!; + const isSelected = i === selectedIdx; + const isEditing = editMode?.key === setting.key; + const isSubDialog = + SUB_DIALOG_KEYS.has(setting.key) || + setting.key === THEME_SETTING_KEY || + setting.key === LANGUAGE_SETTING_KEY; + const hasScopeValue = scopeHasValue(setting, scope); + const hintKey = scopeHintKey(setting, scope); + + return ( +
{ + if (busyKey) return; + setSelectedIdx(i); + handleAction(setting); + }} + // Hover feedback is pure CSS (.item:hover) and deliberately does + // NOT move the selection: arrow keys own the pointer + accent + // label, the mouse only adds a background highlight. This keeps + // mouse and keyboard from fighting when the list scrolls under a + // resting cursor. + > +
+ + {isSelected ? '›' : ' '} + + + {formatSettingLabel(setting, t)} + {/* Cross-scope hint inline after the label, same as the + native CLI — never a separate row. */} + {hintKey && ( + + {' '} + {t(hintKey, { + scope: t( + scope === 'workspace' + ? 'settings.scope.user' + : 'settings.scope.workspace', + ), + })} + + )} + + + {busyKey === setting.key + ? '...' + : `${formatValue(setting, scope, t)}${hasScopeValue ? '*' : ''}${isSubDialog ? ' ▸' : ''}`} + +
+ {isEditing && editMode && ( +
+ + setEditMode({ + key: editMode.key, + draft: e.target.value, + }) + } + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleEditSubmit(); + } + if (e.key === 'Escape') { + e.preventDefault(); + setEditMode(null); + } + }} + /> +
+ )} +
+ ); + })} + + )}
{/* Always rendered (nbsp placeholder) and clamped to one line, so the @@ -582,7 +884,11 @@ export function SettingsMessage({
- {editMode ? t('settings.footer.edit') : t('settings.footer')} + {editMode + ? t('settings.footer.edit') + : subPanel + ? t('settings.footer.theme') + : t('settings.footer')}
); diff --git a/packages/web-shell/client/components/messages/SystemMessage.module.css b/packages/web-shell/client/components/messages/SystemMessage.module.css index 402eac0112..94de3ee989 100644 --- a/packages/web-shell/client/components/messages/SystemMessage.module.css +++ b/packages/web-shell/client/components/messages/SystemMessage.module.css @@ -45,3 +45,26 @@ border: 1px solid var(--warning-border); color: var(--warning-color); } + +.retryHint { + margin-top: 6px; + font-size: 12px; + color: var(--text-secondary, #888); + font-style: italic; +} + +.retryButton { + background: none; + border: none; + padding: 0; + margin: 0; + color: var(--text-secondary, #888); + font: inherit; + font-style: italic; + cursor: pointer; + text-decoration: underline; +} + +.retryButton:hover { + color: var(--text-primary, #fff); +} diff --git a/packages/web-shell/client/components/messages/SystemMessage.tsx b/packages/web-shell/client/components/messages/SystemMessage.tsx index a801a62f6a..1cac734f88 100644 --- a/packages/web-shell/client/components/messages/SystemMessage.tsx +++ b/packages/web-shell/client/components/messages/SystemMessage.tsx @@ -1,4 +1,5 @@ import { memo } from 'react'; +import { useI18n } from '../../i18n'; import { ContextUsageMessage, parseContextUsageMessage, @@ -20,6 +21,8 @@ interface SystemMessageProps { /** Run /context detail, exactly like typing it (context-usage panels). */ onShowContextDetail?: () => void; isLatest?: boolean; + showRetryHint?: boolean; + onRetryClick?: () => void; } export const SystemMessage = memo(function SystemMessage({ @@ -27,7 +30,10 @@ export const SystemMessage = memo(function SystemMessage({ variant, onShowContextDetail, isLatest = false, + showRetryHint = false, + onRetryClick, }: SystemMessageProps) { + const { t } = useI18n(); const contextUsage = variant === 'info' ? parseContextUsageMessage(content) : null; if (contextUsage) { @@ -104,6 +110,17 @@ export const SystemMessage = memo(function SystemMessage({ ) : (
{content}
)} + {showRetryHint && onRetryClick && ( +
+ +
+ )}
); }); diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx index ab00472aba..bd95eb336f 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.tsx @@ -1,4 +1,5 @@ import { memo, useContext, useEffect, useMemo, useState } from 'react'; +import type { DaemonSettingDescriptor } from '@qwen-code/webui/daemon-react-sdk'; import type { ACPToolCall, PermissionRequest, @@ -53,8 +54,11 @@ interface ToolGroupProps { answers?: Record, ) => void; workspaceCwd?: string; + shellOutputMaxLines?: number; } +const DEFAULT_SHELL_OUTPUT_MAX_LINES = 5; + function hasExpandableContent(tool: ACPToolCall): boolean { const name = tool.toolName.toLowerCase(); if (isAskUserQuestionToolName(tool.toolName)) return !!extractText(tool); @@ -147,22 +151,37 @@ function buildUnifiedDiff(oldText: string, newText: string): string { return result.reverse().join('\n'); } -const MAX_BASH_LINES = 5; const MAX_BASH_LINE_CHARS = 150; const MAX_READ_LINES = 25; +export function resolveShellOutputMaxLines( + settings: readonly DaemonSettingDescriptor[], +): number { + const setting = settings.find((s) => s.key === 'ui.shellOutputMaxLines'); + const value = setting?.values.effective; + const raw = + typeof value === 'number' ? value : DEFAULT_SHELL_OUTPUT_MAX_LINES; + return Math.max(0, Math.floor(raw || 0)); +} + function truncateLine(line: string, max: number): string { if (line.length <= max) return line; return line.slice(0, max) + ' …'; } -function ExpandedBashOutput({ tool }: { tool: ACPToolCall }) { +function ExpandedBashOutput({ + tool, + maxLines, +}: { + tool: ACPToolCall; + maxLines: number; +}) { const { t } = useI18n(); const [showAll, setShowAll] = useState(false); const output = useMemo(() => extractText(tool) || '', [tool]); const lines = useMemo(() => output.split('\n'), [output]); - const isLong = lines.length > MAX_BASH_LINES; - const hiddenLinesCount = Math.max(0, lines.length - MAX_BASH_LINES); + const isLong = maxLines > 0 && lines.length > maxLines; + const hiddenLinesCount = Math.max(0, lines.length - maxLines); const hasTruncatedLine = useMemo( () => lines.some((l) => l.length > MAX_BASH_LINE_CHARS), [lines], @@ -171,16 +190,15 @@ function ExpandedBashOutput({ tool }: { tool: ACPToolCall }) { const displayText = useMemo(() => { if (showAll) return output; if (isLong) { - // Match CLI behavior: long shell output shows a fixed tail preview. return [ `... first ${hiddenLinesCount} lines hidden ...`, ...lines - .slice(-MAX_BASH_LINES) + .slice(-maxLines) .map((l) => truncateLine(l, MAX_BASH_LINE_CHARS)), ].join('\n'); } return lines.map((l) => truncateLine(l, MAX_BASH_LINE_CHARS)).join('\n'); - }, [hiddenLinesCount, isLong, lines, output, showAll]); + }, [hiddenLinesCount, isLong, lines, maxLines, output, showAll]); const ansiSegments = useMemo( () => (hasAnsi(displayText) ? parseAnsi(displayText) : null), [displayText], @@ -342,6 +360,7 @@ interface ToolLineProps { approval?: PermissionRequest | null; onConfirm?: (id: string, selectedOption: string) => void; workspaceCwd?: string; + shellOutputMaxLines?: number; } function getAgentDisplayInfo( @@ -522,6 +541,7 @@ function areToolLinePropsEqual( if (prev.approval?.id !== next.approval?.id) return false; if (prev.onConfirm !== next.onConfirm) return false; if (prev.workspaceCwd !== next.workspaceCwd) return false; + if (prev.shellOutputMaxLines !== next.shellOutputMaxLines) return false; const a = prev.tool; const b = next.tool; return ( @@ -570,6 +590,7 @@ export const ToolLine = memo(function ToolLine({ approval, onConfirm, workspaceCwd, + shellOutputMaxLines = DEFAULT_SHELL_OUTPUT_MAX_LINES, }: ToolLineProps) { const { t } = useI18n(); const compactMode = useContext(CompactModeContext); @@ -740,7 +761,9 @@ export const ToolLine = memo(function ToolLine({ )} {!isTodo && expanded && (
- {isShellToolName(name) && } + {isShellToolName(name) && ( + + )} {(name === 'write_file' || name === 'writefile') && ( )} @@ -764,6 +787,7 @@ export const ToolGroup = memo(function ToolGroup({ pendingApproval, onConfirm, workspaceCwd, + shellOutputMaxLines, }: ToolGroupProps) { const compactMode = useContext(CompactModeContext); const directApprovalTool = @@ -791,6 +815,7 @@ export const ToolGroup = memo(function ToolGroup({ approval={pendingApproval} onConfirm={onConfirm} workspaceCwd={workspaceCwd} + shellOutputMaxLines={shellOutputMaxLines} /> ))}
diff --git a/packages/web-shell/client/hooks/useStreamingLoadingMetrics.ts b/packages/web-shell/client/hooks/useStreamingLoadingMetrics.ts new file mode 100644 index 0000000000..6f6e4203d4 --- /dev/null +++ b/packages/web-shell/client/hooks/useStreamingLoadingMetrics.ts @@ -0,0 +1,185 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + useStreamingState, + useTranscriptBlocks, +} from '@qwen-code/webui/daemon-react-sdk'; + +interface LoadingMetrics { + estimatedOutputTokens: number; + isReceivingContent: boolean; +} + +interface BlocksScan { + chars: number; + agentTokens: number; + isReceiving: boolean; +} + +/** + * CLI-aligned streaming loading metrics derived from transcript blocks. + * + * CLI source (useGeminiStream.ts + LoadingIndicator.tsx): + * - streamingChars: accumulated from text_delta (+text.length) and + * ToolCallRequest (+JSON.stringify(args).length). Reset only on new + * user queries, NOT on tool-result continuations. + * - isReceivingContent: false at submitQuery start, true on first + * content event. Never changed elsewhere (tool calls don't flip it). + * - outputTokens = agentTokens + round(animatedChars / 4) + * where agentTokens = sum of subagent task_execution.tokenCount + * - Animation: 100ms interval, gap<70→+3, 70-200→+20%, >200→+50 + */ +export function useStreamingLoadingMetrics(): LoadingMetrics { + const streamingState = useStreamingState(); + const blocks = useTranscriptBlocks(); + const isActive = streamingState !== 'idle'; + + const displayRef = useRef(0); + const prevCharsRef = useRef(0); + + const [metrics, setMetrics] = useState({ + estimatedOutputTokens: 0, + isReceivingContent: false, + }); + + // Derive metrics from transcript blocks via useMemo (avoids O(n) work in render body). + const scan = useMemo((): BlocksScan => { + let chars = 0; + let agentTokens = 0; + let isReceiving = false; + const countedToolIds = new Set(); + + let lastUserIndex = -1; + for (let i = blocks.length - 1; i >= 0; i--) { + if (blocks[i]!.kind === 'user') { + lastUserIndex = i; + break; + } + } + + for (let i = lastUserIndex + 1; i < blocks.length; i++) { + const block = blocks[i]!; + + // Main agent assistant text (not subagent). + if (block.kind === 'assistant' && !block.parentToolCallId) { + chars += block.text.length; + if (block.streaming) { + isReceiving = true; + } + } + + // Tool args (like CLI's ToolCallRequest → JSON.stringify(args).length). + // Also extract subagent tokenCount (like CLI's Composer agentTokens). + if (block.kind === 'tool' && !block.parentToolCallId) { + if (block.rawInput !== undefined) { + try { + chars += JSON.stringify(block.rawInput).length; + } catch { + // Best-effort + } + } + const taskTokens = getTaskExecutionTokenCount(block.rawOutput); + if (taskTokens !== undefined && !countedToolIds.has(block.toolCallId)) { + agentTokens += taskTokens; + countedToolIds.add(block.toolCallId); + } + } + } + + return { chars, agentTokens, isReceiving }; + }, [blocks]); + + // Sync refs from memoized scan results. + const scanRef = useRef(scan); + scanRef.current = scan; + + // Snap down immediately on reset (no animation needed for decrease). + if (scan.chars < prevCharsRef.current) { + displayRef.current = scan.chars; + } + prevCharsRef.current = scan.chars; + + // Animation loop: 100ms interval, smooth interpolation. + useEffect(() => { + if (!isActive) { + displayRef.current = 0; + setMetrics({ estimatedOutputTokens: 0, isReceivingContent: false }); + return; + } + + const id = setInterval(() => { + const { chars: realValue, agentTokens, isReceiving } = scanRef.current; + + // Snap down on reset. + if (realValue < displayRef.current) { + displayRef.current = realValue; + setMetrics({ + estimatedOutputTokens: agentTokens + Math.round(realValue / 4), + isReceivingContent: isReceiving, + }); + return; + } + + const gap = realValue - displayRef.current; + if (gap <= 0) { + // No char movement, but sync agentTokens and isReceivingContent. + setMetrics((prev) => { + const next = { + estimatedOutputTokens: + agentTokens + Math.round(displayRef.current / 4), + isReceivingContent: isReceiving, + }; + if ( + prev.estimatedOutputTokens === next.estimatedOutputTokens && + prev.isReceivingContent === next.isReceivingContent + ) { + return prev; + } + return next; + }); + return; + } + + // Smooth interpolation: small gaps crawl, large gaps leap. + let increment: number; + if (gap < 70) { + increment = 3; + } else if (gap <= 200) { + increment = Math.max(3, Math.round(gap * 0.2)); + } else { + increment = 50; + } + + const next = Math.min(displayRef.current + increment, realValue); + displayRef.current = next; + + setMetrics({ + estimatedOutputTokens: agentTokens + Math.round(next / 4), + isReceivingContent: isReceiving, + }); + }, 100); + + return () => clearInterval(id); + }, [isActive]); + + return metrics; +} + +function getTaskExecutionTokenCount(rawOutput: unknown): number | undefined { + if ( + typeof rawOutput !== 'object' || + rawOutput === null || + !('type' in rawOutput) || + (rawOutput as { type: unknown }).type !== 'task_execution' + ) { + return undefined; + } + const obj = rawOutput as Record; + const tokenCount = obj['tokenCount']; + if (typeof tokenCount === 'number' && tokenCount > 0) return tokenCount; + const summary = obj['executionSummary']; + if (typeof summary === 'object' && summary !== null) { + const totalTokens = (summary as Record)['totalTokens']; + if (typeof totalTokens === 'number' && totalTokens > 0) return totalTokens; + } + return undefined; +} diff --git a/packages/web-shell/client/hooks/useStreamingOutputTokens.ts b/packages/web-shell/client/hooks/useStreamingOutputTokens.ts deleted file mode 100644 index 7fee171055..0000000000 --- a/packages/web-shell/client/hooks/useStreamingOutputTokens.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { - useStreamingState, - useTranscriptStore, -} from '@qwen-code/webui/daemon-react-sdk'; - -// Estimate output tokens from the active assistant block's streamed text -// length (chars / 4), mirroring the CLI's LoadingIndicator approach. -export function useStreamingOutputTokens(): number { - const store = useTranscriptStore(); - const streamingState = useStreamingState(); - const charsRef = useRef(0); - const [displayTokens, setDisplayTokens] = useState(0); - - const isActive = - streamingState === 'responding' || streamingState === 'thinking'; - - useEffect(() => { - if (!isActive) { - charsRef.current = 0; - setDisplayTokens(0); - return; - } - const update = () => { - const { blocks } = store.getSnapshot(); - for (let i = blocks.length - 1; i >= 0; i--) { - const b = blocks[i]!; - if ( - b.kind === 'assistant' && - b.streaming && - !('parentToolCallId' in b && b.parentToolCallId) - ) { - charsRef.current = b.text.length; - return; - } - } - charsRef.current = 0; - }; - update(); - setDisplayTokens(Math.round(charsRef.current / 4)); - return store.subscribe(update); - }, [store, isActive]); - - useEffect(() => { - if (!isActive) return; - const id = setInterval(() => { - setDisplayTokens(Math.round(charsRef.current / 4)); - }, 100); - return () => clearInterval(id); - }, [isActive]); - - return displayTokens; -} diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 45204aaf1a..0ee72cbca7 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -5,7 +5,9 @@ import { type PropsWithChildren, } from 'react'; -export type WebShellLanguage = 'en' | 'zh-CN'; +export const WEB_SHELL_LANGUAGES = ['en', 'zh-CN'] as const; + +export type WebShellLanguage = (typeof WEB_SHELL_LANGUAGES)[number]; type MessageValue = | string @@ -331,6 +333,9 @@ const EN: Messages = { 'help.shortcut.togglePanel': 'Toggle this panel', 'help.shortcut.retry': 'Retry last request', 'help.shortcut.compact': 'Toggle compact mode', + 'retry.hint': 'Press Ctrl+Y to retry or click to retry', + 'retry.none': 'No failed request to retry.', + 'command.hidden': 'This command is not available.', 'help.shortcut.approvals': 'Cycle approval modes', 'help.shortcut.cancel': 'Close dialogs or cancel operation', 'bug.failed': 'Failed to load system info for bug report.', @@ -341,6 +346,7 @@ const EN: Messages = { 'compact.enabled': 'Compact mode enabled', 'compact.disabled': 'Compact mode disabled', 'compact.hint': 'Press Ctrl+O to show full tool output', + 'compact.saveFailed': 'Failed to save compact mode', 'help.subcommands': 'subcommands', 'help.tab.commands': 'commands', 'help.tab.custom': 'custom-commands', @@ -729,6 +735,7 @@ const EN: Messages = { 'stream.cancel': 'esc to cancel', 'stream.tokens': (v) => `${v?.count ?? 0} tokens`, 'theme.current': (v) => `current: ${v?.theme ?? ''}`, + 'theme.auto': 'Auto', 'theme.dark': 'Dark', 'theme.dark.desc': 'Terminal-style dark skin.', 'theme.light': 'Light', @@ -814,6 +821,7 @@ const EN: Messages = { 'settings.footer': '↑↓ Navigate Enter Toggle Tab Scope r Reload ESC Close', 'settings.footer.edit': 'Enter Save ESC Cancel', + 'settings.footer.theme': '↑↓ Navigate Enter Select ESC Back', 'settings.scope.user': 'User', 'settings.scope.workspace': 'Workspace', 'settings.value.on': 'ON', @@ -1126,6 +1134,9 @@ const ZH: Messages = { 'help.shortcut.togglePanel': '切换此面板', 'help.shortcut.retry': '重试上次请求', 'help.shortcut.compact': '切换紧凑模式', + 'retry.hint': '按 Ctrl+Y 重试或点击重试', + 'retry.none': '没有可重试的失败请求。', + 'command.hidden': '该命令不可用。', 'help.shortcut.approvals': '切换审批模式', 'help.shortcut.cancel': '关闭弹窗或取消操作', 'bug.failed': '加载系统信息失败,无法提交 Bug 报告。', @@ -1136,6 +1147,7 @@ const ZH: Messages = { 'compact.enabled': '紧凑模式已开启', 'compact.disabled': '紧凑模式已关闭', 'compact.hint': '按 Ctrl+O 显示完整工具输出', + 'compact.saveFailed': '保存紧凑模式失败', 'help.subcommands': '子命令', 'help.tab.commands': '命令', 'help.tab.custom': '自定义命令', @@ -1509,6 +1521,7 @@ const ZH: Messages = { 'stream.cancel': 'esc 取消', 'stream.tokens': (v) => `${v?.count ?? 0} tokens`, 'theme.current': (v) => `当前:${v?.theme ?? ''}`, + 'theme.auto': '自动', 'theme.dark': '暗色', 'theme.dark.desc': '仿终端暗色皮肤。', 'theme.light': '亮色', @@ -1595,6 +1608,7 @@ const ZH: Messages = { 'settings.empty': '暂无可用设置。', 'settings.footer': '↑↓ 导航 Enter 切换 Tab 切换作用域 r 刷新 ESC 关闭', 'settings.footer.edit': 'Enter 保存 ESC 取消', + 'settings.footer.theme': '↑↓ 导航 Enter 选择 ESC 返回', 'settings.scope.user': '用户', 'settings.scope.workspace': '工作区', 'settings.value.on': '开', @@ -1606,6 +1620,103 @@ const ZH: Messages = { 'settings.requiresRestart': '此更改需要重启后才能生效。', 'settings.corrupted': (v) => `设置文件已损坏${v?.recovered === 'true' ? '(已从备份恢复)' : ''}`, + 'settings.category.General': '通用', + 'settings.category.UI': '界面', + 'settings.category.Privacy': '隐私', + 'settings.category.Model': '模型', + 'settings.category.Context': '上下文', + 'settings.category.Tools': '工具', + 'settings.category.Daemon': '守护进程', + 'settings.category.Experimental': '实验性', + 'settings.category.Advanced': '高级', + 'settings.label.general.enableAutoUpdate': '启用自动更新', + 'settings.description.general.enableAutoUpdate': '启动时自动检查并安装更新。', + 'settings.label.general.showSessionRecap': '显示会话回顾', + 'settings.description.general.showSessionRecap': + '离开终端一段时间后返回时,自动显示一行“上次停在这里”的回顾。默认关闭。也可以随时使用 /recap 手动触发。', + 'settings.label.general.sessionRecapAwayThresholdMinutes': + '会话回顾离开阈值(分钟)', + 'settings.description.general.sessionRecapAwayThresholdMinutes': + '终端失焦多少分钟后,下一次重新聚焦时触发自动回顾。默认与 Claude Code 一致为 5 分钟;如果只是短暂切换窗口,可以调高。', + 'settings.label.general.cleanupPeriodDays': '清理周期(天)', + 'settings.description.general.cleanupPeriodDays': + '~/.qwen/file-history/ 中用于 /rewind 的会话备份保留天数。后台清理最多每天运行一次。设为 0 表示最小保留(约 1 小时),仍会保护最近一小时触碰过的会话和当前活动会话。', + 'settings.label.general.gitCoAuthor.commit': '归因:commit', + 'settings.description.general.gitCoAuthor.commit': + '通过 Qwen Code 创建 commit 时,添加 Co-authored-by trailer,并写入逐文件 AI 归因 git note。关闭后两者都会跳过。', + 'settings.label.general.gitCoAuthor.pr': '归因:PR', + 'settings.description.general.gitCoAuthor.pr': + '运行 gh pr create 时,在 PR 描述中追加 Qwen Code 归因行。', + 'settings.label.general.language': '语言:界面', + 'settings.description.general.language': + '用户界面的语言。使用 auto 可根据系统设置自动检测;也可以在 ~/.qwen/locales/ 中放置 JS 语言文件来使用自定义语言代码。', + 'settings.label.general.dynamicCommandTranslation': '语言:动态命令翻译', + 'settings.description.general.dynamicCommandTranslation': + '为动态 slash command 描述启用 AI 翻译。关闭后动态命令使用原始描述,也不会触发翻译模型调用。', + 'settings.label.general.preventSystemSleep': '运行时防止系统睡眠', + 'settings.description.general.preventSystemSleep': + '当 Qwen Code 正在流式生成模型回复或执行工具时防止系统睡眠。空闲输入状态和权限确认状态不会阻止睡眠。', + 'settings.label.ui.theme': '主题', + 'settings.description.ui.theme': '界面的颜色主题。', + 'settings.label.ui.hideTips': '隐藏提示', + 'settings.description.ui.hideTips': '隐藏界面中的帮助提示。', + 'settings.label.ui.enableWelcomeBack': '显示欢迎回来对话框', + 'settings.description.ui.enableWelcomeBack': + '回到有历史会话的项目时显示欢迎回来对话框。选择“开始新的聊天会话”后,在项目摘要变化前不会再次显示。', + 'settings.label.ui.enableUserFeedback': '启用用户反馈', + 'settings.description.ui.enableUserFeedback': + '对话结束后显示可选反馈对话框,帮助改进 Qwen 表现。', + 'settings.label.ui.enableFollowupSuggestions': '启用后续建议', + 'settings.description.ui.enableFollowupSuggestions': + '任务完成后显示上下文相关的后续建议。按 Tab 或右方向键接受,按 Enter 接受并提交。', + 'settings.label.ui.compactMode': '紧凑模式', + 'settings.description.ui.compactMode': + '隐藏工具输出和思考内容,显示更简洁的视图(可用 Ctrl+O 切换)。', + 'settings.label.ui.compactInline': '紧凑内联', + 'settings.description.ui.compactInline': + '在每个分组内紧凑显示工具内容,而不是跨分组合并。需要先启用紧凑模式。', + 'settings.label.ui.shellOutputMaxLines': 'Shell 输出最大行数', + 'settings.description.ui.shellOutputMaxLines': + '内联显示的 shell 输出最大行数。设为 0 可取消限制并显示完整输出;隐藏行数仍会通过 +N lines 指示器展示。', + 'settings.label.privacy.usageStatisticsEnabled': '启用使用统计', + 'settings.description.privacy.usageStatisticsEnabled': '启用使用统计收集。', + 'settings.label.fastModel': '快速模型', + 'settings.description.fastModel': + '用于生成提示建议和推测执行的模型。留空则使用主模型。较小/更快的模型(例如 qwen3-coder-flash)可以降低延迟和成本。', + 'settings.label.context.fileFiltering.respectGitIgnore': '遵守 .gitignore', + 'settings.description.context.fileFiltering.respectGitIgnore': + '搜索时遵守 .gitignore 文件。', + 'settings.label.context.fileFiltering.respectQwenIgnore': '遵守 .qwenignore', + 'settings.description.context.fileFiltering.respectQwenIgnore': + '搜索时遵守 .qwenignore 文件。', + 'settings.label.context.fileFiltering.enableFuzzySearch': '启用模糊搜索', + 'settings.description.context.fileFiltering.enableFuzzySearch': + '搜索文件时启用模糊搜索。', + 'settings.label.tools.toolSearch.enabled': '启用 ToolSearch', + 'settings.description.tools.toolSearch.enabled': + '启用后,MCP 工具会通过 ToolSearch 按需加载,以减少提示词大小。对于依赖前缀 KV 缓存的模型(如 DeepSeek),可关闭此项来保持提示词前缀稳定并提高缓存命中率。', + 'settings.label.tools.shell.enableInteractiveShell': '交互式 Shell(PTY)', + 'settings.description.tools.shell.enableInteractiveShell': + '使用 node-pty 提供交互式 shell 体验。PTY 不可用时回退到 child_process。', + 'settings.label.tools.computerUse.enabled': '启用 Computer Use', + 'settings.description.tools.computerUse.enabled': + '启用后(默认),会注册 9 个 computer_use__* 延迟内置工具。', + 'settings.label.policy.permissionStrategy': '权限协调策略', + 'settings.description.policy.permissionStrategy': + '多个客户端连接时权限请求的决策方式。first-responder 表示任意客户端先响应者生效;designated 表示仅提示发起方决策;consensus 表示需要 N-of-M 投票同意;local-only 表示只有 loopback 客户端可决策。需要重启 daemon 后生效。', + 'settings.option.policy.permissionStrategy.first-responder': '先响应者', + 'settings.option.policy.permissionStrategy.designated': '指定发起方', + 'settings.option.policy.permissionStrategy.consensus': '共识法定人数', + 'settings.option.policy.permissionStrategy.local-only': '仅本机', + 'settings.label.experimental.enableCronTools': '启用 Cron/Loop 工具', + 'settings.description.experimental.enableCronTools': + '启用会话内 cron/loop 工具(实验性)。启用后,模型可以用 cron_create、cron_list 和 cron_delete 创建周期性提示。也可通过 QWEN_CODE_ENABLE_CRON=1 环境变量启用。', + 'settings.label.experimental.emitToolUseSummaries': '工具使用摘要', + 'settings.description.experimental.emitToolUseSummaries': + '每个工具批次完成后生成一个简短的 LLM 标签。紧凑模式下会替代通用的 Tool × N 标题;完整模式下显示为工具组下方的弱化 ●