diff --git a/packages/web-shell/client/components/messages/GoalStatusMessage.tsx b/packages/web-shell/client/components/messages/GoalStatusMessage.tsx
index 37cf6a58593..21d35a2df55 100644
--- a/packages/web-shell/client/components/messages/GoalStatusMessage.tsx
+++ b/packages/web-shell/client/components/messages/GoalStatusMessage.tsx
@@ -151,7 +151,7 @@ export function GoalStatusMessage({
const renderMode = useTranscriptRenderMode();
useEffect(() => {
- if (!activateFooter || renderMode === 'readonly') return;
+ if (!activateFooter || renderMode !== 'interactive') return;
const active = status.kind === 'set' || status.kind === 'checking';
window.dispatchEvent(
new CustomEvent(GOAL_STATUS_ACTIVE_EVENT, {
diff --git a/packages/web-shell/client/components/messages/Markdown.mermaid.test.ts b/packages/web-shell/client/components/messages/Markdown.mermaid.test.ts
new file mode 100644
index 00000000000..48b268a14b3
--- /dev/null
+++ b/packages/web-shell/client/components/messages/Markdown.mermaid.test.ts
@@ -0,0 +1,126 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act, createElement } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { TranscriptRenderModeProvider } from '../../transcriptRenderMode';
+
+const mermaidMock = vi.hoisted(() => ({
+ initialize: vi.fn(),
+ render: vi.fn(() => Promise.resolve({ svg: '' })),
+}));
+
+vi.mock('mermaid', () => ({ default: mermaidMock }));
+
+const { Markdown } = await import('./Markdown');
+
+const mounted: Array<{ root: Root; container: HTMLElement }> = [];
+
+function mountMermaid(renderMode: 'interactive' | 'readonly' | 'document') {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ mounted.push({ root, container });
+ const render = (mode: 'interactive' | 'readonly' | 'document') =>
+ act(() => {
+ root.render(
+ createElement(
+ TranscriptRenderModeProvider,
+ { value: mode },
+ createElement(Markdown, {
+ content: '```mermaid\ngraph TD\nA --> B\n```',
+ }),
+ ),
+ );
+ });
+ render(renderMode);
+ return { container, render };
+}
+
+async function startMermaidRender(): Promise {
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(200);
+ });
+}
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ mermaidMock.initialize.mockClear();
+ mermaidMock.render.mockReset();
+ mermaidMock.render.mockResolvedValue({ svg: '' });
+});
+
+afterEach(() => {
+ for (const { root, container } of mounted.splice(0)) {
+ act(() => root.unmount());
+ container.remove();
+ }
+ vi.useRealTimers();
+});
+
+describe('Markdown Mermaid render modes', () => {
+ it('applies resource limits only in document mode', async () => {
+ let resolveFirstRender: ((value: { svg: string }) => void) | undefined;
+ mermaidMock.render.mockImplementationOnce(
+ () =>
+ new Promise<{ svg: string }>((resolve) => {
+ resolveFirstRender = resolve;
+ }),
+ );
+ const view = mountMermaid('interactive');
+ await startMermaidRender();
+
+ expect(mermaidMock.initialize).toHaveBeenCalledTimes(1);
+ expect(mermaidMock.initialize.mock.calls[0]?.[0]).not.toHaveProperty(
+ 'maxTextSize',
+ );
+ expect(mermaidMock.initialize.mock.calls[0]?.[0]).not.toHaveProperty(
+ 'maxEdges',
+ );
+
+ view.render('document');
+ await startMermaidRender();
+ expect(mermaidMock.initialize).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ resolveFirstRender?.({ svg: '' });
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(mermaidMock.initialize).toHaveBeenCalledTimes(2);
+ expect(mermaidMock.initialize.mock.calls[1]?.[0]).toMatchObject({
+ maxTextSize: 50_000,
+ maxEdges: 500,
+ });
+
+ view.render('readonly');
+ await startMermaidRender();
+ expect(mermaidMock.initialize).toHaveBeenCalledTimes(3);
+ expect(mermaidMock.initialize.mock.calls[2]?.[0]).not.toHaveProperty(
+ 'maxTextSize',
+ );
+ expect(mermaidMock.initialize.mock.calls[2]?.[0]).not.toHaveProperty(
+ 'maxEdges',
+ );
+ });
+
+ it('times out only in document mode', async () => {
+ mermaidMock.render.mockReturnValue(new Promise(() => {}));
+ const view = mountMermaid('interactive');
+ await startMermaidRender();
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(10_000);
+ });
+ expect(view.container.querySelector('pre code')).toBeNull();
+
+ view.render('document');
+ await startMermaidRender();
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(10_000);
+ });
+ expect(view.container.querySelector('pre code')?.textContent).toContain(
+ 'graph TD',
+ );
+ });
+});
diff --git a/packages/web-shell/client/components/messages/Markdown.test.ts b/packages/web-shell/client/components/messages/Markdown.test.ts
index 7a0cc14b705..6edc00833c8 100644
--- a/packages/web-shell/client/components/messages/Markdown.test.ts
+++ b/packages/web-shell/client/components/messages/Markdown.test.ts
@@ -125,6 +125,16 @@ describe('isSafeImageSrc', () => {
it('allows relative paths', () => {
expect(isSafeImageSrc('/images/logo.png')).toBe(true);
});
+
+ it('allows only approved data images in document mode', () => {
+ expect(isSafeImageSrc('data:image/png;base64,iVBOR', true)).toBe(true);
+ expect(isSafeImageSrc('https://example.com/img.png', true)).toBe(false);
+ expect(isSafeImageSrc('/images/logo.png', true)).toBe(false);
+ expect(isSafeImageSrc('data:image/bmp;base64,Qk0=', true)).toBe(false);
+ expect(
+ isSafeImageSrc('data:image/png;base64,iVBOR" onerror=alert(1)', true),
+ ).toBe(false);
+ });
});
describe('markdownUrlTransform', () => {
@@ -234,6 +244,57 @@ describe('qwen-session:// links', () => {
});
});
+describe('document image policy', () => {
+ it('does not put remote Markdown image URLs into the DOM', () => {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ act(() => {
+ root.render(
+ createElement(
+ TranscriptRenderModeProvider,
+ { value: 'document' },
+ createElement(Markdown, {
+ content: '',
+ }),
+ ),
+ );
+ });
+
+ expect(container.querySelector('img')?.getAttribute('src')).toBeNull();
+ expect(container.innerHTML).not.toContain('https://example.com');
+
+ act(() => root.unmount());
+ container.remove();
+ });
+
+ it('renders chart fences as static code in document mode', () => {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ act(() => {
+ root.render(
+ createElement(
+ TranscriptRenderModeProvider,
+ { value: 'document' },
+ createElement(Markdown, {
+ content: '```echarts\n{"series":[]}\n```',
+ source: 'assistant',
+ }),
+ ),
+ );
+ });
+
+ expect(container.querySelector('pre code')?.textContent).toContain(
+ '{"series":[]}',
+ );
+ expect(container.textContent).not.toContain('Show chart');
+
+ act(() => root.unmount());
+ container.remove();
+ });
+});
+
describe('Markdown enhanced tables', () => {
it('uses enhanced table rendering when configured', () => {
const container = document.createElement('div');
@@ -1325,6 +1386,76 @@ describe('Markdown custom code block rendering', () => {
});
describe('Markdown code highlighting while streaming', () => {
+ it('keeps code plain in document mode without loading the highlighter', async () => {
+ __resetForTesting();
+ await getCodeHighlighter('json');
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(
+ createElement(
+ TranscriptRenderModeProvider,
+ { value: 'document' },
+ createElement(Markdown, {
+ content: '```json\n{ "safe": true }\n```',
+ isStreaming: false,
+ }),
+ ),
+ );
+ });
+
+ expect(container.querySelector('.shiki')).toBeNull();
+ expect(container.querySelector('pre code')?.textContent).toContain(
+ '"safe": true',
+ );
+
+ await act(async () => {
+ root.unmount();
+ });
+ container.remove();
+ });
+
+ it('drops a warmed highlight when switching to document mode', async () => {
+ __resetForTesting();
+ await getCodeHighlighter('json');
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ const content = '```json\n{ "safe": true }\n```';
+
+ await act(async () => {
+ root.render(
+ createElement(
+ TranscriptRenderModeProvider,
+ { value: 'interactive' },
+ createElement(Markdown, { content, isStreaming: false }),
+ ),
+ );
+ });
+ expect(container.querySelector('.shiki')).not.toBeNull();
+
+ await act(async () => {
+ root.render(
+ createElement(
+ TranscriptRenderModeProvider,
+ { value: 'document' },
+ createElement(Markdown, { content, isStreaming: false }),
+ ),
+ );
+ });
+ expect(container.querySelector('.shiki')).toBeNull();
+ expect(container.querySelector('pre code')?.textContent).toContain(
+ '"safe": true',
+ );
+
+ await act(async () => {
+ root.unmount();
+ });
+ container.remove();
+ });
+
it('keeps streamed code content visible while streaming', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
diff --git a/packages/web-shell/client/components/messages/Markdown.tsx b/packages/web-shell/client/components/messages/Markdown.tsx
index 58e0ea318e4..6d9e55b628a 100644
--- a/packages/web-shell/client/components/messages/Markdown.tsx
+++ b/packages/web-shell/client/components/messages/Markdown.tsx
@@ -151,6 +151,8 @@ export function resolveFenceLanguage(
const SAFE_HREF_SCHEMES = /^(https?:|mailto:)/i;
const SAFE_IMAGE_DATA_URI = /^data:image\/(png|jpeg|gif|webp|bmp);base64,/i;
+const SAFE_DOCUMENT_IMAGE_DATA_URI =
+ /^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9+/]*={0,2}$/i;
export function isSafeHref(url: string | undefined): boolean {
if (!url) return false;
@@ -161,10 +163,14 @@ export function isSafeHref(url: string | undefined): boolean {
return SAFE_HREF_SCHEMES.test(trimmed);
}
-export function isSafeImageSrc(url: string | undefined): boolean {
+export function isSafeImageSrc(
+ url: string | undefined,
+ documentMode = false,
+): boolean {
if (!url) return false;
const trimmed = url.trim();
if (!trimmed) return false;
+ if (documentMode) return SAFE_DOCUMENT_IMAGE_DATA_URI.test(trimmed);
if (trimmed.startsWith('#')) return true;
if (trimmed.startsWith('/') && !trimmed.startsWith('//')) return true;
if (SAFE_IMAGE_DATA_URI.test(trimmed)) return true;
@@ -174,12 +180,17 @@ export function isSafeImageSrc(url: string | undefined): boolean {
// Track last initialized theme to avoid redundant mermaid.initialize() calls.
// mermaid.initialize() is idempotent but runs per-block; with N diagrams in a
// transcript this saves N-1 redundant calls per render cycle.
-let lastMermaidTheme: string | undefined;
+let lastMermaidConfigKey: string | undefined;
+let mermaidRenderQueue: Promise = Promise.resolve();
let mermaidRenderId = 0;
+const MAX_MERMAID_TEXT_CHARS = 50_000;
+const MAX_MERMAID_EDGES = 500;
+const MERMAID_RENDER_TIMEOUT_MS = 10_000;
function MermaidBlock({ code }: { code: string }) {
const { t } = useI18n();
const appTheme = useTheme();
+ const documentMode = useTranscriptRenderMode() === 'document';
const [svg, setSvg] = useState(null);
const [error, setError] = useState(null);
const [viewMode, setViewMode] = useState<'diagram' | 'code'>('diagram');
@@ -267,47 +278,73 @@ function MermaidBlock({ code }: { code: string }) {
useEffect(() => {
let cancelled = false;
+ let timedOut = false;
setSvg(null);
setError(null);
+ const renderTimeout = documentMode
+ ? setTimeout(() => {
+ timedOut = true;
+ if (!cancelled) setError('Mermaid render timed out');
+ }, MERMAID_RENDER_TIMEOUT_MS)
+ : undefined;
const timer = setTimeout(() => {
- import('mermaid').then(async (mod) => {
- if (cancelled) return;
- const mermaid = mod.default;
- if (lastMermaidTheme !== mermaidTheme) {
- mermaid.initialize({
- startOnLoad: false,
- theme: mermaidTheme,
- securityLevel: 'strict',
- suppressErrorRendering: true,
- flowchart: {
- wrappingWidth: 300,
- useMaxWidth: false,
- },
+ import('mermaid')
+ .then(async (mod) => {
+ if (cancelled || timedOut) return;
+ const mermaid = mod.default;
+ const configKey = `${mermaidTheme}:${documentMode ? 'document' : 'runtime'}`;
+ const render = mermaidRenderQueue.then(async () => {
+ if (cancelled || timedOut)
+ throw new Error('Mermaid render skipped');
+ if (lastMermaidConfigKey !== configKey) {
+ mermaid.initialize({
+ startOnLoad: false,
+ theme: mermaidTheme,
+ securityLevel: 'strict',
+ suppressErrorRendering: true,
+ ...(documentMode
+ ? {
+ maxTextSize: MAX_MERMAID_TEXT_CHARS,
+ maxEdges: MAX_MERMAID_EDGES,
+ }
+ : {}),
+ flowchart: {
+ wrappingWidth: 300,
+ useMaxWidth: false,
+ },
+ });
+ lastMermaidConfigKey = configKey;
+ }
+ const id = `mermaid-${++mermaidRenderId}`;
+ return mermaid.render(id, code.trim());
});
- lastMermaidTheme = mermaidTheme;
- }
- try {
- const id = `mermaid-${++mermaidRenderId}`;
- const { svg } = await mermaid.render(id, code.trim());
+ mermaidRenderQueue = render.then(
+ () => undefined,
+ () => undefined,
+ );
+ const { svg } = await render;
// No additional sanitization needed: securityLevel:'strict' uses
// DOMPurify internally to sanitize SVG output.
- if (!cancelled) {
+ if (!cancelled && !timedOut) {
+ if (renderTimeout !== undefined) clearTimeout(renderTimeout);
setSvg(svg);
}
- } catch (error: unknown) {
- if (!cancelled) {
+ })
+ .catch((error: unknown) => {
+ if (!cancelled && !timedOut) {
+ if (renderTimeout !== undefined) clearTimeout(renderTimeout);
setError(
error instanceof Error ? error.message : 'Mermaid render failed',
);
}
- }
- });
+ });
}, 150);
return () => {
cancelled = true;
clearTimeout(timer);
+ if (renderTimeout !== undefined) clearTimeout(renderTimeout);
};
- }, [code, mermaidTheme]);
+ }, [code, documentMode, mermaidTheme]);
const handleCopy = () => {
navigator.clipboard.writeText(code).then(
@@ -423,6 +460,7 @@ function CodeBlock({
}) {
const { t } = useI18n();
const appTheme = useTheme();
+ const documentMode = useTranscriptRenderMode() === 'document';
const [html, setHtml] = useState(null);
const [copied, setCopied] = useState(false);
@@ -438,6 +476,7 @@ function CodeBlock({
// repeatedly tokenizes its entire contents and can dominate rendering for
// long responses; the settled render below highlights the final text once.
if (
+ documentMode ||
isStreaming ||
lang === 'mermaid' ||
resolvedLang === 'text' ||
@@ -488,7 +527,7 @@ function CodeBlock({
return () => {
cancelled = true;
};
- }, [code, lang, resolvedLang, shikiTheme, isStreaming]);
+ }, [code, documentMode, lang, resolvedLang, shikiTheme, isStreaming]);
const handleCopy = () => {
navigator.clipboard.writeText(code).then(
@@ -512,7 +551,7 @@ function CodeBlock({
{copied ? t('code.copied') : t('code.copy')}
- {!isStreaming && html !== null ? (
+ {!documentMode && !isStreaming && html !== null ? (
{children};
}
const sessionId = href.trim().replace(QWEN_SESSION_SCHEME, '');
@@ -747,7 +786,10 @@ function MarkdownLink({
}
function MarkdownImage({ src, alt }: { src?: string; alt?: string }) {
- const safeSrc = isSafeImageSrc(src) ? src : undefined;
+ const renderMode = useTranscriptRenderMode();
+ const safeSrc = isSafeImageSrc(src, renderMode === 'document')
+ ? src
+ : undefined;
return

;
}
@@ -891,6 +933,7 @@ export const Markdown = memo(function Markdown({
}: MarkdownProps) {
const { markdown, markdownTableMode } = useWebShellCustomization();
const theme = useTheme();
+ const documentMode = useTranscriptRenderMode() === 'document';
const sourceMarkdown = source ? markdown : undefined;
const throttledContent = useThrottledValue(content ?? '', isStreaming);
@@ -924,7 +967,10 @@ export const Markdown = memo(function Markdown({
};
}, [components, effectiveTableMode, sourceComponents]);
const chart =
- source === 'assistant' && !sourceComponents?.code && !sourceComponents?.pre
+ !documentMode &&
+ source === 'assistant' &&
+ !sourceComponents?.code &&
+ !sourceComponents?.pre
? (sourceMarkdown?.chart ??
(sourceMarkdown?.renderCodeBlock
? undefined
diff --git a/packages/web-shell/client/components/messages/PlanExecutionView.test.tsx b/packages/web-shell/client/components/messages/PlanExecutionView.test.tsx
index f546e4c22b1..59c71cc48f9 100644
--- a/packages/web-shell/client/components/messages/PlanExecutionView.test.tsx
+++ b/packages/web-shell/client/components/messages/PlanExecutionView.test.tsx
@@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
import type { DaemonSessionAgentTaskStatus } from '@qwen-code/sdk/daemon';
import type { ACPToolCall, TodoItem } from '../../adapters/types';
import { I18nProvider } from '../../i18n';
+import { TranscriptRenderModeProvider } from '../../transcriptRenderMode';
import {
getPlanNodeState,
layerPlanTodos,
@@ -82,6 +83,31 @@ function task(
}
describe('PlanExecutionView', () => {
+ it('disables plan selection in document mode', () => {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ act(() => {
+ root.render(
+
+
+
+
+ ,
+ );
+ });
+
+ const planNodes = container.querySelectorAll
(
+ '[data-plan-node-id]',
+ );
+ expect(planNodes).toHaveLength(todos.length);
+ expect([...planNodes].every((button) => button.disabled)).toBe(true);
+ expect(container.querySelector('[data-plan-step-details]')).toBeNull();
+
+ act(() => root.unmount());
+ container.remove();
+ });
+
it('layers dependent todos in topological order', () => {
expect(
layerPlanTodos(todos).map((layer) => layer.map((todo) => todo.id)),
diff --git a/packages/web-shell/client/components/messages/PlanExecutionView.tsx b/packages/web-shell/client/components/messages/PlanExecutionView.tsx
index 922c5e9f936..6ac92790012 100644
--- a/packages/web-shell/client/components/messages/PlanExecutionView.tsx
+++ b/packages/web-shell/client/components/messages/PlanExecutionView.tsx
@@ -13,6 +13,7 @@ import type {
import type { ACPToolCall, TodoItem } from '../../adapters/types';
import { isSubAgentToolCall } from '../../adapters/toolClassification';
import { useI18n } from '../../i18n';
+import { useTranscriptRenderMode } from '../../transcriptRenderMode';
import { getAgentDisplayStatus, isAgentCancelled } from './toolFormatting';
import styles from './PlanExecutionView.module.css';
@@ -309,6 +310,7 @@ export function PlanExecutionView({
onOpenSubagent?: (tool: ACPToolCall) => void;
}) {
const { t } = useI18n();
+ const documentMode = useTranscriptRenderMode() === 'document';
const taskIndex = useMemo(() => createTaskExecutionIndex(tasks), [tasks]);
const knownIds = new Set(todos.map((todo) => todo.id));
@@ -650,6 +652,7 @@ export function PlanExecutionView({
current === todo.id ? undefined : todo.id,
)
}
+ disabled={documentMode}
>
{todo.id}
diff --git a/packages/web-shell/client/components/messages/PlanMessage.test.tsx b/packages/web-shell/client/components/messages/PlanMessage.test.tsx
index 11fcbf14d9b..574f72f099a 100644
--- a/packages/web-shell/client/components/messages/PlanMessage.test.tsx
+++ b/packages/web-shell/client/components/messages/PlanMessage.test.tsx
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { I18nProvider } from '../../i18n';
+import { TranscriptRenderModeProvider } from '../../transcriptRenderMode';
import type { TodoItem } from '../../adapters/types';
// PlanMessage's expanded list reads TodoTimelineContext and (via TodoFullList)
@@ -44,6 +45,7 @@ function renderPlan(
id: string,
todos: TodoItem[],
timeline?: Map
,
+ documentMode = false,
): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
@@ -51,9 +53,13 @@ function renderPlan(
act(() => {
root.render(
-
-
-
+
+
+
+
+
,
);
});
@@ -94,6 +100,14 @@ describe('PlanMessage', () => {
expect(container.textContent).toContain('▾');
});
+ it('renders the complete plan without controls in document mode', () => {
+ const container = renderPlan('p1', TODOS, undefined, true);
+ expect(container.textContent).toContain('First task');
+ expect(container.textContent).toContain('Second task');
+ expect(container.textContent).toContain('Third task');
+ expect(container.querySelector('button')).toBeNull();
+ });
+
it('shows the plan-keyed diff when a timeline is present', () => {
const timeline = new Map([
[
diff --git a/packages/web-shell/client/components/messages/PlanMessage.tsx b/packages/web-shell/client/components/messages/PlanMessage.tsx
index da0b839b27a..d7d6d56cea9 100644
--- a/packages/web-shell/client/components/messages/PlanMessage.tsx
+++ b/packages/web-shell/client/components/messages/PlanMessage.tsx
@@ -3,6 +3,7 @@ import type { TodoItem } from '../../adapters/types';
import { TodoTimelineContext } from '../../App';
import { TodoEventSummary, TodoFullList } from './TodoView';
import { useI18n } from '../../i18n';
+import { useTranscriptRenderMode } from '../../transcriptRenderMode';
import flashStyles from '../MessageLocateFlash.module.css';
import styles from './PlanMessage.module.css';
@@ -27,6 +28,7 @@ export const PlanMessage = memo(function PlanMessage({
isLocateFlashing = false,
}: PlanMessageProps) {
const { t } = useI18n();
+ const documentMode = useTranscriptRenderMode() === 'document';
const [expanded, setExpanded] = useState(false);
if (todos.length === 0) return null;
@@ -39,22 +41,31 @@ export const PlanMessage = memo(function PlanMessage({
isLocateFlashing ? ` ${flashStyles.flash}` : ''
}`}
>
-
- {expanded ? (
+ {documentMode ? (
+
+ {t('plan.title')}
+
+ {completed}/{total}
+
+
+ ) : (
+
+ )}
+ {documentMode || expanded ? (
) : (
diff --git a/packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx b/packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx
index 681981f1f19..1c75253588d 100644
--- a/packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx
+++ b/packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx
@@ -10,6 +10,10 @@ import type {
} from '@qwen-code/sdk/daemon';
import type { ACPToolCall, TodoItem } from '../../adapters/types';
import { I18nProvider } from '../../i18n';
+import {
+ TranscriptRenderModeProvider,
+ type TranscriptRenderMode,
+} from '../../transcriptRenderMode';
// The panel only needs getTasks/cancelTask from the daemon SDK; mock the
// hook so the unit test doesn't pull the whole connection graph. Hoisted
@@ -41,6 +45,7 @@ afterEach(() => {
mounted.length = 0;
getTasksMock.mockReset();
cancelTaskMock.mockReset();
+ vi.useRealTimers();
});
function agentTask(
@@ -88,6 +93,7 @@ function renderPanel(
agentTools?: readonly ACPToolCall[];
onOpenSubagent?: (tool: ACPToolCall) => void;
onOpenMonitor?: (task: DaemonSessionMonitorTaskStatus) => void;
+ renderMode?: TranscriptRenderMode;
} = {},
): HTMLElement {
const snapshot: DaemonSessionTasksStatus = {
@@ -103,15 +109,19 @@ function renderPanel(
act(() => {
root.render(
-
+
+
+
,
);
});
@@ -119,6 +129,42 @@ function renderPanel(
}
describe('TasksStatusMessage monitor details', () => {
+ it('renders a complete inert snapshot without polling in document mode', () => {
+ vi.useFakeTimers();
+ const tasks = Array.from({ length: 10 }, (_, index) =>
+ agentTask(`task-${index}`, {
+ prompt:
+ index === 9
+ ? Array.from(
+ { length: 6 },
+ (_value, line) => `prompt-line-${line}`,
+ ).join('\n')
+ : undefined,
+ recentActivities:
+ index === 9
+ ? Array.from({ length: 8 }, (_value, activity) => ({
+ name: 'read_file',
+ description: `activity-${activity}.ts`,
+ at: activity,
+ }))
+ : undefined,
+ }),
+ );
+ const container = renderPanel(tasks, { renderMode: 'document' });
+
+ act(() => vi.advanceTimersByTime(6_000));
+
+ expect(getTasksMock).not.toHaveBeenCalled();
+ expect(cancelTaskMock).not.toHaveBeenCalled();
+ expect(container.textContent).toContain('label-task-0');
+ expect(container.textContent).toContain('label-task-9');
+ expect(container.textContent).toContain('activity-0.ts');
+ expect(container.textContent).toContain('activity-7.ts');
+ expect(container.textContent).toContain('prompt-line-0');
+ expect(container.textContent).toContain('prompt-line-5');
+ expect(container.querySelectorAll('button')).toHaveLength(0);
+ });
+
it('opens an embedded monitor in the right-panel callback', () => {
const onOpenMonitor = vi.fn();
const task = monitorTask();
diff --git a/packages/web-shell/client/components/messages/TasksStatusMessage.tsx b/packages/web-shell/client/components/messages/TasksStatusMessage.tsx
index d99f28d35f1..cedcd82fd7b 100644
--- a/packages/web-shell/client/components/messages/TasksStatusMessage.tsx
+++ b/packages/web-shell/client/components/messages/TasksStatusMessage.tsx
@@ -23,6 +23,7 @@ import { formatRuntime } from '../../utils/formatRuntime';
import { formatContextTokens } from '../../utils/formatTokenCount';
import { createSentinelSerializer } from '../../utils/sentinelMessage';
import type { ACPToolCall, TodoItem } from '../../adapters/types';
+import { useTranscriptRenderMode } from '../../transcriptRenderMode';
import { PlanExecutionView } from './PlanExecutionView';
import {
localizeAgentTypeName,
@@ -259,6 +260,7 @@ export function TasksStatusMessage({
onOpenMonitor?: (task: DaemonSessionMonitorTaskStatus) => void;
}) {
const { t } = useI18n();
+ const documentMode = useTranscriptRenderMode() === 'document';
const actions = useActions();
const [tasks, setTasks] = useState(() =>
arrangeTasks(message.snapshot.tasks),
@@ -288,7 +290,7 @@ export function TasksStatusMessage({
const blockingIds = useMemo(() => computeUserBlockingIds(tasks), [tasks]);
useEffect(() => {
- if (!isOpen) return;
+ if (documentMode || !isOpen) return;
const refresh = () => {
if (refreshInFlightRef.current) return;
refreshInFlightRef.current = true;
@@ -312,7 +314,7 @@ export function TasksStatusMessage({
};
const id = setInterval(refresh, REFRESH_INTERVAL_MS);
return () => clearInterval(id);
- }, [isOpen, actions]);
+ }, [documentMode, isOpen, actions]);
useEffect(() => {
if (tasks.length === 0 && selectedIndex !== 0) {
@@ -351,14 +353,14 @@ export function TasksStatusMessage({
}, [isOpen, step, selectedTask]);
useEffect(() => {
- if (!manageActiveEvent) return undefined;
+ if (documentMode || !manageActiveEvent) return undefined;
const id = panelIdRef.current;
dispatchActive(id, isOpen);
return () => dispatchActive(id, false);
- }, [isOpen, manageActiveEvent]);
+ }, [documentMode, isOpen, manageActiveEvent]);
useEffect(() => {
- if (!manageActiveEvent) return undefined;
+ if (documentMode || !manageActiveEvent) return undefined;
const onActiveChange = (event: Event) => {
const detail = (event as CustomEvent<{ id?: string; active?: boolean }>)
.detail;
@@ -368,15 +370,15 @@ export function TasksStatusMessage({
};
window.addEventListener(ACTIVE_EVENT, onActiveChange);
return () => window.removeEventListener(ACTIVE_EVENT, onActiveChange);
- }, [manageActiveEvent]);
+ }, [documentMode, manageActiveEvent]);
useEffect(() => {
- if (!isOpen) onClose?.();
- }, [isOpen, onClose]);
+ if (!documentMode && !isOpen) onClose?.();
+ }, [documentMode, isOpen, onClose]);
const handleCancel = useCallback(
async (task: DaemonSessionTaskStatus) => {
- if (busy) return;
+ if (documentMode || busy) return;
const isRunning = task.status === 'running';
const isAbandonable = task.kind === 'agent' && task.status === 'paused';
if (!isRunning && !isAbandonable) return;
@@ -409,12 +411,12 @@ export function TasksStatusMessage({
setBusy(false);
}
},
- [actions, busy, blockingIds, pendingCancelId, t],
+ [actions, busy, blockingIds, documentMode, pendingCancelId, t],
);
useDelayedGlobalKeyDown(
(event: KeyboardEvent) => {
- if (!isOpen) return;
+ if (documentMode || !isOpen) return;
if (
event.key !== 'Escape' &&
@@ -499,6 +501,7 @@ export function TasksStatusMessage({
},
[
embedded,
+ documentMode,
isOpen,
step,
tasks.length,
@@ -509,7 +512,7 @@ export function TasksStatusMessage({
],
);
- if (!isOpen) return null;
+ if (!documentMode && !isOpen) return null;
const showCancelConfirm =
pendingCancelId !== null &&
@@ -579,7 +582,7 @@ export function TasksStatusMessage({
- {!embedded && (
+ {!documentMode && !embedded && (
{t('tasks.shortcut.close')}
)}
@@ -590,8 +593,8 @@ export function TasksStatusMessage({
tasks,
clampedSelectedIndex,
);
- const listTasks = embedded ? tasks : visible;
- const listOffset = embedded ? 0 : windowStart;
+ const listTasks = embedded || documentMode ? tasks : visible;
+ const listOffset = embedded || documentMode ? 0 : windowStart;
return (
({tasks.length})
)}
- {!embedded && hiddenAbove > 0 && (
+ {!documentMode && !embedded && hiddenAbove > 0 && (
{t('tasks.moreAbove', { count: hiddenAbove })}
)}
{listTasks.map((task, visibleIndex) => {
const index = listOffset + visibleIndex;
- const selected = index === clampedSelectedIndex;
+ const selected = !documentMode && index === clampedSelectedIndex;
const stClass = statusClassName(task.status);
const taskStatusLabel = statusLabel(task.status, t);
- const expanded = embedded && selected && step === 'detail';
+ const expanded =
+ documentMode || (embedded && selected && step === 'detail');
const showSelected = embedded ? expanded : selected;
const tree: AgentTreeInfo | undefined =
task.kind === 'agent' ? treeInfo.get(task.id) : undefined;
@@ -671,17 +675,29 @@ export function TasksStatusMessage({
? `${styles.row} ${styles.selected}`
: styles.row
}
- onClick={() => {
- setSelectedIndex(index);
- if (embedded && task.kind === 'monitor' && onOpenMonitor) {
- onOpenMonitor(task);
- } else {
- setStep(embedded && expanded ? 'list' : 'detail');
- }
- }}
- onMouseEnter={() => {
- if (!embedded) setSelectedIndex(index);
- }}
+ onClick={
+ documentMode
+ ? undefined
+ : () => {
+ setSelectedIndex(index);
+ if (
+ embedded &&
+ task.kind === 'monitor' &&
+ onOpenMonitor
+ ) {
+ onOpenMonitor(task);
+ } else {
+ setStep(embedded && expanded ? 'list' : 'detail');
+ }
+ }
+ }
+ onMouseEnter={
+ documentMode
+ ? undefined
+ : () => {
+ if (!embedded) setSelectedIndex(index);
+ }
+ }
>
{showSelected ? '❯' : ''}
@@ -724,16 +740,24 @@ export function TasksStatusMessage({
t={t}
hideHeader
busy={busy}
- showCancelConfirm={pendingCancelId === task.id}
- onCancel={() => void handleCancel(task)}
- onCancelConfirmDismiss={() => setPendingCancelId(null)}
+ showCancelConfirm={
+ !documentMode && pendingCancelId === task.id
+ }
+ onCancel={
+ documentMode ? undefined : () => void handleCancel(task)
+ }
+ onCancelConfirmDismiss={
+ documentMode
+ ? undefined
+ : () => setPendingCancelId(null)
+ }
/>
)}
);
})}
- {!embedded && hiddenBelow > 0 && (
+ {!documentMode && !embedded && hiddenBelow > 0 && (