feat(web-shell): daemon web-shell improvements — token usage, settings, retry, streaming metrics, hidden commands (#5066)

* feat(web-shell): daemon web-shell improvements

- Align daemon token usage with structured DaemonTokenUsage type
- Optimize settings panel with i18n, theme/language pickers, compact mode
- Handle missing session recovery (404/410) with configurable behavior
- Restore settings event signal bump for workspace changes
- Prevent queued prompt loss on useEffect dependency change
- Align streaming loading indicator with CLI metrics logic
- Add Ctrl+Y retry for turn_error with daemon support
- Hide non-essential UI elements on narrow screens (≤700px)
- Prevent loading indicator flicker on page refresh
- Hydrate displayName from persisted session title on load

* fix(web-shell): harden retry affordance

* fix(web-shell): gate retry handling

---------

Co-authored-by: ytahdn <ytahdn@gmail.com>
This commit is contained in:
ytahdn 2026-06-13 10:58:08 +08:00 committed by GitHub
parent 66c69865c7
commit c61006b978
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1715 additions and 316 deletions

View file

@ -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

View file

@ -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();

View file

@ -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<string, unknown> })._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);

View file

@ -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<string> {
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(

View file

@ -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<DaemonStreamingState>(streamingState);
const lastSubmittedPromptRef = useRef<string>('');
const lastSubmittedImagesRef = useRef<PromptImage[] | undefined>(undefined);
const retryableTurnErrorIdRef = useRef<string | null>(null);
const retriedTurnErrorIdRef = useRef<string | null>(null);
const [showRetryHint, setShowRetryHint] = useState(false);
const showRetryHintRef = useRef(showRetryHint);
showRetryHintRef.current = showRetryHint;
const connected = connection.status === 'connected';
const [loadedSkills, setLoadedSkills] = useState<SkillInfo[]>([]);
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<WebShellTheme>(providedTheme);
const [selectedTheme, setSelectedTheme] = useState<WebShellTheme>(
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 && (
<SettingsMessage
settingsState={workspaceSettingsState}
onClose={() => 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}
/>
))}
</div>

View file

@ -92,6 +92,7 @@ export interface DaemonSystemMessage {
role: 'system';
content: string;
variant: 'info' | 'error' | 'warning';
retryable?: boolean;
}
export interface DaemonUserShellMessage {

View file

@ -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,
},
]);
});

View file

@ -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({

View file

@ -285,3 +285,9 @@
line-height: 1.35;
max-height: 2.7em;
}
@media (max-width: 700px) {
.borderTopLabel {
display: none;
}
}

View file

@ -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 (

View file

@ -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,
],
);

View file

@ -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;
}
}

View file

