diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index a751e1d0a7..a41e0c5a7c 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -21,7 +21,10 @@ import { 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 type { + DaemonTranscriptBlock, + DaemonSessionTaskStatus, +} from '@qwen-code/sdk/daemon'; import { extractPendingPermission } from './adapters/transcriptAdapter'; import { MessageList, type MessageListHandle } from './components/MessageList'; import { Editor, type EditorHandle } from './components/Editor'; @@ -72,6 +75,7 @@ import { ReleaseSessionDialog } from './components/dialogs/ReleaseSessionDialog' import { getLocalCommands } from './constants/localCommands'; import { mergeCommands } from './hooks/daemonSessionMappers'; import { useAnimationFrameValue } from './hooks/useAnimationFrameValue'; +import { useBackgroundTasks } from './hooks/useBackgroundTasks'; import { useMessages } from './hooks/useMessages'; import { usePanelActive } from './hooks/usePanelActive'; import { useShallowMemo, useStableArray } from './hooks/useShallowMemo'; @@ -143,6 +147,8 @@ import { type WebShellMarkdownCustomization, type ToolHeaderExtraRenderer, type WelcomeHeaderRenderer, + type FooterRenderer, + type WebShellTaskInfo, } from './customization'; import type { CommandDisplayCategoryOrder } from './utils/commandDisplay'; import styles from './App.module.css'; @@ -317,6 +323,8 @@ export interface WebShellProps { renderToolHeaderExtra?: ToolHeaderExtraRenderer; /** Custom renderer for the welcome header. Receives version, cwd, model, and mode. */ renderWelcomeHeader?: WelcomeHeaderRenderer; + /** Custom component for the footer area below the Editor. Replaces the built-in StatusBar. */ + renderFooter?: FooterRenderer; /** Collapse thinking blocks to 5 lines with a click-to-expand toggle. */ compactThinking?: boolean; /** Auto-collapse completed turns to just the prompt and final answer, with a per-turn toggle. Defaults to true. */ @@ -532,6 +540,53 @@ function getBackgroundTaskActivityKey(messages: readonly Message[]): string { return parts.join('|'); } +function mapToWebShellTaskInfo( + task: DaemonSessionTaskStatus, +): WebShellTaskInfo { + const base = { + id: task.id, + label: task.label, + description: task.description, + runtimeMs: task.runtimeMs, + startTime: task.startTime, + endTime: task.endTime, + error: task.error, + }; + + switch (task.kind) { + case 'agent': + return { + ...base, + kind: 'agent', + status: task.status, + subagentType: task.subagentType, + isBackgrounded: task.isBackgrounded, + prompt: task.prompt, + }; + case 'shell': + return { + ...base, + kind: 'shell', + status: task.status, + command: task.command, + cwd: task.cwd, + pid: task.pid, + exitCode: task.exitCode, + }; + case 'monitor': + return { + ...base, + kind: 'monitor', + status: task.status, + command: task.command, + pid: task.pid, + exitCode: task.exitCode, + }; + default: + return task satisfies never; + } +} + function translateCopyMessage( message: string, t: ReturnType, @@ -612,6 +667,7 @@ export function App({ slashCommandCategoryOrder, renderToolHeaderExtra, renderWelcomeHeader, + renderFooter, compactThinking = false, collapseCompletedTurns = true, virtualScrollThreshold, @@ -629,6 +685,7 @@ export function App({ () => ({ renderToolHeaderExtra, renderWelcomeHeader, + renderFooter, compactThinking, collapseCompletedTurns, markdown, @@ -636,11 +693,13 @@ export function App({ [ renderToolHeaderExtra, renderWelcomeHeader, + renderFooter, compactThinking, collapseCompletedTurns, markdown, ], ); + const CustomFooter = renderFooter; const store = useTranscriptStore(); const blocks = useTranscriptBlocks(); const connection = useConnection(); @@ -797,6 +856,14 @@ export function App({ () => getBackgroundTaskActivityKey(messages), [messages], ); + const backgroundTasks = useBackgroundTasks( + backgroundTaskActivityKey, + connection.status === 'connected', + ); + const footerTasks = useMemo( + () => (renderFooter ? backgroundTasks.map(mapToWebShellTaskInfo) : []), + [backgroundTasks, renderFooter], + ); const statusBarRef = useRef(null); const editorRef = useRef(null); const messageListRef = useRef(null); @@ -2856,6 +2923,34 @@ export function App({ !tasksPanelMessage && (showShortcuts ? ( + ) : CustomFooter ? ( + 0 + ? (connection.tokenCount ?? 0) / + (connection.contextWindow ?? 0) + : 0 + } + activeGoal={activeGoal} + tasks={footerTasks} + availableModes={MODES_CYCLE} + availableModels={(connection.models ?? []).map((m) => ({ + id: m.id, + label: m.label, + contextWindow: m.contextWindow, + }))} + skills={loadedSkills} + onSelectMode={(mode) => handleSetMode(mode)} + onSelectModel={(model) => { + sessionActions.setModel(model).then(() => { + setCurrentModel(model); + }); + }} + /> ) : ( openTasksPanel()} onReturnToInput={handleReturnToEditor} - taskActivityKey={backgroundTaskActivityKey} + tasks={backgroundTasks} activeGoal={activeGoal} hideSettings={hideSettings} onToggleShortcuts={handleToggleShortcuts} diff --git a/packages/web-shell/client/components/StatusBar.tsx b/packages/web-shell/client/components/StatusBar.tsx index b962d8a0c6..67cb0e4387 100644 --- a/packages/web-shell/client/components/StatusBar.tsx +++ b/packages/web-shell/client/components/StatusBar.tsx @@ -8,13 +8,10 @@ import { type KeyboardEvent, } from 'react'; import type { DaemonSessionTaskStatus } from '@qwen-code/sdk/daemon'; -import { useActions, useConnection } from '@qwen-code/webui/daemon-react-sdk'; +import { useConnection } from '@qwen-code/webui/daemon-react-sdk'; import { useI18n } from '../i18n'; -import { TASKS_STATUS_ACTIVE_EVENT } from './messages/TasksStatusMessage'; import styles from './StatusBar.module.css'; -const TASKS_POLL_INTERVAL_MS = 3000; -const MAX_EMPTY_TASK_POLLS = 2; const GOAL_PILL_INTERVAL_MS = 1000; export interface StatusBarHandle { @@ -53,7 +50,7 @@ interface StatusBarProps { onOpenSettings: () => void; onOpenTasks?: () => void; onReturnToInput?: (text?: string) => void; - taskActivityKey?: string; + tasks: readonly DaemonSessionTaskStatus[]; activeGoal?: { condition: string; setAt: number; @@ -147,12 +144,6 @@ export function getTaskPillLabel( }); } -function hasActiveTask(tasks: readonly DaemonSessionTaskStatus[]): boolean { - return tasks.some( - (task) => task.status === 'running' || task.status === 'paused', - ); -} - function formatGoalElapsed(ms: number): string { if (ms < 1000) return ''; const seconds = Math.floor(ms / 1000); @@ -173,7 +164,7 @@ export const StatusBar = forwardRef( onOpenSettings, onOpenTasks, onReturnToInput, - taskActivityKey, + tasks, activeGoal, hideSettings, onToggleShortcuts, @@ -181,7 +172,6 @@ export const StatusBar = forwardRef( ref, ) { const connection = useConnection(); - const actions = useActions(); const connected = connection.status === 'connected'; const currentModel = connection.currentModel ?? ''; const currentMode = connection.currentMode ?? ''; @@ -191,88 +181,9 @@ export const StatusBar = forwardRef( const pct = contextWindow > 0 ? (tokenCount / contextWindow) * 100 : 0; const pctDisplay = pct.toFixed(1); const modeIndicator = getModeIndicator(currentMode, t); - const [tasks, setTasks] = useState([]); - const [pollingActive, setPollingActive] = useState(false); - const [tasksPanelActive, setTasksPanelActive] = useState(false); const [, setGoalTick] = useState(0); - const emptyPollsRef = useRef(0); - const tasksRefreshInFlightRef = useRef(false); const taskPillRef = useRef(null); - useEffect(() => { - if (!connected) { - setTasks([]); - setPollingActive(false); - emptyPollsRef.current = 0; - return; - } - }, [connected]); - - useEffect(() => { - if (!connected || !taskActivityKey) return; - emptyPollsRef.current = 0; - setPollingActive(true); - }, [connected, taskActivityKey]); - - useEffect(() => { - if (tasksPanelActive) return; - if (!connected || !pollingActive) return; - - let disposed = false; - const refresh = () => { - if (tasksRefreshInFlightRef.current) return; - tasksRefreshInFlightRef.current = true; - actions - .getTasks() - .then((snapshot) => { - if (disposed) return; - setTasks(snapshot.tasks); - if (snapshot.tasks.length === 0) { - emptyPollsRef.current += 1; - if (emptyPollsRef.current >= MAX_EMPTY_TASK_POLLS) { - setPollingActive(false); - } - return; - } - emptyPollsRef.current = 0; - if (!hasActiveTask(snapshot.tasks)) { - setPollingActive(false); - } - }) - .catch((error: unknown) => { - if (disposed) return; - console.warn('[web-shell] failed to refresh tasks:', error); - }) - .finally(() => { - tasksRefreshInFlightRef.current = false; - }); - }; - - refresh(); - const id = setInterval(refresh, TASKS_POLL_INTERVAL_MS); - return () => { - disposed = true; - clearInterval(id); - }; - }, [actions, connected, pollingActive, tasksPanelActive]); - - useEffect(() => { - const onTasksPanelActive = (event: Event) => { - const detail = (event as CustomEvent<{ active?: boolean }>).detail; - const active = detail?.active === true; - setTasksPanelActive(active); - if (!active && hasActiveTask(tasks)) { - setPollingActive(true); - } - }; - window.addEventListener(TASKS_STATUS_ACTIVE_EVENT, onTasksPanelActive); - return () => - window.removeEventListener( - TASKS_STATUS_ACTIVE_EVENT, - onTasksPanelActive, - ); - }, [tasks]); - useEffect(() => { if (!activeGoal) return; const id = setInterval( diff --git a/packages/web-shell/client/customization.tsx b/packages/web-shell/client/customization.tsx index a6643b3027..a694a2bb47 100644 --- a/packages/web-shell/client/customization.tsx +++ b/packages/web-shell/client/customization.tsx @@ -1,5 +1,11 @@ -import { createContext, useContext, type ReactNode } from 'react'; +import { + createContext, + useContext, + type ComponentType, + type ReactNode, +} from 'react'; import type { Components, Options } from 'react-markdown'; +import type { DaemonStreamingState } from '@qwen-code/webui/daemon-react-sdk'; import type { ACPToolCall } from './adapters/types'; import type { WelcomeHeaderProps } from './components/WelcomeHeader'; @@ -44,9 +50,87 @@ export type ToolHeaderExtraRenderer = ( export type WelcomeHeaderRenderer = (props: WelcomeHeaderProps) => ReactNode; +// ---- Background task info (public type for footer renderer) ---- + +interface WebShellTaskBase { + id: string; + label: string; + description: string; + runtimeMs: number; + startTime: number; + endTime?: number; + error?: string; +} + +export interface WebShellAgentTask extends WebShellTaskBase { + kind: 'agent'; + status: 'running' | 'paused' | 'completed' | 'failed' | 'cancelled'; + subagentType?: string; + isBackgrounded: boolean; + prompt?: string; +} + +export interface WebShellShellTask extends WebShellTaskBase { + kind: 'shell'; + status: 'running' | 'completed' | 'failed' | 'cancelled'; + command: string; + cwd: string; + pid?: number; + exitCode?: number; +} + +export interface WebShellMonitorTask extends WebShellTaskBase { + kind: 'monitor'; + status: 'running' | 'completed' | 'failed' | 'cancelled'; + command: string; + pid?: number; + exitCode?: number; +} + +export type WebShellTaskInfo = + | WebShellAgentTask + | WebShellShellTask + | WebShellMonitorTask; + +// ---- Model info (public type for footer renderer) ---- + +export interface WebShellModelInfo { + id: string; + label?: string; + contextWindow?: number; +} + +// ---- Skill info (public type for footer renderer) ---- + +export interface WebShellSkillInfo { + name: string; + description: string; +} + +// ---- Footer renderer ---- + +export interface WebShellFooterRenderInfo { + connected: boolean; + mode: string; + model: string; + streamingState: DaemonStreamingState; + contextUsageRatio: number; + activeGoal: { condition: string; setAt: number } | null; + tasks: readonly WebShellTaskInfo[]; + availableModes: readonly string[]; + availableModels: readonly WebShellModelInfo[]; + skills: readonly WebShellSkillInfo[]; + + onSelectMode: (mode: string) => void; + onSelectModel: (model: string) => void; +} + +export type FooterRenderer = ComponentType; + export interface WebShellCustomization { renderToolHeaderExtra?: ToolHeaderExtraRenderer; renderWelcomeHeader?: WelcomeHeaderRenderer; + renderFooter?: FooterRenderer; compactThinking?: boolean; /** * Auto-collapse each completed turn's intermediate steps (thinking, tool diff --git a/packages/web-shell/client/hooks/useBackgroundTasks.ts b/packages/web-shell/client/hooks/useBackgroundTasks.ts new file mode 100644 index 0000000000..be791b7901 --- /dev/null +++ b/packages/web-shell/client/hooks/useBackgroundTasks.ts @@ -0,0 +1,101 @@ +import { useEffect, useRef, useState } from 'react'; +import type { DaemonSessionTaskStatus } from '@qwen-code/sdk/daemon'; +import { useActions } from '@qwen-code/webui/daemon-react-sdk'; +import { TASKS_STATUS_ACTIVE_EVENT } from '../components/messages/TasksStatusMessage'; + +const TASKS_POLL_INTERVAL_MS = 3000; +const MAX_EMPTY_TASK_POLLS = 2; + +function hasActiveTask(tasks: readonly DaemonSessionTaskStatus[]): boolean { + return tasks.some( + (task) => task.status === 'running' || task.status === 'paused', + ); +} + +export function useBackgroundTasks( + taskActivityKey: string, + connected: boolean, +): DaemonSessionTaskStatus[] { + const actions = useActions(); + const [tasks, setTasks] = useState([]); + const [pollingActive, setPollingActive] = useState(false); + const [tasksPanelActive, setTasksPanelActive] = useState(false); + const emptyPollsRef = useRef(0); + const tasksRefreshInFlightRef = useRef(false); + + useEffect(() => { + if (!connected) { + setTasks([]); + setPollingActive(false); + emptyPollsRef.current = 0; + return; + } + }, [connected]); + + useEffect(() => { + if (!connected || !taskActivityKey) return; + emptyPollsRef.current = 0; + setPollingActive(true); + }, [connected, taskActivityKey]); + + useEffect(() => { + if (tasksPanelActive) return; + if (!connected || !pollingActive) return; + + let disposed = false; + const refresh = () => { + if (tasksRefreshInFlightRef.current) return; + tasksRefreshInFlightRef.current = true; + actions + .getTasks() + .then((snapshot) => { + if (disposed) return; + setTasks(snapshot.tasks); + if (snapshot.tasks.length === 0) { + emptyPollsRef.current += 1; + if (emptyPollsRef.current >= MAX_EMPTY_TASK_POLLS) { + setPollingActive(false); + } + return; + } + emptyPollsRef.current = 0; + if (!hasActiveTask(snapshot.tasks)) { + setPollingActive(false); + } + }) + .catch((error: unknown) => { + if (disposed) return; + console.warn('[web-shell] failed to refresh tasks:', error); + }) + .finally(() => { + tasksRefreshInFlightRef.current = false; + }); + }; + + refresh(); + const id = setInterval(refresh, TASKS_POLL_INTERVAL_MS); + return () => { + disposed = true; + clearInterval(id); + }; + }, [actions, connected, pollingActive, tasksPanelActive]); + + const tasksRef = useRef(tasks); + tasksRef.current = tasks; + + useEffect(() => { + const onTasksPanelActive = (event: Event) => { + const detail = (event as CustomEvent<{ active?: boolean }>).detail; + const active = detail?.active === true; + setTasksPanelActive(active); + if (!active && hasActiveTask(tasksRef.current)) { + setPollingActive(true); + } + }; + window.addEventListener(TASKS_STATUS_ACTIVE_EVENT, onTasksPanelActive); + return () => + window.removeEventListener(TASKS_STATUS_ACTIVE_EVENT, onTasksPanelActive); + }, []); + + return tasks; +} diff --git a/packages/web-shell/client/index.ts b/packages/web-shell/client/index.ts index 81ca76b9d1..66ef577de6 100644 --- a/packages/web-shell/client/index.ts +++ b/packages/web-shell/client/index.ts @@ -10,5 +10,13 @@ export type { ToolHeaderKind, WelcomeHeaderRenderer, WebShellMarkdownCustomization, + WebShellFooterRenderInfo, + FooterRenderer, + WebShellTaskInfo, + WebShellAgentTask, + WebShellShellTask, + WebShellMonitorTask, + WebShellModelInfo, + WebShellSkillInfo, } from './customization'; export type { WelcomeHeaderProps } from './components/WelcomeHeader';