docs/design/2026-07-14-web-shell-readonly-daemon-transcript.md
packages/web-shellreadonly DaemonTranscriptBlock[]MessageList presentation capabilitiesWebShell already has a complete daemon transcript rendering path, but it can currently be used only indirectly through App or ChatPane in split view. The component first reads transcript blocks from DaemonSessionProvider, converts those blocks to WebShell's internal messages, and finally passes them to MessageList for rendering.
The new use case already holds a DaemonTranscriptBlock[] directly and needs only WebShell's message styling and rendering capabilities to display historical content. It does not need to establish a daemon session connection and must not perform session mutations. Interactions explicitly outside the target include tool approval, AskUserQuestion, retry, branch, prompt submission, and opening panels that modify session state.
If the host directly consumes the result of transcriptBlocksToDaemonMessages and assembles internal components, it exposes WebShell's private DaemonMessage model, contexts, and CSS constraints. It would also drift from the supported rendering when MessageList gains features. @qwen-code/web-shell therefore needs to provide a stable public entry point.
readonly DaemonTranscriptBlock[].transcriptBlocksToDaemonMessages() and the same MessageList, so user, assistant, thinking, tool, sub-agent, plan, status, Markdown, timeline, and long-session virtual-scrolling capabilities automatically evolve with MessageList.DaemonWorkspaceProvider, DaemonSessionProvider, or a network connection.AskUserQuestion.WebShell, WebShellWithProviders, App, or ChatPane.WebShellProps, or adding conditional readOnly/blocks dual data sources to App.MessageList, Message, or DaemonMessage types.AskUserQuestion.MessageList remains.In this design, “read-only” means not reading or modifying daemon/session runtime state. It does not mean setting pointer-events: none on the entire DOM.
| Category | Behavior | Retained |
|---|---|---|
| Passive presentation | Text, Markdown, images, diff, shell output, tool/sub-agent status | Yes |
| Local viewing | Copy, collapse, expand, virtual scroll, timeline, table sort/filter | Yes |
| Host-customized presentation | Markdown/code-block renderer, message-content renderer | Yes; the host owns any side effects |
| Ordinary external links | New-window navigation after browser-safe URL transformation | Yes |
| WebShell semantic navigation | qwen-session:// dispatches the global qwen:open-session event | No; render as non-interactive text |
| Session mutation | Send prompt, cancel, retry, branch, rewind, switch model/mode | No |
| Permission mutation | Approve/reject tool, submit/ignore AskUserQuestion | No |
| External data loading | Component-initiated session attach or transcript/artifact/task/MCP fetch | No |
This boundary preserves the MessageList reading experience while ensuring that the component itself has no capability to write to the daemon.
| Module | Current responsibility | Relationship to this design |
|---|---|---|
packages/sdk-typescript/src/daemon/ui/types.ts | Defines the DaemonTranscriptBlock union | Public input model for the new component |
packages/web-shell/client/adapters/transcriptToMessages.ts | Combines blocks into WebShell DaemonMessage[] | Reuse directly; do not create a new converter |
packages/web-shell/client/hooks/useMessages.ts | Reads blocks from a session hook and supplies localized conversion options | Extract a shared pure conversion entry that accepts external blocks |
packages/web-shell/client/components/MessageList.tsx | Turn collapse, tool/sub-agent groups, timeline, virtual scrolling, and per-message rendering | The only list implementation shared by the new and existing paths |
packages/web-shell/client/components/MessageItem.tsx | Dispatches concrete renderers by message role | No changes needed |
packages/web-shell/client/App.tsx | Full single-session WebShell, approvals, composer, side panels | Existing path remains unchanged |
packages/web-shell/client/components/ChatPane.tsx | Full interactive session in split view | Existing path remains unchanged |
packages/web-shell/client/index.tsx / index.ts | Package runtime/source exports | Export the new component and type |
The current primary path is:
flowchart LR
A["DaemonSessionProvider"] --> B["useTranscriptBlocks()"]
B --> C["transcriptBlocksToDaemonMessages()"]
C --> D["MessageList"]
D --> E["MessageItem / ToolGroup / Markdown"]
B --> F["extractPendingPermission()"]
F --> G["ToolApproval / AskUserQuestion"]
The new read-only path bypasses the session provider and permission branch:
flowchart LR
A["Host-owned readonly DaemonTranscriptBlock[]"] --> B["Shared localized conversion entry"]
B --> D["MessageList pendingApproval=null"]
E["readonly render-mode context"] --> D
D --> F["MessageItem / ToolGroup / Markdown"]
In the main WebShell editor, /tasks and /mcp are intercepted inside App. They update only dialog React state, do not call sendPrompt(), and do not write to session JSONL. Persisted transcripts therefore contain no sentinel for these two local panels, and the new entry adds no corresponding recognition or filtering branch.
Add a component named WebShellTranscript, exported from the @qwen-code/web-shell package root.
export interface WebShellTranscriptProps {
/** Ordered transcript blocks from one logical session. */
blocks: readonly DaemonTranscriptBlock[];
theme?: WebShellTheme;
language?: 'en' | 'zh-CN' | 'zh' | 'zh-cn';
className?: string;
style?: React.CSSProperties;
chatMaxWidth?: number;
workspaceCwd?: string;
compactThinking?: boolean;
collapseCompletedTurns?: boolean;
markdownTableMode?: MarkdownTableMode;
virtualScrollThreshold?: number;
markdown?: WebShellMarkdownCustomization;
composerTagIcons?: WebShellComposerTagIconMap;
renderToolHeaderExtra?: ToolHeaderExtraRenderer;
parseUserMessageContent?: UserMessageContentParser;
renderUserMessageContent?: UserMessageContentRenderer;
renderComposerTag?: ComposerTagRenderer;
renderComposerTagTooltip?: ComposerTagRenderer;
renderAssistantTurnFooter?: AssistantTurnFooterRenderer;
}
export function WebShellTranscript(
props: WebShellTranscriptProps,
): React.ReactElement;
Notes:
blocks is required and is neither copied nor modified. Callers should keep block sessions and ordering consistent within the array.WebShellProps, avoiding a second set of configuration semantics for the same capabilities.onComposerTagClick, onRetryClick, onBranchSession, onTurnOutputOpen, permission callbacks, or composer callbacks.theme defaults to dark. When language is omitted, use WebShell's URL/browser-language resolution rules. chatMaxWidth defaults to 1000px.compactThinking defaults to false and collapseCompletedTurns defaults to true, matching the existing WebShell.isResponding={false} to MessageList. Live streaming is outside the current API scope.Example:
import { WebShellTranscript } from '@qwen-code/web-shell';
import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon';
export function HistoryView({
blocks,
}: {
blocks: readonly DaemonTranscriptBlock[];
}) {
return (
<WebShellTranscript
blocks={blocks}
theme="dark"
language="zh-CN"
workspaceCwd="/workspace/project"
style={{ height: 640 }}
/>
);
}
The host must give the component a usable height. The component itself preserves WebShell's height: 100%, internal scrolling, and content-width behavior.
Keep transcriptBlocksToDaemonMessages() as the only block-to-message adapter. Extract an internal pure function in useMessages.ts, for example:
export function transcriptBlocksToLocalizedMessages(
blocks: readonly DaemonTranscriptBlock[],
t: Translator,
): Message[];
Export this function only from its internal package module for reuse by the new component; do not expose it from the package root.
The function only assembles the localized labels currently used by useMessages() and then calls the existing adapter. Both the existing useMessages() and the new component call it, preventing drift in copy for prompt cancellation, branch, mid-turn insertion, and interrupted streams.
This is the only internal restructuring needed in the existing rendering path. Function input, output, and existing conversion results stay unchanged, and the adapter's block-combination rules are not modified.
WebShellTranscript Component StructureAdd packages/web-shell/client/components/WebShellTranscript.tsx with this internal sequence:
blocks to Message[] with useMemo.data-web-shell-root and data-web-shell-shadcn, reusing the App's theme class, base variables, fonts, background, and CSS-isolation rules.MessageList.The important fixed MessageList inputs are:
<MessageList
messages={messages}
pendingApproval={null}
isResponding={false}
workspaceCwd={workspaceCwd ?? ''}
virtualScrollThreshold={virtualScrollThreshold}
/>
Never pass these action props:
onShowContextDetailonRetryClickonBranchSessiononReviewChangesonOpenArtifactonOpenScheduledTaskonTurnOutputOpenDo not pass loading, catch-up, tail, or turn-output data, avoiding any dependency on the App's connection-state and external-resource models.
Passing only pendingApproval=null to MessageList does not fully guarantee read-only behavior. Session links in goal status, Markdown, and tool results do not use MessageList callbacks; they dispatch global semantic events to window, potentially changing the footer or active session of another WebShell on the same page.
Add a package-internal transcript render-mode context in client/transcriptRenderMode.ts with a default value of interactive. Existing App and ChatPane need no new provider, so their behavior remains unchanged. WebShellTranscript sets the value to readonly. Read-only mode applies only these restrictions:
qwen-session:// links, but do not dispatch qwen:open-session.GoalStatusMessage does not dispatch GOAL_STATUS_ACTIVE_EVENT.This context changes only semantic-event exits in Markdown, ToolGroup, and GoalStatusMessage, and its default is locked to interactive. This avoids adding a readOnly prop that must thread through every renderer from MessageList. New unit tests must prove both that the default interactive behavior is unchanged and that read-only behavior is suppressed.
The WebShell library build injects and scopes component CSS under [data-web-shell-root] or [data-web-shell-portal-root]. The new component must create its own WebShell root; otherwise MessageList may produce DOM that CSS module rules do not match.
Timeline tooltips and advanced Markdown tables use portals. To fully inherit those capabilities, the new component uses a portal-host lifecycle equivalent to the App's:
data-web-shell-portal-root and data-web-shell-shadcn to document.body.WebShellPortalRootContext.Keep this lifecycle inside the new component rather than refactoring the App's existing portal code, limiting the regression surface of existing behavior to the new entry. Do not access document during SSR; enable the portal only after client mount.
The new entry has an outer public boundary and an inner content component. Block conversion, provider/portal initialization, and MessageList all occur in a child of the boundary, ensuring that failures during any of these stages reach the same RootErrorFallback as the public WebShell entry. Each message remains isolated by MessageItem's own boundary, so a failure in one Markdown, KaTeX, Mermaid, or tool renderer does not blank the entire transcript.
All strategies continue to use the existing adapter; do not add a second switch in the new component.
DaemonTranscriptBlock.kind | Read-only result |
|---|---|
user | User messages, images, and input annotations |
assistant | Assistant Markdown; consecutive blocks merged; sub-agent content assigned by parent |
thought | Thinking messages; consecutive blocks merged |
tool | Existing cards for tool groups, diff/read/shell/fetch/todo/sub-agent |
shell | Associate with the nearest execution tool; existing raw-shell fallback when unavailable |
user_shell | User shell command/output |
status / debug | Plan or system/status message |
error | Error system message with no retry action |
prompt_cancelled | Localized cancellation status |
unresolved permission | Do not convert, display, or provide an action entry |
resolved permission | Existing historical tool placeholder/result rules from the adapter |
AskUserQuestion permission | Do not show the form; show historical results only when a later real tool block exists |
blocks identity or language changes.MessageList retains its existing memoization, turn grouping, and virtual-scrolling threshold.useTranscriptBlocks() update model.WebShellProps gains no required fields and changes no defaults.WebShell and WebShellWithProviders continue to render App.App and ChatPane continue to read session state from their respective providers/hooks.MessageList gains no readOnly prop branch. The new caller establishes read-only behavior by passing pendingApproval=null, omitting action callbacks, and using an internal render-mode context whose default remains interactive to isolate the few global semantic events.Update both client/index.tsx and client/index.ts to export:
export { WebShellTranscript } from './components/WebShellTranscript';
export type { WebShellTranscriptProps } from './components/WebShellTranscript';
Both barrels must change to avoid the current dual runtime-entry and declaration/source-entry paths producing “exported at runtime but missing from type declarations.” Do not add a package subpath export.
useActions(), useTranscriptStore(), useConnection(), or fetch./tasks and /mcp is inherently absent from persisted transcripts.dangerouslySetInnerHTML or another bypass.Add WebShellTranscript.test.tsx, mocking MessageList to verify the boundary and wiring:
pendingApproval is always null.isResponding defaults to false, and workspace and virtual-scroll configuration are forwarded correctly.Add WebShellTranscript.dom.test.tsx using the real MessageList:
MessageList capabilities are reused.AskUserQuestion does not produce option, input, submit, or ignore UI.useMessages/adapter tests to prove that the existing hook and external blocks use exactly the same localized options.index.test.tsx or build-artifact tests to verify that the runtime named export exists.dist/types/index.d.ts contains exports for WebShellTranscript and its props, preventing drift between the two entry declarations.The minimum required verification sequence after implementation is:
cd packages/web-shell
npm run build
npx vitest run --config vitest.config.ts \
client/components/WebShellTranscript.test.tsx \
client/components/WebShellTranscript.dom.test.tsx \
client/hooks/useMessages.test.ts \
client/adapters/transcriptToMessages.test.ts \
client/components/MessageList.test.ts \
client/components/MessageList.dom.test.tsx \
client/components/messages/Markdown.test.ts \
client/components/messages/ToolGroup.test.tsx \
client/components/messages/SystemMessage.test.tsx \
client/index.test.tsx
npm test
npm run format:check
npm run lint
npm run typecheck
cd ../..
npm run build
npm run typecheck
npm test is the existing full WebShell suite and must pass for this change. The change adds no standalone page and does not alter the existing Playwright smoke test's App/daemon protocol, so no browser E2E test is added. WebShellTranscript.dom.test.tsx covers real DOM behavior.
useMessages.ts, preserving the current hook output.interactive as the default.WebShellTranscript and its props, implementing root/provider/portal/MessageList wiring.packages/web-shell/README.md with a read-only integration example, the host-height requirement, and the read-only boundary.blocks and readOnly to the Existing WebShellRejected. App currently consumes several daemon hooks unconditionally and manages approvals, the composer, session, sidebar, and panels. Dual data sources would add conditional branches throughout App, requiring providers while also guarding against mutation. Its regression surface is much larger than this requirement.
MessageListRejected. Callers would still depend on private Message[], multiple contexts, CSS-root conventions, and portal conventions, and the internal model would become a long-term public API.
Rejected. Duplication would immediately fork Markdown, tool/sub-agent, turn-collapse, timeline, and virtual-scrolling behavior, failing the requirement to inherit MessageList rendering capabilities.
Rejected. Disabled forms still create interactive semantics and additional state branches, and they mislead users into thinking they can answer in a historical view. Pending permissions are hidden in this release; subsequent tool blocks carry historical results.
| Risk | Mitigation |
|---|---|
| Localized conversion drifts between the new entry and App | Both call the same localized conversion helper |
| Portal misses the CSS scope | Create a separate data-web-shell-portal-root, synchronize variables, and cover with DOM tests |
| Accidental daemon mutation | New component imports no action hooks and exposes no mutation callback; contract tests lock this down |
| App-local dialog state is mistaken for transcript data | Explicitly document that /tasks and /mcp do not write JSONL; new entry does not copy App dialog state |
| Global semantic events affect another WebShell on the page | Read-only render mode suppresses session/goal events; regression tests cover default behavior |
| A new block kind has no presentation | Continue supporting it through the shared adapter; do not duplicate a switch in the component |
| Package runtime and type exports diverge | Update both barrels and inspect the built declarations |
| Large-transcript recomputation cost | useMemo plus existing virtual scrolling; defer incremental conversion until supported by measurements |
| Custom renderer introduces side effects | Document host responsibility; default renderers remain read-only |
MessageList.AskUserQuestion produce no interactive UI or submission path.MessageList's local reading interactions and long-list capabilities.WebShell/WebShellWithProviders APIs, defaults, tests, and runtime behavior remain unchanged..d.ts for @qwen-code/web-shell export the new component and props.