feat(web-shell): support custom footer renderer (#5166)

* feat(web-shell): support custom footer renderer

* fix(web-shell): address review feedback for custom footer renderer

Restore Editor dialogOpen prop to prevent focus/keyboard regression when
inline panels are open. Add exhaustiveness check to mapToWebShellTaskInfo,
fix per-variant status types, remove unnecessary FooterRendererWrapper
indirection, and simplify the FooterRenderer callback interface.

* feat(web-shell): expose skills to custom footer renderer

Add WebShellSkillInfo public type and pass loaded skills list to
FooterRenderer via the skills prop.

---------

Co-authored-by: ytahdn <ytahdn@gmail.com>
This commit is contained in:
ytahdn 2026-06-15 21:47:15 +08:00 committed by GitHub
parent 18a0120242
commit 5209d6d279
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 294 additions and 95 deletions

View file

@ -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<typeof getTranslator>,
@ -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<StatusBarHandle>(null);
const editorRef = useRef<EditorHandle>(null);
const messageListRef = useRef<MessageListHandle>(null);
@ -2856,6 +2923,34 @@ export function App({
!tasksPanelMessage &&
(showShortcuts ? (
<ShortcutsPanel onClose={handleCloseShortcuts} />
) : CustomFooter ? (
<CustomFooter
connected={connected}
mode={currentMode}
model={currentModel}
streamingState={streamingState}
contextUsageRatio={
(connection.contextWindow ?? 0) > 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);
});
}}
/>
) : (
<StatusBar
escapeHint={escapeHintVisible}
@ -2868,7 +2963,7 @@ export function App({
ref={statusBarRef}
onOpenTasks={() => openTasksPanel()}
onReturnToInput={handleReturnToEditor}
taskActivityKey={backgroundTaskActivityKey}
tasks={backgroundTasks}
activeGoal={activeGoal}
hideSettings={hideSettings}
onToggleShortcuts={handleToggleShortcuts}

View file

@ -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<StatusBarHandle, StatusBarProps>(
onOpenSettings,
onOpenTasks,
onReturnToInput,
taskActivityKey,
tasks,
activeGoal,
hideSettings,
onToggleShortcuts,
@ -181,7 +172,6 @@ export const StatusBar = forwardRef<StatusBarHandle, StatusBarProps>(
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<StatusBarHandle, StatusBarProps>(
const pct = contextWindow > 0 ? (tokenCount / contextWindow) * 100 : 0;
const pctDisplay = pct.toFixed(1);
const modeIndicator = getModeIndicator(currentMode, t);
const [tasks, setTasks] = useState<DaemonSessionTaskStatus[]>([]);
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<HTMLButtonElement>(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(

View file

@ -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<WebShellFooterRenderInfo>;
export interface WebShellCustomization {
renderToolHeaderExtra?: ToolHeaderExtraRenderer;
renderWelcomeHeader?: WelcomeHeaderRenderer;
renderFooter?: FooterRenderer;
compactThinking?: boolean;
/**
* Auto-collapse each completed turn's intermediate steps (thinking, tool

View file

@ -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<DaemonSessionTaskStatus[]>([]);
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;
}

View file

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