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 && ( + +
+ + +