@ -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<StatusBarHandle, StatusBarProps>(
onReturnToInput,
taskActivityKey,
activeGoal,
hideSettings,
},
ref,
) {
@ -332,7 +335,7 @@ export const StatusBar = forwardRef<StatusBarHandle, StatusBarProps>(
return (
<div className={styles.bar}>
<div className={styles.left}>
{connected && (
{connected && !hideSettings && (
<button
type="button"
className={styles.settingsButton}

View file

@ -21,3 +21,9 @@
color: var(--text-dimmed);
font-size: 13px;
}
@media (max-width: 700px) {
.label {
display: none;
}
}

View file

@ -5,13 +5,14 @@ import {
} from '../constants/loadingPhrases';
import { useStreamingState } from '@qwen-code/webui/daemon-react-sdk';
import { useI18n } from '../i18n';
import { useStreamingOutputTokens } from '../hooks/useStreamingOutputTokens';
import { useStreamingLoadingMetrics } from '../hooks/useStreamingLoadingMetrics';
import { formatTokenCount } from '../utils/formatTokenCount';
import styles from './StreamingStatus.module.css';
export function StreamingStatus() {
const streamingState = useStreamingState();
const outputTokens = useStreamingOutputTokens();
const { estimatedOutputTokens, isReceivingContent } =
useStreamingLoadingMetrics();
const { language, t } = useI18n();
const [elapsed, setElapsed] = useState(0);
const startTime = useRef(Date.now());
@ -21,14 +22,21 @@ export function StreamingStatus() {
return phrases[0] ?? '';
});
const isActive = streamingState !== 'idle';
useEffect(() => {
if (!isActive) {
setElapsed(0);
return;
}
startTime.current = Date.now();
setElapsed(0);
const interval = setInterval(() => {
setElapsed(Math.floor((Date.now() - startTime.current) / 1000));
}, 1000);
return () => clearInterval(interval);
}, [streamingState]);
}, [isActive]);
useEffect(() => {
const phrases = getLoadingPhrases(language);
@ -59,10 +67,11 @@ export function StreamingStatus() {
const dots = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const spinnerChar = dots[dotFrame % dots.length];
const arrow = streamingState === 'responding' ? '↓' : '↑';
const arrow = isReceivingContent ? '↓' : '↑';
const timeStr = elapsed < 60 ? `${elapsed}s` : formatDuration(elapsed * 1000);
const tokenStr =
outputTokens > 0
? ` · ${arrow} ${t('stream.tokens', { count: formatTokenCount(outputTokens) })}`
estimatedOutputTokens > 0
? ` · ${arrow} ${t('stream.tokens', { count: formatTokenCount(estimatedOutputTokens) })}`
: '';
return (
@ -70,8 +79,27 @@ export function StreamingStatus() {
<span className={styles.spinner}>{spinnerChar}</span>
{loadingPhrase && <span className={styles.label}>{loadingPhrase}</span>}
<span className={styles.meta}>
({elapsed}s{tokenStr} · {t('stream.cancel')})
({timeStr}
{tokenStr} · {t('stream.cancel')})
</span>
</div>
);
}
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';
}

View file

@ -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({
</div>
</div>
<div className={styles.tip}>
<span className={styles.tipLabel}>{t('welcome.tipLabel')}</span>
<span className={styles.tipText}>{tip}</span>
</div>
{!hideTips && (
<div className={styles.tip}>
<span className={styles.tipLabel}>{t('welcome.tipLabel')}</span>
<span className={styles.tipText}>{tip}</span>
</div>
)}
</div>
);
}

View file

@ -8,6 +8,7 @@
border: 1px solid var(--border-color);
border-radius: var(--radius);
overflow: hidden;
margin: 24px;
}
.resume-picker-header {

View file

@ -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`),

View file

@ -11,6 +11,7 @@
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.45;
margin-left: 14px;
}
.title {

View file

@ -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 (
<div className={styles.detailRow}>
@ -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

View file

@ -37,6 +37,8 @@
gap: 2px;
max-height: min(360px, calc(100vh - 260px));
overflow-y: auto;
padding-right: 14px;
scrollbar-gutter: stable;
}
.category {

View file

@ -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<DaemonWorkspaceSettingsStatus | undefined>;
setValue: (
scope: 'workspace',
key: string,
value: unknown,
) => Promise<DaemonSettingUpdateResult>;
}
const SUB_DIALOG_KEYS = new Set(['fastModel']);
type Scope = 'user' | 'workspace';
@ -31,6 +63,55 @@ type Translator = (
vars?: Record<string, string | number>,
) => 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<string, DaemonSettingDescriptor[]>();
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<Scope>('workspace');
const [selectedIdx, setSelectedIdx] = useState(0);
const [busyKey, setBusyKey] = useState<string | null>(null);
@ -170,6 +265,10 @@ export function SettingsMessage({
key: string;
draft: string;
} | null>(null);
type SubPanel = null | 'theme' | 'language';
const [subPanel, setSubPanel] = useState<SubPanel>(null);
const [selectedThemeIdx, setSelectedThemeIdx] = useState(0);
const [selectedLanguageIdx, setSelectedLanguageIdx] = useState(0);
const panelIdRef = useRef(`settings-${Math.random().toString(36).slice(2)}`);
const panelRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(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 (
<div ref={panelRef} className={styles.panel} data-keyboard-scope>
<div className={styles.header}>
<span className={styles.title}>{t('settings.title')}</span>
<span className={styles.title}>
{subPanel === 'theme'
? `${t('settings.title')} / ${t('theme.title')}`
: subPanel === 'language'
? `${t('settings.title')} / ${t('language.set')}`
: t('settings.title')}
</span>
<span className={styles.secondary}>{scopeLabel}</span>
</div>
{(message || loading) && (
{(message || showInitialLoading) && (
<div className={styles.hint}>{message || t('settings.loading')}</div>
)}
<div
className={styles.list}
ref={listRef}
role="listbox"
aria-label={t('settings.title')}
>
{!loading && rows.length === 0 && (
<div className={styles.empty}>{t('settings.empty')}</div>
)}
{rows.map((row, i) => {
if (row.type === 'header') {
return (
<div
key={`cat-${row.category}`}
role="presentation"
className={styles.category}
>
{row.category}
</div>
);
}
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 (
<div className={styles.list} ref={listRef} role="listbox">
{subPanel === 'theme' ? (
WEB_SHELL_THEMES.map((themeName, index) => (
<div
key={setting.key}
key={themeName}
role="option"
aria-selected={isSelected}
className={`${styles.item} ${isSelected ? styles.selected : ''}`}
aria-selected={index === selectedThemeIdx}
className={`${styles.item} ${
index === selectedThemeIdx ? styles.selected : ''
}`}
onClick={() => {
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.
>
<div className={styles.row}>
<span className={styles.pointer}>{isSelected ? '' : ' '}</span>
<span className={styles.label}>
{setting.label}
{/* Cross-scope hint inline after the label, same as the
native CLI never a separate row. */}
{hintKey && (
<span className={styles.scopeHint}>
{' '}
{t(hintKey, {
scope: t(
scope === 'workspace'
? 'settings.scope.user'
: 'settings.scope.workspace',
),
})}
</span>
)}
<span className={styles.pointer}>
{index === selectedThemeIdx ? '' : ' '}
</span>
<span className={styles.label}>{t(`theme.${themeName}`)}</span>
<span className={styles.value}>
{busyKey === setting.key
? '...'
: `${formatValue(setting, scope, t)}${hasScopeValue ? '*' : ''}${setting.requiresRestart ? ' ⟳' : ''}${isSubDialog ? ' ▸' : ''}`}
{themeName === themeValue ? '✓' : ''}
</span>
</div>
{isEditing && editMode && (
<div className={styles.editWrap}>
<input
ref={inputRef}
className={styles.editInput}
type={setting.type === 'number' ? 'number' : 'text'}
value={editMode.draft}
onChange={(e) =>
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);
}
}}
/>
</div>
)}
</div>
);
})}
))
) : subPanel === 'language' ? (
WEB_SHELL_LANGUAGES.map((languageName, index) => (
<div
key={languageName}
role="option"
aria-selected={index === selectedLanguageIdx}
className={`${styles.item} ${
index === selectedLanguageIdx ? styles.selected : ''
}`}
onClick={() => {
if (busyKey) return;
setSelectedLanguageIdx(index);
setSubPanel(null);
onLanguageChange(languageName);
onClose();
}}
>
<div className={styles.row}>
<span className={styles.pointer}>
{index === selectedLanguageIdx ? '' : ' '}
</span>
<span className={styles.label}>
{languageLabel(languageName)}
</span>
<span className={styles.value}>
{languageName === languageValue ? '✓' : ''}
</span>
</div>
</div>
))
) : (
<>
{!loading && rows.length === 0 && (
<div className={styles.empty}>{t('settings.empty')}</div>
)}
{rows.map((row, i) => {
if (row.type === 'header') {
return (
<div
key={`cat-${row.category}`}
role="presentation"
className={styles.category}
>
{row.category}
</div>
);
}
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 (
<div
key={setting.key}
role="option"
aria-selected={isSelected}
className={`${styles.item} ${isSelected ? styles.selected : ''}`}
onClick={() => {
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.
>
<div className={styles.row}>
<span className={styles.pointer}>
{isSelected ? '' : ' '}
</span>
<span className={styles.label}>
{formatSettingLabel(setting, t)}
{/* Cross-scope hint inline after the label, same as the
native CLI never a separate row. */}
{hintKey && (
<span className={styles.scopeHint}>
{' '}
{t(hintKey, {
scope: t(
scope === 'workspace'
? 'settings.scope.user'
: 'settings.scope.workspace',
),
})}
</span>
)}
</span>
<span className={styles.value}>
{busyKey === setting.key
? '...'
: `${formatValue(setting, scope, t)}${hasScopeValue ? '*' : ''}${isSubDialog ? ' ▸' : ''}`}
</span>
</div>
{isEditing && editMode && (
<div className={styles.editWrap}>
<input
ref={inputRef}
className={styles.editInput}
type={setting.type === 'number' ? 'number' : 'text'}
value={editMode.draft}
onChange={(e) =>
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);
}
}}
/>
</div>
)}
</div>
);
})}
</>
)}
</div>
{/* Always rendered (nbsp placeholder) and clamped to one line, so the
@ -582,7 +884,11 @@ export function SettingsMessage({
</div>
<div className={styles.footer}>
{editMode ? t('settings.footer.edit') : t('settings.footer')}
{editMode
? t('settings.footer.edit')
: subPanel
? t('settings.footer.theme')
: t('settings.footer')}
</div>
</div>
);

View file

@ -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);
}

View file

@ -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({
) : (
<pre>{content}</pre>
)}
{showRetryHint && onRetryClick && (
<div className={styles.retryHint}>
<button
type="button"
className={styles.retryButton}
onClick={onRetryClick}
>
{t('retry.hint')}
</button>
</div>
)}
</div>
);
});

View file

@ -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<string, string>,
) => 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 && (
<div className={styles.lineDetail}>
{isShellToolName(name) && <ExpandedBashOutput tool={tool} />}
{isShellToolName(name) && (
<ExpandedBashOutput tool={tool} maxLines={shellOutputMaxLines} />
)}
{(name === 'write_file' || name === 'writefile') && (
<ExpandedEditDiff tool={tool} />
)}
@ -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}
/>
))}
</div>

View file

@ -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<LoadingMetrics>({
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<string>();
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<string, unknown>;
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<string, unknown>)['totalTokens'];
if (typeof totalTokens === 'number' && totalTokens > 0) return totalTokens;
}
return undefined;
}

View file

@ -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;
}

View file

@ -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': '交互式 ShellPTY',
'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 标题;完整模式下显示为工具组下方的弱化 ● <label> 行。需要配置快速模型。',
'settings.label.agents.arena.preserveArtifacts': '保留 Arena 产物',
'settings.description.agents.arena.preserveArtifacts':
'启用后Arena worktree 和会话状态文件会在会话结束或主智能体退出后保留。',
'welcome.modeHint': 'Shift+Tab 或 /approval-mode',
'welcome.tipLabel': '提示:',
};
@ -1639,6 +1750,35 @@ export function normalizeLanguage(
return 'en';
}
export function languageSettingToWebShellLanguage(
value: unknown,
): WebShellLanguage | undefined {
if (typeof value !== 'string') return undefined;
const normalized = value.trim().toLowerCase().replace(/_/g, '-');
if (!normalized) return undefined;
if (normalized === 'auto') {
return normalizeLanguage(
typeof navigator !== 'undefined' ? navigator.language : undefined,
);
}
if (
normalized === 'zh' ||
normalized === 'zh-cn' ||
normalized === 'chinese' ||
normalized === '中文'
) {
return 'zh-CN';
}
if (
normalized === 'en' ||
normalized === 'en-us' ||
normalized === 'english'
) {
return 'en';
}
return undefined;
}
export function languageLabel(language: WebShellLanguage): string {
return LANGUAGE_LABELS[language];
}

View file

@ -12,6 +12,7 @@ import {
removeDaemonTokenFromUrl,
} from './config/daemon';
import { normalizeLanguage, type WebShellLanguage } from './i18n';
import { WebShellThemeId, type WebShellTheme } from './themeContext';
import 'katex/dist/katex.min.css';
import './styles/standalone.css';
@ -21,23 +22,28 @@ removeDaemonTokenFromUrl();
const LANGUAGE_STORAGE_KEY = 'qwen-code-web-shell-language';
const THEME_STORAGE_KEY = 'qwen-code-web-shell-theme';
type StandaloneTheme = 'dark' | 'light';
function getThemeFromUrl(): StandaloneTheme | undefined {
const theme = new URLSearchParams(window.location.search).get('theme');
return theme === 'dark' || theme === 'light' ? theme : undefined;
function parseTheme(value: string | null): WebShellTheme | undefined {
if (value === WebShellThemeId.Dark || value === WebShellThemeId.Light) {
return value;
}
return undefined;
}
function readStoredTheme(): StandaloneTheme | undefined {
function getThemeFromUrl(): WebShellTheme | undefined {
const theme = new URLSearchParams(window.location.search).get('theme');
return parseTheme(theme);
}
function readStoredTheme(): WebShellTheme | undefined {
try {
const raw = window.localStorage.getItem(THEME_STORAGE_KEY);
return raw === 'dark' || raw === 'light' ? raw : undefined;
return parseTheme(window.localStorage.getItem(THEME_STORAGE_KEY));
} catch {
return undefined;
}
}
function storeTheme(theme: StandaloneTheme): void {
function storeTheme(theme: WebShellTheme): void {
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
@ -45,8 +51,8 @@ function storeTheme(theme: StandaloneTheme): void {
}
}
function getInitialTheme(): StandaloneTheme {
return getThemeFromUrl() ?? readStoredTheme() ?? 'dark';
function getInitialTheme(): WebShellTheme {
return getThemeFromUrl() ?? readStoredTheme() ?? WebShellThemeId.Dark;
}
function readStoredLanguage(): WebShellLanguage | undefined {
@ -84,13 +90,13 @@ function getSessionIdFromUrl(): string | undefined {
}
function StandaloneApp() {
const [theme, setTheme] = useState<StandaloneTheme>(() => getInitialTheme());
const [theme, setTheme] = useState<WebShellTheme>(() => getInitialTheme());
const [language, setLanguage] = useState<WebShellLanguage>(() =>
getInitialLanguage(),
);
const initialSessionId = useMemo(() => getSessionIdFromUrl(), []);
const baseUrl = DAEMON_BASE_URL || window.location.origin;
const handleThemeChange = useCallback((nextTheme: StandaloneTheme) => {
const handleThemeChange = useCallback((nextTheme: WebShellTheme) => {
setTheme(nextTheme);
storeTheme(nextTheme);
}, []);

View file

@ -1,11 +1,40 @@
import { createContext, useContext } from 'react';
export type WebShellTheme = 'dark' | 'light';
export const WebShellThemeId = {
Dark: 'dark',
Light: 'light',
} as const;
const ThemeContext = createContext<WebShellTheme>('dark');
export type WebShellTheme =
(typeof WebShellThemeId)[keyof typeof WebShellThemeId];
export const WEB_SHELL_THEMES: readonly WebShellTheme[] = [
WebShellThemeId.Dark,
WebShellThemeId.Light,
];
const ThemeContext = createContext<WebShellTheme>(WebShellThemeId.Dark);
export const ThemeProvider = ThemeContext.Provider;
export function useTheme(): WebShellTheme {
return useContext(ThemeContext);
}
export const THEME_SETTING_KEY = 'ui.theme';
export const LANGUAGE_SETTING_KEY = 'general.language';
export function themeSettingToWebShellTheme(
value: unknown,
fallback?: WebShellTheme,
): WebShellTheme | undefined {
if (value === WebShellThemeId.Light || value === 'Qwen Light')
return WebShellThemeId.Light;
if (value === WebShellThemeId.Dark || value === 'Qwen Dark')
return WebShellThemeId.Dark;
return fallback;
}
export function webShellThemeToSettingValue(theme: WebShellTheme): string {
return theme === WebShellThemeId.Light ? 'Qwen Light' : 'Qwen Dark';
}

View file

@ -247,16 +247,16 @@ Do NOT nest multiple `<DaemonSessionProvider>` for the same session — that cre
### Session hooks
| Hook | Returns |
| ------------------------- | ------------------------------------------------------------------------------------------------------- |
| `useTranscriptBlocks()` | `readonly DaemonTranscriptBlock[]` (raw blocks) |
| `useTranscriptState()` | Full `DaemonTranscriptState` (blocks + metadata) |
| `useActions()` | `{ sendPrompt, cancel, setModel, setApprovalMode, respondToPermission, loadSession, newSession, ... }` |
| `useConnection()` | `{ status, sessionId, currentModel, currentMode, commands, skills, models, tokenCount, contextWindow }` |
| `useStreamingState()` | `'idle' \| 'waiting' \| 'responding' \| 'thinking'` |
| `usePromptStatus()` | `'idle' \| 'waiting' \| 'streaming'` |
| `usePendingPermissions()` | Unresolved permission blocks |
| `useActiveTodoList()` | Latest todo list, only when it still has active items |
| Hook | Returns |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `useTranscriptBlocks()` | `readonly DaemonTranscriptBlock[]` (raw blocks) |
| `useTranscriptState()` | Full `DaemonTranscriptState` (blocks + metadata) |
| `useActions()` | `{ sendPrompt, cancel, setModel, setApprovalMode, respondToPermission, loadSession, newSession, ... }` |
| `useConnection()` | `{ status, sessionId, currentModel, currentMode, commands, skills, models, tokenCount, tokenUsage, contextWindow }` |
| `useStreamingState()` | `'idle' \| 'waiting' \| 'responding' \| 'thinking'` |
| `usePromptStatus()` | `'idle' \| 'waiting' \| 'streaming'` |
| `usePendingPermissions()` | Unresolved permission blocks |
| `useActiveTodoList()` | Latest todo list, only when it still has active items |
### Workspace hooks

View file

@ -146,6 +146,8 @@ export type {
DaemonConnectionState,
/** Connection lifecycle: `'idle' | 'connecting' | 'connected' | 'disconnected' | 'error'`. */
DaemonConnectionStatus,
/** Latest main-conversation token usage reported by the daemon. */
DaemonTokenUsage,
/** Model descriptor: id, display label, context window size. */
DaemonModelInfo,
/** Classified notice category for host-owned UI routing. */

View file

@ -46,6 +46,7 @@ export type {
DaemonSessionContextValue,
DaemonSessionNotice,
DaemonSessionProviderProps,
DaemonTokenUsage,
DaemonTodoItem,
DaemonTodoList,
DaemonTodoPriority,

View file

@ -665,6 +665,40 @@ describe('DaemonSessionProvider', () => {
);
});
it('passes retry prompts through the daemon action', async () => {
const prompt = vi.fn(async () => ({ stopReason: 'end_turn' }));
const session = createMockSession({
prompt,
events: createIdleEvents(),
});
sdkMocks.sessions.push(session);
let actions: DaemonSessionActions | undefined;
function Harness() {
actions = useDaemonActions();
return null;
}
await renderWithProvider(<Harness />, { autoConnect: true });
const providerActions = actions;
if (!providerActions) throw new Error('actions were not initialized');
await act(async () => {
await providerActions.sendPrompt('retry this', {
optimisticUserMessage: false,
retry: true,
});
});
expect(prompt).toHaveBeenCalledWith(
{
prompt: [{ type: 'text', text: 'retry this' }],
retry: true,
},
expect.any(AbortSignal),
);
});
it('submits permission selections with optional answers', async () => {
const respondToSessionPermission = vi.fn(async () => true);
const session = createMockSession({
@ -792,6 +826,16 @@ describe('DaemonSessionProvider', () => {
yield {
id: 24,
v: 1,
type: 'settings_changed',
data: {
key: 'ui.theme',
scope: 'workspace',
value: 'Qwen Dark',
},
};
yield {
id: 25,
v: 1,
type: 'mcp_server_restarted',
data: {
serverName: 'chrome-devtools',
@ -817,6 +861,7 @@ describe('DaemonSessionProvider', () => {
memoryVersion: 1,
agentsVersion: 1,
toolsVersion: 1,
settingsVersion: 1,
mcpVersion: 1,
initVersion: 0,
authVersion: 0,
@ -1829,6 +1874,10 @@ describe('DaemonSessionProvider', () => {
});
expect(connection?.tokenCount).toBe(23_000);
expect(connection?.tokenUsage).toEqual({
inputTokens: 23_000,
totalTokens: 25_000,
});
});
it('keeps the in-memory tokenCount across SSE re-subscribe when replay has no usage', async () => {
@ -1886,6 +1935,10 @@ describe('DaemonSessionProvider', () => {
// the live count.
expect(events).toHaveBeenCalledTimes(2);
expect(connection?.tokenCount).toBe(7_000);
expect(connection?.tokenUsage).toEqual({
inputTokens: 7_000,
totalTokens: 7_500,
});
});
it('resets tokenCount when reconnect attaches a different session without replay usage', async () => {
@ -1932,6 +1985,10 @@ describe('DaemonSessionProvider', () => {
await flushPromises();
});
expect(connection?.tokenCount).toBe(7_000);
expect(connection?.tokenUsage).toEqual({
inputTokens: 7_000,
totalTokens: 7_500,
});
firstEvents.close();
await act(async () => {
@ -1941,6 +1998,7 @@ describe('DaemonSessionProvider', () => {
expect(connection).toMatchObject({ sessionId: 'session-usage-b' });
expect(connection?.tokenCount).toBe(0);
expect(connection?.tokenUsage).toBeUndefined();
});
it('bumps workspace event signals from replay snapshot events', async () => {
@ -2699,6 +2757,45 @@ describe('DaemonSessionProvider', () => {
},
);
it.each([404, 410])(
'leaves missing sessions disconnected on %d when requested',
async (status) => {
let createAttempts = 0;
sdkMocks.MockDaemonSessionClient.createOrAttach.mockImplementation(
async () => {
createAttempts += 1;
throw Object.assign(new Error('session gone'), { status });
},
);
let connection: DaemonConnectionState | undefined;
function Harness() {
connection = useDaemonConnection();
return null;
}
await renderWithProvider(<Harness />, {
autoConnect: true,
autoReconnect: true,
missingSessionBehavior: 'disconnect',
reconnectDelayMs: 1,
maxReconnectDelayMs: 1,
});
await act(async () => {
await wait(30);
await flushPromises();
});
expect(createAttempts).toBe(1);
expect(connection).toMatchObject({
status: 'disconnected',
error: 'session gone',
});
expect(connection?.sessionId).toBeUndefined();
},
);
it.each([401, 403])(
'preserves transcript and clears prompt state on %d auth failures from the SSE stream',
async (status) => {
@ -3786,7 +3883,7 @@ describe('DaemonSessionProvider', () => {
content: { type: 'text', text: 'before error' },
},
},
};
} satisfies DaemonEvent;
throw new Error('network timeout');
}
// Second call: delta resume succeeds with new content
@ -3800,7 +3897,7 @@ describe('DaemonSessionProvider', () => {
content: { type: 'text', text: ' after resume' },
},
},
};
} satisfies DaemonEvent;
await new Promise<void>((resolve) => {
if (opts.signal?.aborted) {
resolve();

View file

@ -40,8 +40,9 @@ import {
import { useOptionalDaemonWorkspace } from '../workspace/DaemonWorkspaceProvider.js';
import {
getCurrentMode,
getReplayTokenCount,
getSessionDisplayName,
getReplayTokenUsage,
getTokenCountFromUsage,
mapProviderStatus,
mapSupportedCommands,
updateConnectionFromDaemonEvent,
@ -149,8 +150,8 @@ const INITIAL_WORKSPACE_EVENT_SIGNALS: DaemonWorkspaceEventSignals = {
* and risking transcript wipes if reconnect later attaches a different
* session and hits the sessionId-change `store.reset()` branch.
*
* 404/410 (session-not-found) keep the reconnect-then-recreate behavior
* those are recoverable by creating a fresh session.
* 404/410 (session-not-found) normally keep the reconnect-then-recreate
* behavior, unless the caller opts into leaving missing sessions disconnected.
*/
const AUTH_FAILURE_HTTP_STATUSES = new Set([401, 403]);
@ -167,6 +168,7 @@ export function DaemonSessionProvider({
includeRawEvent = false,
autoConnect = true,
autoReconnect = true,
missingSessionBehavior = 'create',
reconnectDelayMs = 1_000,
maxReconnectDelayMs = 10_000,
heartbeatIntervalMs = 30_000,
@ -308,6 +310,7 @@ export function DaemonSessionProvider({
// Only populated when this attempt (re)loads the session: a reused
// session object carries the snapshot from its original load, whose
// usage may be older than the in-memory count.
let replayTokenUsage: DaemonConnectionState['tokenUsage'];
let replayTokenCount: number | undefined;
if (!session) {
setConnection((current) => ({
@ -425,10 +428,12 @@ export function DaemonSessionProvider({
shouldInjectReplaySnapshot =
nextSession.replaySnapshot.compactedReplay.length > 0 ||
nextSession.replaySnapshot.liveJournal.length > 0;
replayTokenCount = getReplayTokenCount([
const replayEvents = [
...nextSession.replaySnapshot.compactedReplay,
...nextSession.replaySnapshot.liveJournal,
]);
];
replayTokenUsage = getReplayTokenUsage(replayEvents);
replayTokenCount = getTokenCountFromUsage(replayTokenUsage);
session = nextSession;
reconnectSessionId = session.sessionId;
shouldCreateFreshSession = false;
@ -485,6 +490,15 @@ export function DaemonSessionProvider({
(current.sessionId === activeSession.sessionId
? current.displayName
: undefined),
tokenUsage:
// Keep token usage in sync with tokenCount: replay usage
// supersedes in-memory state, same-session reconnect keeps it,
// and a different session without replay usage starts empty.
replayTokenUsage !== undefined
? replayTokenUsage
: current.sessionId === activeSession.sessionId
? current.tokenUsage
: undefined,
tokenCount:
// A freshly loaded snapshot covers everything up to the SSE
// resume point, so its usage supersedes the in-memory count;
@ -611,6 +625,19 @@ export function DaemonSessionProvider({
{ requireBoundPromptId: true },
);
}
// If replay has events but no terminal signal
// (turn_complete/turn_error/prompt_cancelled), the turn was
// likely still in progress — seed promptStatus so the loading
// indicator shows immediately instead of flickering.
const hasTurnTerminalEvent = replayEvents.some(
(e) =>
e.type === 'turn_complete' ||
e.type === 'turn_error' ||
e.type === 'prompt_cancelled',
);
if (replayEvents.length > 0 && !hasTurnTerminalEvent) {
setPromptStatus((s) => (s === 'idle' ? 'waiting' : s));
}
setConnection((c) => ({ ...c, catchingUp: undefined }));
}
if (pendingLoadToResolve) {
@ -884,6 +911,10 @@ export function DaemonSessionProvider({
const failedSessionId = session?.sessionId;
const isAuthFailure = isAuthFailureHttpError(error);
const isTerminal = isTerminalSessionHttpError(error);
const shouldDisconnectMissingSession =
isTerminal &&
!isAuthFailure &&
missingSessionBehavior === 'disconnect';
if (failedSessionId && (isAuthFailure || isTerminal)) {
const active = activePromptsRef.current.get(failedSessionId);
active?.controller.abort();
@ -911,6 +942,15 @@ export function DaemonSessionProvider({
setConnection({ status: 'error', error: message });
return;
}
if (shouldDisconnectMissingSession) {
setConnection((current) => ({
...current,
status: 'disconnected',
sessionId: undefined,
error: message,
}));
return;
}
reconnectSessionId = undefined;
if (restoreSessionId) {
setRestoreSessionId(undefined);
@ -998,6 +1038,7 @@ export function DaemonSessionProvider({
}, [
autoConnect,
autoReconnect,
missingSessionBehavior,
resolvedBaseUrl,
resolvedToken,
workspaceCwd,
@ -1315,6 +1356,7 @@ export function useDaemonActiveTodoList() {
export function useDaemonStreamingState() {
const blocks = useDaemonTranscriptBlocks();
const promptStatus = useDaemonPromptStatus();
return useMemo(
() => selectDaemonStreamingState(blocks, promptStatus),
[blocks, promptStatus],

View file

@ -153,10 +153,14 @@ export function createDaemonSessionActions({
if (options?.optimisticUserMessage !== false) {
store.appendLocalUserMessage(text, normalizedImages);
}
const promptRequest: Record<string, unknown> = {
prompt: toDaemonPromptContent(text, normalizedImages),
};
if (options?.retry) {
promptRequest['retry'] = true;
}
const result = await session.prompt(
{
prompt: toDaemonPromptContent(text, normalizedImages),
},
promptRequest as Parameters<typeof session.prompt>[0],
ctrl.signal,
);
if (isNonBlockingAccepted(result)) {

View file

@ -34,6 +34,7 @@ export type {
DaemonSessionContextValue,
DaemonSessionNotice,
DaemonSessionProviderProps,
DaemonTokenUsage,
DaemonTodoItem,
DaemonTodoList,
DaemonTodoPriority,

View file

@ -6,7 +6,7 @@
import { describe, expect, it } from 'vitest';
import type { DaemonEvent } from '@qwen-code/sdk/daemon';
import { getReplayTokenCount } from './mappers.js';
import { getReplayTokenCount, getReplayTokenUsage } from './mappers.js';
function usageEvent(
id: number,
@ -65,6 +65,34 @@ describe('getReplayTokenCount', () => {
).toBe(23_000);
});
it('returns the latest structured usage fields', () => {
expect(
getReplayTokenUsage([
usageEvent(1, {
cachedReadTokens: 10,
inputTokens: 11_000,
outputTokens: 100,
thoughtTokens: 5,
totalTokens: 11_105,
}),
turnComplete,
usageEvent(3, {
cachedReadTokens: 0,
inputTokens: 23_279,
outputTokens: 182,
thoughtTokens: 0,
totalTokens: 23_461,
}),
]),
).toEqual({
cachedReadTokens: 0,
inputTokens: 23_279,
outputTokens: 182,
thoughtTokens: 0,
totalTokens: 23_461,
});
});
it('prefers inputTokens over totalTokens and falls back to totalTokens', () => {
expect(
getReplayTokenCount([

View file

@ -16,6 +16,7 @@ import type {
DaemonCommandInfo,
DaemonConnectionState,
DaemonModelInfo,
DaemonTokenUsage,
} from './types.js';
export function mapProviderStatus(
@ -130,9 +131,13 @@ export function updateConnectionFromDaemonEvent(
): void {
if (event.type === 'session_update') {
const update = getRecord(getRecord(event.data)?.['update']);
const tokenCount = getUsageTokenCount(update);
if (tokenCount !== undefined) {
setConnection((current) => ({ ...current, tokenCount }));
const tokenUsage = getUsageTokenUsage(update);
if (tokenUsage) {
setConnection((current) => ({
...current,
tokenUsage,
tokenCount: getTokenCountFromUsage(tokenUsage),
}));
}
if (getString(update, 'sessionUpdate') === 'available_commands_update') {
const { commands, skills } = mapAvailableCommandsUpdate(update);
@ -200,13 +205,32 @@ export function getCurrentMode(
export function getReplayTokenCount(
events: readonly DaemonEvent[],
): number | undefined {
return getTokenCountFromUsage(getReplayTokenUsage(events));
}
export function getTokenCountFromUsage(
usage: DaemonTokenUsage | undefined,
): number | undefined {
const preferred = usage?.inputTokens ?? usage?.totalTokens;
if (preferred !== undefined && preferred > 0) return preferred;
if (!usage) return undefined;
const total = Object.values(usage).reduce(
(sum, value) => sum + (typeof value === 'number' ? value : 0),
0,
);
return total > 0 ? total : undefined;
}
export function getReplayTokenUsage(
events: readonly DaemonEvent[],
): DaemonTokenUsage | undefined {
for (let i = events.length - 1; i >= 0; i--) {
try {
const event = events[i];
if (event.type !== 'session_update') continue;
const update = getRecord(getRecord(event.data)?.['update']);
const tokenCount = getUsageTokenCount(update);
if (tokenCount !== undefined) return tokenCount;
const tokenUsage = getUsageTokenUsage(update);
if (tokenUsage) return tokenUsage;
} catch {
// Malformed replay events are skipped, mirroring the replay
// injection loop — a usage scan must not fail the whole attach.
@ -217,15 +241,30 @@ export function getReplayTokenCount(
// Sub-agent usage events carry `parentToolCallId` in `_meta`; skip them
// so the status bar only reflects the main conversation's context usage.
function getUsageTokenCount(
function getUsageTokenUsage(
update: Record<string, unknown> | undefined,
): number | undefined {
): DaemonTokenUsage | undefined {
const meta = getRecord(update?.['_meta']);
if (meta?.['parentToolCallId'] !== undefined) return undefined;
const usage = getRecord(meta?.['usage']);
const count =
getNumber(usage, 'inputTokens') ?? getNumber(usage, 'totalTokens');
return count !== undefined && count > 0 ? count : undefined;
const tokenUsage: DaemonTokenUsage = {
...mapTokenUsageNumber(usage, 'inputTokens'),
...mapTokenUsageNumber(usage, 'outputTokens'),
...mapTokenUsageNumber(usage, 'totalTokens'),
...mapTokenUsageNumber(usage, 'thoughtTokens'),
...mapTokenUsageNumber(usage, 'cachedReadTokens'),
};
return getTokenCountFromUsage(tokenUsage) !== undefined
? tokenUsage
: undefined;
}
function mapTokenUsageNumber(
usage: Record<string, unknown> | undefined,
key: keyof DaemonTokenUsage,
): Partial<DaemonTokenUsage> {
const value = getNumber(usage, key);
return value !== undefined && value >= 0 ? { [key]: value } : {};
}
function mapAvailableCommandsUpdate(

View file

@ -48,6 +48,9 @@ export interface DaemonConnectionState {
currentModel?: string;
currentMode?: string;
displayName?: string;
/** Latest main-conversation model usage event. */
tokenUsage?: DaemonTokenUsage;
/** Current context-window occupancy, used with contextWindow for percentages. */
tokenCount?: number;
contextWindow?: number;
providers?: DaemonWorkspaceProvidersStatus;
@ -59,30 +62,59 @@ export interface DaemonConnectionState {
error?: string;
}
export interface DaemonTokenUsage {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
thoughtTokens?: number;
cachedReadTokens?: number;
}
export interface DaemonSessionProviderProps {
/** Daemon base URL. Optional when nested inside DaemonWorkspaceProvider (inherited). */
baseUrl?: string;
/** Bearer token. Optional when nested inside DaemonWorkspaceProvider (inherited). */
token?: string;
/** Workspace cwd used when creating, loading, or resuming daemon sessions. */
workspaceCwd?: string;
/** Session id to load on mount instead of creating or attaching automatically. */
initialSessionId?: string;
/** Stable client identity to reuse for session-scoped daemon requests. */
clientId?: string;
/** Extra create-session options, excluding workspaceCwd which is owned by the provider. */
createSessionRequest?: Omit<CreateSessionRequest, 'workspaceCwd'>;
/** Maximum queued SSE events requested from the daemon per subscription. */
maxQueued?: number;
/** Maximum normalized transcript blocks retained in memory. */
maxBlocks?: number;
/** Hide this client's own user prompt echo when the daemon replays events. */
suppressOwnUserEcho?: boolean;
/** Attach raw daemon events to normalized transcript blocks for debugging. */
includeRawEvent?: boolean;
/** Connect to the daemon automatically on mount. */
autoConnect?: boolean;
/** Reconnect automatically after recoverable daemon/session failures. */
autoReconnect?: boolean;
/** Behavior when the active session is missing (404/410). Defaults to create. */
missingSessionBehavior?: 'create' | 'disconnect';
/** Initial reconnect delay in milliseconds. */
reconnectDelayMs?: number;
/** Maximum reconnect delay in milliseconds after backoff. */
maxReconnectDelayMs?: number;
/** Interval in milliseconds for client heartbeat checks. */
heartbeatIntervalMs?: number;
/** Consecutive heartbeat failures before marking the session disconnected. */
heartbeatFailureThreshold?: number;
/** Optional user-facing fallback warnings for partial session load failures. */
loadWarnings?: {
/** Warning shown when model/provider status cannot be loaded. */
models?: string;
/** Warning shown when supported command metadata cannot be loaded. */
commands?: string;
/** Warning shown when session context metadata cannot be loaded. */
context?: string;
};
/** React children rendered inside the daemon session contexts. */
children: ReactNode;
}
@ -175,6 +207,12 @@ export interface DaemonCommandInfo {
export interface SendPromptOptions {
optimisticUserMessage?: boolean;
images?: DaemonPromptImage[];
/**
* When true, the daemon strips orphaned user entries from the chat
* history before re-sending, and skips recording a duplicate user
* message in the JSONL transcript. Used by Ctrl+Y retry.
*/
retry?: boolean;
}
export interface DaemonPromptImage {