diff --git a/.qwen/skills/triage/references/pr-workflow.md b/.qwen/skills/triage/references/pr-workflow.md
index de61460cf83..82a9a01f9bb 100644
--- a/.qwen/skills/triage/references/pr-workflow.md
+++ b/.qwen/skills/triage/references/pr-workflow.md
@@ -363,6 +363,7 @@ gh pr review "$PR_NUMBER" --repo "$REPO" --request-changes --body "Needs some re
```
Genuinely unsure, or `GUARD` blocked approval — **don't approve or reject**, but **never defer silently**. Post an explicit defer comment that:
+
1. States you are escalating to the maintainer.
2. Names the specific reason(s) for uncertainty — what you cannot resolve from the diff, tests, and PR description.
3. @mentions the maintainer (use `$QWEN_MAINTAINER_HANDLE` if set, or the most recent human reviewer).
diff --git a/package-lock.json b/package-lock.json
index b57fad68398..cca4ce4962c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -901,6 +901,19 @@
"crelt": "^1.0.5"
}
},
+ "node_modules/@codemirror/merge": {
+ "version": "6.12.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz",
+ "integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/highlight": "^1.0.0",
+ "style-mod": "^4.1.0"
+ }
+ },
"node_modules/@codemirror/search": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz",
@@ -27366,6 +27379,7 @@
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/commands": "^6.7.0",
"@codemirror/language": "^6.10.0",
+ "@codemirror/merge": "^6.12.2",
"@codemirror/state": "^6.5.0",
"@codemirror/view": "^6.35.0",
"@tanstack/react-virtual": "^3.13.26",
diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts
index 306253346b9..5344e939515 100644
--- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts
+++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts
@@ -9,6 +9,7 @@ import type {
DaemonAuthProviderId,
DaemonErrorKind,
DaemonEvent,
+ DaemonSessionArtifactChange,
} from '../types.js';
import { DAEMON_ERROR_KINDS } from '../types.js';
import type {
@@ -299,6 +300,9 @@ export function normalizeDaemonEvent(
case 'extensions_changed':
return normalizeExtensionsChanged(event, base);
+ case 'artifact_changed':
+ return normalizeArtifactChanged(event, base);
+
// ── Auth device-flow events (RFC 8628) ─────────────────
case 'auth_device_flow_started':
return normalizeAuthDeviceFlowStarted(event, base);
@@ -1126,6 +1130,30 @@ function normalizeSessionMetadataUpdated(
];
}
+function normalizeArtifactChanged(
+ event: DaemonEvent,
+ base: NormalizedEventBase,
+): DaemonUiEvent[] {
+ const sessionId = getString(event.data, 'sessionId');
+ const change = isRecord(event.data) ? event.data['change'] : undefined;
+ if (!sessionId || !isRecord(change)) {
+ return fallbackDebug(event, base, 'malformed artifact_changed payload');
+ }
+ const action = getString(change, 'action');
+ const artifactId = getString(change, 'artifactId');
+ if (!action || !artifactId) {
+ return fallbackDebug(event, base, 'missing action or artifactId');
+ }
+ return [
+ {
+ ...base,
+ type: 'session.artifact.changed',
+ sessionId,
+ change: change as unknown as DaemonSessionArtifactChange,
+ },
+ ];
+}
+
function normalizeApprovalModeChanged(
event: DaemonEvent,
base: NormalizedEventBase,
diff --git a/packages/sdk-typescript/src/daemon/ui/terminal.ts b/packages/sdk-typescript/src/daemon/ui/terminal.ts
index 619d7a21389..a12fdd325c5 100644
--- a/packages/sdk-typescript/src/daemon/ui/terminal.ts
+++ b/packages/sdk-typescript/src/daemon/ui/terminal.ts
@@ -58,6 +58,12 @@ export function daemonUiEventToTerminalText(event: DaemonUiEvent): string {
`metadata: ${event.displayName ?? '(no display name)'}`,
'36',
);
+ case 'session.artifact.changed':
+ return terminalLine(
+ 'artifact',
+ `${event.change.action} ${event.change.artifact?.title ?? event.change.artifactId}`,
+ '36',
+ );
case 'session.approval_mode.changed':
return terminalLine(
'approval-mode',
diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts
index bdc8ac1a639..a1ec377ddb2 100644
--- a/packages/sdk-typescript/src/daemon/ui/transcript.ts
+++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts
@@ -293,6 +293,7 @@ function applyDaemonTranscriptEvent(
next.approvalMode = event.next;
break;
case 'session.metadata.changed':
+ case 'session.artifact.changed':
case 'session.available_commands':
// Intentional no-op against `blocks[]`.
break;
diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts
index 0b7be648165..7cd59c90ce8 100644
--- a/packages/sdk-typescript/src/daemon/ui/types.ts
+++ b/packages/sdk-typescript/src/daemon/ui/types.ts
@@ -9,6 +9,7 @@ import type {
DaemonAuthProviderId,
DaemonEvent,
DaemonErrorKind,
+ DaemonSessionArtifactChange,
PermissionResponse,
} from '../types.js';
@@ -34,6 +35,7 @@ export type DaemonUiEventType =
| 'debug'
// Session-meta events
| 'session.metadata.changed'
+ | 'session.artifact.changed'
| 'session.approval_mode.changed'
| 'session.available_commands'
| 'session.state_resync_required'
@@ -281,6 +283,12 @@ export interface DaemonUiSessionMetadataChangedEvent extends DaemonUiEventBase {
displayName?: string;
}
+export interface DaemonUiSessionArtifactChangedEvent extends DaemonUiEventBase {
+ type: 'session.artifact.changed';
+ sessionId: string;
+ change: DaemonSessionArtifactChange;
+}
+
export interface DaemonUiSessionApprovalModeChangedEvent
extends DaemonUiEventBase {
type: 'session.approval_mode.changed';
@@ -556,6 +564,7 @@ export type DaemonUiEvent =
| DaemonUiErrorEvent
// Session-meta events
| DaemonUiSessionMetadataChangedEvent
+ | DaemonUiSessionArtifactChangedEvent
| DaemonUiSessionApprovalModeChangedEvent
| DaemonUiSessionAvailableCommandsEvent
| DaemonUiStateResyncRequiredEvent
diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts
index 868c3485b4b..bd2bd38c3e3 100644
--- a/packages/sdk-typescript/test/unit/daemonUi.test.ts
+++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts
@@ -6651,6 +6651,71 @@ describe('parallel subAgent text interleaving — normalizer', () => {
});
});
+describe('daemon UI normalizer — artifact events', () => {
+ it('normalizes artifact_changed as a structured session event', () => {
+ const events = normalizeDaemonEvent({
+ type: 'artifact_changed',
+ data: {
+ sessionId: 'session-1',
+ change: {
+ action: 'updated',
+ artifactId: 'artifact-1',
+ artifact: {
+ id: 'artifact-1',
+ title: 'Report',
+ kind: 'html',
+ storage: 'workspace',
+ source: 'tool',
+ status: 'available',
+ },
+ },
+ },
+ } as never);
+
+ expect(events).toEqual([
+ expect.objectContaining({
+ type: 'session.artifact.changed',
+ sessionId: 'session-1',
+ change: expect.objectContaining({
+ action: 'updated',
+ artifactId: 'artifact-1',
+ }),
+ }),
+ ]);
+ });
+
+ it('falls back to debug for malformed artifact_changed payloads', () => {
+ const events = normalizeDaemonEvent({
+ type: 'artifact_changed',
+ data: { sessionId: 'session-1' },
+ } as never);
+
+ expect(events).toEqual([
+ expect.objectContaining({
+ type: 'debug',
+ text: 'artifact_changed: malformed artifact_changed payload',
+ }),
+ ]);
+ });
+
+ it('falls back to debug when artifact_changed change misses required fields', () => {
+ const events = normalizeDaemonEvent({
+ type: 'artifact_changed',
+ data: {
+ sessionId: 'session-1',
+ change: {},
+ },
+ } as never);
+
+ expect(events).toEqual([
+ expect.objectContaining({
+ type: 'debug',
+ text: 'artifact_changed: missing action or artifactId',
+ }),
+ ]);
+ });
+});
+
describe('parallel subAgent text interleaving fix', () => {
it('T1: separates text chunks by parentToolCallId into independent blocks', () => {
let state = createDaemonTranscriptState({ now: 1 });
diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css
index bfd31780b18..da026217514 100644
--- a/packages/web-shell/client/App.module.css
+++ b/packages/web-shell/client/App.module.css
@@ -61,6 +61,23 @@
overflow: hidden;
}
+.artifactResizeHandle {
+ flex: 0 0 8px;
+ width: 8px;
+ margin-left: -4px;
+ cursor: col-resize;
+ position: relative;
+ z-index: 2;
+ touch-action: none;
+}
+
+.artifactResizeHandle::after {
+ content: '';
+ position: absolute;
+ inset: 0 3px;
+ background: transparent;
+}
+
/* Positioning context so the scheduled-tasks page (position:absolute) covers
exactly the chat pane. Only applied while the page is shown, so normal chat
layout is untouched. */
@@ -176,6 +193,12 @@
height: 20px;
}
+@media (max-width: 900px) {
+ .artifactResizeHandle {
+ display: none;
+ }
+}
+
@media (max-width: 760px) {
.mobileDrawer {
display: block;
diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx
index 2b36f5201e7..16fd3955ba2 100644
--- a/packages/web-shell/client/App.test.tsx
+++ b/packages/web-shell/client/App.test.tsx
@@ -82,6 +82,7 @@ const {
forkSession: vi.fn().mockResolvedValue({ launched: false }),
sendShellCommand: vi.fn().mockResolvedValue(undefined),
getStats: vi.fn().mockResolvedValue({}),
+ loadArtifacts: vi.fn().mockResolvedValue({ artifacts: [] }),
loadSession: vi.fn().mockResolvedValue(undefined),
},
mockWorkspaceActions: {
@@ -137,6 +138,7 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
onDismissFollowup: mockFollowup.onDismissFollowup,
}),
useSessionNotices: () => ({ notices: [], dismissNotice: vi.fn() }),
+ usePromptStatus: () => 'idle',
useSettings: () => ({
settings: [],
setValue: vi.fn().mockResolvedValue(undefined),
@@ -147,7 +149,10 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
useTranscriptBlocks: () => testState.blocks,
useTranscriptStore: () => mockStore,
useWorkspaceActions: () => mockWorkspaceActions,
- useWorkspaceEventSignals: () => ({ extensionsVersion: 0 }),
+ useWorkspaceEventSignals: () => ({
+ artifactsVersion: 0,
+ extensionsVersion: 0,
+ }),
}));
vi.mock('@qwen-code/sdk/daemon', async (importOriginal) => ({
@@ -367,8 +372,33 @@ vi.doMock('./components/SplitView', async () => {
onExit?: () => void;
sessionIds?: string[];
onPanesChange?: (ids: string[]) => void;
- }) =>
- React.createElement(
+ onPaneArtifactsChange?: (
+ sessionId: string,
+ artifacts: unknown[],
+ workspaceActions: unknown,
+ ) => void;
+ onRightPanelOpen?: (request: unknown) => void;
+ }) => {
+ const paneActions = {
+ readWorkspaceFile: vi.fn().mockResolvedValue('
pane
'),
+ };
+ const artifact = {
+ id: 'pane-artifact',
+ kind: 'report',
+ storage: 'memory',
+ source: 'tool',
+ status: 'available',
+ title: 'Pane artifact',
+ updatedAt: '2026-07-10T00:00:00Z',
+ sizeBytes: 10,
+ };
+ const updatedArtifact = {
+ ...artifact,
+ title: 'Updated pane artifact',
+ updatedAt: '2026-07-10T00:01:00Z',
+ sizeBytes: 20,
+ };
+ return React.createElement(
'div',
{ 'data-testid': 'split-view-mock' },
// Surface the seed so a test can assert the App preserved / restored it.
@@ -387,6 +417,63 @@ vi.doMock('./components/SplitView', async () => {
},
'report',
),
+ React.createElement(
+ 'button',
+ {
+ 'data-testid': 'split-report-artifact',
+ type: 'button',
+ onClick: () =>
+ props.onPaneArtifactsChange?.(
+ 'pane-session',
+ [artifact],
+ paneActions,
+ ),
+ },
+ 'artifact',
+ ),
+ React.createElement(
+ 'button',
+ {
+ 'data-testid': 'split-report-updated-artifact',
+ type: 'button',
+ onClick: () =>
+ props.onPaneArtifactsChange?.(
+ 'pane-session',
+ [updatedArtifact],
+ paneActions,
+ ),
+ },
+ 'updated artifact',
+ ),
+ React.createElement(
+ 'button',
+ {
+ 'data-testid': 'split-clear-artifacts',
+ type: 'button',
+ onClick: () =>
+ props.onPaneArtifactsChange?.('pane-session', [], paneActions),
+ },
+ 'clear artifacts',
+ ),
+ React.createElement(
+ 'button',
+ {
+ 'data-testid': 'split-open-artifact',
+ type: 'button',
+ onClick: () =>
+ props.onRightPanelOpen?.({
+ id: 'artifact:pane-artifact:pane-session',
+ kind: 'artifact',
+ title: artifact.title,
+ turnId: 'turn-1',
+ artifactId: artifact.id,
+ artifact,
+ workspaceActions: paneActions,
+ previewContent: 'stale
',
+ }),
+ },
+ 'open artifact',
+ ),
React.createElement(
'button',
{
@@ -396,7 +483,8 @@ vi.doMock('./components/SplitView', async () => {
},
'back',
),
- ),
+ );
+ },
};
});
// Capturing mock: stores the onRunPrompt handler (App's real runTaskManually)
@@ -1451,6 +1539,93 @@ describe('App session callbacks', () => {
).toBe('s1,s2,s3');
});
+ it('reconciles split pane artifact snapshots in the right panel', async () => {
+ const { container } = renderApp();
+ await flush();
+
+ await act(async () => {
+ container
+ .querySelector('[data-testid="open-split-view"]')
+ ?.click();
+ await Promise.resolve();
+ });
+ await act(async () => {
+ container
+ .querySelector(
+ '[data-testid="split-report-artifact"]',
+ )
+ ?.click();
+ await Promise.resolve();
+ });
+ await act(async () => {
+ container
+ .querySelector('[data-testid="split-open-artifact"]')
+ ?.click();
+ await Promise.resolve();
+ });
+
+ expect(container.textContent).toContain('Pane artifact');
+ expect(container.textContent).toContain('10 B');
+
+ await act(async () => {
+ container
+ .querySelector(
+ '[data-testid="split-report-updated-artifact"]',
+ )
+ ?.click();
+ await Promise.resolve();
+ });
+
+ expect(container.textContent).toContain('20 B');
+
+ await act(async () => {
+ container
+ .querySelector(
+ '[data-testid="split-clear-artifacts"]',
+ )
+ ?.click();
+ await Promise.resolve();
+ });
+
+ expect(container.textContent).toContain('Artifact not found.');
+ });
+
+ it('clears split pane artifact snapshots when switching sessions', async () => {
+ const { container, rerender } = renderApp();
+ await flush();
+
+ await act(async () => {
+ container
+ .querySelector('[data-testid="open-split-view"]')
+ ?.click();
+ await Promise.resolve();
+ });
+ await act(async () => {
+ container
+ .querySelector(
+ '[data-testid="split-report-artifact"]',
+ )
+ ?.click();
+ await Promise.resolve();
+ });
+ await act(async () => {
+ container
+ .querySelector('[data-testid="split-open-artifact"]')
+ ?.click();
+ await Promise.resolve();
+ });
+
+ expect(container.textContent).toContain('Pane artifact');
+
+ await act(async () => {
+ mockConnection.sessionId = 'session-2';
+ rerender();
+ await Promise.resolve();
+ });
+
+ expect(container.textContent).not.toContain('Pane artifact');
+ });
+
it('enters the split view from a ?split= URL and consumes the param', async () => {
window.history.pushState({}, '', '/?split=s1,s2');
try {
diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx
index 968b2ebfd83..4c86257d11b 100644
--- a/packages/web-shell/client/App.tsx
+++ b/packages/web-shell/client/App.tsx
@@ -7,6 +7,7 @@ import {
useRef,
useState,
type CSSProperties,
+ type PointerEvent as ReactPointerEvent,
type ReactNode,
} from 'react';
import {
@@ -21,6 +22,7 @@ import {
useTranscriptStore,
useWorkspaceActions,
useWorkspaceEventSignals,
+ type DaemonWorkspaceActions,
type DaemonSessionNotice,
type DaemonStreamingState,
} from '@qwen-code/webui/daemon-react-sdk';
@@ -28,6 +30,7 @@ import { isDaemonTurnError } from '@qwen-code/sdk/daemon';
import type {
DaemonTranscriptBlock,
DaemonSessionTaskStatus,
+ DaemonSessionArtifact,
} from '@qwen-code/sdk/daemon';
import { extractPendingPermission } from './adapters/transcriptAdapter';
import { MessageList, type MessageListHandle } from './components/MessageList';
@@ -67,6 +70,22 @@ import { ToolsDialog } from './components/dialogs/ToolsDialog';
import { DaemonStatusDialog } from './components/dialogs/DaemonStatusDialog';
import { SessionOverviewPanel } from './components/SessionOverviewPanel';
import { SplitView } from './components/SplitView';
+import {
+ ArtifactPanel,
+ type ArtifactPanelTab,
+} from './components/artifacts/ArtifactPanel';
+import type {
+ TurnOutputFileChange,
+ TurnOutputKind,
+ TurnOutputOpenRequest,
+ TurnOutputScheduledTask,
+} from './components/artifacts/TurnOutputs';
+import { TURN_OUTPUT_KINDS } from './components/artifacts/TurnOutputs';
+import {
+ getArtifactsByTurn,
+ getFileChangesByTurn,
+ getScheduledTasksByTurn,
+} from './components/artifacts/turnOutputSelectors';
import { useIsLargeScreen } from './hooks/useIsLargeScreen';
import { MAX_SPLIT_PANES, parseSplitSessionIds } from './utils/splitUrl';
import { ScheduledTasksDialog } from './components/dialogs/ScheduledTasksDialog';
@@ -90,6 +109,7 @@ import { mergeCommands } from './hooks/daemonSessionMappers';
import { useAnimationFrameValue } from './hooks/useAnimationFrameValue';
import { useBackgroundTasks } from './hooks/useBackgroundTasks';
import { useMessages } from './hooks/useMessages';
+import { useSessionArtifacts } from './hooks/useSessionArtifacts';
import { useShallowMemo, useStableArray } from './hooks/useShallowMemo';
import {
I18nProvider,
@@ -241,6 +261,23 @@ function TodoContextsProvider({
const MODES_CYCLE = DAEMON_APPROVAL_MODES;
const MAX_TOASTS = 4;
+const DEFAULT_REVIEW_PANEL_WIDTH = 760;
+const MIN_ARTIFACT_PANEL_WIDTH = 320;
+const MIN_CHAT_PANE_WIDTH_WITH_ARTIFACT_PANEL = 500;
+const MAX_ARTIFACT_PANEL_SESSION_STATES = 20;
+interface ArtifactPanelSessionState {
+ open: boolean;
+ tabs: ArtifactPanelTab[];
+ activeTabId: string | null;
+ reviewChanges: readonly TurnOutputFileChange[];
+ selectedReviewPath: string | null;
+ extraArtifacts: DaemonSessionArtifact[];
+ width: number;
+}
+interface PaneArtifactSnapshot {
+ artifacts: readonly DaemonSessionArtifact[];
+ workspaceActions: DaemonWorkspaceActions;
+}
// Cap on how long a manual "run now" waits for its bound session to become
// active before giving up, so the scheduled-tasks UI can't stay stuck disabled
// if the switch never completes.
@@ -397,6 +434,15 @@ export interface WebShellProps {
splitSessionIds?: readonly string[];
/** Called when the split pane list changes from inside WebShell. */
onSplitSessionIdsChange?: (sessionIds: string[]) => void;
+ /**
+ * Called instead of the built-in right panel open behavior when a user clicks
+ * a turn output such as review changes, an artifact, or a scheduled task.
+ */
+ onRightPanelOpen?: (request: TurnOutputOpenRequest) => void;
+ /**
+ * Controls which turn output cards appear below messages. Defaults to all.
+ */
+ messageTurnOutputs?: readonly TurnOutputKind[];
/** Imperative handle for externally opening WebShell surfaces. */
shellRef?: React.Ref;
/** Built-in composer toolbar actions to show. Defaults to all actions. */
@@ -876,6 +922,8 @@ export function App({
sidebar,
splitSessionIds: externalSplitSessionIds,
onSplitSessionIdsChange,
+ onRightPanelOpen,
+ messageTurnOutputs,
shellRef,
composerToolbarActions,
compactThinking = false,
@@ -1071,6 +1119,7 @@ export function App({
const nextRecapMessageIdRef = useRef(1);
const nextBtwMessageIdRef = useRef(1);
const btwAbortControllerRef = useRef(null);
+ const chatPaneRef = useRef(null);
const currentSessionIdRef = useRef(connection.sessionId);
const lastNotifiedSessionIdRef = useRef(undefined);
const lastGoalSessionIdRef = useRef(connection.sessionId);
@@ -1099,6 +1148,502 @@ export function App({
}
return filterModelSwitchMessages(result);
}, [messages, recapMessage]);
+ const {
+ artifacts,
+ loading: artifactsLoading,
+ error: artifactsError,
+ } = useSessionArtifacts();
+ const [artifactPanelExtraArtifacts, setArtifactPanelExtraArtifacts] =
+ useState([]);
+ const [paneArtifactSnapshots, setPaneArtifactSnapshots] = useState<
+ Map
+ >(() => new Map());
+ const [artifactPanelTabs, setArtifactPanelTabs] = useState<
+ ArtifactPanelTab[]
+ >([]);
+ useEffect(() => {
+ if (artifactPanelExtraArtifacts.length === 0 || artifacts.length === 0) {
+ return;
+ }
+ const artifactIds = new Set(artifacts.map((artifact) => artifact.id));
+ const paneArtifactIds = new Set(
+ artifactPanelTabs
+ .filter((tab) => tab.kind === 'artifact' && tab.workspaceActions)
+ .map((tab) => (tab.kind === 'artifact' ? tab.artifactId : '')),
+ );
+ setArtifactPanelExtraArtifacts((previous) => {
+ const next = previous.filter(
+ (artifact) =>
+ !artifactIds.has(artifact.id) || paneArtifactIds.has(artifact.id),
+ );
+ return next.length === previous.length ? previous : next;
+ });
+ }, [artifacts, artifactPanelExtraArtifacts.length, artifactPanelTabs]);
+ const paneArtifactExtras = useMemo(
+ () =>
+ Array.from(paneArtifactSnapshots.values()).flatMap((snapshot) => [
+ ...snapshot.artifacts,
+ ]),
+ [paneArtifactSnapshots],
+ );
+ const artifactPanelArtifacts = useMemo(() => {
+ if (
+ artifactPanelExtraArtifacts.length === 0 &&
+ paneArtifactExtras.length === 0
+ ) {
+ return artifacts;
+ }
+ const merged = [...artifacts];
+ for (const artifact of [
+ ...artifactPanelExtraArtifacts,
+ ...paneArtifactExtras,
+ ]) {
+ const index = merged.findIndex((item) => item.id === artifact.id);
+ if (index < 0) {
+ merged.push(artifact);
+ }
+ }
+ return merged;
+ }, [artifacts, artifactPanelExtraArtifacts, paneArtifactExtras]);
+ const handlePaneArtifactsChange = useCallback(
+ (
+ paneSessionId: string,
+ paneArtifacts: readonly DaemonSessionArtifact[],
+ paneWorkspaceActions: DaemonWorkspaceActions,
+ ) => {
+ setPaneArtifactSnapshots((current) => {
+ const previous = current.get(paneSessionId);
+ const unchanged =
+ previous?.workspaceActions === paneWorkspaceActions &&
+ previous.artifacts.length === paneArtifacts.length &&
+ previous.artifacts.every((artifact, index) => {
+ const nextArtifact = paneArtifacts[index];
+ return (
+ nextArtifact?.id === artifact.id &&
+ nextArtifact.updatedAt === artifact.updatedAt &&
+ nextArtifact.sizeBytes === artifact.sizeBytes
+ );
+ });
+ if (unchanged) return current;
+ const next = new Map(current);
+ if (paneArtifacts.length === 0) {
+ next.delete(paneSessionId);
+ } else {
+ next.set(paneSessionId, {
+ artifacts: [...paneArtifacts],
+ workspaceActions: paneWorkspaceActions,
+ });
+ }
+ return next;
+ });
+ const artifactIds = new Set(paneArtifacts.map((artifact) => artifact.id));
+ setArtifactPanelTabs((tabs) => {
+ let changed = false;
+ const next = tabs.map((tab) => {
+ if (tab.kind !== 'artifact' || !artifactIds.has(tab.artifactId)) {
+ return tab;
+ }
+ const updated = {
+ id: tab.id,
+ kind: 'artifact' as const,
+ title: tab.title,
+ artifactId: tab.artifactId,
+ workspaceActions: tab.workspaceActions ?? paneWorkspaceActions,
+ };
+ if (tab.previewContent !== undefined) changed = true;
+ if (tab.workspaceActions) return updated;
+ changed = true;
+ return updated;
+ });
+ return changed ? next : tabs;
+ });
+ },
+ [],
+ );
+ const artifactsByTurn = useMemo(
+ () =>
+ getArtifactsByTurn(
+ displayMessages,
+ artifacts,
+ connection.workspaceCwd || '',
+ ),
+ [displayMessages, artifacts, connection.workspaceCwd],
+ );
+ const fileChangesByTurn = useMemo(
+ () =>
+ getFileChangesByTurn(
+ displayMessages,
+ artifactsByTurn,
+ connection.workspaceCwd || '',
+ ),
+ [displayMessages, artifactsByTurn, connection.workspaceCwd],
+ );
+ const scheduledTasksByTurn = useMemo(
+ () => getScheduledTasksByTurn(displayMessages),
+ [displayMessages],
+ );
+ const visibleTurnOutputKinds = useMemo(
+ () => new Set(messageTurnOutputs ?? TURN_OUTPUT_KINDS),
+ [messageTurnOutputs],
+ );
+ const [artifactPanelOpen, setArtifactPanelOpen] = useState(false);
+ const artifactPanelOpenRef = useRef(artifactPanelOpen);
+ artifactPanelOpenRef.current = artifactPanelOpen;
+ const [activeArtifactPanelTabId, setActiveArtifactPanelTabId] = useState<
+ string | null
+ >(null);
+ const activeArtifactPanelTabIdRef = useRef(activeArtifactPanelTabId);
+ activeArtifactPanelTabIdRef.current = activeArtifactPanelTabId;
+ const [reviewChanges, setReviewChanges] = useState<
+ readonly TurnOutputFileChange[]
+ >([]);
+ const [selectedReviewPath, setSelectedReviewPath] = useState(
+ null,
+ );
+ const [artifactPanelWidth, setArtifactPanelWidth] = useState(
+ DEFAULT_REVIEW_PANEL_WIDTH,
+ );
+ const artifactPanelResizeCleanupRef = useRef<(() => void) | null>(null);
+ const artifactPanelSessionStateRef = useRef(
+ null,
+ );
+ const artifactPanelStateBySessionRef = useRef(
+ new Map(),
+ );
+ const artifactPanelSessionIdRef = useRef(connection.sessionId);
+ artifactPanelSessionStateRef.current = {
+ open: artifactPanelOpen,
+ tabs: artifactPanelTabs,
+ activeTabId: activeArtifactPanelTabId,
+ reviewChanges,
+ selectedReviewPath,
+ extraArtifacts: artifactPanelExtraArtifacts,
+ width: artifactPanelWidth,
+ };
+ useEffect(() => {
+ const previousSessionId = artifactPanelSessionIdRef.current;
+ if (previousSessionId) {
+ const currentState = artifactPanelSessionStateRef.current;
+ if (currentState) {
+ artifactPanelStateBySessionRef.current.set(
+ previousSessionId,
+ currentState,
+ );
+ if (
+ artifactPanelStateBySessionRef.current.size >
+ MAX_ARTIFACT_PANEL_SESSION_STATES
+ ) {
+ const oldestSessionId = artifactPanelStateBySessionRef.current
+ .keys()
+ .next().value;
+ if (oldestSessionId) {
+ artifactPanelStateBySessionRef.current.delete(oldestSessionId);
+ }
+ }
+ }
+ }
+
+ const nextSessionId = connection.sessionId;
+ artifactPanelSessionIdRef.current = nextSessionId;
+ const savedState = nextSessionId
+ ? artifactPanelStateBySessionRef.current.get(nextSessionId)
+ : undefined;
+ if (!savedState) {
+ setArtifactPanelOpen(false);
+ setArtifactPanelTabs([]);
+ setActiveArtifactPanelTabId(null);
+ setReviewChanges([]);
+ setSelectedReviewPath(null);
+ setArtifactPanelExtraArtifacts([]);
+ setPaneArtifactSnapshots(new Map());
+ setArtifactPanelWidth(DEFAULT_REVIEW_PANEL_WIDTH);
+ return;
+ }
+
+ setArtifactPanelOpen(savedState.open);
+ setArtifactPanelTabs(savedState.tabs);
+ setActiveArtifactPanelTabId(savedState.activeTabId);
+ setReviewChanges(savedState.reviewChanges);
+ setSelectedReviewPath(savedState.selectedReviewPath);
+ setArtifactPanelExtraArtifacts(savedState.extraArtifacts);
+ setPaneArtifactSnapshots(new Map());
+ setArtifactPanelWidth(savedState.width);
+ }, [connection.sessionId]);
+ const getMaxArtifactPanelWidth = useCallback(() => {
+ const chatPaneWidth = chatPaneRef.current?.getBoundingClientRect().width;
+ if (!chatPaneWidth) {
+ return Math.max(
+ MIN_ARTIFACT_PANEL_WIDTH,
+ window.innerWidth - MIN_CHAT_PANE_WIDTH_WITH_ARTIFACT_PANEL,
+ );
+ }
+ return Math.max(
+ MIN_ARTIFACT_PANEL_WIDTH,
+ artifactPanelWidth +
+ chatPaneWidth -
+ MIN_CHAT_PANE_WIDTH_WITH_ARTIFACT_PANEL,
+ );
+ }, [artifactPanelWidth]);
+ const getDefaultReviewPanelWidth = useCallback(() => {
+ const chatPaneWidth =
+ chatPaneRef.current?.getBoundingClientRect().width ?? window.innerWidth;
+ const maxWidth = Math.max(
+ MIN_ARTIFACT_PANEL_WIDTH,
+ chatPaneWidth - MIN_CHAT_PANE_WIDTH_WITH_ARTIFACT_PANEL,
+ );
+ return Math.min(
+ maxWidth,
+ Math.max(DEFAULT_REVIEW_PANEL_WIDTH, Math.round(chatPaneWidth * 0.56)),
+ );
+ }, []);
+ const openArtifactPanel = useCallback(
+ (artifactId: string, previewContent?: string) => {
+ if (!artifactId) return;
+ const artifact = artifactPanelArtifacts.find(
+ (item) => item.id === artifactId,
+ );
+ const tab: ArtifactPanelTab = {
+ id: `artifact:${artifactId}`,
+ kind: 'artifact',
+ artifactId,
+ title: artifact?.title ?? 'Artifact',
+ ...(previewContent !== undefined ? { previewContent } : {}),
+ };
+ setArtifactPanelTabs((tabs) =>
+ tabs.some((item) => item.id === tab.id)
+ ? tabs.map((item) =>
+ item.id === tab.id ? { ...item, ...tab } : item,
+ )
+ : [...tabs, tab],
+ );
+ setActiveArtifactPanelTabId(tab.id);
+ setArtifactPanelWidth((width) =>
+ artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
+ );
+ setArtifactPanelOpen(true);
+ },
+ [artifactPanelArtifacts, getDefaultReviewPanelWidth],
+ );
+ const openReviewPanel = useCallback(
+ (changes: readonly TurnOutputFileChange[], selectedPath?: string) => {
+ const reviewTab: ArtifactPanelTab = {
+ id: 'review',
+ kind: 'review',
+ title: t('turnOutputs.review'),
+ };
+ setArtifactPanelTabs((tabs) =>
+ tabs.some((item) => item.id === reviewTab.id)
+ ? tabs
+ : [reviewTab, ...tabs],
+ );
+ setActiveArtifactPanelTabId(reviewTab.id);
+ setReviewChanges(changes);
+ setSelectedReviewPath(selectedPath ?? null);
+ setArtifactPanelWidth((width) =>
+ artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
+ );
+ setArtifactPanelOpen(true);
+ },
+ [getDefaultReviewPanelWidth, t],
+ );
+ const openScheduledTaskPanel = useCallback(
+ (
+ task: TurnOutputScheduledTask,
+ tabWorkspaceActions?: ReturnType,
+ ) => {
+ const tab: ArtifactPanelTab = {
+ id: `scheduled-task:${task.toolCallId}`,
+ kind: 'scheduled_task',
+ title: t('scheduledTasks.title'),
+ task,
+ ...(tabWorkspaceActions
+ ? { workspaceActions: tabWorkspaceActions }
+ : {}),
+ };
+ setArtifactPanelTabs((tabs) =>
+ tabs.some((item) => item.id === tab.id)
+ ? tabs.map((item) => (item.id === tab.id ? tab : item))
+ : [...tabs, tab],
+ );
+ setActiveArtifactPanelTabId(tab.id);
+ setArtifactPanelWidth((width) =>
+ artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
+ );
+ setArtifactPanelOpen(true);
+ },
+ [getDefaultReviewPanelWidth, t],
+ );
+ const handleTurnOutputOpen = useCallback(
+ (request: TurnOutputOpenRequest) => {
+ if (onRightPanelOpen) {
+ onRightPanelOpen(request);
+ return;
+ }
+ if (request.kind === 'review') {
+ openReviewPanel(request.changes, request.selectedPath);
+ return;
+ }
+ if (request.kind === 'scheduled_task') {
+ openScheduledTaskPanel(request.task, request.workspaceActions);
+ return;
+ }
+
+ if (!request.workspaceActions) {
+ setArtifactPanelExtraArtifacts((current) => {
+ const index = current.findIndex(
+ (artifact) => artifact.id === request.artifact.id,
+ );
+ if (index < 0) return [...current, request.artifact];
+ const next = [...current];
+ next[index] = request.artifact;
+ return next;
+ });
+ }
+ const tab: ArtifactPanelTab = {
+ id: request.id,
+ kind: 'artifact',
+ title: request.title,
+ artifactId: request.artifactId,
+ ...(request.workspaceActions
+ ? { workspaceActions: request.workspaceActions }
+ : {}),
+ ...(request.previewContent !== undefined
+ ? { previewContent: request.previewContent }
+ : {}),
+ };
+ setArtifactPanelTabs((tabs) =>
+ tabs.some((item) => item.id === tab.id)
+ ? tabs.map((item) =>
+ item.id === tab.id ? { ...item, ...tab } : item,
+ )
+ : [...tabs, tab],
+ );
+ setActiveArtifactPanelTabId(tab.id);
+ setArtifactPanelWidth((width) =>
+ artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
+ );
+ setArtifactPanelOpen(true);
+ },
+ [
+ getDefaultReviewPanelWidth,
+ onRightPanelOpen,
+ openReviewPanel,
+ openScheduledTaskPanel,
+ ],
+ );
+ const closeArtifactPanel = useCallback(() => {
+ setArtifactPanelOpen(false);
+ setArtifactPanelTabs([]);
+ setActiveArtifactPanelTabId(null);
+ setReviewChanges([]);
+ setSelectedReviewPath(null);
+ setArtifactPanelExtraArtifacts([]);
+ setPaneArtifactSnapshots(new Map());
+ }, []);
+ useLayoutEffect(() => {
+ if (!artifactPanelOpen) return;
+ const clampWidth = () => {
+ setArtifactPanelWidth((width) => {
+ const chatPaneWidth =
+ chatPaneRef.current?.getBoundingClientRect().width ??
+ window.innerWidth - width;
+ const maxWidth = Math.max(
+ MIN_ARTIFACT_PANEL_WIDTH,
+ width + chatPaneWidth - MIN_CHAT_PANE_WIDTH_WITH_ARTIFACT_PANEL,
+ );
+ return Math.min(width, maxWidth);
+ });
+ };
+ clampWidth();
+ window.addEventListener('resize', clampWidth);
+ const chatPane = chatPaneRef.current;
+ const observer = new ResizeObserver(clampWidth);
+ if (chatPane) observer.observe(chatPane);
+ return () => {
+ window.removeEventListener('resize', clampWidth);
+ observer.disconnect();
+ };
+ }, [artifactPanelOpen]);
+ const closeArtifactPanelTab = useCallback((tabId: string) => {
+ setArtifactPanelTabs((tabs) => {
+ const nextTabs = tabs.filter((tab) => tab.id !== tabId);
+ if (nextTabs.length === 0) {
+ setArtifactPanelOpen(false);
+ setActiveArtifactPanelTabId(null);
+ setReviewChanges([]);
+ setSelectedReviewPath(null);
+ setArtifactPanelExtraArtifacts([]);
+ setPaneArtifactSnapshots(new Map());
+ return nextTabs;
+ }
+ if (activeArtifactPanelTabIdRef.current === tabId) {
+ const closedIndex = tabs.findIndex((tab) => tab.id === tabId);
+ const nextActive =
+ nextTabs[Math.min(closedIndex, nextTabs.length - 1)] ?? nextTabs[0];
+ setActiveArtifactPanelTabId(nextActive.id);
+ }
+ return nextTabs;
+ });
+ }, []);
+ const handleArtifactPanelResizeStart = useCallback(
+ (event: ReactPointerEvent) => {
+ event.preventDefault();
+ const resizeHandle = event.currentTarget;
+ resizeHandle.setPointerCapture(event.pointerId);
+ const startX = event.clientX;
+ const startWidth = artifactPanelWidth;
+ const maxWidth = getMaxArtifactPanelWidth();
+ const previousCursor = document.body.style.cursor;
+ const previousUserSelect = document.body.style.userSelect;
+ let pendingWidth = startWidth;
+ let animationFrame: number | null = null;
+
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+
+ const flushWidth = () => {
+ animationFrame = null;
+ setArtifactPanelWidth(pendingWidth);
+ };
+
+ const handlePointerMove = (moveEvent: PointerEvent) => {
+ pendingWidth = Math.min(
+ maxWidth,
+ Math.max(
+ MIN_ARTIFACT_PANEL_WIDTH,
+ startWidth - (moveEvent.clientX - startX),
+ ),
+ );
+ if (animationFrame === null) {
+ animationFrame = window.requestAnimationFrame(flushWidth);
+ }
+ };
+ let handlePointerUp: () => void = () => {};
+ const cleanupResize = (commitWidth: boolean) => {
+ artifactPanelResizeCleanupRef.current = null;
+ if (animationFrame !== null) {
+ window.cancelAnimationFrame(animationFrame);
+ animationFrame = null;
+ }
+ if (commitWidth) setArtifactPanelWidth(pendingWidth);
+ if (resizeHandle.hasPointerCapture(event.pointerId)) {
+ resizeHandle.releasePointerCapture(event.pointerId);
+ }
+ document.body.style.cursor = previousCursor;
+ document.body.style.userSelect = previousUserSelect;
+ window.removeEventListener('pointermove', handlePointerMove);
+ window.removeEventListener('pointerup', handlePointerUp);
+ window.removeEventListener('pointercancel', handlePointerUp);
+ };
+ handlePointerUp = () => cleanupResize(true);
+ artifactPanelResizeCleanupRef.current = () => cleanupResize(false);
+ window.addEventListener('pointermove', handlePointerMove);
+ window.addEventListener('pointerup', handlePointerUp);
+ window.addEventListener('pointercancel', handlePointerUp);
+ },
+ [artifactPanelWidth, getMaxArtifactPanelWidth],
+ );
+ useEffect(() => () => artifactPanelResizeCleanupRef.current?.(), []);
const messageBlocks = useAnimationFrameValue(blocks);
const rawPendingApproval = useMemo(
() => extractPendingPermission(messageBlocks),
@@ -4776,6 +5321,7 @@ export function App({
)}
@@ -5082,6 +5631,25 @@ export function App({
handleCanScrollToBottomChange
}
virtualScrollThreshold={virtualScrollThreshold}
+ turnFileChanges={
+ visibleTurnOutputKinds.has('file')
+ ? fileChangesByTurn
+ : undefined
+ }
+ turnArtifacts={
+ visibleTurnOutputKinds.has('artifact')
+ ? artifactsByTurn
+ : undefined
+ }
+ turnScheduledTasks={
+ visibleTurnOutputKinds.has('scheduled_task')
+ ? scheduledTasksByTurn
+ : undefined
+ }
+ onTurnOutputOpen={handleTurnOutputOpen}
+ onReviewChanges={openReviewPanel}
+ onOpenArtifact={openArtifactPanel}
+ onOpenScheduledTask={openScheduledTaskPanel}
/>
{btwMessage?.role === 'btw' && (
@@ -5295,6 +5863,33 @@ export function App({
+ {artifactPanelOpen && (
+ <>
+
+
+ >
+ )}
diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx
index ddfb920e79d..3193cbbdb7c 100644
--- a/packages/web-shell/client/components/ChatPane.test.tsx
+++ b/packages/web-shell/client/components/ChatPane.test.tsx
@@ -30,6 +30,15 @@ const submitPermission = vi.fn(async () => {});
const cancel = vi.fn(async () => {});
const setApprovalMode = vi.fn(async (mode: string) => ({ mode }));
const setModel = vi.fn(async () => ({}) as any);
+const loadArtifacts = vi.fn(async () => ({ artifacts: [] }));
+const daemonActions = {
+ sendPrompt,
+ submitPermission,
+ cancel,
+ setApprovalMode,
+ setModel,
+ loadArtifacts,
+};
const enqueuePrompt = vi.fn(() => true);
const removeQueuedPrompt = vi.fn();
const insertQueuedPrompt = vi.fn();
@@ -41,13 +50,7 @@ let queuedTextsMock: string[] = [];
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
DAEMON_APPROVAL_MODES: ['default', 'plan', 'auto-edit', 'auto', 'yolo'],
- useActions: () => ({
- sendPrompt,
- submitPermission,
- cancel,
- setApprovalMode,
- setModel,
- }),
+ useActions: () => daemonActions,
useConnection: () => connectionState,
useDaemonFollowupSuggestion: (options: any) => {
latestFollowupAccept = options?.onAccept;
@@ -63,6 +66,9 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
useTranscriptStore: () => ({
dispatch: transcriptDispatch,
}),
+ usePromptStatus: () => 'idle',
+ useWorkspaceActions: () => ({}),
+ useWorkspaceEventSignals: () => ({ artifactsVersion: 0 }),
}));
vi.mock('../hooks/useQueuedPrompts', () => ({
@@ -218,6 +224,8 @@ beforeEach(() => {
queuedPromptsMock = [];
queuedTextsMock = [];
sendPrompt.mockReset();
+ loadArtifacts.mockReset();
+ loadArtifacts.mockResolvedValue({ artifacts: [] });
sendPrompt.mockImplementation(async (_text: string, options?: any) => {
sendPromptAdmit = options?.onAdmitted;
return {} as any;
@@ -269,6 +277,32 @@ describe('ChatPane', () => {
expect(container!.textContent).toContain('Refactor core');
});
+ it('reports loaded pane artifacts to the outer panel owner', async () => {
+ const onPaneArtifactsChange = vi.fn();
+ connectionState.capabilities = { features: ['session_artifacts'] };
+ const artifact = {
+ id: 'artifact-1',
+ title: 'Report',
+ kind: 'html',
+ storage: 'workspace',
+ workspacePath: 'reports/a.html',
+ updatedAt: '2026-07-10T00:00:00Z',
+ };
+ loadArtifacts.mockResolvedValueOnce({ artifacts: [artifact] });
+
+ render({ onPaneArtifactsChange });
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(onPaneArtifactsChange).toHaveBeenLastCalledWith(
+ 'sess-1',
+ [artifact],
+ expect.any(Object),
+ );
+ });
+
it('suppresses the rotating loading phrase in its compact status', () => {
render();
expect(testid('pane-streaming')?.getAttribute('data-show-phrase')).toBe(
diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx
index d1a40d562e7..5499d7fce69 100644
--- a/packages/web-shell/client/components/ChatPane.tsx
+++ b/packages/web-shell/client/components/ChatPane.tsx
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { useCallback, useMemo, useRef } from 'react';
+import { useCallback, useEffect, useMemo, useRef } from 'react';
import {
useActions,
useConnection,
@@ -12,9 +12,13 @@ import {
useStreamingState,
useTranscriptBlocks,
useTranscriptStore,
+ useWorkspaceActions,
+ type DaemonWorkspaceActions,
} from '@qwen-code/webui/daemon-react-sdk';
+import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
import { useI18n } from '../i18n';
import { useMessages } from '../hooks/useMessages';
+import { useSessionArtifacts } from '../hooks/useSessionArtifacts';
import { extractPendingPermission } from '../adapters/transcriptAdapter';
import type { PromptImage } from '../adapters/promptTypes';
import type {
@@ -38,6 +42,16 @@ import { ChatEditor, type ComposerToolbarAction } from './ChatEditor';
import { QueuedPromptDisplay } from './QueuedPromptDisplay';
import { ToolApproval } from './messages/ToolApproval';
import { AskUserQuestion } from './messages/AskUserQuestion';
+import type {
+ TurnOutputKind,
+ TurnOutputOpenRequest,
+} from './artifacts/TurnOutputs';
+import { TURN_OUTPUT_KINDS } from './artifacts/TurnOutputs';
+import {
+ getArtifactsByTurn,
+ getFileChangesByTurn,
+ getScheduledTasksByTurn,
+} from './artifacts/turnOutputSelectors';
import styles from './ChatPane.module.css';
// Split-view panes get the same interactive composer controls as the main chat,
@@ -55,6 +69,13 @@ export interface ChatPaneProps {
title?: string;
onClose?: () => void;
onError?: (error: unknown, fallback: string) => void;
+ onRightPanelOpen?: (request: TurnOutputOpenRequest) => void;
+ onPaneArtifactsChange?: (
+ sessionId: string,
+ artifacts: readonly DaemonSessionArtifact[],
+ workspaceActions: DaemonWorkspaceActions,
+ ) => void;
+ messageTurnOutputs?: readonly TurnOutputKind[];
}
/**
@@ -64,14 +85,36 @@ export interface ChatPaneProps {
* state, approvals, and composer, and the browser scopes keyboard focus to the
* pane the user clicks into — so there is no cross-pane approval arbitration.
*/
-export function ChatPane({ title, onClose, onError }: ChatPaneProps) {
+export function ChatPane({
+ title,
+ onClose,
+ onError,
+ onRightPanelOpen,
+ onPaneArtifactsChange,
+ messageTurnOutputs,
+}: ChatPaneProps) {
const { t } = useI18n();
const connection = useConnection();
const actions = useActions();
+ const workspaceActions = useWorkspaceActions();
const messages = useMessages(t);
const blocks = useTranscriptBlocks();
const store = useTranscriptStore();
const streamingState = useStreamingState();
+ const { artifacts } = useSessionArtifacts();
+ useEffect(() => {
+ const sessionId = connection.sessionId;
+ if (!sessionId) return;
+ onPaneArtifactsChange?.(sessionId, artifacts, workspaceActions);
+ return () => {
+ onPaneArtifactsChange?.(sessionId, [], workspaceActions);
+ };
+ }, [
+ artifacts,
+ connection.sessionId,
+ onPaneArtifactsChange,
+ workspaceActions,
+ ]);
const streamingStateRef = useRef(streamingState);
streamingStateRef.current = streamingState;
const editorRef = useRef(null);
@@ -115,6 +158,28 @@ export function ChatPane({ title, onClose, onError }: ChatPaneProps) {
const approvalActive =
pendingToolApproval !== null || pendingAskUserApproval !== null;
const isResponding = streamingState !== 'idle';
+ const artifactsByTurn = useMemo(
+ () =>
+ getArtifactsByTurn(messages, artifacts, connection.workspaceCwd || ''),
+ [messages, artifacts, connection.workspaceCwd],
+ );
+ const fileChangesByTurn = useMemo(
+ () =>
+ getFileChangesByTurn(
+ messages,
+ artifactsByTurn,
+ connection.workspaceCwd || '',
+ ),
+ [messages, artifactsByTurn, connection.workspaceCwd],
+ );
+ const scheduledTasksByTurn = useMemo(
+ () => getScheduledTasksByTurn(messages),
+ [messages],
+ );
+ const visibleTurnOutputKinds = useMemo(
+ () => new Set(messageTurnOutputs ?? TURN_OUTPUT_KINDS),
+ [messageTurnOutputs],
+ );
const {
queuedPrompts,
queuedTexts,
@@ -196,6 +261,18 @@ export function ChatPane({ title, onClose, onError }: ChatPaneProps) {
);
}, [actions, reportError]);
+ const handleRightPanelOpen = useCallback(
+ (request: TurnOutputOpenRequest) => {
+ if (!onRightPanelOpen) return;
+ if (request.kind === 'artifact' || request.kind === 'scheduled_task') {
+ onRightPanelOpen({ ...request, workspaceActions });
+ return;
+ }
+ onRightPanelOpen(request);
+ },
+ [onRightPanelOpen, workspaceActions],
+ );
+
// Composer wiring, all scoped to THIS pane's own DaemonSession context. The
// slash menu lists the session's daemon commands — they run server-side when
// submitted (via sendPrompt), so e.g. `/clear` clears this pane's session, not
@@ -325,6 +402,18 @@ export function ChatPane({ title, onClose, onError }: ChatPaneProps) {
isResponding={isResponding}
workspaceCwd={connection.workspaceCwd || ''}
hideSessionTimeline
+ turnFileChanges={
+ visibleTurnOutputKinds.has('file') ? fileChangesByTurn : undefined
+ }
+ turnArtifacts={
+ visibleTurnOutputKinds.has('artifact') ? artifactsByTurn : undefined
+ }
+ turnScheduledTasks={
+ visibleTurnOutputKinds.has('scheduled_task')
+ ? scheduledTasksByTurn
+ : undefined
+ }
+ onTurnOutputOpen={handleRightPanelOpen}
/>
diff --git a/packages/web-shell/client/components/MessageList.test.ts b/packages/web-shell/client/components/MessageList.test.ts
index 84c9959301b..db216d46e5a 100644
--- a/packages/web-shell/client/components/MessageList.test.ts
+++ b/packages/web-shell/client/components/MessageList.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import type { Message, TurnCollapseHead } from '../adapters/types';
import {
+ attachTurnOutputs,
applyTurnCollapse,
findDisplayItemIndex,
findTurnIdForIndex,
@@ -15,6 +16,7 @@ import {
VIRTUAL_SCROLL_THRESHOLD,
type DisplayItem,
} from './MessageList';
+import type { TurnOutputFileChange } from './artifacts/TurnOutputs';
function messageRow(
item: DisplayItem,
@@ -315,6 +317,67 @@ describe('groupParallelAgents', () => {
});
});
+describe('attachTurnOutputs', () => {
+ it('keeps outputs for a transcript that starts before a user turn', () => {
+ const message = makeMultiToolGroup('tg1');
+ const changes: TurnOutputFileChange[] = [
+ {
+ path: 'src/app.ts',
+ status: 'modified',
+ toolCallId: 'call-tg1-a',
+ diffs: [{ oldText: 'one\n', newText: 'two\n' }],
+ },
+ ];
+
+ const items = attachTurnOutputs(
+ [{ type: 'message', key: message.id, message }],
+ false,
+ new Map([[message.id, changes]]),
+ );
+
+ expect(items).toHaveLength(2);
+ expect(items[1]).toMatchObject({
+ type: 'turn_outputs',
+ key: message.id,
+ turnId: message.id,
+ changes,
+ });
+ });
+
+ it('keeps outputs for a leading grouped parallel-agent row', () => {
+ const items = groupParallelAgents([
+ makeAgentToolGroup('x1'),
+ makeAgentToolGroup('x2'),
+ ]);
+ const changes: TurnOutputFileChange[] = [
+ {
+ path: 'src/app.ts',
+ status: 'modified',
+ toolCallId: 'call-x1-a',
+ diffs: [{ oldText: 'one\n', newText: 'two\n' }],
+ },
+ ];
+
+ const outputItems = attachTurnOutputs(
+ items,
+ false,
+ new Map([['x1', changes]]),
+ );
+
+ expect(outputItems).toHaveLength(2);
+ expect(outputItems[0]).toMatchObject({
+ type: 'parallel_agents',
+ turnId: 'x1',
+ });
+ expect(outputItems[1]).toMatchObject({
+ type: 'turn_outputs',
+ key: 'x1',
+ turnId: 'x1',
+ changes,
+ });
+ });
+});
+
describe('getTurnTimelineNode', () => {
const item = (
message: Message,
@@ -725,6 +788,7 @@ describe('getDisplayItemVirtualKey', () => {
getDisplayItemVirtualKey({
type: 'parallel_agents',
key: 'header',
+ turnId: 'header',
agents: [makeAgentToolGroup('a').tools[0]],
}),
).toBe('group:header');
diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx
index 33f31abdc62..571d3c083dc 100644
--- a/packages/web-shell/client/components/MessageList.tsx
+++ b/packages/web-shell/client/components/MessageList.tsx
@@ -17,6 +17,7 @@ import {
} from 'react';
import { createPortal } from 'react-dom';
import { useVirtualizer } from '@tanstack/react-virtual';
+import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
import type { Message, ACPToolCall, TurnCollapseHead } from '../adapters/types';
import type { PermissionRequest } from '../adapters/types';
import {
@@ -31,6 +32,12 @@ import {
import { useI18n } from '../i18n';
import { MessageItem } from './MessageItem';
import { MessageTimestamp } from './MessageTimestamp';
+import {
+ TurnOutputs,
+ type TurnOutputFileChange,
+ type TurnOutputOpenRequest,
+ type TurnOutputScheduledTask,
+} from './artifacts/TurnOutputs';
import { ParallelAgentsGroup } from './messages/tools/ParallelAgentsGroup';
import { useSharedNow } from '../hooks/useSharedNow';
import { toolContainsCallId } from './messages/toolFormatting';
@@ -38,6 +45,8 @@ import turnCollapseStyles from './TurnCollapseRow.module.css';
import flashStyles from './MessageLocateFlash.module.css';
import styles from './MessageList.module.css';
+const noopTurnOutputAction = () => undefined;
+
interface MessageListProps {
messages: Message[];
pendingApproval: PermissionRequest | null;
@@ -73,6 +82,16 @@ interface MessageListProps {
onRetryClick?: () => void;
onBranchSession?: () => void;
onCanScrollToBottomChange?: (canScrollToBottom: boolean) => void;
+ turnFileChanges?: ReadonlyMap;
+ turnArtifacts?: ReadonlyMap;
+ turnScheduledTasks?: ReadonlyMap;
+ onReviewChanges?: (
+ changes: readonly TurnOutputFileChange[],
+ selectedPath?: string,
+ ) => void;
+ onOpenArtifact?: (artifactId: string, previewContent?: string) => void;
+ onOpenScheduledTask?: (task: TurnOutputScheduledTask) => void;
+ onTurnOutputOpen?: (request: TurnOutputOpenRequest) => void;
}
function getLastUserMessageId(messages: Message[]): string | null {
@@ -118,12 +137,21 @@ export type DisplayItem =
| {
type: 'parallel_agents';
key: string;
+ turnId: string;
agents: ACPToolCall[];
/**
* Wall-clock time of the first grouped launch, carried so the grouped
* box reveals its time on hover exactly like a standalone message row.
*/
timestamp?: number;
+ }
+ | {
+ type: 'turn_outputs';
+ key: string;
+ turnId: string;
+ changes: readonly TurnOutputFileChange[];
+ artifacts: readonly DaemonSessionArtifact[];
+ scheduledTasks: readonly TurnOutputScheduledTask[];
};
interface LocateFlashTarget {
@@ -304,6 +332,7 @@ export function groupParallelAgents(messages: Message[]): DisplayItem[] {
items.push({
type: 'parallel_agents',
key: `par-${grouped[0].id}`,
+ turnId: grouped[0].id,
agents: grouped.map((m) => (m as { tools: ACPToolCall[] }).tools[0]),
timestamp: grouped[0].timestamp,
});
@@ -320,6 +349,7 @@ export function groupParallelAgents(messages: Message[]): DisplayItem[] {
items.push({
type: 'parallel_agents',
key: `par-${grouped[0].id}`,
+ turnId: grouped[0].id,
agents: grouped.map((m) => (m as { tools: ACPToolCall[] }).tools[0]),
timestamp: grouped[0].timestamp,
});
@@ -344,6 +374,7 @@ export function groupParallelAgents(messages: Message[]): DisplayItem[] {
export function getDisplayItemVirtualKey(item: DisplayItem): string {
if (item.type === 'parallel_agents') return `group:${item.key}`;
+ if (item.type === 'turn_outputs') return `outputs:${item.key}`;
if (item.type === 'turn_collapse') {
const liveKey = item.turnCollapse.liveStartedAt;
return liveKey === undefined
@@ -354,6 +385,61 @@ export function getDisplayItemVirtualKey(item: DisplayItem): string {
return `msg:${item.key}`;
}
+export function attachTurnOutputs(
+ items: DisplayItem[],
+ isResponding: boolean,
+ turnFileChanges?: ReadonlyMap,
+ turnArtifacts?: ReadonlyMap,
+ turnScheduledTasks?: ReadonlyMap,
+): DisplayItem[] {
+ if (
+ (!turnFileChanges || turnFileChanges.size === 0) &&
+ (!turnArtifacts || turnArtifacts.size === 0) &&
+ (!turnScheduledTasks || turnScheduledTasks.size === 0)
+ ) {
+ return items;
+ }
+
+ const result: DisplayItem[] = [];
+ let currentTurnId: string | null = null;
+ const pushTurnOutputs = (turnId: string | null, isFinalTurn: boolean) => {
+ if (isFinalTurn && isResponding) return;
+ if (!turnId) return;
+ const changes = turnFileChanges?.get(turnId) ?? [];
+ const artifacts = turnArtifacts?.get(turnId) ?? [];
+ const scheduledTasks = turnScheduledTasks?.get(turnId) ?? [];
+ if (
+ changes.length === 0 &&
+ artifacts.length === 0 &&
+ scheduledTasks.length === 0
+ ) {
+ return;
+ }
+ result.push({
+ type: 'turn_outputs',
+ key: turnId,
+ turnId,
+ changes,
+ artifacts,
+ scheduledTasks,
+ });
+ };
+
+ for (const item of items) {
+ if (item.type === 'message' && isTurnStartMessage(item.message)) {
+ pushTurnOutputs(currentTurnId, false);
+ currentTurnId = item.message.id;
+ } else if (!currentTurnId && item.type === 'message') {
+ currentTurnId = item.message.id;
+ } else if (!currentTurnId && item.type === 'parallel_agents') {
+ currentTurnId = item.turnId;
+ }
+ result.push(item);
+ }
+ pushTurnOutputs(currentTurnId, true);
+ return result;
+}
+
export interface ApplyTurnCollapseOptions {
/**
* Per-turn user override keyed by the turn's user-message id:
@@ -447,6 +533,7 @@ function collectFinalAssistantTurnIds(
*/
function isHideableStep(item: DisplayItem, isFinalAnswer: boolean): boolean {
if (item.type === 'parallel_agents') return true;
+ if (item.type === 'turn_outputs') return false;
if (item.type === 'turn_collapse') return false;
if (item.type === 'turn_content') {
return item.items.some((child) => isHideableStep(child, isFinalAnswer));
@@ -502,6 +589,7 @@ export function getTurnTimelineNode(
label: t ? t('timeline.parallelAgents') : 'Parallel agents',
};
}
+ if (item.type === 'turn_outputs') return { kind: 'none' };
if (item.type !== 'message') return { kind: 'none' };
const { message } = item;
@@ -710,6 +798,7 @@ function timelineDetailSnippetForItem(
? t('timeline.parallelAgentsDetail', { count })
: `${count} parallel agent${count === 1 ? '' : 's'}`;
}
+ if (item.type === 'turn_outputs') return '';
if (item.type !== 'message') return '';
return timelineDetailSnippetForMessage(item.message, t);
}
@@ -906,6 +995,7 @@ export function getSessionTimelineSignature(
function isExecutionWorkStep(item: DisplayItem): boolean {
if (item.type === 'parallel_agents') return true;
+ if (item.type === 'turn_outputs') return false;
if (item.type === 'turn_collapse') return false;
if (item.type === 'turn_content') return item.items.some(isExecutionWorkStep);
return item.message.role === 'tool_group' || item.message.role === 'plan';
@@ -926,6 +1016,8 @@ function activeExecutionKey(item: DisplayItem): string | null {
return null;
}
+ if (item.type === 'turn_outputs') return null;
+
if (item.type === 'turn_collapse') {
if (item.turnCollapse.liveStartedAt === undefined) return null;
if (
@@ -1008,6 +1100,7 @@ function itemAssistantUsage(item: DisplayItem):
function itemToolCallCount(item: DisplayItem): number {
if (item.type === 'parallel_agents') return item.agents.length;
+ if (item.type === 'turn_outputs') return 0;
if (item.type === 'turn_collapse') return 0;
if (item.type === 'turn_content') {
return item.items.reduce((sum, child) => sum + itemToolCallCount(child), 0);
@@ -1393,6 +1486,8 @@ export function findDisplayItemIndex(
findDisplayItemIndex(item.items, messageId, callId) >= 0
) {
return i;
+ } else if (item.type === 'turn_outputs') {
+ continue;
}
}
return -1;
@@ -1422,6 +1517,7 @@ function displayItemMatchesLocateTarget(
displayItemMatchesLocateTarget(child, target),
);
}
+ if (item.type === 'turn_outputs') return false;
return false;
}
@@ -1648,6 +1744,7 @@ const TurnCollapseRow = memo(function TurnCollapseRow({
function getChatRowClassName(item: DisplayItem): string | undefined {
if (item.type === 'turn_collapse') return styles.turnStatusRow;
+ if (item.type === 'turn_outputs') return styles.turnContentRow;
if (item.type === 'turn_content') {
return styles.turnContentRow;
}
@@ -2086,6 +2183,13 @@ export const MessageList = memo(
onRetryClick,
onBranchSession,
onCanScrollToBottomChange,
+ turnFileChanges,
+ turnArtifacts,
+ turnScheduledTasks,
+ onReviewChanges,
+ onOpenArtifact,
+ onOpenScheduledTask,
+ onTurnOutputOpen,
},
ref,
) {
@@ -2099,8 +2203,21 @@ export const MessageList = memo(
[compactMode, messages, pendingApproval],
);
const displayItems = useMemo(
- () => groupParallelAgents(mergedMessages),
- [mergedMessages],
+ () =>
+ attachTurnOutputs(
+ groupParallelAgents(mergedMessages),
+ isResponding,
+ turnFileChanges,
+ turnArtifacts,
+ turnScheduledTasks,
+ ),
+ [
+ mergedMessages,
+ isResponding,
+ turnFileChanges,
+ turnArtifacts,
+ turnScheduledTasks,
+ ],
);
const [isSessionTimelineVisible, setIsSessionTimelineVisible] =
useState(false);
@@ -3124,6 +3241,24 @@ export const MessageList = memo(
);
}
+ if (displayItem.type === 'turn_outputs') {
+ return (
+
+ );
+ }
+
if (displayItem.type === 'turn_collapse') {
return (
void;
onError?: (error: unknown, fallback: string) => void;
+ onRightPanelOpen?: (request: TurnOutputOpenRequest) => void;
+ onPaneArtifactsChange?: (
+ sessionId: string,
+ artifacts: readonly DaemonSessionArtifact[],
+ workspaceActions: DaemonWorkspaceActions,
+ ) => void;
+ messageTurnOutputs?: readonly TurnOutputKind[];
/**
* Bumped by the parent whenever the session list changes elsewhere (create /
* delete / rename). The "add pane" picker reloads on a change so it never
@@ -56,6 +69,9 @@ export function SplitView({
onPanesChange,
onExit,
onError,
+ onRightPanelOpen,
+ onPaneArtifactsChange,
+ messageTurnOutputs,
sessionListReloadToken,
}: SplitViewProps) {
const { t } = useI18n();
@@ -325,6 +341,9 @@ export function SplitView({
title={titleById.get(sessionId)}
onClose={() => removePane(sessionId)}
onError={onError}
+ onRightPanelOpen={onRightPanelOpen}
+ onPaneArtifactsChange={onPaneArtifactsChange}
+ messageTurnOutputs={messageTurnOutputs}
/>
diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css b/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css
new file mode 100644
index 00000000000..34ec9ee4642
--- /dev/null
+++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css
@@ -0,0 +1,807 @@
+.panel {
+ flex: 0 0 min(420px, 36vw);
+ min-width: 320px;
+ border-left: 1px solid var(--border);
+ background: var(--background);
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.header {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 14px 0 14px;
+}
+
+.title {
+ min-width: 0;
+ flex: 1 1 auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 600;
+}
+
+.tabs {
+ min-width: 0;
+ flex: 1 1 auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ overflow-x: auto;
+}
+
+.tabItem {
+ appearance: none;
+ border: 1px solid transparent;
+ background: transparent;
+ color: var(--muted-foreground);
+ border-radius: 6px;
+ max-width: 180px;
+ min-width: 0;
+ display: inline-flex;
+ align-items: center;
+}
+
+.tab {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+ font: inherit;
+ font-size: 13px;
+ min-width: 0;
+ overflow: hidden;
+ padding: 5px 4px 5px 8px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+}
+
+.tabIcon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 16px;
+ height: 16px;
+ opacity: 0.7;
+}
+
+.tabIconSvg {
+ width: 16px;
+ height: 16px;
+ display: block;
+}
+
+.tabItem:hover .tabIcon,
+.tabActive .tabIcon {
+ opacity: 1;
+}
+
+.tabItem:hover {
+ background: var(--accent);
+ color: var(--foreground);
+}
+
+.tabActive {
+ background: var(--accent);
+ border-color: var(--border);
+ color: var(--foreground);
+}
+
+.tabCloseButton {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: var(--muted-foreground);
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ border-radius: 5px;
+}
+
+.tabCloseButton:hover {
+ background: var(--background);
+ color: var(--foreground);
+}
+
+.tabCloseIcon {
+ width: 13px;
+ height: 13px;
+ display: block;
+}
+
+.iconButton {
+ appearance: none;
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--foreground);
+ border-radius: 6px;
+ width: 28px;
+ height: 28px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+}
+
+.iconButton:hover {
+ background: var(--accent);
+}
+
+.iconButtonActive {
+ background: var(--accent);
+}
+
+.toolbarIcon {
+ width: 16px;
+ height: 16px;
+ display: block;
+}
+
+.body {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow: auto;
+ padding: 12px;
+}
+
+.empty {
+ color: var(--muted-foreground);
+ font-size: 13px;
+ padding: 16px 4px;
+}
+
+.list {
+ display: grid;
+ gap: 6px;
+}
+
+.row {
+ appearance: none;
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--foreground);
+ border-radius: 6px;
+ padding: 8px;
+ display: grid;
+ gap: 4px;
+ text-align: left;
+ cursor: pointer;
+}
+
+.row:hover,
+.rowActive {
+ background: var(--accent);
+}
+
+.rowTitle {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 500;
+}
+
+.rowMeta,
+.meta {
+ color: var(--muted-foreground);
+ font-size: 12px;
+}
+
+.detail {
+ display: grid;
+ gap: 12px;
+}
+
+.section {
+ display: grid;
+ gap: 6px;
+}
+
+.sectionTitle {
+ color: var(--muted-foreground);
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.description {
+ white-space: pre-wrap;
+}
+
+.actionsRow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ justify-content: flex-end;
+}
+
+.fieldGrid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 4px;
+ font-size: 13px;
+}
+
+.fieldLabel {
+ color: var(--muted-foreground);
+ margin-top: 8px;
+}
+
+.fieldLabel:first-child {
+ margin-top: 0;
+}
+
+.fieldValue {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+.link {
+ color: var(--link-color, #2563eb);
+ text-decoration: none;
+}
+
+.link:hover {
+ text-decoration: underline;
+}
+
+.review {
+ min-height: 100%;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ gap: 12px;
+}
+
+.reviewToolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ color: var(--foreground);
+ font-weight: 600;
+}
+
+.reviewToolbarTitle {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.reviewToolbarActions {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.reviewTotalsButton {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: var(--muted-foreground);
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 0;
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.reviewTotalsButton:hover {
+ color: var(--foreground);
+}
+
+.reviewContent {
+ min-height: 0;
+ display: grid;
+ grid-template-columns:
+ minmax(180px, var(--review-list-width, 520px))
+ 8px minmax(0, 1fr);
+ border-top: 1px solid var(--border);
+ overflow: hidden;
+}
+
+.reviewContentListOnly {
+ grid-template-columns: 1fr;
+}
+
+.reviewContentStacked {
+ grid-template-columns: 1fr;
+ grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
+}
+
+.reviewList {
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: auto;
+}
+
+.reviewListWithExpanded {
+ overflow: hidden;
+}
+
+.reviewContentListOnly .reviewList {
+ border-right: 0;
+}
+
+.reviewContentStacked .reviewList {
+ border-bottom: 1px solid var(--border);
+ max-height: none;
+}
+
+.reviewSplitHandle {
+ min-width: 8px;
+ cursor: col-resize;
+ position: relative;
+ touch-action: none;
+ border-left: 1px solid var(--border);
+}
+
+.reviewSplitHandle::after {
+ content: '';
+ position: absolute;
+ inset: 0 3px;
+ background: transparent;
+}
+
+.reviewItem {
+ min-width: 0;
+ flex: 0 0 auto;
+}
+
+.reviewItemExpanded {
+ min-height: 0;
+ flex: 1 1 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.reviewRow {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: var(--foreground);
+ font: inherit;
+ cursor: pointer;
+ width: 100%;
+ min-width: 0;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto auto;
+ gap: 8px;
+ align-items: center;
+ padding: 8px 10px;
+ text-align: left;
+}
+
+.reviewRow:hover {
+ background: var(--accent);
+}
+
+.fileIcon {
+ min-width: 24px;
+ height: 20px;
+ border-radius: 6px;
+ background: var(--accent);
+ color: var(--muted-foreground);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 4px;
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.reviewPath,
+.treeName {
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+.reviewPath {
+ display: inline-flex;
+ width: 100%;
+ max-width: 100%;
+}
+
+.treeName {
+ text-overflow: ellipsis;
+}
+
+.pathPrefix {
+ flex: 0 0 auto;
+ white-space: nowrap;
+ color: var(--muted-foreground);
+}
+
+.pathFileName {
+ flex: 0 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.lineStats {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 13px;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.additions {
+ color: #16a34a;
+}
+
+.deletions {
+ color: #dc2626;
+}
+
+.chevron {
+ color: var(--muted-foreground);
+ display: inline-flex;
+ transition: transform 120ms ease;
+}
+
+.chevronOpen {
+ transform: rotate(90deg);
+}
+
+.chevronIcon {
+ width: 14px;
+ height: 14px;
+ display: block;
+}
+
+.diffPreview {
+ border: 1px solid var(--border);
+ overflow: hidden;
+ background: var(--muted, rgba(0, 0, 0, 0.03));
+ min-height: 0;
+ flex: 1 1 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.codeMirrorDiff {
+ min-width: 0;
+ min-height: 0;
+ flex: 1 1 0;
+ overflow: auto;
+ background: var(--background);
+}
+
+.codeMirrorDiffWrap {
+ min-width: 0;
+ min-height: 0;
+ flex: 1 1 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.diffError {
+ color: var(--muted-foreground);
+ font-size: 12px;
+ padding: 8px 10px;
+ border-top: 1px solid var(--border);
+ background: var(--background);
+}
+
+.codeMirrorDiff :global(.cm-editor) {
+ font-size: 12px;
+ background: var(--background);
+ color: var(--foreground);
+}
+
+.codeMirrorDiff :global(.cm-mergeView) {
+ min-width: max-content;
+}
+
+.codeMirrorDiff :global(.cm-scroller) {
+ font-family:
+ ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
+ 'Courier New', monospace;
+}
+
+.codeMirrorDiff :global(.cm-gutters),
+.codeMirrorFile :global(.cm-gutters) {
+ background: var(--background);
+ border-right: 1px solid var(--border);
+ color: var(--muted-foreground);
+}
+
+.codeMirrorDiff :global(.cm-gutter),
+.codeMirrorFile :global(.cm-gutter),
+.codeMirrorDiff :global(.cm-lineNumbers .cm-gutterElement),
+.codeMirrorFile :global(.cm-lineNumbers .cm-gutterElement) {
+ background: transparent;
+ color: var(--muted-foreground);
+}
+
+.codeMirrorDiff :global(.cm-activeLineGutter),
+.codeMirrorFile :global(.cm-activeLineGutter) {
+ background: var(--accent);
+ color: var(--foreground);
+}
+
+.codeMirrorDiff :global(.cm-collapsedLines),
+.codeMirrorFile :global(.cm-collapsedLines) {
+ background: var(--muted);
+ border-color: var(--border);
+ color: var(--muted-foreground);
+}
+
+.codeMirrorDiff :global(.cm-collapsedLines:hover),
+.codeMirrorFile :global(.cm-collapsedLines:hover) {
+ background: var(--accent);
+ color: var(--foreground);
+}
+
+.codeMirrorDiff :global(.cm-mergeView .cm-editor) {
+ min-width: 0;
+}
+
+.codeMirrorDiff :global(.cm-line),
+.codeMirrorDiff :global(.cm-deletedLine) {
+ position: relative;
+ padding-left: 18px;
+}
+
+.codeMirrorDiff :global(.cm-line::before),
+.codeMirrorDiff :global(.cm-deletedLine::before) {
+ content: '';
+ position: absolute;
+ left: 4px;
+ font-weight: 700;
+}
+
+.codeMirrorDiff :global(.cm-merge-a .cm-line.cm-changedLine::before),
+.codeMirrorDiff :global(.cm-deletedLine::before) {
+ content: '-';
+ color: #dc2626;
+}
+
+.codeMirrorDiff :global(.cm-merge-b .cm-line.cm-changedLine::before) {
+ content: '+';
+ color: #16a34a;
+}
+
+.codeMirrorDiff :global(.cm-merge-b .cm-changedText) {
+ background: transparent;
+}
+
+.diffEmpty {
+ color: var(--muted-foreground);
+ font-size: 12px;
+ padding: 8px 10px;
+ border: 1px solid var(--border);
+}
+
+.tree {
+ min-width: 0;
+ overflow: auto;
+ padding: 8px 0;
+}
+
+.treeNode {
+ position: relative;
+}
+
+.treeChildren {
+ position: relative;
+}
+
+.treeChildren::before {
+ content: '';
+ position: absolute;
+ top: -2px;
+ bottom: 8px;
+ left: var(--tree-children-line-left);
+ border-left: 1px dashed var(--border);
+ pointer-events: none;
+}
+
+.treeRow {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ font: inherit;
+ text-align: left;
+ width: 100%;
+ min-width: 0;
+ height: 32px;
+ display: grid;
+ grid-template-columns: 18px minmax(0, 1fr) auto;
+ gap: 8px;
+ align-items: center;
+ padding-right: 10px;
+ color: var(--foreground);
+ position: relative;
+}
+
+.treeRow:hover {
+ background: var(--accent);
+ color: var(--foreground);
+}
+
+.treeRow[data-depth]:not([data-depth='0'])::before {
+ content: '';
+ position: absolute;
+ left: var(--tree-row-line-left);
+ top: 50%;
+ width: 9px;
+ border-top: 1px dashed var(--border);
+ pointer-events: none;
+}
+
+.treeFolder {
+ color: var(--foreground);
+}
+
+.treeFile {
+ color: var(--muted-foreground);
+}
+
+button.treeRow {
+ cursor: pointer;
+ color: var(--foreground);
+}
+
+.treeTwisty {
+ color: inherit;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 20px;
+ line-height: 1;
+}
+
+.treeChevronIcon {
+ width: 14px;
+ height: 14px;
+ display: block;
+}
+
+.treeChevron {
+ display: inline-flex;
+ transition: transform 120ms ease;
+}
+
+.treeChevronClosed {
+ transform: rotate(-90deg);
+}
+
+.treeContent {
+ min-width: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: inherit;
+}
+
+.treeRow .treeName {
+ color: inherit;
+}
+
+.treeFile:not(:hover) .treeName,
+.treeFile:not(:hover) .treeContent,
+.treeFile:not(:hover) .treeTwisty,
+.treeFile:not(:hover) .treeChevron {
+ color: var(--muted-foreground);
+}
+
+button.treeRow:hover {
+ color: var(--foreground);
+}
+
+.reviewBadge {
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 1px 6px;
+ color: var(--muted-foreground);
+ font-size: 11px;
+}
+
+.treeRow:hover .reviewBadge,
+.treeRow:hover .fileIcon {
+ color: var(--foreground);
+}
+
+.htmlPreviewWrap {
+ min-height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.htmlPreview {
+ width: 100%;
+ flex: 1 1 auto;
+ min-height: 520px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: #fff;
+ display: block;
+}
+
+.previewError {
+ margin-top: 8px;
+ color: var(--muted-foreground);
+ font-size: 12px;
+}
+
+.filePreviewWrap {
+ min-height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.codeMirrorFile {
+ min-width: 0;
+ flex: 1 1 auto;
+ min-height: 520px;
+ overflow: auto;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--background);
+}
+
+.codeMirrorFile :global(.cm-editor) {
+ min-height: 520px;
+ font-size: 12px;
+ background: var(--background);
+ color: var(--foreground);
+}
+
+.codeMirrorFile :global(.cm-scroller) {
+ font-family:
+ ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
+ 'Courier New', monospace;
+}
+
+@media (max-width: 900px) {
+ .panel {
+ position: fixed;
+ inset: env(safe-area-inset-top) 0 env(safe-area-inset-bottom) auto;
+ z-index: 60;
+ width: min(420px, 100vw);
+ max-width: 100vw;
+ box-shadow: -12px 0 30px rgba(0, 0, 0, 0.18);
+ }
+
+ .reviewContent {
+ grid-template-columns: 1fr !important;
+ }
+
+ .reviewSplitHandle {
+ display: none;
+ }
+
+ .reviewList {
+ border-right: 0;
+ border-bottom: 1px solid var(--border);
+ max-height: 220px;
+ }
+
+ .reviewContentListOnly .reviewList {
+ border-bottom: 0;
+ max-height: none;
+ }
+}
diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx
new file mode 100644
index 00000000000..a7d282515cc
--- /dev/null
+++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx
@@ -0,0 +1,1814 @@
+import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
+import {
+ useWorkspaceActions,
+ type DaemonWorkspaceActions,
+ type DaemonScheduledTask,
+} from '@qwen-code/webui/daemon-react-sdk';
+import { EditorState } from '@codemirror/state';
+import { basicSetup, EditorView } from 'codemirror';
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+ type CSSProperties,
+ type PointerEvent as ReactPointerEvent,
+} from 'react';
+import { useI18n } from '../../i18n';
+import { DialogShell } from '../dialogs/DialogShell';
+import { isSafeHref } from '../messages/Markdown';
+import {
+ buildCron,
+ describeCron,
+ parseCronToBuilder,
+ type BuilderState,
+ type Frequency,
+} from '../dialogs/scheduledTasksSchedule';
+import taskStyles from '../dialogs/ScheduledTasksDialog.module.css';
+import {
+ artifactKindLabel,
+ formatArtifactSize,
+ getArtifactLocation,
+ normalizePath,
+ withArtifactPreviewCsp,
+} from './artifactUtils';
+import {
+ displayPath,
+ type TurnOutputFileChange,
+ type TurnOutputFileDiff,
+ type TurnOutputScheduledTask,
+} from './TurnOutputs';
+import { LineStats, sumLineStats } from './LineStats';
+import styles from './ArtifactPanel.module.css';
+
+const MIN_PANEL_WIDTH_FOR_DEFAULT_TREE = 740;
+const MAX_REVIEW_SIDE_BY_SIDE_WIDTH = 700;
+const FREQUENCIES: Frequency[] = [
+ 'daily',
+ 'weekdays',
+ 'weekly',
+ 'hourly',
+ 'minutes',
+ 'custom',
+];
+const MINUTE_INTERVALS = [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30];
+
+export type ArtifactPanelTab =
+ | {
+ id: string;
+ kind: 'review';
+ title: string;
+ }
+ | {
+ id: string;
+ kind: 'artifact';
+ title: string;
+ artifactId: string;
+ workspaceActions?: DaemonWorkspaceActions;
+ previewContent?: string;
+ }
+ | {
+ id: string;
+ kind: 'scheduled_task';
+ title: string;
+ task: TurnOutputScheduledTask;
+ workspaceActions?: DaemonWorkspaceActions;
+ };
+
+interface ArtifactPanelProps {
+ artifacts: readonly DaemonSessionArtifact[];
+ tabs: readonly ArtifactPanelTab[];
+ activeTabId: string | null;
+ reviewChanges: readonly TurnOutputFileChange[];
+ selectedReviewPath: string | null;
+ panelWidth?: number;
+ workspaceCwd?: string;
+ loading?: boolean;
+ error?: string | null;
+ onSelectTab: (tabId: string) => void;
+ onCloseTab: (tabId: string) => void;
+ onClose: () => void;
+}
+
+export function ArtifactPanel({
+ artifacts,
+ tabs,
+ activeTabId,
+ reviewChanges,
+ selectedReviewPath,
+ panelWidth,
+ workspaceCwd,
+ loading,
+ error,
+ onSelectTab,
+ onCloseTab,
+ onClose,
+}: ArtifactPanelProps) {
+ const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0];
+ const defaultWorkspaceActions = useWorkspaceActions();
+ const activeWorkspaceActions =
+ activeTab?.kind === 'artifact' || activeTab?.kind === 'scheduled_task'
+ ? (activeTab.workspaceActions ?? defaultWorkspaceActions)
+ : defaultWorkspaceActions;
+
+ return (
+
+ );
+}
+
+function CloseIcon() {
+ return (
+
+ );
+}
+
+function TabReviewIcon() {
+ return (
+
+ );
+}
+
+function TabArtifactIcon() {
+ return (
+
+ );
+}
+
+function TabScheduledTaskIcon() {
+ return (
+
+ );
+}
+
+function ArtifactDetailTab({
+ artifacts,
+ artifactId,
+ workspaceActions,
+ previewContent,
+ loading,
+ error,
+}: {
+ artifacts: readonly DaemonSessionArtifact[];
+ artifactId: string;
+ workspaceActions: DaemonWorkspaceActions;
+ previewContent?: string;
+ loading?: boolean;
+ error?: string | null;
+}) {
+ const artifact = artifacts.find((item) => item.id === artifactId);
+ if (artifact) {
+ return (
+
+ );
+ }
+ if (loading) {
+ return Loading artifact...
;
+ }
+ if (error) {
+ return {error}
;
+ }
+ return Artifact not found.
;
+}
+
+function ScheduledTaskDetail({
+ task,
+ actions,
+}: {
+ task: TurnOutputScheduledTask;
+ actions: DaemonWorkspaceActions;
+}) {
+ const { t } = useI18n();
+ const [loadedTask, setLoadedTask] = useState(
+ null,
+ );
+ const [loading, setLoading] = useState(true);
+ const [loadError, setLoadError] = useState(null);
+ const [name, setName] = useState('');
+ const [prompt, setPrompt] = useState(task.prompt);
+ const [builder, setBuilder] = useState(() =>
+ parseCronToBuilder(task.cron),
+ );
+ const [showForm, setShowForm] = useState(false);
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [formError, setFormError] = useState(null);
+
+ const loadTask = useCallback(async () => {
+ if (!task.durable) {
+ setLoadedTask(null);
+ setName('');
+ setPrompt(task.prompt);
+ setBuilder(parseCronToBuilder(task.cron));
+ setLoadError(null);
+ setLoading(false);
+ return;
+ }
+ setLoading(true);
+ setLoadError(null);
+ try {
+ const tasks = await actions.listScheduledTasks();
+ const match = tasks.find((item) => item.id === task.id) ?? null;
+ setLoadedTask(match);
+ if (match) {
+ setName(match.name ?? '');
+ setPrompt(match.prompt);
+ setBuilder(parseCronToBuilder(match.cron));
+ } else {
+ setName('');
+ setPrompt(task.prompt);
+ setBuilder(parseCronToBuilder(task.cron));
+ }
+ } catch (err) {
+ setLoadError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setLoading(false);
+ }
+ }, [actions, task.cron, task.durable, task.id, task.prompt]);
+
+ useEffect(() => {
+ void loadTask();
+ }, [loadTask]);
+
+ const isSessionScoped = !task.durable;
+ const isDeleted = task.durable && !loading && !loadError && !loadedTask;
+ const canEdit = Boolean(loadedTask);
+ const detailTitle = loadedTask?.name || loadedTask?.prompt || task.title;
+ const detailPrompt = loadedTask?.prompt ?? task.prompt;
+ const detailCron = loadedTask?.cron ?? task.cron;
+ const detailRecurring = loadedTask?.recurring ?? task.recurring;
+ const detailEnabled = loadedTask?.enabled;
+
+ const openEdit = useCallback(() => {
+ if (!loadedTask) return;
+ setName(loadedTask.name ?? '');
+ setPrompt(loadedTask.prompt);
+ setBuilder(parseCronToBuilder(loadedTask.cron));
+ setFormError(null);
+ setShowForm(true);
+ }, [loadedTask]);
+
+ const closeEdit = useCallback(() => {
+ setShowForm(false);
+ setFormError(null);
+ if (!loadedTask) return;
+ setName(loadedTask.name ?? '');
+ setPrompt(loadedTask.prompt);
+ setBuilder(parseCronToBuilder(loadedTask.cron));
+ }, [loadedTask]);
+
+ const handleSave = useCallback(async () => {
+ if (!loadedTask) return;
+ const cron = buildCron(builder);
+ if (!cron) {
+ setFormError(t('scheduledTasks.error.invalidSchedule'));
+ return;
+ }
+ if (prompt.trim().length === 0) {
+ setFormError(t('scheduledTasks.error.emptyPrompt'));
+ return;
+ }
+ setSubmitting(true);
+ setFormError(null);
+ try {
+ const updated = await actions.updateScheduledTask(loadedTask.id, {
+ cron,
+ prompt: prompt.trim(),
+ name: name.trim() || null,
+ });
+ setLoadedTask(updated);
+ setName(updated.name ?? '');
+ setPrompt(updated.prompt);
+ setBuilder(parseCronToBuilder(updated.cron));
+ setShowForm(false);
+ } catch (err) {
+ setFormError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setSubmitting(false);
+ }
+ }, [actions, builder, loadedTask, name, prompt, t]);
+
+ const handleToggle = useCallback(async () => {
+ if (!loadedTask) return;
+ setBusy(true);
+ setFormError(null);
+ try {
+ const updated = await actions.updateScheduledTask(loadedTask.id, {
+ enabled: !loadedTask.enabled,
+ });
+ setLoadedTask(updated);
+ } catch (err) {
+ setFormError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setBusy(false);
+ }
+ }, [actions, loadedTask]);
+
+ const handleDelete = useCallback(async () => {
+ if (!loadedTask) return;
+ setBusy(true);
+ setFormError(null);
+ try {
+ await actions.deleteScheduledTask(loadedTask.id);
+ setLoadedTask(null);
+ setShowDeleteConfirm(false);
+ } catch (err) {
+ setFormError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setBusy(false);
+ }
+ }, [actions, loadedTask]);
+
+ const previewCron = buildCron(builder);
+ const previewLabel = previewCron ? describeCron(previewCron, t) : null;
+
+ return (
+
+ {loading && (
+
{t('scheduledTasks.loading')}
+ )}
+ {loadError &&
{loadError}
}
+ {isDeleted && (
+
+ {t('scheduledTasks.deletedSnapshot')}
+
+ )}
+ {isSessionScoped && (
+
+ {t('scheduledTasks.sessionScopedSnapshot')}
+
+ )}
+ {!isDeleted && (
+
+
+
+ {t('scheduledTasks.name')}
+
+ {detailTitle}
+
+ {t('scheduledTasks.taskId')}
+
+ {task.id}
+
+ {t('scheduledTasks.schedule')}
+
+
+ {describeCron(detailCron, t)}
+
+ Cron
+ {detailCron}
+
+ {t('scheduledTasks.type')}
+
+
+ {detailRecurring
+ ? t('scheduledTasks.repeats')
+ : t('scheduledTasks.runsOnce')}
+
+ {detailEnabled !== undefined && (
+ <>
+
+ {t('scheduledTasks.status')}
+
+
+ {detailEnabled
+ ? t('scheduledTasks.enable')
+ : t('scheduledTasks.disable')}
+
+ >
+ )}
+
+
+ )}
+
+ {!isDeleted && (
+
+
Prompt
+
{detailPrompt}
+
+ )}
+
+ {formError &&
{formError}
}
+
+
+
+
+
+
+
+ {showDeleteConfirm && loadedTask && (
+
setShowDeleteConfirm(false)}
+ >
+
+
+ {t('scheduledTasks.deleteConfirm', {
+ name: loadedTask.name || loadedTask.prompt,
+ })}
+
+ {formError && (
+
{formError}
+ )}
+
+
+
+
+
+
+ )}
+
+ {showForm && (
+
+
+
+
+
+
+
+
+
+ {(builder.frequency === 'daily' ||
+ builder.frequency === 'weekdays' ||
+ builder.frequency === 'weekly') && (
+
+ )}
+
+ {builder.frequency === 'weekly' && (
+
+ )}
+
+ {builder.frequency === 'minutes' && (
+
+ )}
+
+ {builder.frequency === 'custom' && (
+
+ )}
+
+
+
+ {previewLabel ? (
+ <>
+
+ {previewLabel}
+
+ {previewCron}
+ >
+ ) : (
+
+ {t('scheduledTasks.error.invalidSchedule')}
+
+ )}
+
+
+ {formError && (
+
{formError}
+ )}
+
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+function ReviewChanges({
+ changes,
+ selectedPath,
+ panelWidth,
+ workspaceCwd,
+}: {
+ changes: readonly TurnOutputFileChange[];
+ selectedPath: string | null;
+ panelWidth?: number;
+ workspaceCwd?: string;
+}) {
+ const { t } = useI18n();
+ const [isTreeOpen, setIsTreeOpen] = useState(
+ () => !panelWidth || panelWidth >= MIN_PANEL_WIDTH_FOR_DEFAULT_TREE,
+ );
+ const [isFileListOpen, setIsFileListOpen] = useState(true);
+ const [isReviewStacked, setIsReviewStacked] = useState(false);
+ const [reviewListWidth, setReviewListWidth] = useState(520);
+ const reviewListWidthRef = useRef(reviewListWidth);
+ const reviewContentRef = useRef(null);
+ const reviewResizeCleanupRef = useRef<(() => void) | null>(null);
+ const [expandedPath, setExpandedPath] = useState(null);
+ const showTree = isTreeOpen;
+ const fileTree = useMemo(
+ () => buildFileTree(changes, workspaceCwd),
+ [changes, workspaceCwd],
+ );
+
+ useEffect(() => {
+ setExpandedPath(selectedPath);
+ }, [selectedPath]);
+
+ useEffect(() => {
+ reviewListWidthRef.current = reviewListWidth;
+ }, [reviewListWidth]);
+
+ useEffect(() => {
+ const container = reviewContentRef.current;
+ if (!container) return;
+ const update = () => {
+ setIsReviewStacked(container.clientWidth < MAX_REVIEW_SIDE_BY_SIDE_WIDTH);
+ };
+ update();
+ const observer = new ResizeObserver(update);
+ observer.observe(container);
+ return () => observer.disconnect();
+ }, [isFileListOpen]);
+
+ useEffect(() => () => reviewResizeCleanupRef.current?.(), []);
+
+ const handleReviewSplitResizeStart = useCallback(
+ (event: ReactPointerEvent) => {
+ const container = reviewContentRef.current;
+ if (!container) return;
+ event.preventDefault();
+ const resizeHandle = event.currentTarget;
+ resizeHandle.setPointerCapture(event.pointerId);
+ const startX = event.clientX;
+ const startWidth = reviewListWidthRef.current;
+ const containerWidth = container.getBoundingClientRect().width;
+ const maxWidth = Math.max(180, containerWidth - 180);
+ const previousCursor = document.body.style.cursor;
+ const previousUserSelect = document.body.style.userSelect;
+ let pendingWidth = startWidth;
+ let animationFrame: number | null = null;
+
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+
+ const flushWidth = () => {
+ animationFrame = null;
+ setReviewListWidth(pendingWidth);
+ };
+
+ const handlePointerMove = (moveEvent: PointerEvent) => {
+ pendingWidth = Math.min(
+ maxWidth,
+ Math.max(180, startWidth + (moveEvent.clientX - startX)),
+ );
+ if (animationFrame === null) {
+ animationFrame = window.requestAnimationFrame(flushWidth);
+ }
+ };
+ let handlePointerUp: () => void = () => {};
+ const cleanupResize = (commitWidth: boolean) => {
+ reviewResizeCleanupRef.current = null;
+ if (animationFrame !== null) {
+ window.cancelAnimationFrame(animationFrame);
+ animationFrame = null;
+ }
+ if (commitWidth) setReviewListWidth(pendingWidth);
+ if (resizeHandle.hasPointerCapture(event.pointerId)) {
+ resizeHandle.releasePointerCapture(event.pointerId);
+ }
+ document.body.style.cursor = previousCursor;
+ document.body.style.userSelect = previousUserSelect;
+ window.removeEventListener('pointermove', handlePointerMove);
+ window.removeEventListener('pointerup', handlePointerUp);
+ window.removeEventListener('pointercancel', handlePointerUp);
+ };
+ handlePointerUp = () => cleanupResize(true);
+ reviewResizeCleanupRef.current = () => cleanupResize(false);
+ window.addEventListener('pointermove', handlePointerMove);
+ window.addEventListener('pointerup', handlePointerUp);
+ window.addEventListener('pointercancel', handlePointerUp);
+ },
+ [],
+ );
+ if (changes.length === 0) {
+ return No file changes to review.
;
+ }
+
+ const totals = sumLineStats(changes);
+ const toggleDiff = (path: string) => {
+ setExpandedPath((current) => (current === path ? null : path));
+ };
+
+ return (
+
+
+
+ {t('turnOutputs.previousTurn')}
+
+
+
+
+
+
+
+ {isFileListOpen && (
+
+
+ {changes.map((change) => {
+ const isExpanded = expandedPath === change.path;
+ return (
+
+
+ {isExpanded &&
}
+
+ );
+ })}
+
+ {showTree && !isReviewStacked && (
+
+ )}
+ {showTree && (
+
+ {fileTree.children.map((child) => (
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+}
+
+function DiffPreview({ change }: { change: TurnOutputFileChange }) {
+ if (change.diffs.length === 0) {
+ return No diff available.
;
+ }
+ const diffs = getDisplayDiffs(change.diffs);
+ return (
+
+ {diffs.map((diff, index) => (
+
+ ))}
+
+ );
+}
+
+function getDisplayDiffs(
+ diffs: readonly TurnOutputFileDiff[],
+): readonly TurnOutputFileDiff[] {
+ for (let index = diffs.length - 1; index >= 0; index--) {
+ const diff = diffs[index];
+ if (diff?.fullContent) return diffs.slice(index);
+ }
+ return diffs;
+}
+
+function CodeMirrorDiff({
+ oldText,
+ newText,
+}: {
+ oldText: string;
+ newText: string;
+}) {
+ const hostRef = useRef(null);
+ const [isWide, setIsWide] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const host = hostRef.current;
+ if (!host) return;
+ const update = () => setIsWide(host.clientWidth >= 720);
+ update();
+ const observer = new ResizeObserver(update);
+ observer.observe(host);
+ return () => observer.disconnect();
+ }, []);
+
+ useEffect(() => {
+ const host = hostRef.current;
+ if (!host || isWide === null) return;
+ host.replaceChildren();
+ setError(null);
+ let cancelled = false;
+ let view: { destroy(): void } | null = null;
+
+ const extensions = [
+ basicSetup,
+ EditorView.editable.of(false),
+ EditorState.readOnly.of(true),
+ EditorView.lineWrapping,
+ ];
+ const diffConfig = { scanLimit: 1_000, timeout: 500 };
+ const collapseUnchanged = { margin: 3, minSize: 8 };
+
+ void import('@codemirror/merge')
+ .then(({ MergeView, unifiedMergeView }) => {
+ if (cancelled) return;
+ try {
+ if (isWide) {
+ view = new MergeView({
+ a: { doc: oldText, extensions },
+ b: { doc: newText, extensions },
+ parent: host,
+ highlightChanges: true,
+ gutter: true,
+ revertControls: undefined,
+ collapseUnchanged,
+ diffConfig,
+ });
+ return;
+ }
+
+ view = new EditorView({
+ doc: newText,
+ extensions: [
+ ...extensions,
+ unifiedMergeView({
+ original: oldText,
+ highlightChanges: true,
+ gutter: true,
+ mergeControls: false,
+ allowInlineDiffs: true,
+ collapseUnchanged,
+ diffConfig,
+ }),
+ ],
+ parent: host,
+ });
+ } catch (err) {
+ if (!cancelled) {
+ setError(err instanceof Error ? err.message : String(err));
+ }
+ }
+ })
+ .catch((err: unknown) => {
+ if (!cancelled) {
+ setError(err instanceof Error ? err.message : String(err));
+ }
+ });
+ return () => {
+ cancelled = true;
+ view?.destroy();
+ };
+ }, [isWide, newText, oldText]);
+
+ return (
+
+
+ {error && (
+
Diff unavailable: {error}
+ )}
+
+ );
+}
+
+interface FileTreeNode {
+ name: string;
+ path: string;
+ file?: TurnOutputFileChange;
+ children: FileTreeNode[];
+}
+
+function TreeNode({
+ node,
+ depth,
+ selectedPath,
+}: {
+ node: FileTreeNode;
+ depth: number;
+ selectedPath: string | null;
+}) {
+ const isFile = Boolean(node.file);
+ const [isOpen, setIsOpen] = useState(true);
+ const rowClassName = [
+ styles.treeRow,
+ isFile ? styles.treeFile : styles.treeFolder,
+ ]
+ .filter(Boolean)
+ .join(' ');
+ const rowStyle = {
+ paddingLeft: 10 + depth * 18,
+ '--tree-row-line-left': `${19 + Math.max(0, depth - 1) * 18}px`,
+ } as CSSProperties;
+ const childrenStyle = {
+ '--tree-children-line-left': `${19 + depth * 18}px`,
+ } as CSSProperties;
+ const rowContent = (
+ <>
+
+ {!isFile && (
+
+
+
+ )}
+
+
+ {isFile && (
+
+ {fileExtensionLabel(node.path)}
+
+ )}
+ {node.name}
+
+ {node.file?.isArtifact && (
+ artifact
+ )}
+ >
+ );
+
+ return (
+
+ {isFile ? (
+
+ {rowContent}
+
+ ) : (
+
+ )}
+ {!isFile && isOpen && node.children.length > 0 && (
+
+ {node.children.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function PathText({ path, title }: { path: string; title?: string }) {
+ const ref = useRef(null);
+ const [display, setDisplay] = useState(() => splitReviewPath(path));
+ useLayoutEffect(() => {
+ const node = ref.current;
+ if (!node) return;
+ const update = () => setDisplay(compactReviewPath(path, node));
+ update();
+ const observer = new ResizeObserver(update);
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [path]);
+ return (
+
+ {display.prefix && (
+ {display.prefix}
+ )}
+ {display.leaf}
+
+ );
+}
+
+function splitReviewPath(path: string) {
+ const slashIndex = path.lastIndexOf('/');
+ return slashIndex < 0
+ ? { prefix: '', leaf: path }
+ : {
+ prefix: path.slice(0, slashIndex + 1),
+ leaf: path.slice(slashIndex + 1),
+ };
+}
+
+let measureCanvas: HTMLCanvasElement | null = null;
+
+function compactReviewPath(path: string, container: HTMLElement) {
+ const full = splitReviewPath(path);
+ const width = container.clientWidth;
+ if (width <= 0) return full;
+ const measure = createTextMeasurer(container);
+ if (measure(path) <= width) return full;
+ const parts = path.split('/').filter(Boolean);
+ const leaf = parts.at(-1) ?? path;
+ const fileWidth = measure(leaf);
+ if (parts.length <= 1 || fileWidth + measure('.../') > width) {
+ return { prefix: '', leaf };
+ }
+ let prefix = '.../';
+ for (let dirCount = 1; dirCount < parts.length; dirCount++) {
+ const dirs = parts.slice(parts.length - 1 - dirCount, -1);
+ const candidate = `.../${dirs.join('/')}/`;
+ if (measure(candidate) + fileWidth > width) break;
+ prefix = candidate;
+ }
+ return { prefix, leaf };
+}
+
+function createTextMeasurer(element: HTMLElement) {
+ measureCanvas ??= document.createElement('canvas');
+ const context = measureCanvas.getContext('2d');
+ const style = window.getComputedStyle(element);
+ if (context) {
+ context.font = [
+ style.fontStyle,
+ style.fontVariant,
+ style.fontWeight,
+ style.fontSize,
+ style.fontFamily,
+ ].join(' ');
+ }
+ return (text: string) => context?.measureText(text).width ?? text.length * 8;
+}
+
+function FolderIcon() {
+ return (
+
+ );
+}
+
+function FolderOpenIcon() {
+ return (
+
+ );
+}
+
+function ChevronIcon() {
+ return (
+
+ );
+}
+
+function TreeChevronIcon() {
+ return (
+
+ );
+}
+
+function buildFileTree(
+ changes: readonly TurnOutputFileChange[],
+ workspaceCwd?: string,
+): FileTreeNode {
+ const root: FileTreeNode = { name: '', path: '', children: [] };
+ for (const change of changes) {
+ const parts = displayPath(change.path, workspaceCwd)
+ .split('/')
+ .filter(Boolean);
+ let current = root;
+ for (let index = 0; index < parts.length; index++) {
+ const part = parts[index]!;
+ const path = parts.slice(0, index + 1).join('/');
+ let child = current.children.find((node) => node.name === part);
+ if (!child) {
+ child = { name: part, path, children: [] };
+ current.children.push(child);
+ }
+ if (index === parts.length - 1) child.file = change;
+ current = child;
+ }
+ }
+ sortTree(root);
+ return root;
+}
+
+function sortTree(node: FileTreeNode) {
+ node.children.sort((left, right) => {
+ if (Boolean(left.file) !== Boolean(right.file)) return left.file ? 1 : -1;
+ return left.name.localeCompare(right.name);
+ });
+ for (const child of node.children) sortTree(child);
+}
+
+function fileName(value: string) {
+ const parts = normalizePath(value).split('/').filter(Boolean);
+ return parts.at(-1) ?? value;
+}
+
+function fileExtensionLabel(value: string) {
+ const name = fileName(value);
+ const extension = name.includes('.')
+ ? name.split('.').pop()?.toLowerCase()
+ : '';
+ if (!extension) return 'FILE';
+ const labels: Record = {
+ css: 'CSS',
+ html: 'HTML',
+ js: 'JS',
+ json: 'JSON',
+ jsx: 'JSX',
+ md: 'MD',
+ ts: 'TS',
+ tsx: 'TSX',
+ };
+ return labels[extension] ?? extension.slice(0, 3).toUpperCase();
+}
+
+function ArtifactDetail({
+ artifact,
+ workspaceActions,
+ previewContent,
+}: {
+ artifact: DaemonSessionArtifact;
+ workspaceActions: DaemonWorkspaceActions;
+ previewContent?: string;
+}) {
+ const location = getArtifactLocation(artifact);
+ const safeUrl = isSafeHref(artifact.url) ? artifact.url : undefined;
+ const isAutomationSnapshot =
+ artifact.metadata?.['artifactType'] === 'automation_snapshot';
+ const canPreviewWorkspaceFile =
+ artifact.storage === 'workspace' && Boolean(artifact.workspacePath);
+ const canPreviewHtml =
+ canPreviewWorkspaceFile &&
+ artifact.workspacePath &&
+ isHtmlArtifact(artifact);
+
+ if (canPreviewHtml && artifact.workspacePath) {
+ return (
+
+ );
+ }
+
+ if (canPreviewWorkspaceFile && artifact.workspacePath) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ {isAutomationSnapshot ? 'Automation Snapshot' : 'Artifact'}
+
+
+
+
+
+
+
+
+
+ {artifact.toolName && (
+
+ )}
+ {artifact.toolCallId && (
+
+ )}
+
+
+
+ {artifact.description && (
+
+
Description
+
{artifact.description}
+
+ )}
+
+ {isAutomationSnapshot && artifact.metadata && (
+
+
Details
+
+ {metadataField(artifact.metadata, 'automationId', 'Automation ID')}
+ {metadataField(artifact.metadata, 'schedule', 'Schedule')}
+ {metadataField(artifact.metadata, 'timezone', 'Timezone')}
+ {metadataField(artifact.metadata, 'status', 'Status')}
+ {metadataField(artifact.metadata, 'nextRunAt', 'Next run')}
+ {metadataField(artifact.metadata, 'prompt', 'Prompt')}
+
+
+ )}
+
+ {(location || safeUrl) && (
+
+
Location
+ {safeUrl ? (
+
+ {safeUrl}
+
+ ) : (
+
{location}
+ )}
+
+ )}
+
+ );
+}
+
+function isHtmlArtifact(artifact: DaemonSessionArtifact) {
+ const path = artifact.workspacePath?.toLowerCase() ?? '';
+ return (
+ artifact.kind === 'html' || path.endsWith('.html') || path.endsWith('.htm')
+ );
+}
+
+function HtmlArtifactPreview({
+ workspacePath,
+ artifactVersion,
+ workspaceActions,
+ previewContent,
+}: {
+ workspacePath: string;
+ artifactVersion?: string;
+ workspaceActions: DaemonWorkspaceActions;
+ previewContent?: string;
+}) {
+ const [html, setHtml] = useState(previewContent ?? null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ setHtml(previewContent ?? null);
+ setError(null);
+ workspaceActions
+ .readWorkspaceFile(workspacePath)
+ .then((file) => {
+ if (cancelled) return;
+ setHtml(file.content);
+ if (file.truncated) {
+ setError('Preview is truncated because the file is too large.');
+ }
+ })
+ .catch((err: unknown) => {
+ if (cancelled) return;
+ setError(err instanceof Error ? err.message : String(err));
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [artifactVersion, previewContent, workspaceActions, workspacePath]);
+
+ return (
+
+ {html === null ? (
+
Loading preview...
+ ) : (
+
+ )}
+ {error &&
{error}
}
+
+ );
+}
+
+function FileArtifactPreview({
+ workspacePath,
+ artifactVersion,
+ workspaceActions,
+}: {
+ workspacePath: string;
+ artifactVersion?: string;
+ workspaceActions: DaemonWorkspaceActions;
+}) {
+ const hostRef = useRef(null);
+ const [content, setContent] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ setContent(null);
+ setError(null);
+ workspaceActions
+ .readWorkspaceFile(workspacePath)
+ .then((file) => {
+ if (cancelled) return;
+ setContent(file.content);
+ if (file.truncated) {
+ setError('File is truncated because it is too large.');
+ }
+ })
+ .catch((err: unknown) => {
+ if (cancelled) return;
+ setError(err instanceof Error ? err.message : String(err));
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [artifactVersion, workspaceActions, workspacePath]);
+
+ useEffect(() => {
+ const host = hostRef.current;
+ if (!host || content === null) return;
+ host.replaceChildren();
+ let view: EditorView;
+ try {
+ view = new EditorView({
+ doc: content,
+ extensions: [
+ basicSetup,
+ EditorView.editable.of(false),
+ EditorState.readOnly.of(true),
+ EditorView.lineWrapping,
+ ],
+ parent: host,
+ });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ return undefined;
+ }
+ return () => view.destroy();
+ }, [content]);
+
+ return (
+
+ {content === null ? (
+
Loading file...
+ ) : (
+
+ )}
+ {error &&
{error}
}
+
+ );
+}
+
+function Field({ label, value }: { label: string; value?: string }) {
+ if (!value) return null;
+ return (
+ <>
+ {label}
+ {value}
+ >
+ );
+}
+
+function metadataField(
+ metadata: NonNullable,
+ key: string,
+ label: string,
+) {
+ const value = metadata[key];
+ if (value === undefined || value === null || value === '') return null;
+ return ;
+}
diff --git a/packages/web-shell/client/components/artifacts/LineStats.tsx b/packages/web-shell/client/components/artifacts/LineStats.tsx
new file mode 100644
index 00000000000..3ef87743ae0
--- /dev/null
+++ b/packages/web-shell/client/components/artifacts/LineStats.tsx
@@ -0,0 +1,41 @@
+import type { TurnOutputFileChange } from './TurnOutputs';
+
+export function LineStats({
+ additions,
+ deletions,
+ className,
+ additionsClassName,
+ deletionsClassName,
+}: {
+ additions: number | undefined;
+ deletions: number | undefined;
+ className: string;
+ additionsClassName: string;
+ deletionsClassName: string;
+}) {
+ if (additions === undefined || deletions === undefined) return null;
+ return (
+
+ +{additions}
+ -{deletions}
+
+ );
+}
+
+export function sumLineStats(changes: readonly TurnOutputFileChange[]) {
+ if (
+ changes.some(
+ (change) =>
+ change.additions === undefined || change.deletions === undefined,
+ )
+ ) {
+ return undefined;
+ }
+ return changes.reduce(
+ (sum, change) => ({
+ additions: sum.additions + (change.additions ?? 0),
+ deletions: sum.deletions + (change.deletions ?? 0),
+ }),
+ { additions: 0, deletions: 0 },
+ );
+}
diff --git a/packages/web-shell/client/components/artifacts/TurnOutputs.module.css b/packages/web-shell/client/components/artifacts/TurnOutputs.module.css
new file mode 100644
index 00000000000..e6be8cc14dc
--- /dev/null
+++ b/packages/web-shell/client/components/artifacts/TurnOutputs.module.css
@@ -0,0 +1,182 @@
+.root {
+ margin: 8px 0 12px;
+ display: grid;
+ gap: 8px;
+}
+
+.card {
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--background);
+ overflow: hidden;
+}
+
+.summary {
+ display: grid;
+ grid-template-columns: 34px minmax(0, 1fr) auto;
+ gap: 12px;
+ align-items: center;
+ padding: 12px;
+}
+
+.icon {
+ width: 34px;
+ height: 34px;
+ border-radius: 6px;
+ background: var(--accent);
+ color: var(--muted-foreground);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.iconSvg {
+ width: 24px;
+ height: 24px;
+ display: block;
+}
+
+.title {
+ min-width: 0;
+ color: var(--foreground);
+ font-size: 14px;
+ font-weight: 400;
+}
+
+.linkButton,
+.reviewButton,
+.showMoreButton {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: var(--foreground);
+ font: inherit;
+ cursor: pointer;
+}
+
+.linkButton {
+ margin-top: 2px;
+ margin-left: 8px;
+ padding: 0;
+ color: var(--muted-foreground);
+ font-size: 13px;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 120ms ease;
+}
+
+.card:hover .linkButton,
+.linkButton:focus-visible {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.linkButton:hover,
+.showMoreButton:hover {
+ color: var(--foreground);
+}
+
+.actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.reviewButton {
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 4px 10px;
+ font-size: 14px;
+ font-weight: 400;
+}
+
+.reviewButton:hover {
+ background: var(--accent);
+}
+
+.list {
+ display: grid;
+ gap: 0;
+ border-top: 1px solid var(--border);
+ padding: 0;
+}
+
+.fileRow {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: var(--foreground);
+ font: inherit;
+ cursor: pointer;
+ min-width: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 10px;
+ align-items: center;
+ padding: 7px 14px;
+ text-align: left;
+}
+
+.fileRow:hover {
+ background: var(--accent);
+}
+
+.path {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--foreground);
+ font-size: 13px;
+}
+
+.lineStats {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 13px;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.additions {
+ color: #16a34a;
+}
+
+.deletions {
+ color: #dc2626;
+}
+
+.showMoreButton {
+ justify-self: start;
+ padding: 7px 14px;
+ color: var(--foreground);
+ font-weight: 400;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.chevronIcon {
+ width: 14px;
+ height: 14px;
+ display: block;
+ color: var(--muted-foreground);
+ transition: transform 120ms ease;
+}
+
+.chevronIconOpen {
+ transform: rotate(180deg);
+}
+
+.artifactInfo {
+ min-width: 0;
+ display: grid;
+ gap: 2px;
+}
+
+.artifactMeta {
+ color: var(--muted-foreground);
+ font-size: 13px;
+ white-space: nowrap;
+}
diff --git a/packages/web-shell/client/components/artifacts/TurnOutputs.test.ts b/packages/web-shell/client/components/artifacts/TurnOutputs.test.ts
new file mode 100644
index 00000000000..eef8141f759
--- /dev/null
+++ b/packages/web-shell/client/components/artifacts/TurnOutputs.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest';
+import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
+import {
+ getArtifactPreviewContent,
+ type TurnOutputFileChange,
+} from './TurnOutputs';
+
+describe('TurnOutputs helpers', () => {
+ it('uses workspace cwd when matching artifact preview content', () => {
+ const artifact = {
+ id: 'artifact-1',
+ kind: 'html',
+ workspacePath: 'reports/summary.html',
+ } as DaemonSessionArtifact;
+ const changes: TurnOutputFileChange[] = [
+ {
+ path: '/workspace/project/reports/summary.html',
+ status: 'modified',
+ toolCallId: 'tool-1',
+ isArtifact: true,
+ diffs: [
+ {
+ oldText: 'old',
+ newText: 'new',
+ fullContent: true,
+ },
+ ],
+ },
+ ];
+
+ expect(
+ getArtifactPreviewContent(artifact, changes, '/workspace/project'),
+ ).toBe('new');
+ });
+});
diff --git a/packages/web-shell/client/components/artifacts/TurnOutputs.tsx b/packages/web-shell/client/components/artifacts/TurnOutputs.tsx
new file mode 100644
index 00000000000..36b0ea920b8
--- /dev/null
+++ b/packages/web-shell/client/components/artifacts/TurnOutputs.tsx
@@ -0,0 +1,473 @@
+import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
+import type { DaemonWorkspaceActions } from '@qwen-code/webui/daemon-react-sdk';
+import { memo, useState } from 'react';
+import { useI18n } from '../../i18n';
+import { describeCron } from '../dialogs/scheduledTasksSchedule';
+import {
+ artifactKindLabel,
+ formatArtifactSize,
+ isSamePath,
+ stripWorkspacePath,
+} from './artifactUtils';
+import { LineStats, sumLineStats } from './LineStats';
+import styles from './TurnOutputs.module.css';
+
+export interface TurnOutputFileChange {
+ path: string;
+ status: 'created' | 'modified';
+ toolCallId: string;
+ isArtifact: boolean;
+ additions?: number;
+ deletions?: number;
+ diffs: TurnOutputFileDiff[];
+}
+
+export interface TurnOutputFileDiff {
+ oldText: string;
+ newText: string;
+ fileDiff?: string;
+ fullContent?: boolean;
+}
+
+export interface TurnOutputScheduledTask {
+ id: string;
+ toolCallId: string;
+ title: string;
+ cron: string;
+ prompt: string;
+ recurring: boolean;
+ durable: boolean;
+ display?: string;
+}
+
+export type TurnOutputKind = 'file' | 'artifact' | 'scheduled_task';
+
+export const TURN_OUTPUT_KINDS: readonly TurnOutputKind[] = [
+ 'file',
+ 'artifact',
+ 'scheduled_task',
+];
+
+export type TurnOutputOpenRequest =
+ | {
+ id: 'review';
+ kind: 'review';
+ title: string;
+ turnId: string;
+ changes: readonly TurnOutputFileChange[];
+ selectedPath?: string;
+ }
+ | {
+ id: string;
+ kind: 'artifact';
+ title: string;
+ turnId: string;
+ artifactId: string;
+ artifact: DaemonSessionArtifact;
+ workspaceActions?: DaemonWorkspaceActions;
+ previewContent?: string;
+ }
+ | {
+ id: string;
+ kind: 'scheduled_task';
+ title: string;
+ turnId: string;
+ task: TurnOutputScheduledTask;
+ workspaceActions?: DaemonWorkspaceActions;
+ };
+
+interface TurnOutputsProps {
+ turnId: string;
+ changes: readonly TurnOutputFileChange[];
+ artifacts: readonly DaemonSessionArtifact[];
+ scheduledTasks: readonly TurnOutputScheduledTask[];
+ workspaceCwd?: string;
+ onOpenRequest?: (request: TurnOutputOpenRequest) => void;
+ onReviewChanges: (
+ changes: readonly TurnOutputFileChange[],
+ selectedPath?: string,
+ ) => void;
+ onOpenArtifact: (artifactId: string, previewContent?: string) => void;
+ onOpenScheduledTask: (task: TurnOutputScheduledTask) => void;
+}
+
+function TurnOutputsComponent({
+ turnId,
+ changes,
+ artifacts,
+ scheduledTasks,
+ workspaceCwd,
+ onOpenRequest,
+ onReviewChanges,
+ onOpenArtifact,
+ onOpenScheduledTask,
+}: TurnOutputsProps) {
+ const { t } = useI18n();
+ const [showAllChanges, setShowAllChanges] = useState(false);
+ if (
+ changes.length === 0 &&
+ artifacts.length === 0 &&
+ scheduledTasks.length === 0
+ ) {
+ return null;
+ }
+ const visibleChanges = showAllChanges ? changes : changes.slice(0, 3);
+ const remainingChanges = changes.length - 3;
+ const totals = sumLineStats(changes);
+ const openReview = (selectedPath?: string) => {
+ if (onOpenRequest) {
+ onOpenRequest({
+ id: 'review',
+ kind: 'review',
+ title: t('turnOutputs.review'),
+ turnId,
+ changes,
+ ...(selectedPath ? { selectedPath } : {}),
+ });
+ return;
+ }
+ onReviewChanges(changes, selectedPath);
+ };
+ const openArtifact = (artifact: DaemonSessionArtifact) => {
+ const previewContent = getArtifactPreviewContent(
+ artifact,
+ changes,
+ workspaceCwd,
+ );
+ if (onOpenRequest) {
+ onOpenRequest({
+ id: `artifact:${artifact.id}`,
+ kind: 'artifact',
+ title: artifact.title ?? 'Artifact',
+ turnId,
+ artifactId: artifact.id,
+ artifact,
+ ...(previewContent !== undefined ? { previewContent } : {}),
+ });
+ return;
+ }
+ onOpenArtifact(artifact.id, previewContent);
+ };
+ const openScheduledTask = (task: TurnOutputScheduledTask) => {
+ if (onOpenRequest) {
+ onOpenRequest({
+ id: `scheduled-task:${task.toolCallId}`,
+ kind: 'scheduled_task',
+ title: t('scheduledTasks.title'),
+ turnId,
+ task,
+ });
+ return;
+ }
+ onOpenScheduledTask(task);
+ };
+
+ return (
+
+ {changes.length > 0 && (
+
+
+
+
+
+
+
+ {t('turnOutputs.filesEdited', { count: changes.length })}
+
+
+
+
+
+
+
+
+
+
+ {visibleChanges.map((change) => (
+
+ ))}
+ {remainingChanges > 0 && (
+
+ )}
+
+
+ )}
+
+ {artifacts.map((artifact) => (
+
openArtifact(artifact)}
+ />
+ ))}
+
+ {scheduledTasks.map((task) => (
+ openScheduledTask(task)}
+ />
+ ))}
+
+ );
+}
+
+function ArtifactCard({
+ artifact,
+ onOpen,
+}: {
+ artifact: DaemonSessionArtifact;
+ onOpen: () => void;
+}) {
+ const { t } = useI18n();
+ const size = formatArtifactSize(artifact.sizeBytes);
+ return (
+
+
+
+
+
+
+
{artifact.title}
+
+ {[artifactKindLabel(artifact.kind), size]
+ .filter(Boolean)
+ .join(' · ')}
+
+
+
+
+
+
+
+ );
+}
+
+function ScheduledTaskCard({
+ task,
+ scheduleLabel,
+ onOpen,
+}: {
+ task: TurnOutputScheduledTask;
+ scheduleLabel: string;
+ onOpen: () => void;
+}) {
+ const { t } = useI18n();
+ return (
+
+
+
+
+
+
+
{task.title}
+
+ {[
+ scheduleLabel,
+ task.recurring
+ ? t('scheduledTasks.repeats')
+ : t('scheduledTasks.runsOnce'),
+ ]
+ .filter(Boolean)
+ .join(' · ')}
+
+
+
+
+
+
+
+ );
+}
+
+function DocumentIcon() {
+ return (
+
+ );
+}
+
+function ClockIcon() {
+ return (
+
+ );
+}
+
+function ChevronIcon({ open }: { open: boolean }) {
+ return (
+
+ );
+}
+
+export const TurnOutputs = memo(TurnOutputsComponent);
+
+export function getArtifactPreviewContent(
+ artifact: DaemonSessionArtifact,
+ changes: readonly TurnOutputFileChange[],
+ workspaceCwd?: string,
+) {
+ if (artifact.kind !== 'html' || !artifact.workspacePath) return undefined;
+ const change = changes.find((item) =>
+ isSamePath(item.path, artifact.workspacePath, workspaceCwd),
+ );
+ if (!change) return undefined;
+ for (let index = change.diffs.length - 1; index >= 0; index--) {
+ const diff = change.diffs[index];
+ if (diff?.fullContent) return diff.newText;
+ }
+ return undefined;
+}
+
+export function displayPath(path: string, workspaceCwd?: string) {
+ return stripWorkspacePath(path, workspaceCwd);
+}
diff --git a/packages/web-shell/client/components/artifacts/artifactUtils.test.ts b/packages/web-shell/client/components/artifacts/artifactUtils.test.ts
new file mode 100644
index 00000000000..d262d06fa34
--- /dev/null
+++ b/packages/web-shell/client/components/artifacts/artifactUtils.test.ts
@@ -0,0 +1,58 @@
+// @vitest-environment jsdom
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { normalizePath, withArtifactPreviewCsp } from './artifactUtils';
+
+describe('artifactUtils', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('resolves parent path segments', () => {
+ expect(normalizePath('src/foo/../bar.ts')).toBe('src/bar.ts');
+ expect(normalizePath('/workspace/app/../app/src/./main.ts')).toBe(
+ '/workspace/app/src/main.ts',
+ );
+ expect(normalizePath('../outside/file.ts')).toBe('../outside/file.ts');
+ });
+
+ it('injects preview CSP and strips unsafe metadata', () => {
+ const output = withArtifactPreviewCsp(`
+
+
+
+
+
+
+
+ Hello
+
+
+ `);
+
+ expect(output).toContain('Content-Security-Policy');
+ expect(output).toContain("default-src 'none'");
+ expect(output).not.toContain('report-uri');
+ expect(output).not.toMatch(/http-equiv=["']?refresh/i);
+ expect(output).not.toMatch(/