mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
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:
parent
66c69865c7
commit
c61006b978
40 changed files with 1715 additions and 316 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ export interface DaemonSystemMessage {
|
|||
role: 'system';
|
||||
content: string;
|
||||
variant: 'info' | 'error' | 'warning';
|
||||
retryable?: boolean;
|
||||
}
|
||||
|
||||
export interface DaemonUserShellMessage {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -285,3 +285,9 @@
|
|||
line-height: 1.35;
|
||||
max-height: 2.7em;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.borderTopLabel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -21,3 +21,9 @@
|
|||
color: var(--text-dimmed);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
margin: 24px;
|
||||
}
|
||||
|
||||
.resume-picker-header {
|
||||
|
|
|
|||
|
|
@ -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`),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
||||
.title {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@
|
|||
gap: 2px;
|
||||
max-height: min(360px, calc(100vh - 260px));
|
||||
overflow-y: auto;
|
||||
padding-right: 14px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.category {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
185
packages/web-shell/client/hooks/useStreamingLoadingMetrics.ts
Normal file
185
packages/web-shell/client/hooks/useStreamingLoadingMetrics.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -5,7 +5,9 @@ import {
|
|||
type PropsWithChildren,
|
||||
} from 'react';
|
||||
|
||||
export type WebShellLanguage = 'en' | 'zh-CN';
|
||||
export const WEB_SHELL_LANGUAGES = ['en', 'zh-CN'] as const;
|
||||
|
||||
export type WebShellLanguage = (typeof WEB_SHELL_LANGUAGES)[number];
|
||||
|
||||
type MessageValue =
|
||||
| string
|
||||
|
|
@ -331,6 +333,9 @@ const EN: Messages = {
|
|||
'help.shortcut.togglePanel': 'Toggle this panel',
|
||||
'help.shortcut.retry': 'Retry last request',
|
||||
'help.shortcut.compact': 'Toggle compact mode',
|
||||
'retry.hint': 'Press Ctrl+Y to retry or click to retry',
|
||||
'retry.none': 'No failed request to retry.',
|
||||
'command.hidden': 'This command is not available.',
|
||||
'help.shortcut.approvals': 'Cycle approval modes',
|
||||
'help.shortcut.cancel': 'Close dialogs or cancel operation',
|
||||
'bug.failed': 'Failed to load system info for bug report.',
|
||||
|
|
@ -341,6 +346,7 @@ const EN: Messages = {
|
|||
'compact.enabled': 'Compact mode enabled',
|
||||
'compact.disabled': 'Compact mode disabled',
|
||||
'compact.hint': 'Press Ctrl+O to show full tool output',
|
||||
'compact.saveFailed': 'Failed to save compact mode',
|
||||
'help.subcommands': 'subcommands',
|
||||
'help.tab.commands': 'commands',
|
||||
'help.tab.custom': 'custom-commands',
|
||||
|
|
@ -729,6 +735,7 @@ const EN: Messages = {
|
|||
'stream.cancel': 'esc to cancel',
|
||||
'stream.tokens': (v) => `${v?.count ?? 0} tokens`,
|
||||
'theme.current': (v) => `current: ${v?.theme ?? ''}`,
|
||||
'theme.auto': 'Auto',
|
||||
'theme.dark': 'Dark',
|
||||
'theme.dark.desc': 'Terminal-style dark skin.',
|
||||
'theme.light': 'Light',
|
||||
|
|
@ -814,6 +821,7 @@ const EN: Messages = {
|
|||
'settings.footer':
|
||||
'↑↓ Navigate Enter Toggle Tab Scope r Reload ESC Close',
|
||||
'settings.footer.edit': 'Enter Save ESC Cancel',
|
||||
'settings.footer.theme': '↑↓ Navigate Enter Select ESC Back',
|
||||
'settings.scope.user': 'User',
|
||||
'settings.scope.workspace': 'Workspace',
|
||||
'settings.value.on': 'ON',
|
||||
|
|
@ -1126,6 +1134,9 @@ const ZH: Messages = {
|
|||
'help.shortcut.togglePanel': '切换此面板',
|
||||
'help.shortcut.retry': '重试上次请求',
|
||||
'help.shortcut.compact': '切换紧凑模式',
|
||||
'retry.hint': '按 Ctrl+Y 重试或点击重试',
|
||||
'retry.none': '没有可重试的失败请求。',
|
||||
'command.hidden': '该命令不可用。',
|
||||
'help.shortcut.approvals': '切换审批模式',
|
||||
'help.shortcut.cancel': '关闭弹窗或取消操作',
|
||||
'bug.failed': '加载系统信息失败,无法提交 Bug 报告。',
|
||||
|
|
@ -1136,6 +1147,7 @@ const ZH: Messages = {
|
|||
'compact.enabled': '紧凑模式已开启',
|
||||
'compact.disabled': '紧凑模式已关闭',
|
||||
'compact.hint': '按 Ctrl+O 显示完整工具输出',
|
||||
'compact.saveFailed': '保存紧凑模式失败',
|
||||
'help.subcommands': '子命令',
|
||||
'help.tab.commands': '命令',
|
||||
'help.tab.custom': '自定义命令',
|
||||
|
|
@ -1509,6 +1521,7 @@ const ZH: Messages = {
|
|||
'stream.cancel': 'esc 取消',
|
||||
'stream.tokens': (v) => `${v?.count ?? 0} tokens`,
|
||||
'theme.current': (v) => `当前:${v?.theme ?? ''}`,
|
||||
'theme.auto': '自动',
|
||||
'theme.dark': '暗色',
|
||||
'theme.dark.desc': '仿终端暗色皮肤。',
|
||||
'theme.light': '亮色',
|
||||
|
|
@ -1595,6 +1608,7 @@ const ZH: Messages = {
|
|||
'settings.empty': '暂无可用设置。',
|
||||
'settings.footer': '↑↓ 导航 Enter 切换 Tab 切换作用域 r 刷新 ESC 关闭',
|
||||
'settings.footer.edit': 'Enter 保存 ESC 取消',
|
||||
'settings.footer.theme': '↑↓ 导航 Enter 选择 ESC 返回',
|
||||
'settings.scope.user': '用户',
|
||||
'settings.scope.workspace': '工作区',
|
||||
'settings.value.on': '开',
|
||||
|
|
@ -1606,6 +1620,103 @@ const ZH: Messages = {
|
|||
'settings.requiresRestart': '此更改需要重启后才能生效。',
|
||||
'settings.corrupted': (v) =>
|
||||
`设置文件已损坏${v?.recovered === 'true' ? '(已从备份恢复)' : ''}`,
|
||||
'settings.category.General': '通用',
|
||||
'settings.category.UI': '界面',
|
||||
'settings.category.Privacy': '隐私',
|
||||
'settings.category.Model': '模型',
|
||||
'settings.category.Context': '上下文',
|
||||
'settings.category.Tools': '工具',
|
||||
'settings.category.Daemon': '守护进程',
|
||||
'settings.category.Experimental': '实验性',
|
||||
'settings.category.Advanced': '高级',
|
||||
'settings.label.general.enableAutoUpdate': '启用自动更新',
|
||||
'settings.description.general.enableAutoUpdate': '启动时自动检查并安装更新。',
|
||||
'settings.label.general.showSessionRecap': '显示会话回顾',
|
||||
'settings.description.general.showSessionRecap':
|
||||
'离开终端一段时间后返回时,自动显示一行“上次停在这里”的回顾。默认关闭。也可以随时使用 /recap 手动触发。',
|
||||
'settings.label.general.sessionRecapAwayThresholdMinutes':
|
||||
'会话回顾离开阈值(分钟)',
|
||||
'settings.description.general.sessionRecapAwayThresholdMinutes':
|
||||
'终端失焦多少分钟后,下一次重新聚焦时触发自动回顾。默认与 Claude Code 一致为 5 分钟;如果只是短暂切换窗口,可以调高。',
|
||||
'settings.label.general.cleanupPeriodDays': '清理周期(天)',
|
||||
'settings.description.general.cleanupPeriodDays':
|
||||
'~/.qwen/file-history/ 中用于 /rewind 的会话备份保留天数。后台清理最多每天运行一次。设为 0 表示最小保留(约 1 小时),仍会保护最近一小时触碰过的会话和当前活动会话。',
|
||||
'settings.label.general.gitCoAuthor.commit': '归因:commit',
|
||||
'settings.description.general.gitCoAuthor.commit':
|
||||
'通过 Qwen Code 创建 commit 时,添加 Co-authored-by trailer,并写入逐文件 AI 归因 git note。关闭后两者都会跳过。',
|
||||
'settings.label.general.gitCoAuthor.pr': '归因:PR',
|
||||
'settings.description.general.gitCoAuthor.pr':
|
||||
'运行 gh pr create 时,在 PR 描述中追加 Qwen Code 归因行。',
|
||||
'settings.label.general.language': '语言:界面',
|
||||
'settings.description.general.language':
|
||||
'用户界面的语言。使用 auto 可根据系统设置自动检测;也可以在 ~/.qwen/locales/ 中放置 JS 语言文件来使用自定义语言代码。',
|
||||
'settings.label.general.dynamicCommandTranslation': '语言:动态命令翻译',
|
||||
'settings.description.general.dynamicCommandTranslation':
|
||||
'为动态 slash command 描述启用 AI 翻译。关闭后动态命令使用原始描述,也不会触发翻译模型调用。',
|
||||
'settings.label.general.preventSystemSleep': '运行时防止系统睡眠',
|
||||
'settings.description.general.preventSystemSleep':
|
||||
'当 Qwen Code 正在流式生成模型回复或执行工具时防止系统睡眠。空闲输入状态和权限确认状态不会阻止睡眠。',
|
||||
'settings.label.ui.theme': '主题',
|
||||
'settings.description.ui.theme': '界面的颜色主题。',
|
||||
'settings.label.ui.hideTips': '隐藏提示',
|
||||
'settings.description.ui.hideTips': '隐藏界面中的帮助提示。',
|
||||
'settings.label.ui.enableWelcomeBack': '显示欢迎回来对话框',
|
||||
'settings.description.ui.enableWelcomeBack':
|
||||
'回到有历史会话的项目时显示欢迎回来对话框。选择“开始新的聊天会话”后,在项目摘要变化前不会再次显示。',
|
||||
'settings.label.ui.enableUserFeedback': '启用用户反馈',
|
||||
'settings.description.ui.enableUserFeedback':
|
||||
'对话结束后显示可选反馈对话框,帮助改进 Qwen 表现。',
|
||||
'settings.label.ui.enableFollowupSuggestions': '启用后续建议',
|
||||
'settings.description.ui.enableFollowupSuggestions':
|
||||
'任务完成后显示上下文相关的后续建议。按 Tab 或右方向键接受,按 Enter 接受并提交。',
|
||||
'settings.label.ui.compactMode': '紧凑模式',
|
||||
'settings.description.ui.compactMode':
|
||||
'隐藏工具输出和思考内容,显示更简洁的视图(可用 Ctrl+O 切换)。',
|
||||
'settings.label.ui.compactInline': '紧凑内联',
|
||||
'settings.description.ui.compactInline':
|
||||
'在每个分组内紧凑显示工具内容,而不是跨分组合并。需要先启用紧凑模式。',
|
||||
'settings.label.ui.shellOutputMaxLines': 'Shell 输出最大行数',
|
||||
'settings.description.ui.shellOutputMaxLines':
|
||||
'内联显示的 shell 输出最大行数。设为 0 可取消限制并显示完整输出;隐藏行数仍会通过 +N lines 指示器展示。',
|
||||
'settings.label.privacy.usageStatisticsEnabled': '启用使用统计',
|
||||
'settings.description.privacy.usageStatisticsEnabled': '启用使用统计收集。',
|
||||
'settings.label.fastModel': '快速模型',
|
||||
'settings.description.fastModel':
|
||||
'用于生成提示建议和推测执行的模型。留空则使用主模型。较小/更快的模型(例如 qwen3-coder-flash)可以降低延迟和成本。',
|
||||
'settings.label.context.fileFiltering.respectGitIgnore': '遵守 .gitignore',
|
||||
'settings.description.context.fileFiltering.respectGitIgnore':
|
||||
'搜索时遵守 .gitignore 文件。',
|
||||
'settings.label.context.fileFiltering.respectQwenIgnore': '遵守 .qwenignore',
|
||||
'settings.description.context.fileFiltering.respectQwenIgnore':
|
||||
'搜索时遵守 .qwenignore 文件。',
|
||||
'settings.label.context.fileFiltering.enableFuzzySearch': '启用模糊搜索',
|
||||
'settings.description.context.fileFiltering.enableFuzzySearch':
|
||||
'搜索文件时启用模糊搜索。',
|
||||
'settings.label.tools.toolSearch.enabled': '启用 ToolSearch',
|
||||
'settings.description.tools.toolSearch.enabled':
|
||||
'启用后,MCP 工具会通过 ToolSearch 按需加载,以减少提示词大小。对于依赖前缀 KV 缓存的模型(如 DeepSeek),可关闭此项来保持提示词前缀稳定并提高缓存命中率。',
|
||||
'settings.label.tools.shell.enableInteractiveShell': '交互式 Shell(PTY)',
|
||||
'settings.description.tools.shell.enableInteractiveShell':
|
||||
'使用 node-pty 提供交互式 shell 体验。PTY 不可用时回退到 child_process。',
|
||||
'settings.label.tools.computerUse.enabled': '启用 Computer Use',
|
||||
'settings.description.tools.computerUse.enabled':
|
||||
'启用后(默认),会注册 9 个 computer_use__* 延迟内置工具。',
|
||||
'settings.label.policy.permissionStrategy': '权限协调策略',
|
||||
'settings.description.policy.permissionStrategy':
|
||||
'多个客户端连接时权限请求的决策方式。first-responder 表示任意客户端先响应者生效;designated 表示仅提示发起方决策;consensus 表示需要 N-of-M 投票同意;local-only 表示只有 loopback 客户端可决策。需要重启 daemon 后生效。',
|
||||
'settings.option.policy.permissionStrategy.first-responder': '先响应者',
|
||||
'settings.option.policy.permissionStrategy.designated': '指定发起方',
|
||||
'settings.option.policy.permissionStrategy.consensus': '共识法定人数',
|
||||
'settings.option.policy.permissionStrategy.local-only': '仅本机',
|
||||
'settings.label.experimental.enableCronTools': '启用 Cron/Loop 工具',
|
||||
'settings.description.experimental.enableCronTools':
|
||||
'启用会话内 cron/loop 工具(实验性)。启用后,模型可以用 cron_create、cron_list 和 cron_delete 创建周期性提示。也可通过 QWEN_CODE_ENABLE_CRON=1 环境变量启用。',
|
||||
'settings.label.experimental.emitToolUseSummaries': '工具使用摘要',
|
||||
'settings.description.experimental.emitToolUseSummaries':
|
||||
'每个工具批次完成后生成一个简短的 LLM 标签。紧凑模式下会替代通用的 Tool × N 标题;完整模式下显示为工具组下方的弱化 ● <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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export type {
|
|||
DaemonSessionContextValue,
|
||||
DaemonSessionNotice,
|
||||
DaemonSessionProviderProps,
|
||||
DaemonTokenUsage,
|
||||
DaemonTodoItem,
|
||||
DaemonTodoList,
|
||||
DaemonTodoPriority,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export type {
|
|||
DaemonSessionContextValue,
|
||||
DaemonSessionNotice,
|
||||
DaemonSessionProviderProps,
|
||||
DaemonTokenUsage,
|
||||
DaemonTodoItem,
|
||||
DaemonTodoList,
|
||||
DaemonTodoPriority,
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue