qwen-code/packages/web-shell/client/App.tsx
Shaojin Wen 9f2168f78e
feat(web-shell): collapsible TodoWrite history with status diff (#5109)
* feat(web-shell): collapsible TodoWrite history with status diff

Inline todo_write updates rendered as a generic, non-collapsible tool row
crammed in with surrounding tool calls — the todo-specific renderer was
effectively dead code because the detector matched the literal "todowrite"
while the wire name is "todo_write" (kind "think").

- Detect the todo tool by name (todo_write / todowrite) instead of relying
  on the unrelated tool kind, reviving the rich rendering and also
  populating the floating todo panel that was empty on the daemon path.
- Render each update as its own standalone group, collapsed by default to
  the per-snapshot diff (just-completed / just-started items) or the current
  step, expanding to the full checklist; the header shows completed/total.
- Use consistent status glyphs (●/◐/○) in both collapsed and expanded views
  via a shared TodoView component used by ToolGroup and PlanMessage.

* fix(web-shell): scope todo diff per task identity; address review

- [Critical] computeTodoTimeline keyed its running state on todo.id alone, but
  ids aren't globally unique (ACP assigns positional ids, models renumber per
  plan), so a later plan diffed against a previous plan's stale terminal status
  and silently dropped events in the collapsed view. Key on id+content so
  distinct tasks stay separate; add an id-reuse regression test.
- Stabilize the TodoTimelineContext value with a signature-cached Map so
  streaming ticks that don't touch a todo snapshot no longer re-render every
  todo/plan row.
- Drop the unused completed/total from TodoSnapshotDiff (consumers compute the
  count locally — single source of truth).
- Fall back to the raw result summary when a todo_write payload is unparseable.
- Add PlanMessage rendering tests and extractTodosFromToolCall coverage; remove
  stale "step time" comments left after dropping per-step timing.

* test(web-shell): document todo-diff keying limits; cover signature

Follow-up to review on the id+content keying in computeTodoTimeline:
- Document the two rare trade-offs in the todoStateKey doc (a mid-task reword on
  a stable id, and unrelated plans reusing both id and content), both degrading
  to "the collapsed diff omits one event" while the expanded list stays correct.
- Pin behavior with tests: an item carried over and completed in a later turn
  (which id+content handles but a user-turn reset would drop), plus the two
  documented gaps.
- Add todoTimelineSignature tests: stable across non-todo edits; changes on any
  id/status/content change.

* refactor(web-shell): isolate plan context read; expand todo test coverage

Address follow-up review:
- Extract PlanEventSummary as the sole TodoTimelineContext consumer, mirroring
  ToolGroup's TodoToolBody so the memo-shielded PlanMessage stays stable when
  the timeline Map reference changes.
- Note the spurious-`started` axis of the reword trade-off in the todoStateKey
  doc (a reword can drop a completion or emit a stray start).
- Add direct isTodoWriteToolName tests (incl. the `todowrite` ACP variant) and
  a todoTimelineSignature empty-transcript test.
2026-06-15 00:44:15 +08:00

2813 lines
95 KiB
TypeScript

import {
createContext,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
useActions,
useConnection,
useDaemonFollowupSuggestion,
useSettings,
useSessionNotices,
useStreamingState,
useTranscriptBlocks,
useTranscriptStore,
useWorkspaceActions,
type DaemonSessionNotice,
type DaemonStreamingState,
} from '@qwen-code/webui/daemon-react-sdk';
import { isDaemonTurnError } from '@qwen-code/sdk/daemon';
import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon';
import { extractPendingPermission } from './adapters/transcriptAdapter';
import { MessageList, type MessageListHandle } from './components/MessageList';
import { Editor, type EditorHandle } from './components/Editor';
import type { PromptImage } from './adapters/promptTypes';
import { StatusBar, type StatusBarHandle } from './components/StatusBar';
import { ShortcutsPanel } from './components/ShortcutsPanel';
import { StreamingStatus } from './components/StreamingStatus';
import {
ToastHost,
type ToastTone,
type WebShellToast,
} from './components/ToastHost';
import { TodoPanel } from './components/panels/TodoPanel';
import { WelcomeHeader } from './components/WelcomeHeader';
import {
APPROVAL_MODE_ACTIVE_EVENT,
ApprovalModeMessage,
} from './components/messages/ApprovalModeMessage';
import { ResumeDialog } from './components/dialogs/ResumeDialog';
import {
AGENTS_ACTIVE_EVENT,
AgentsMessage,
type AgentsInitialMode,
} from './components/messages/AgentsMessage';
import {
MEMORY_ACTIVE_EVENT,
MemoryMessage,
} from './components/messages/MemoryMessage';
import {
MODEL_ACTIVE_EVENT,
ModelMessage,
type ModelInlineMode,
} from './components/messages/ModelMessage';
import {
AUTH_ACTIVE_EVENT,
AuthMessage,
} from './components/messages/AuthMessage';
import { ToolsDialog } from './components/dialogs/ToolsDialog';
import {
SETTINGS_ACTIVE_EVENT,
SettingsMessage,
} from './components/messages/SettingsMessage';
import { resolveShellOutputMaxLines } from './components/messages/ToolGroup';
import { HelpDialog } from './components/dialogs/HelpDialog';
import { ThemeDialog } from './components/dialogs/ThemeDialog';
import { DeleteSessionDialog } from './components/dialogs/DeleteSessionDialog';
import { ReleaseSessionDialog } from './components/dialogs/ReleaseSessionDialog';
import { getLocalCommands } from './constants/localCommands';
import { mergeCommands } from './hooks/daemonSessionMappers';
import { useAnimationFrameValue } from './hooks/useAnimationFrameValue';
import { useMessages } from './hooks/useMessages';
import { usePanelActive } from './hooks/usePanelActive';
import { useShallowMemo, useStableArray } from './hooks/useShallowMemo';
import {
I18nProvider,
getTranslator,
languageSettingToWebShellLanguage,
languageLabel,
normalizeLanguage,
type WebShellLanguage,
} from './i18n';
import {
copyFromLastAssistantMessage,
COPY_MESSAGES,
} from './utils/copyCommand';
import type { SkillInfo } from './completions/slashCompletion';
import { collectSystemInfo } from './utils/systemInfo';
import {
TasksStatusMessage,
type SerializedTasksMessage,
} from './components/messages/TasksStatusMessage';
import { handleTasksSlashCommand } from './utils/tasksCommand';
import { isBackgroundSubAgentToolCall } from './adapters/toolClassification';
import {
DAEMON_APPROVAL_MODES,
type DaemonApprovalMode,
} from '@qwen-code/webui/daemon-react-sdk';
import { serializeContextUsageMessage } from './components/messages/ContextUsageMessage';
import {
serializeStatsMessage,
type StatsView,
} from './components/messages/StatsMessage';
import {
serializeStatusMessage,
type StatusInfo,
} from './components/messages/StatusMessage';
import {
MCP_STATUS_ACTIVE_EVENT,
parseMcpStatusMessage,
serializeMcpStatusMessage,
} from './components/messages/McpStatusMessage';
import {
GOAL_STATUS_ACTIVE_EVENT,
parseGoalStatusMessage,
serializeGoalStatusMessage,
} from './components/messages/GoalStatusMessage';
import { TASKS_STATUS_ACTIVE_EVENT } from './components/messages/TasksStatusMessage';
import { BtwMessage } from './components/messages/BtwMessage';
import type { ACPToolCall, Message, PermissionRequest } from './adapters/types';
import {
computeTodoTimeline,
getFloatingTodos,
todoTimelineSignature,
type TodoSnapshotDiff,
} from './utils/todos';
import { ThemeProvider } from './themeContext';
import {
WebShellThemeId,
THEME_SETTING_KEY,
LANGUAGE_SETTING_KEY,
themeSettingToWebShellTheme,
type WebShellTheme,
} from './themeContext';
import {
WebShellCustomizationProvider,
type WebShellMarkdownCustomization,
type ToolHeaderExtraRenderer,
type WelcomeHeaderRenderer,
} from './customization';
import type { CommandDisplayCategoryOrder } from './utils/commandDisplay';
import styles from './App.module.css';
export const CompactModeContext = createContext(false);
/**
* Per-snapshot status diffs (keyed by tool callId or plan message id), so a
* history row can render what changed in that snapshot without re-deriving it
* from the whole transcript. Empty by default so a row rendered outside the
* provider still falls back gracefully.
*/
export const TodoTimelineContext = createContext<Map<string, TodoSnapshotDiff>>(
new Map(),
);
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_SETTING_KEY = 'ui.compactMode';
const HIDE_TIPS_SETTING_KEY = 'ui.hideTips';
function normalizeHiddenCommand(command: string): string {
return command.trim().replace(/^\/+/, '').toLowerCase();
}
// Keep in sync with CLEAR_KEYWORDS in packages/cli/src/ui/commands/goalCommand.ts
const GOAL_CLEAR_KEYWORDS = new Set([
'clear',
'stop',
'off',
'reset',
'none',
'cancel',
]);
function isGoalClearCommand(text: string): boolean {
const goalArg = text
.replace(/^\/goal\b/i, '')
.trim()
.toLowerCase();
return GOAL_CLEAR_KEYWORDS.has(goalArg);
}
interface QueuedPrompt {
id: number;
text: string;
images?: PromptImage[];
onComplete?: () => void;
}
interface ActiveGoalStatus {
condition: string;
setAt: number;
}
interface SendPromptOptionsWithRetry {
optimisticUserMessage?: boolean;
images?: PromptImage[];
retry?: boolean;
}
type GoalStatusTranscriptBlock = DaemonTranscriptBlock & {
text: string;
source?: string;
data?: unknown;
};
function getLatestActiveGoalFromBlocks(
blocks: readonly DaemonTranscriptBlock[],
): ActiveGoalStatus | null {
for (let i = blocks.length - 1; i >= 0; i--) {
const block = blocks[i];
if (block.kind !== 'status') continue;
const statusBlock = block as GoalStatusTranscriptBlock;
const status =
statusBlock.source === 'goal'
? parseGoalStatusMessage(statusBlock.data)
: parseGoalStatusMessage(statusBlock.text);
if (!status) continue;
if (status.kind === 'set' || status.kind === 'checking') {
return {
condition: status.condition,
setAt: status.setAt ?? block.serverTimestamp ?? block.createdAt,
};
}
return null;
}
return null;
}
interface LocalAnchoredMessage {
anchorAfterId?: string;
anchorIndex: number;
message: Message;
}
interface ModelSwitchSummary {
authType: string;
modelId: string;
baseUrl: string;
apiKey: string;
isRuntime?: boolean;
}
export interface BugReportInfo {
title: string;
systemInfo: Record<string, string>;
}
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?: 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. */
language?: 'en' | 'zh-CN' | 'zh' | 'zh-cn';
/** Called when `/language ui` changes the web-shell UI language. */
onLanguageChange?: (language: WebShellLanguage) => void;
/** Additional CSS class name appended to the root element. */
className?: string;
/** Inline styles applied to the root element. */
style?: React.CSSProperties;
/** Called when connection status changes (idle/connecting/connected/disconnected/error). */
onConnectionChange?: (status: string) => void;
/** Called when prompt status changes (idle/waiting/responding). */
onStreamingStateChange?: (state: DaemonStreamingState) => void;
/** Called when a critical error occurs (auth failure, session gone, etc). */
onError?: (error: Error) => void;
/** Called when `/bug` is invoked. Receives system info. If omitted, web-shell opens the report URL itself. */
onBugReport?: (info: BugReportInfo) => void;
/** Slash command names to hide from completion/help, for example `['approval-mode']`. */
hiddenSlashCommands?: string[];
/** Slash command category order. Defaults to custom, skill, system. */
slashCommandCategoryOrder?: CommandDisplayCategoryOrder;
/** Custom renderer for the tool-card header content after the status icon and tool name. */
renderToolHeaderExtra?: ToolHeaderExtraRenderer;
/** Custom renderer for the welcome header. Receives version, cwd, model, and mode. */
renderWelcomeHeader?: WelcomeHeaderRenderer;
/** Collapse thinking blocks to 5 lines with a click-to-expand toggle. */
compactThinking?: boolean;
/** Enable virtual scrolling only when rendered transcript rows exceed this threshold. Defaults to 200. */
virtualScrollThreshold?: number;
/** Custom Markdown behavior for assistant content only. */
markdown?: WebShellMarkdownCustomization;
/** When provided, all toast notifications are forwarded to this callback and the built-in ToastHost is hidden. */
onToast?: (tone: ToastTone, message: string) => void;
}
function replaceSessionUrl(sessionId: string): void {
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
url.pathname = `/session/${encodeURIComponent(sessionId)}`;
if (!import.meta.env.DEV) {
url.searchParams.delete('token');
url.searchParams.delete('daemon');
}
window.history.replaceState(null, '', url);
}
function getInitialLanguage(): WebShellLanguage {
if (typeof window === 'undefined') return 'en';
const params = new URLSearchParams(window.location.search);
return normalizeLanguage(
params.get('language') ?? params.get('lang') ?? navigator.language,
);
}
function formatError(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
function isAbortError(error: unknown): boolean {
return (
(error instanceof DOMException && error.name === 'AbortError') ||
(error instanceof Error && error.name === 'AbortError')
);
}
interface AlreadyDispatchedError extends Error {
_alreadyDispatched: true;
}
function isAlreadyDispatched(error: unknown): error is AlreadyDispatchedError {
return (
typeof error === 'object' &&
error !== null &&
(error as AlreadyDispatchedError)._alreadyDispatched === true
);
}
function logSessionNoticesHook(notices: readonly DaemonSessionNotice[]): void {
if (notices.length > 0) {
console.info('[web-shell] useSessionNotices()', { notices });
}
}
function shouldToastNotice(notice: DaemonSessionNotice): boolean {
return (
notice.category === 'validation' ||
notice.category === 'user_action' ||
notice.category === 'system'
);
}
function toastToneFromNotice(notice: DaemonSessionNotice): ToastTone {
if (notice.severity === 'warning') return 'warning';
if (notice.severity === 'info') return 'info';
return 'error';
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function formatModelAuthType(authType: string): string {
const normalized = authType.trim();
if (normalized.startsWith('USE_')) {
return normalized.slice(4).toLowerCase().replace(/_/g, '-');
}
return normalized.toLowerCase();
}
function getModelSwitchSummary(result: unknown): ModelSwitchSummary | null {
if (!isRecord(result)) return null;
const meta = result._meta;
if (!isRecord(meta)) return null;
const summary = meta.qwenModelSwitch;
if (!isRecord(summary)) return null;
const authType = summary.authType;
const modelId = summary.modelId;
const baseUrl = summary.baseUrl;
const apiKey = summary.apiKey;
if (
typeof authType !== 'string' ||
typeof modelId !== 'string' ||
typeof baseUrl !== 'string' ||
typeof apiKey !== 'string'
) {
return null;
}
return {
authType,
modelId,
baseUrl,
apiKey,
...(typeof summary.isRuntime === 'boolean'
? { isRuntime: summary.isRuntime }
: {}),
};
}
function serializeModelSwitchSummary(summary: ModelSwitchSummary): string {
return (
`● authType: ${formatModelAuthType(summary.authType)}` +
`\n Using ${summary.isRuntime ? 'runtime ' : ''}model: ${summary.modelId}` +
`\n Base URL: ${summary.baseUrl}` +
`\n API key: ${summary.apiKey}`
);
}
function parseModelSwitchStatusModel(content: string): string | null {
const prefix = 'Model switched: ';
if (!content.startsWith(prefix)) return null;
const rawModel = content.slice(prefix.length).trim();
return rawModel.replace(/\([^()]+\)$/, '');
}
function parseModelSwitchSummaryModel(content: string): string | null {
if (!content.startsWith('● authType:')) return null;
const match = content.match(/\n {2}Using (?:runtime )?model: ([^\n]+)/);
return match?.[1]?.trim() || null;
}
function filterDuplicateModelSwitchMessages(
messages: readonly Message[],
): Message[] {
const summarizedModels = new Set<string>();
for (const message of messages) {
if (message.role !== 'system' || message.variant !== 'info') continue;
const model = parseModelSwitchSummaryModel(message.content);
if (model) summarizedModels.add(model);
}
if (summarizedModels.size === 0) return [...messages];
return messages.filter((message) => {
if (message.role !== 'system' || message.variant !== 'info') return true;
const statusModel = parseModelSwitchStatusModel(message.content);
return !statusModel || !summarizedModels.has(statusModel);
});
}
function hasMcpStatusPanel(messages: readonly Message[]): boolean {
return messages.some(
(message) =>
message.role === 'system' &&
message.variant === 'info' &&
parseMcpStatusMessage(message.content) !== null,
);
}
function isDaemonApprovalMode(mode: string): mode is DaemonApprovalMode {
return DAEMON_APPROVAL_MODES.includes(mode as DaemonApprovalMode);
}
function isEditToolPermission(request: PermissionRequest): boolean {
return request.toolKind === 'edit';
}
function parseRenameArgument(
raw: string,
):
| { type: 'auto' }
| { type: 'manual'; displayName: string }
| { type: 'delegate' } {
const trimmed = raw.trim().replace(/[\r\n]+/g, ' ');
if (!trimmed) return { type: 'auto' };
if (trimmed === '--') return { type: 'manual', displayName: '' };
if (trimmed.startsWith('-- ')) {
return { type: 'manual', displayName: trimmed.slice(3).trim() };
}
if (trimmed.toLowerCase() === '--auto') return { type: 'auto' };
if (trimmed.startsWith('--')) return { type: 'delegate' };
return { type: 'manual', displayName: trimmed };
}
function isBackgroundShellToolCall(tool: ACPToolCall): boolean {
if (tool.args?.is_background !== true) return false;
const name = tool.toolName.toLowerCase();
return (
name === 'shell' ||
name === 'bash' ||
name === 'run_shell_command' ||
name === 'exec'
);
}
function getBackgroundTaskActivityKey(messages: readonly Message[]): string {
const parts: string[] = [];
for (const message of messages) {
if (message.role !== 'tool_group') continue;
for (const tool of message.tools) {
if (
isBackgroundSubAgentToolCall(tool) ||
isBackgroundShellToolCall(tool)
) {
parts.push(`${tool.callId}:${tool.status}`);
}
}
}
return parts.join('|');
}
function translateCopyMessage(
message: string,
t: ReturnType<typeof getTranslator>,
): string {
if (message === COPY_MESSAGES.NO_OUTPUT) return t('copy.noOutput');
if (message === COPY_MESSAGES.NO_TEXT) return t('copy.noText');
if (message === COPY_MESSAGES.CODE_MISSING) return t('copy.codeMissing');
if (message === COPY_MESSAGES.LATEX_MISSING) return t('copy.latexMissing');
if (message === COPY_MESSAGES.INLINE_LATEX_MISSING) {
return t('copy.inlineLatexMissing');
}
if (message === COPY_MESSAGES.OUTPUT_COPIED) return t('copy.outputCopied');
if (message.startsWith(COPY_MESSAGES.CLIPBOARD_PREFIX)) {
return `${t('copy.failedFallback')}. ${message.slice(
COPY_MESSAGES.CLIPBOARD_PREFIX.length,
)}`;
}
if (message.endsWith(COPY_MESSAGES.COPIED_SUFFIX)) {
return t('copy.toClipboard', {
label: message.slice(0, -COPY_MESSAGES.COPIED_SUFFIX.length),
});
}
return message;
}
function QueuedPromptDisplay({
prompts,
t,
}: {
prompts: readonly QueuedPrompt[];
t: ReturnType<typeof getTranslator>;
}) {
if (prompts.length === 0) return null;
return (
<div className={styles.queuedPrompts}>
{prompts.slice(0, MAX_DISPLAYED_QUEUED_PROMPTS).map((prompt) => {
const normalizedPreview = prompt.text.replace(/\s+/g, ' ').trim();
const preview =
normalizedPreview.length > MAX_QUEUED_PROMPT_PREVIEW_CHARS
? `${normalizedPreview.slice(0, MAX_QUEUED_PROMPT_PREVIEW_CHARS)}...`
: normalizedPreview;
const imageCount = prompt.images?.length ?? 0;
return (
<div key={prompt.id} className={styles.queuedPrompt}>
{preview}
{imageCount > 0
? ` ${t('queue.imageCount', { count: imageCount })}`
: ''}
</div>
);
})}
{prompts.length > MAX_DISPLAYED_QUEUED_PROMPTS && (
<div className={styles.queuedPrompt}>
{t('queue.more', {
count: prompts.length - MAX_DISPLAYED_QUEUED_PROMPTS,
})}
</div>
)}
<div className={styles.queuedHint}>{t('queue.footer')}</div>
</div>
);
}
export function App({
onSessionIdChange,
theme: providedTheme,
onThemeChange,
language: providedLanguage,
onLanguageChange,
className: externalClassName,
style: externalStyle,
onConnectionChange,
onStreamingStateChange,
onError,
onBugReport,
hiddenSlashCommands,
slashCommandCategoryOrder,
renderToolHeaderExtra,
renderWelcomeHeader,
compactThinking = false,
virtualScrollThreshold,
markdown,
onToast,
}: WebShellProps = {}) {
const [selectedLanguage, setSelectedLanguage] = useState<WebShellLanguage>(
() =>
providedLanguage === undefined
? getInitialLanguage()
: normalizeLanguage(providedLanguage),
);
const t = useMemo(() => getTranslator(selectedLanguage), [selectedLanguage]);
const customization = useMemo(
() => ({
renderToolHeaderExtra,
renderWelcomeHeader,
compactThinking,
markdown,
}),
[renderToolHeaderExtra, renderWelcomeHeader, compactThinking, markdown],
);
const store = useTranscriptStore();
const blocks = useTranscriptBlocks();
const connection = useConnection();
const sessionActions = useActions();
const { notices, dismissNotice } = useSessionNotices();
const workspaceActions = useWorkspaceActions();
const onToastRef = useRef(onToast);
onToastRef.current = onToast;
const toastIdRef = useRef(0);
const [toasts, setToasts] = useState<WebShellToast[]>([]);
const dismissToast = useCallback((id: string) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const pushToast = useCallback((tone: ToastTone, message: string) => {
if (onToastRef.current) {
onToastRef.current(tone, message);
return;
}
const toast: WebShellToast = {
id: `web-shell-toast-${Date.now()}-${++toastIdRef.current}`,
tone,
message,
};
setToasts((current) => {
const withoutDuplicate = current.filter(
(item) => item.tone !== tone || item.message !== message,
);
return [...withoutDuplicate, toast].slice(-MAX_TOASTS);
});
}, []);
const messages = useMessages(t);
const messagesRef = useRef(messages);
messagesRef.current = messages;
const [recapMessage, setRecapMessage] = useState<LocalAnchoredMessage | null>(
null,
);
const [btwMessage, setBtwMessage] = useState<Message | null>(null);
const nextRecapMessageIdRef = useRef(1);
const nextBtwMessageIdRef = useRef(1);
const btwAbortControllerRef = useRef<AbortController | null>(null);
const activeSessionIdRef = useRef(connection.sessionId);
const displayMessages = useMemo(() => {
const localMessages = [recapMessage].filter(
(message): message is LocalAnchoredMessage => message !== null,
);
if (localMessages.length === 0) {
return filterDuplicateModelSwitchMessages(messages);
}
const result = [...messages];
for (const localMessage of localMessages.sort(
(a, b) => a.anchorIndex - b.anchorIndex,
)) {
const anchorIndex = localMessage.anchorAfterId
? result.findIndex(
(message) => message.id === localMessage.anchorAfterId,
)
: -1;
const index =
anchorIndex >= 0
? anchorIndex + 1
: Math.min(localMessage.anchorIndex, result.length);
result.splice(index, 0, localMessage.message);
}
return filterDuplicateModelSwitchMessages(result);
}, [messages, recapMessage]);
const hasMcpPanelMessage = useMemo(
() => hasMcpStatusPanel(displayMessages),
[displayMessages],
);
useEffect(() => {
if (hasMcpPanelMessage) return;
window.dispatchEvent(
new CustomEvent(MCP_STATUS_ACTIVE_EVENT, {
detail: { active: false },
}),
);
}, [hasMcpPanelMessage]);
const messageBlocks = useAnimationFrameValue(blocks);
const rawPendingApproval = useMemo(
() => extractPendingPermission(messageBlocks),
[messageBlocks],
);
const pendingApproval = useShallowMemo(rawPendingApproval);
const pendingApprovalRef = useRef(pendingApproval);
pendingApprovalRef.current = pendingApproval;
const shouldHideComposer = pendingApproval !== null;
const floatingTodosState = useMemo(
() => getFloatingTodos(messages),
[messages],
);
// Keep the timeline Map referentially stable across streaming ticks that
// don't touch any todo snapshot. The Map is a context value, so a fresh
// reference would re-render every todo/plan row regardless of memoization;
// only rebuild when the todo snapshots themselves change.
const todoTimelineRef = useRef<{
signature: string;
timeline: Map<string, TodoSnapshotDiff>;
} | null>(null);
const todoTimeline = useMemo(() => {
const signature = todoTimelineSignature(messages);
const cached = todoTimelineRef.current;
if (cached && cached.signature === signature) return cached.timeline;
const timeline = computeTodoTimeline(messages);
todoTimelineRef.current = { signature, timeline };
return timeline;
}, [messages]);
const floatingTodos = useStableArray(
floatingTodosState.todos,
(t) => `${t.id}:${t.status}:${t.content}`,
);
const floatingTodosAllCompleted = floatingTodosState.allCompleted;
// The all-completed list is only shown as a transient "all done" moment
// when the panel was already visible live in this client; on session
// restore (catch-up replay) a historical finished list stays hidden.
// State is adjusted during render (not in an effect) so the
// active → completed transition doesn't unmount the panel for a frame.
const [todoPanelMode, setTodoPanelMode] = useState<
'hidden' | 'active' | 'completed'
>('hidden');
const nextTodoPanelMode =
connection.catchingUp || floatingTodos.length === 0
? 'hidden'
: !floatingTodosAllCompleted
? 'active'
: todoPanelMode === 'hidden'
? 'hidden'
: 'completed';
if (nextTodoPanelMode !== todoPanelMode) {
setTodoPanelMode(nextTodoPanelMode);
}
const showFloatingTodos = nextTodoPanelMode !== 'hidden';
const backgroundTaskActivityKey = useMemo(
() => getBackgroundTaskActivityKey(messages),
[messages],
);
const statusBarRef = useRef<StatusBarHandle>(null);
const editorRef = useRef<EditorHandle>(null);
const messageListRef = useRef<MessageListHandle>(null);
const handleLocateFloatingTodos = useCallback(() => {
if (!floatingTodosState.sourceMessageId) return;
messageListRef.current?.scrollToMessage(
floatingTodosState.sourceMessageId,
floatingTodosState.sourceCallId ?? undefined,
);
}, [floatingTodosState.sourceMessageId, floatingTodosState.sourceCallId]);
const [activeGoal, setActiveGoal] = useState<ActiveGoalStatus | null>(null);
const activeGoalRef = useRef<ActiveGoalStatus | null>(null);
activeGoalRef.current = activeGoal;
const {
followupState,
onAcceptFollowup,
onDismissFollowup,
clear: clearFollowup,
} = useDaemonFollowupSuggestion({
onAccept: (suggestion) => {
editorRef.current?.insertText(suggestion);
},
});
const sendPrompt = useCallback(
(
text: string,
images?: PromptImage[],
opts?: { optimisticUserMessage?: boolean; retry?: boolean },
) => {
clearFollowup();
const isUserPrompt = !text.trimStart().startsWith('/');
if (!opts?.retry && isUserPrompt) {
lastSubmittedPromptRef.current = text;
lastSubmittedImagesRef.current = images;
retriedTurnErrorIdRef.current = null;
}
setShowRetryHint(false);
const promptOptions: SendPromptOptionsWithRetry = {
images,
optimisticUserMessage: opts?.optimisticUserMessage,
retry: opts?.retry,
};
return (
sessionActions.sendPrompt as (
promptText: string,
options?: SendPromptOptionsWithRetry,
) => ReturnType<typeof sessionActions.sendPrompt>
)(text, promptOptions);
},
[clearFollowup, sessionActions],
);
const streamingState = useStreamingState();
const streamingStateRef = useRef<DaemonStreamingState>(streamingState);
const 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(() => {
if (!connected) return;
workspaceActions
.loadSkillsStatus()
.then((status) => {
setLoadedSkills(
(status?.skills ?? [])
.map((s) => ({ name: s.name, description: s.description ?? '' }))
.sort((a, b) => a.name.localeCompare(b.name)),
);
})
.catch(() => {});
}, [connected, workspaceActions]);
const [modelInlineMode, setModelInlineMode] =
useState<ModelInlineMode | null>(null);
const [approvalModeInlineOpen, setApprovalModeInlineOpen] = useState(false);
const [showResumeDialog, setShowResumeDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showReleaseDialog, setShowReleaseDialog] = useState(false);
const [showHelpDialog, setShowHelpDialog] = useState(false);
const [showThemeDialog, setShowThemeDialog] = useState(false);
const [showToolsDialog, setShowToolsDialog] = useState(false);
const [settingsInlineOpen, setSettingsInlineOpen] = useState(false);
const [memoryInlineOpen, setMemoryInlineOpen] = useState(false);
const [authInlineOpen, setAuthInlineOpen] = useState(false);
const [memoryRefreshSignal, setMemoryRefreshSignal] = useState(0);
const [memoryAddSignal, setMemoryAddSignal] = useState(0);
const [memoryAddScope, setMemoryAddScope] = useState<'workspace' | 'global'>(
'workspace',
);
const [agentsInlineMode, setAgentsInlineMode] =
useState<AgentsInitialMode | null>(null);
const [memoryPortalHost, setMemoryPortalHost] =
useState<HTMLDivElement | null>(null);
const [showShortcuts, setShowShortcuts] = useState(false);
const [escapeHintVisible, setEscapeHintVisible] = useState(false);
const escPressCountRef = useRef(0);
const escapeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const approvalModePanelActive = usePanelActive(APPROVAL_MODE_ACTIVE_EVENT);
const [tasksPanelMessage, setTasksPanelMessage] =
useState<SerializedTasksMessage | null>(null);
const mcpPanelActive = usePanelActive(MCP_STATUS_ACTIVE_EVENT);
const tasksPanelActive = usePanelActive(TASKS_STATUS_ACTIVE_EVENT);
const agentsPanelActive = usePanelActive(AGENTS_ACTIVE_EVENT);
const memoryPanelActive = usePanelActive(MEMORY_ACTIVE_EVENT);
const modelPanelActive = usePanelActive(MODEL_ACTIVE_EVENT);
const settingsPanelActive = usePanelActive(SETTINGS_ACTIVE_EVENT);
const authPanelActive = usePanelActive(AUTH_ACTIVE_EVENT);
const [selectedTheme, setSelectedTheme] = useState<WebShellTheme>(
providedTheme ?? WebShellThemeId.Dark,
);
const [currentModel, setCurrentModel] = useState('');
const currentModelRef = useRef(currentModel);
currentModelRef.current = currentModel;
const connectionRef = useRef(connection);
connectionRef.current = connection;
const sessionDisplayName = connection.displayName;
const [currentMode, setCurrentMode] = useState('default');
const [queuedPrompts, setQueuedPrompts] = useState<QueuedPrompt[]>([]);
const queuedPromptsRef = useRef<QueuedPrompt[]>([]);
const nextQueuedPromptIdRef = useRef(1);
const drainingQueueRef = useRef(false);
const dialogOpen =
showResumeDialog ||
showDeleteDialog ||
showReleaseDialog ||
showHelpDialog ||
showThemeDialog ||
showToolsDialog;
const inlinePanelOpen =
approvalModeInlineOpen ||
authInlineOpen ||
agentsInlineMode !== null ||
memoryInlineOpen ||
modelInlineMode !== null ||
settingsInlineOpen;
const bottomHidden =
dialogOpen ||
inlinePanelOpen ||
approvalModePanelActive ||
mcpPanelActive ||
tasksPanelActive ||
agentsPanelActive ||
memoryPanelActive ||
modelPanelActive ||
settingsPanelActive ||
authPanelActive;
const reportError = useCallback(
(error: unknown, fallback: string) => {
if (isAbortError(error)) return;
if (isDaemonTurnError(error)) {
console.debug('[web-shell] turn error rendered in transcript', error);
return;
}
if (isAlreadyDispatched(error)) {
console.debug('[web-shell] error already handled by notice', error);
return;
}
const message = formatError(error, fallback);
console.error('[web-shell]', message, error);
pushToast('error', message);
},
[pushToast],
);
useEffect(() => {
logSessionNoticesHook(notices);
for (const notice of notices) {
if (shouldToastNotice(notice)) {
pushToast(toastToneFromNotice(notice), notice.message);
} else if (notice.category === 'lifecycle') {
console.debug('[web-shell] daemon notice', notice);
} else {
console.warn('[web-shell] daemon notice', notice);
}
dismissNotice(notice.id);
}
}, [dismissNotice, notices, pushToast]);
const onBugReportRef = useRef(onBugReport);
onBugReportRef.current = onBugReport;
useEffect(() => {
activeSessionIdRef.current = connection.sessionId;
btwAbortControllerRef.current?.abort();
btwAbortControllerRef.current = null;
setRecapMessage(null);
setBtwMessage(null);
setTasksPanelMessage(null);
lastRecapBlockCountRef.current = 0;
}, [connection.sessionId]);
const runVisibleRecap = useCallback(() => {
const messageId = `local-recap-${nextRecapMessageIdRef.current++}`;
const anchorIndex = messages.length;
const anchorAfterId = messages.at(-1)?.id;
const sessionId = connection.sessionId;
setRecapMessage({
anchorAfterId,
anchorIndex,
message: {
id: messageId,
role: 'system',
content: `※ recap: ${t('recap.loading')}`,
variant: 'info',
},
});
sessionActions.recapSession().then(
(result) => {
if (activeSessionIdRef.current !== sessionId) return;
setRecapMessage({
anchorAfterId,
anchorIndex,
message: {
id: messageId,
role: 'system',
content: result.recap
? `※ recap: ${result.recap}`
: t('recap.empty'),
variant: 'info',
},
});
},
(error: unknown) => {
if (activeSessionIdRef.current !== sessionId) return;
setRecapMessage(null);
if (!isAbortError(error) && !isAlreadyDispatched(error)) {
console.warn('[web-shell] unhandled recap failure', error);
}
},
);
}, [connection.sessionId, messages, sessionActions, t]);
const runVisibleBtw = useCallback(
(rawQuestion: string) => {
const question = rawQuestion.trim();
if (!question) {
pushToast('error', t('btw.empty'));
return;
}
const messageId = `local-btw-${nextBtwMessageIdRef.current++}`;
const sessionId = connection.sessionId;
btwAbortControllerRef.current?.abort();
const abortController = new AbortController();
btwAbortControllerRef.current = abortController;
setBtwMessage({
id: messageId,
role: 'btw',
question,
answer: '',
isPending: true,
});
sessionActions
.btwSession(question, { signal: abortController.signal })
.then(
(result) => {
if (activeSessionIdRef.current !== sessionId) return;
if (btwAbortControllerRef.current !== abortController) return;
btwAbortControllerRef.current = null;
setBtwMessage({
id: messageId,
role: 'btw',
question,
answer: result.answer || t('btw.emptyAnswer'),
isPending: false,
});
},
(error: unknown) => {
if (activeSessionIdRef.current !== sessionId) return;
if (btwAbortControllerRef.current !== abortController) return;
btwAbortControllerRef.current = null;
setBtwMessage(null);
if (!isAbortError(error) && !isAlreadyDispatched(error)) {
console.warn('[web-shell] unhandled btw failure', error);
}
},
);
},
[connection.sessionId, pushToast, sessionActions, t],
);
const dismissBtwMessage = useCallback(() => {
btwAbortControllerRef.current?.abort();
btwAbortControllerRef.current = null;
setBtwMessage(null);
}, []);
useEffect(() => {
const onBtwShortcut = (e: KeyboardEvent) => {
if (bottomHidden || pendingApproval) return;
const message = btwMessage;
if (!message || message.role !== 'btw') return;
const key = e.key.toLowerCase();
const isPlainEscape =
e.key === 'Escape' &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
!e.shiftKey;
const isCtrlCancel =
e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
!e.shiftKey &&
(key === 'c' || key === 'd');
if (message.isPending) {
if (!isPlainEscape && !isCtrlCancel) return;
} else {
const editorHasText =
(editorRef.current?.getText().trim().length ?? 0) > 0;
const isPlainDismiss =
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
!e.shiftKey &&
(e.key === 'Escape' ||
(!editorHasText && (e.key === 'Enter' || e.key === ' ')));
if (!isPlainDismiss) return;
}
e.preventDefault();
e.stopPropagation();
dismissBtwMessage();
};
window.addEventListener('keydown', onBtwShortcut, true);
return () => window.removeEventListener('keydown', onBtwShortcut, true);
}, [bottomHidden, btwMessage, dismissBtwMessage, pendingApproval]);
useEffect(() => {
queuedPromptsRef.current = queuedPrompts;
}, [queuedPrompts]);
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;
if (!nextPrompt) return null;
queuedPromptsRef.current = rest;
setQueuedPrompts(rest);
return nextPrompt;
}, []);
const popQueuedPromptsForEdit = useCallback((): string | null => {
const current = queuedPromptsRef.current;
if (current.length === 0) return null;
queuedPromptsRef.current = [];
setQueuedPrompts([]);
return current.map((prompt) => prompt.text).join('\n\n');
}, []);
const clearQueuedPrompts = useCallback((): boolean => {
if (queuedPromptsRef.current.length === 0) return false;
queuedPromptsRef.current = [];
setQueuedPrompts([]);
store.dispatch([{ type: 'status', text: t('queue.cleared') }]);
return true;
}, [store, t]);
const handleThemeChange = useCallback(
(nextTheme: WebShellTheme) => {
setSelectedTheme(nextTheme);
onThemeChange?.(nextTheme);
},
[onThemeChange],
);
const handleLanguageChange = useCallback(
(nextLanguage: WebShellLanguage) => {
setSelectedLanguage(nextLanguage);
onLanguageChange?.(nextLanguage);
},
[onLanguageChange],
);
const handleToggleShortcuts = useCallback(() => {
setShowShortcuts((prev) => !prev);
}, []);
// Idempotent close for the shortcuts panel's outside-press / Escape dismissal.
// Must not toggle: on touch, touchstart and the synthesized mousedown both
// fire, and a toggle would reopen the panel right after closing it.
const handleCloseShortcuts = useCallback(() => {
setShowShortcuts(false);
}, []);
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') }]);
return;
}
store.reset();
}, [store, t]);
const handleToggleCompact = useCallback(() => {
const previous = compactModeRef.current;
const next = !compactModeRef.current;
setCompactMode(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) => {
if (!isDaemonApprovalMode(modeId)) {
reportError(
new Error(`Unsupported approval mode: ${modeId}`),
t('local.approvalMode'),
);
return;
}
sessionActions
.setApprovalMode(modeId)
.then((result) => {
const effectiveMode = result.mode || modeId;
setCurrentMode(effectiveMode);
if (effectiveMode === 'auto') {
// TODO: CLI also shows stripped dangerous allow rules via
// PermissionManager.getStrippedDangerousRules(). The daemon
// API (DaemonApprovalModeResult) doesn't expose this info yet.
// Once the daemon returns strippedRules in the response, display
// them here like CLI's emitAutoModeEntryNotices does.
store.dispatch([{ type: 'status', text: t('mode.auto.notice') }]);
}
const approval = pendingApprovalRef.current;
if (!approval) return;
const shouldAutoApprove =
modeId === 'yolo' ||
(modeId === 'auto-edit' && isEditToolPermission(approval));
if (shouldAutoApprove) {
const allowOnce = approval.options.find(
(o) => o.kind === 'allow_once',
);
if (allowOnce) {
const toolDesc = approval.title || '';
store.dispatch([
{
type: 'status',
text: t('mode.autoApproved', { tool: toolDesc }),
},
]);
sessionActions
.submitPermission(approval.id, allowOnce.id)
.catch((error: unknown) => {
reportError(error, 'Failed to auto-approve tool call');
});
}
}
})
.catch((error: unknown) => {
reportError(error, t('local.approvalMode'));
});
},
[sessionActions, reportError, store, t],
);
useEffect(() => {
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]);
useEffect(() => {
onConnectionChange?.(connection.status);
}, [connection.status, onConnectionChange]);
useEffect(() => {
if (connection.error) {
const error = new Error(connection.error);
onError?.(error);
}
}, [connection.error, onError]);
useEffect(() => {
if (connection.currentModel) {
setCurrentModel(connection.currentModel);
}
}, [connection.currentModel]);
useEffect(() => {
if (connection.currentMode) {
setCurrentMode(connection.currentMode);
}
}, [connection.currentMode]);
useEffect(() => {
if (connection.sessionId) {
setActiveGoal(null);
onSessionIdChange?.(connection.sessionId);
if (!onSessionIdChange) {
replaceSessionUrl(connection.sessionId);
}
}
}, [connection.sessionId, onSessionIdChange]);
useEffect(() => {
const nextGoal = getLatestActiveGoalFromBlocks(blocks);
setActiveGoal((current) => {
if (!nextGoal) return current ? null : current;
if (
current?.condition === nextGoal.condition &&
current.setAt === nextGoal.setAt
) {
return current;
}
return nextGoal;
});
}, [blocks]);
useEffect(() => {
const onGoalStatusActive = (event: Event) => {
const detail = (
event as CustomEvent<{
active?: boolean;
condition?: string;
setAt?: number;
}>
).detail;
if (!detail?.active) {
setActiveGoal(null);
return;
}
if (!detail.condition) return;
setActiveGoal({
condition: detail.condition,
setAt: detail.setAt ?? Date.now(),
});
};
window.addEventListener(GOAL_STATUS_ACTIVE_EVENT, onGoalStatusActive);
return () =>
window.removeEventListener(GOAL_STATUS_ACTIVE_EVENT, onGoalStatusActive);
}, []);
// Auto-recap: fire when the user returns after being away ≥ 3 minutes
const hiddenAtRef = useRef<number | null>(null);
const lastRecapBlockCountRef = useRef(0);
useEffect(() => {
lastRecapBlockCountRef.current = 0;
}, [connection.sessionId]);
useEffect(() => {
const AWAY_THRESHOLD_MS = 3 * 60 * 1000;
const MIN_NEW_BLOCKS = 4;
function onVisibilityChange() {
if (document.hidden) {
if (hiddenAtRef.current === null) hiddenAtRef.current = Date.now();
return;
}
const hiddenAt = hiddenAtRef.current;
hiddenAtRef.current = null;
if (hiddenAt === null) return;
if (Date.now() - hiddenAt < AWAY_THRESHOLD_MS) return;
if (streamingStateRef.current !== 'idle') return;
if (!connection.sessionId) return;
const currentCount = store.getSnapshot().blocks.length;
if (currentCount - lastRecapBlockCountRef.current < MIN_NEW_BLOCKS)
return;
lastRecapBlockCountRef.current = currentCount;
sessionActions.recapSession().then(
(result) => {
if (result.recap) {
store.dispatch([
{ type: 'status', text: `※ recap: ${result.recap}` },
]);
}
},
(error: unknown) => {
console.error('[auto-recap] failed:', error);
},
);
}
document.addEventListener('visibilitychange', onVisibilityChange);
return () =>
document.removeEventListener('visibilitychange', onVisibilityChange);
}, [connection.sessionId, sessionActions, store]);
const handleCycleMode = useCallback(() => {
const idx = isDaemonApprovalMode(currentMode)
? MODES_CYCLE.indexOf(currentMode)
: -1;
const next = MODES_CYCLE[(idx + 1) % MODES_CYCLE.length];
handleSetMode(next);
}, [currentMode, handleSetMode]);
// Shared by the /context slash command and the status-bar context
// indicator. Echoes the command as a local user message first — that also
// makes the transcript follow the tail (MessageList Rule 4), so the panel
// is revealed even when the click comes while scrolled up.
const showContextUsage = useCallback(
(commandText: string, detail: boolean) => {
store.appendLocalUserMessage(commandText);
sessionActions
.getContextUsage({ detail })
.then((result) => {
store.dispatch([
{
type: 'status',
text: serializeContextUsageMessage(result),
},
]);
})
.catch((error: unknown) => {
reportError(error, 'Failed to load context usage');
});
},
[store, sessionActions, reportError],
);
// Stable reference: this travels through the memoized MessageList →
// MessageItem chain, so an inline closure would defeat their memo.
const handleShowContextDetail = useCallback(() => {
showContextUsage('/context detail', true);
}, [showContextUsage]);
const openTasksPanel = useCallback(() => {
sessionActions
.getTasks()
.then((snapshot) => {
setTasksPanelMessage({ snapshot });
})
.catch((error: unknown) => {
reportError(error, 'Failed to load tasks');
});
}, [reportError, sessionActions]);
const dispatchGoalSet = useCallback(
(condition: string, setAt: number) => {
setActiveGoal({ condition, setAt });
store.dispatch([
{
type: 'status',
text: serializeGoalStatusMessage({
kind: 'set',
condition,
setAt,
}),
},
]);
},
[store],
);
const dispatchGoalCleared = useCallback(
(goal: ActiveGoalStatus | null) => {
if (!goal) return;
store.dispatch([
{
type: 'status',
text: serializeGoalStatusMessage({
kind: 'cleared',
condition: goal.condition,
durationMs: Date.now() - goal.setAt,
}),
},
]);
setActiveGoal(null);
},
[store],
);
const handleBusyGoalClear = useCallback(
(text: string) => {
store.appendLocalUserMessage(text);
sessionActions.clearGoal().catch((error: unknown) => {
reportError(error, 'Failed to clear /goal');
});
return true;
},
[reportError, sessionActions, store],
);
const handleGoalSlashCommand = useCallback(
(
text: string,
images?: PromptImage[],
opts?: { sendToDaemon?: boolean },
) => {
const goalArg = text.replace(/^\/goal\b/i, '').trim();
const lowerGoalArg = goalArg.toLowerCase();
const sendToDaemon = opts?.sendToDaemon ?? true;
if (goalArg && GOAL_CLEAR_KEYWORDS.has(lowerGoalArg)) {
if (!sendToDaemon) {
store.appendLocalUserMessage(text);
dispatchGoalCleared(activeGoalRef.current);
return true;
}
return handleBusyGoalClear(text);
} else if (goalArg) {
store.appendLocalUserMessage(text);
if (!sendToDaemon) {
dispatchGoalSet(goalArg, Date.now());
return true;
}
sendPrompt(text, images, { optimisticUserMessage: false }).catch(
(error: unknown) => {
reportError(error, 'Failed to send /goal command');
},
);
return true;
}
store.appendLocalUserMessage(text);
if (sendToDaemon) {
sendPrompt(text, images, { optimisticUserMessage: false }).catch(
(error: unknown) =>
reportError(error, 'Failed to send /goal command'),
);
}
return true;
},
[
dispatchGoalCleared,
dispatchGoalSet,
handleBusyGoalClear,
reportError,
sendPrompt,
store,
],
);
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';
if (text.startsWith('/')) {
const match = text.match(/^\/([\w-]+)/);
if (match) {
const cmd = match[1];
if (cmd === 'help') {
setShowHelpDialog(true);
return true;
}
if (cmd === 'tasks') {
store.appendLocalUserMessage(text);
handleTasksSlashCommand({
cmd,
getTasks: sessionActions.getTasks,
dispatch: (events) => store.dispatch(events),
reportError,
});
return true;
}
if (cmd === 'goal') {
if (promptBlocked) {
if (isGoalClearCommand(text)) {
return handleBusyGoalClear(text);
}
return enqueuePrompt(text, images);
}
return handleGoalSlashCommand(text, images);
}
if (cmd === 'theme') {
const themeArg = text.slice(match[0].length).trim().toLowerCase();
if (themeArg === 'dark' || themeArg === 'light') {
handleThemeChange(themeArg);
} else if (!themeArg) {
setShowThemeDialog(true);
} else {
pushToast('error', t('error.unsupportedTheme'));
}
return true;
}
if (cmd === 'language') {
const args = text.slice(match[0].length).trim();
const [subCommand, languageArg] = args.split(/\s+/);
if (!args) {
store.dispatch([
{
type: 'status',
text: [
t('language.current', {
language: languageLabel(selectedLanguage),
}),
t('language.usage'),
t('language.options'),
' - en: English',
' - zh-CN: 中文',
].join('\n'),
},
]);
return true;
}
if (subCommand?.toLowerCase() === 'ui') {
if (!languageArg) {
store.dispatch([
{
type: 'status',
text: [
t('language.set'),
'',
t('language.usage'),
'',
t('language.options'),
' - en: English',
' - zh-CN: 中文',
].join('\n'),
},
]);
return true;
}
const normalizedArg = languageArg.toLowerCase();
const valid = ['en', 'zh', 'zh-cn', 'zh_cn'].includes(
normalizedArg,
);
if (!valid) {
pushToast('error', t('language.invalid'));
return true;
}
const nextLanguage = normalizeLanguage(languageArg);
handleLanguageChange(nextLanguage);
if (!promptBlocked) {
sendPrompt(`/language ui ${nextLanguage}`)
.then(() => sessionActions.refreshCommands())
.catch((error: unknown) => {
reportError(error, 'Failed to sync /language command');
});
}
return true;
}
}
if (cmd === 'copy') {
const copyArg = text.slice(match[0].length).trim();
copyFromLastAssistantMessage(messagesRef.current, copyArg)
.then((result) => {
store.dispatch([
{
type: result.status === 'error' ? 'error' : 'status',
text: translateCopyMessage(result.message, t),
},
]);
})
.catch((error: unknown) => {
reportError(error, t('copy.failedFallback'));
});
return true;
}
if (cmd === 'delete') {
setShowDeleteDialog(true);
return true;
}
if (cmd === 'release') {
setShowReleaseDialog(true);
return true;
}
if (cmd === 'auth') {
store.appendLocalUserMessage(text);
setAuthInlineOpen(true);
return true;
}
if (cmd === 'model') {
const modelArg = text.slice(match[0].length).trim();
if (modelArg === '--fast') {
store.appendLocalUserMessage(text);
setModelInlineMode('fast');
return true;
}
if (modelArg.startsWith('--fast ')) {
if (promptBlocked) return enqueuePrompt(text, images);
sendPrompt(text, images).catch((error: unknown) =>
reportError(error, 'Failed to send /model --fast'),
);
return true;
}
if (modelArg) {
sessionActions
.setModel(modelArg)
.then(() => {
setCurrentModel(modelArg);
})
.catch((error: unknown) => {
reportError(error, t('model.switch'));
});
} else {
store.appendLocalUserMessage(text);
setModelInlineMode('main');
}
return true;
}
if (cmd === 'plan') {
if (promptBlocked) return enqueuePrompt(text, images);
const prompt = text.slice(match[0].length).trim();
sessionActions
.setApprovalMode('plan')
.then(() => {
setCurrentMode('plan');
if (prompt) {
sendPrompt(prompt, images).catch((error: unknown) =>
reportError(error, 'Failed to send plan prompt'),
);
}
})
.catch((error: unknown) => {
reportError(error, t('mode.plan'));
});
return true;
}
if (cmd === 'approval-mode') {
const modeArg = text.slice(match[0].length).trim();
if (modeArg) {
handleSetMode(modeArg);
} else {
store.appendLocalUserMessage(text);
setApprovalModeInlineOpen(true);
}
return true;
}
if (cmd === 'mcp') {
const mcpArg = text.slice(match[0].length).trim().toLowerCase();
store.appendLocalUserMessage(text);
workspaceActions
.loadMcpStatus()
.then(async (status) => {
const toolsByServer: Record<
string,
Awaited<ReturnType<typeof workspaceActions.loadMcpTools>>
> = {};
await Promise.all(
(status?.servers ?? []).map(async (server) => {
try {
toolsByServer[server.name] =
await workspaceActions.loadMcpTools(server.name);
} catch {
// Allow partial failure — other servers still render
}
}),
);
store.dispatch([
{
type: 'status',
text: serializeMcpStatusMessage({
status,
toolsByServer,
showDescriptions: mcpArg === 'desc',
showSchema: mcpArg === 'schema',
showTips: !mcpArg,
}),
},
]);
})
.catch((error: unknown) => {
reportError(error, 'Failed to load MCP status');
});
return true;
}
if (cmd === 'skills') {
const skillArg = text.slice(match[0].length).trim();
if (skillArg) {
if (promptBlocked) return enqueuePrompt(text, images);
sendPrompt(text, images).catch((error: unknown) =>
reportError(error, 'Failed to send /skills command'),
);
} else {
store.appendLocalUserMessage(text);
workspaceActions
.loadSkillsStatus()
.then((status) => {
const skills = (status?.skills ?? [])
.map((s) => ({
name: s.name,
description: s.description ?? '',
}))
.sort((a, b) => a.name.localeCompare(b.name));
setLoadedSkills(skills);
if (skills.length === 0) {
store.dispatch([
{ type: 'status', text: t('skills.none') },
]);
} else {
const list = skills.map((s) => `- ${s.name}`).join('\n');
store.dispatch([
{
type: 'status',
text: `${t('skills.available')}\n\n${list}`,
},
]);
}
})
.catch((error: unknown) => {
reportError(error, 'Failed to load skills');
});
}
return true;
}
if (cmd === 'tools') {
const toolsArg = text.slice(match[0].length).trim().toLowerCase();
if (toolsArg === 'desc' || toolsArg === 'descriptions') {
setShowToolsDialog(true);
} else {
store.appendLocalUserMessage(text);
workspaceActions
.loadToolsStatus()
.then((status) => {
const tools = status?.tools ?? [];
if (tools.length === 0) {
store.dispatch([{ type: 'status', text: t('tools.none') }]);
} else {
const list = tools
.map((tool) => `- ${tool.displayName || tool.name}`)
.join('\n');
store.dispatch([
{
type: 'status',
text: `${t('tools.available')}\n\n${list}`,
},
]);
}
})
.catch((error: unknown) => {
reportError(error, 'Failed to load tools');
});
}
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;
}
if (cmd === 'context') {
const contextArg = text.slice(match[0].length).trim().toLowerCase();
if (
contextArg === '' ||
contextArg === 'detail' ||
contextArg === '-d'
) {
showContextUsage(
text,
contextArg === 'detail' || contextArg === '-d',
);
return true;
}
}
if (cmd === 'memory') {
const memoryArg = text.slice(match[0].length).trim().toLowerCase();
store.appendLocalUserMessage(text);
if (memoryArg === 'refresh') {
setMemoryRefreshSignal((signal) => signal + 1);
} else if (memoryArg === 'add' || memoryArg.startsWith('add ')) {
const addTarget = memoryArg.slice('add'.length).trim();
setMemoryAddScope(
addTarget === 'user' || addTarget === 'global'
? 'global'
: 'workspace',
);
setMemoryAddSignal((signal) => signal + 1);
}
setMemoryInlineOpen(true);
return true;
}
if (cmd === 'agents') {
const subCommand = text.slice(match[0].length).trim().toLowerCase();
store.appendLocalUserMessage(text);
let agentsMode: AgentsInitialMode = 'menu';
if (subCommand === 'create') {
agentsMode = 'create';
} else if (
subCommand === 'create user' ||
subCommand === 'create global'
) {
agentsMode = 'create-user';
} else if (
subCommand === 'create project' ||
subCommand === 'create workspace'
) {
agentsMode = 'create-project';
} else if (subCommand === 'manage') {
agentsMode = 'manage';
}
setAgentsInlineMode(agentsMode);
return true;
}
if (cmd === 'clear') {
sessionActions.newSession().catch((error: unknown) => {
reportError(error, 'Failed to create a new session');
});
return true;
}
if (cmd === 'new' || cmd === 'reset') {
sessionActions.newSession().catch((error: unknown) => {
reportError(error, 'Failed to create a new session');
});
return true;
}
if (cmd === 'rename') {
const renameArg = parseRenameArgument(text.slice(match[0].length));
if (renameArg.type === 'auto' || renameArg.type === 'delegate') {
if (promptBlocked) return enqueuePrompt(text, images);
sendPrompt(text, images).catch((error: unknown) =>
reportError(error, 'Failed to send /rename command'),
);
return true;
}
const displayName = renameArg.displayName;
if (!displayName) {
pushToast('error', t('rename.empty'));
return true;
}
sessionActions
.renameSession(displayName)
.then(() => {
store.dispatch([
{
type: 'status',
text: t('rename.success', { name: displayName }),
},
]);
})
.catch((error: unknown) => {
reportError(error, 'Failed to rename session');
});
return true;
}
if (cmd === 'resume') {
const sessionId = text.slice(match[0].length).trim();
if (sessionId) {
sessionActions.loadSession(sessionId).catch((error: unknown) => {
reportError(error, 'Failed to load session');
});
} else {
setShowResumeDialog(true);
}
return true;
}
if (cmd === 'recap') {
runVisibleRecap();
return true;
}
if (cmd === 'btw') {
runVisibleBtw(text.slice(match[0].length));
return true;
}
if (cmd === 'stats') {
const statsArg = text.slice(match[0].length).trim().toLowerCase();
let statsView: StatsView = 'overview';
if (statsArg === 'model') statsView = 'model';
else if (statsArg === 'tools') statsView = 'tools';
store.appendLocalUserMessage(text);
sessionActions
.getStats()
.then((result) => {
store.dispatch([
{
type: 'status',
text: serializeStatsMessage(result, statsView),
},
]);
})
.catch(() => {});
return true;
}
if (cmd === 'status' || cmd === 'about') {
store.appendLocalUserMessage(text);
Promise.all([
workspaceActions.loadPreflight().catch(() => null),
workspaceActions.loadProviders().catch(() => null),
workspaceActions.loadEnv().catch(() => null),
]).then(([preflight, providers, env]) => {
const sys = collectSystemInfo(preflight, env);
let authSource = sys.authSource;
if (!authSource && providers?.current?.authType) {
authSource = providers.current.authType;
}
const runtimeParts: string[] = [];
if (sys.nodeVersion)
runtimeParts.push(`Node.js v${sys.nodeVersion}`);
if (sys.npmVersion) runtimeParts.push(`npm ${sys.npmVersion}`);
let formattedAuth = '';
if (authSource) {
if (
authSource.startsWith('oauth') ||
authSource === 'qwen-oauth'
) {
formattedAuth = 'Qwen OAuth';
} else {
formattedAuth = `API Key - ${authSource}`;
}
}
const platformStr = `${sys.platform} ${sys.arch}`.trim();
const curModel = currentModelRef.current;
const conn = connectionRef.current;
const qwenCodeVersion = conn.capabilities?.qwenCodeVersion || '';
const info: StatusInfo = {
cliVersion: qwenCodeVersion,
runtime: runtimeParts.join(' / '),
platform: platformStr,
auth: formattedAuth,
baseUrl: providers?.current?.baseUrl || '',
model:
curModel ||
conn.currentModel ||
providers?.current?.modelId ||
'',
fastModel:
providers?.current?.fastModelId ||
curModel ||
conn.currentModel ||
providers?.current?.modelId ||
'',
sessionId: conn.sessionId || '',
sandbox: sys.sandbox,
proxy: sys.proxy,
memoryUsage: sys.memoryUsage,
};
store.dispatch([
{ type: 'status', text: serializeStatusMessage(info) },
]);
});
return true;
}
if (cmd === 'bug') {
const bugTitle = text.slice(match[0].length).trim();
store.appendLocalUserMessage(text);
Promise.all([
workspaceActions.loadPreflight().catch(() => null),
workspaceActions.loadEnv().catch(() => null),
])
.then(([preflight, env]) => {
const sys = collectSystemInfo(preflight, env);
const qwenCodeVersion =
connectionRef.current.capabilities?.qwenCodeVersion || '';
const sysInfo: Record<string, string> = {};
if (qwenCodeVersion) sysInfo.cliVersion = qwenCodeVersion;
if (sys.nodeVersion) sysInfo.nodeVersion = sys.nodeVersion;
if (sys.npmVersion) sysInfo.npmVersion = sys.npmVersion;
if (sys.platform) sysInfo.platform = sys.platform;
if (sys.arch) sysInfo.arch = sys.arch;
if (sys.sandbox) sysInfo.sandbox = sys.sandbox;
if (sys.memoryUsage) sysInfo.memoryUsage = sys.memoryUsage;
if (onBugReportRef.current) {
onBugReportRef.current({
title: bugTitle,
systemInfo: sysInfo,
});
store.dispatch([
{ type: 'status', text: t('bug.submitted') },
]);
} else {
const fields = Object.entries(sysInfo)
.filter(([, v]) => v)
.map(([k, v]) => `${k}: ${v}`)
.join('\n');
const url =
`https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml` +
`&title=${encodeURIComponent(bugTitle)}` +
`&info=${encodeURIComponent('\n' + fields + '\n')}`;
const win = window.open(url, '_blank');
if (win) {
win.opener = null;
store.dispatch([
{ type: 'status', text: t('bug.submitted') },
]);
} else {
pushToast('error', t('bug.popupBlocked'));
}
}
})
.catch((error: unknown) => {
reportError(error, t('bug.failed'));
});
return true;
}
}
// Forward slash commands as prompts
if (promptBlocked) return enqueuePrompt(text, images);
sendPrompt(text, images).catch((error: unknown) =>
reportError(error, 'Failed to send command'),
);
return true;
} else if (text.startsWith('!')) {
if (promptBlocked) return enqueuePrompt(text, images);
const cmd = text.slice(1).trim();
if (!cmd) return false;
sessionActions.sendShellCommand(cmd).catch((error: unknown) => {
reportError(error, 'Failed to execute shell command');
});
return true;
} else {
if (promptBlocked) return enqueuePrompt(text, images);
sendPrompt(text, images).catch((error: unknown) =>
reportError(error, 'Failed to send message'),
);
return true;
}
},
[
sendPrompt,
sessionActions,
store,
enqueuePrompt,
handleBusyGoalClear,
handleGoalSlashCommand,
handleThemeChange,
handleSetMode,
handleLanguageChange,
hideSettings,
pushToast,
reportError,
runVisibleRecap,
runVisibleBtw,
selectedLanguage,
showContextUsage,
t,
workspaceActions,
],
);
useEffect(() => {
if (drainingQueueRef.current) return;
if (!connected) return;
if (streamingState !== 'idle') return;
if (bottomHidden) return;
if (pendingApproval) return;
if (queuedPrompts.length === 0) return;
const nextPrompt = popNextQueuedPrompt();
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;
};
}, [
connected,
bottomHidden,
handleSubmit,
pendingApproval,
popNextQueuedPrompt,
queuedPrompts,
streamingState,
]);
const handleConfirm = useCallback(
(id: string, selectedOption: string, answers?: Record<string, string>) => {
sessionActions
.submitPermission(id, selectedOption, answers)
.catch((error: unknown) => {
reportError(error, 'Failed to submit permission choice');
});
},
[sessionActions, reportError],
);
const handleCancel = useCallback(() => {
sessionActions.cancel().catch((error: unknown) => {
reportError(error, 'Failed to cancel request');
});
}, [sessionActions, reportError]);
const handleFocusTaskPill = useCallback((): boolean => {
if (bottomHidden) return false;
return statusBarRef.current?.focusTaskPill() ?? false;
}, [bottomHidden]);
const handleReturnToEditor = useCallback((text?: string) => {
if (text) {
editorRef.current?.insertText(text);
return;
}
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;
if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
if (e.key === 'l') {
e.preventDefault();
handleClearScreen();
return;
}
if (e.key === 'o') {
e.preventDefault();
handleToggleCompact();
return;
}
if (e.key === 'y') {
e.preventDefault();
handleRetry();
return;
}
}
};
window.addEventListener('keydown', onGlobalShortcut, true);
return () => window.removeEventListener('keydown', onGlobalShortcut, true);
}, [
bottomHidden,
handleClearScreen,
handleToggleCompact,
handleRetry,
store,
t,
]);
useEffect(() => {
const resetEscapeState = () => {
escPressCountRef.current = 0;
setEscapeHintVisible(false);
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
escapeTimerRef.current = null;
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented || e.isComposing) return;
if (e.key !== 'Escape') {
if (escPressCountRef.current > 0) {
resetEscapeState();
}
if (e.key === 'Tab' && e.shiftKey && !bottomHidden) {
e.preventDefault();
handleCycleMode();
}
return;
}
if (pendingApproval || bottomHidden) return;
if (tasksPanelMessage) {
e.preventDefault();
e.stopPropagation();
setTasksPanelMessage(null);
handleReturnToEditor();
resetEscapeState();
return;
}
if (clearQueuedPrompts()) {
e.preventDefault();
resetEscapeState();
return;
}
const text = editorRef.current?.getText() ?? '';
if (text.length > 0) {
e.preventDefault();
if (escPressCountRef.current === 0) {
escPressCountRef.current = 1;
setEscapeHintVisible(true);
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
}
escapeTimerRef.current = setTimeout(() => {
resetEscapeState();
}, 500);
} else {
editorRef.current?.clearText();
resetEscapeState();
}
return;
}
if (streamingState !== 'idle') {
e.preventDefault();
handleCancel();
resetEscapeState();
return;
}
};
window.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('keydown', onKeyDown);
escPressCountRef.current = 0;
setEscapeHintVisible(false);
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
escapeTimerRef.current = null;
}
};
}, [
streamingState,
handleCancel,
handleCycleMode,
pendingApproval,
bottomHidden,
tasksPanelMessage,
handleReturnToEditor,
clearQueuedPrompts,
]);
const isDisabled = !connected;
const handleModelSelect = useCallback(
(modelId: string) => {
sessionActions
.setModel(modelId)
.then((result) => {
const summary = getModelSwitchSummary(result);
setCurrentModel(summary?.modelId ?? modelId);
if (summary) {
store.dispatch({
type: 'debug',
text: serializeModelSwitchSummary(summary),
});
}
})
.catch((error: unknown) => {
reportError(error, t('model.switch'));
});
},
[sessionActions, store, reportError, t],
);
const handleFastModelSelect = useCallback(
(modelId: string) => {
if (streamingState !== 'idle') return;
sendPrompt(`/model --fast ${modelId}`).catch((error: unknown) => {
reportError(error, 'Failed to switch fast model');
});
},
[sendPrompt, streamingState, reportError],
);
const commands = useMemo(() => {
const skillNames = new Set(connection.skills ?? []);
return mergeCommands(connection.commands ?? [], getLocalCommands(t))
.filter(
(command) => !hiddenCommands.has(normalizeHiddenCommand(command.name)),
)
.map((command) => {
if (!skillNames.has(command.name)) return command;
return {
...command,
displayCategory: 'skill' as const,
description: command.description || t('skills.run'),
};
});
}, [connection.commands, connection.skills, hiddenCommands, t]);
const welcomeHeaderProps = useMemo(
() => ({
version: connection.capabilities?.qwenCodeVersion || '',
cwd: connection.workspaceCwd || '',
currentModel,
currentMode,
hideTips: hideTipsSetting?.values.effective === true,
}),
[
connection.capabilities?.qwenCodeVersion,
connection.workspaceCwd,
currentModel,
currentMode,
hideTipsSetting?.values.effective,
],
);
const welcomeHeader = useMemo(
() =>
renderWelcomeHeader ? (
renderWelcomeHeader(welcomeHeaderProps)
) : (
<WelcomeHeader {...welcomeHeaderProps} />
),
[renderWelcomeHeader, welcomeHeaderProps],
);
const appClassName = [
styles.app,
selectedTheme === WebShellThemeId.Light
? styles.themeLight
: styles.themeDark,
externalClassName,
]
.filter(Boolean)
.join(' ');
return (
<ThemeProvider value={selectedTheme}>
<I18nProvider language={selectedLanguage}>
<div className={appClassName} style={externalStyle} data-web-shell-root>
{!onToast && <ToastHost toasts={toasts} onDismiss={dismissToast} />}
{dialogOpen && (
<div className={styles.dialogOverlay} data-keyboard-scope>
{showResumeDialog && (
<ResumeDialog
onSelect={(sessionId) => {
sessionActions
.loadSession(sessionId)
.catch((error: unknown) => {
reportError(error, 'Failed to load session');
});
}}
onClose={() => setShowResumeDialog(false)}
/>
)}
{showDeleteDialog && (
<DeleteSessionDialog
onDeleted={(sessionIds) => {
store.dispatch([
{
type: 'status',
text:
sessionIds.length === 1
? `${t('delete.deleted')} (${sessionIds[0]!.slice(0, 8)})`
: t('delete.deletedCount', {
count: sessionIds.length,
}),
},
]);
}}
onError={(error) => {
if (isAlreadyDispatched(error)) return;
const reason =
error instanceof Error ? error.message : String(error);
pushToast('error', t('delete.failed', { reason }));
}}
onClose={() => setShowDeleteDialog(false)}
/>
)}
{showReleaseDialog && (
<ReleaseSessionDialog
onReleased={(sessionId) => {
store.dispatch([
{
type: 'status',
text: `${t('release.released')} (${sessionId.slice(0, 8)})`,
},
]);
}}
onError={(error) => {
if (isAlreadyDispatched(error)) return;
const reason =
error instanceof Error ? error.message : String(error);
pushToast('error', t('release.failed', { reason }));
}}
onClose={() => setShowReleaseDialog(false)}
/>
)}
{showHelpDialog && (
<HelpDialog
commands={commands}
onClose={() => setShowHelpDialog(false)}
/>
)}
{showThemeDialog && (
<ThemeDialog
currentTheme={selectedTheme}
onSelect={handleThemeChange}
onClose={() => setShowThemeDialog(false)}
/>
)}
{showToolsDialog && (
<ToolsDialog onClose={() => setShowToolsDialog(false)} />
)}
</div>
)}
<WebShellCustomizationProvider value={customization}>
<CompactModeContext.Provider value={compactMode}>
<TodoTimelineContext.Provider value={todoTimeline}>
<div
className={
showFloatingTodos
? `${styles.content} ${styles.contentHasMessages}`
: styles.content
}
style={dialogOpen ? { visibility: 'hidden' } : undefined}
>
<MessageList
ref={messageListRef}
messages={displayMessages}
pendingApproval={pendingApproval}
onConfirm={handleConfirm}
onShowContextDetail={handleShowContextDetail}
catchingUp={connection.catchingUp}
workspaceCwd={connection.workspaceCwd || ''}
shellOutputMaxLines={shellOutputMaxLines}
showRetryHint={showRetryHint}
onRetryClick={handleRetry}
welcomeHeader={welcomeHeader}
tailContent={
agentsInlineMode ||
memoryInlineOpen ||
modelInlineMode ||
authInlineOpen ||
approvalModeInlineOpen ||
settingsInlineOpen ? (
<>
{authInlineOpen && (
<AuthMessage
onMessage={(text, type = 'status') => {
store.dispatch([
type === 'error'
? { type: 'error', text }
: { type: 'status', text },
]);
}}
onClose={() => setAuthInlineOpen(false)}
/>
)}
{approvalModeInlineOpen && (
<ApprovalModeMessage
currentMode={currentMode}
onSelect={handleSetMode}
onClose={() => setApprovalModeInlineOpen(false)}
/>
)}
{modelInlineMode && (
<ModelMessage
mode={modelInlineMode}
onSelect={
modelInlineMode === 'fast'
? handleFastModelSelect
: handleModelSelect
}
onClose={() => setModelInlineMode(null)}
/>
)}
{agentsInlineMode && (
<AgentsMessage
mode={agentsInlineMode}
onMessage={(text) =>
store.dispatch([{ type: 'status', text }])
}
onClose={() => setAgentsInlineMode(null)}
/>
)}
{memoryInlineOpen && (
<MemoryMessage
refreshSignal={memoryRefreshSignal}
addSignal={memoryAddSignal}
addScope={memoryAddScope}
portalHost={memoryPortalHost}
onMessage={(text, type = 'status') => {
store.dispatch([{ type, text }]);
}}
onClose={() => setMemoryInlineOpen(false)}
/>
)}
{settingsInlineOpen && (
<SettingsMessage
settingsState={workspaceSettingsState}
onClose={() => setSettingsInlineOpen(false)}
onLanguageChange={handleSettingsLanguageChange}
onThemeChange={handleThemeChange}
onSubDialog={(key) => {
setSettingsInlineOpen(false);
if (key === 'fastModel')
setModelInlineMode('fast');
else if (key === 'tools.approvalMode')
setApprovalModeInlineOpen(true);
}}
/>
)}
</>
) : undefined
}
tailKey={
agentsInlineMode ||
memoryInlineOpen ||
modelInlineMode ||
authInlineOpen ||
approvalModeInlineOpen ||
settingsInlineOpen
? `inline-${authInlineOpen ? 'auth' : 'none'}-${modelInlineMode ?? 'none'}-${agentsInlineMode ?? 'none'}-${memoryInlineOpen ? 'memory' : 'none'}-${approvalModeInlineOpen ? 'approval' : 'none'}-${settingsInlineOpen ? 'settings' : 'none'}`
: undefined
}
// The approval-mode/model pickers and the settings panel are
// reachable by mouse from the status bar, so they reveal
// themselves when opened while the user is scrolled up; the
// agents/memory panels keep the user's scroll position.
autoScrollTailIntoView={
approvalModeInlineOpen ||
modelInlineMode !== null ||
settingsInlineOpen
}
virtualScrollThreshold={virtualScrollThreshold}
/>
{btwMessage?.role === 'btw' && (
<div className={styles.btwPanel}>
<BtwMessage
question={btwMessage.question}
answer={btwMessage.answer}
isPending={btwMessage.isPending}
/>
</div>
)}
<StreamingStatus />
</div>
<div ref={setMemoryPortalHost} data-web-shell-overlay-root />
</TodoTimelineContext.Provider>
</CompactModeContext.Provider>
</WebShellCustomizationProvider>
<div
className={
bottomHidden
? `${styles.footer} ${styles.footerHidden}`
: styles.footer
}
>
{showFloatingTodos && !tasksPanelMessage && (
<div className={styles.bottomPanels}>
<TodoPanel
todos={floatingTodos}
onLocateSource={
floatingTodosState.sourceMessageId
? handleLocateFloatingTodos
: undefined
}
/>
</div>
)}
{!shouldHideComposer && (
<div className={styles.composer}>
<QueuedPromptDisplay prompts={queuedPrompts} t={t} />
<Editor
ref={editorRef}
onSubmit={handleSubmit}
onCycleMode={handleCycleMode}
onToggleShortcuts={handleToggleShortcuts}
disabled={isDisabled}
commands={commands}
skills={loadedSkills}
slashCommandCategoryOrder={slashCommandCategoryOrder}
queuedMessages={queuedPrompts.map((prompt) => prompt.text)}
onFocusFooter={handleFocusTaskPill}
onPopQueuedMessages={popQueuedPromptsForEdit}
onClearQueuedMessages={clearQueuedPrompts}
currentMode={currentMode}
sessionName={sessionDisplayName}
dialogOpen={bottomHidden || tasksPanelMessage !== null}
followupState={followupState}
onAcceptFollowup={onAcceptFollowup}
onDismissFollowup={onDismissFollowup}
placeholderText={
!connected
? t('common.loading')
: streamingState !== 'idle'
? t('editor.processing')
: t('editor.placeholder')
}
/>
</div>
)}
{tasksPanelMessage && (
<div className={styles.tasksBottomPanel}>
<TasksStatusMessage
message={tasksPanelMessage}
manageActiveEvent={false}
onClose={() => {
setTasksPanelMessage(null);
handleReturnToEditor();
}}
/>
</div>
)}
{!shouldHideComposer &&
!tasksPanelMessage &&
(showShortcuts ? (
<ShortcutsPanel onClose={handleCloseShortcuts} />
) : (
<StatusBar
escapeHint={escapeHintVisible}
onSelectMode={() => setApprovalModeInlineOpen((v) => !v)}
onSelectModel={() =>
setModelInlineMode((v) => (v ? null : 'main'))
}
onShowContext={() => showContextUsage('/context', false)}
onOpenSettings={() => setSettingsInlineOpen((v) => !v)}
ref={statusBarRef}
onOpenTasks={() => openTasksPanel()}
onReturnToInput={handleReturnToEditor}
taskActivityKey={backgroundTaskActivityKey}
activeGoal={activeGoal}
hideSettings={hideSettings}
onToggleShortcuts={handleToggleShortcuts}
/>
))}
</div>
</div>
</I18nProvider>
</ThemeProvider>
);
}