mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-17 12:44:51 +00:00
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:
parent
18a0120242
commit
5209d6d279
5 changed files with 294 additions and 95 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
101
packages/web-shell/client/hooks/useBackgroundTasks.ts
Normal file
101
packages/web-shell/client/hooks/useBackgroundTasks.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue