From fb2d9e312ad23d8d129df0e04cb6163746ebff4f Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Wed, 20 May 2026 11:35:53 +0800 Subject: [PATCH] fix image and html preview (#1050) --- src/components/file-preview/ImageViewer.tsx | 103 ++++++++++++++++-- .../file-preview/WorkspaceBrowserBody.tsx | 12 ++ src/pages/Chat/index.tsx | 67 +++++++++--- src/pages/Chat/task-visualization.ts | 28 +++++ src/stores/chat.ts | 20 ++-- src/stores/gateway.ts | 27 ++++- .../chat-history-reply-while-sending.test.tsx | 91 ++++++++++++++++ tests/unit/image-viewer.test.tsx | 57 ++++++++++ tests/unit/run-segment-indices.test.ts | 56 +++++++++- tests/unit/workspace-browser-body.test.tsx | 81 ++++++++++++++ 10 files changed, 499 insertions(+), 43 deletions(-) create mode 100644 tests/unit/chat-history-reply-while-sending.test.tsx create mode 100644 tests/unit/image-viewer.test.tsx create mode 100644 tests/unit/workspace-browser-body.test.tsx diff --git a/src/components/file-preview/ImageViewer.tsx b/src/components/file-preview/ImageViewer.tsx index 305d23b..f46e65e 100644 --- a/src/components/file-preview/ImageViewer.tsx +++ b/src/components/file-preview/ImageViewer.tsx @@ -1,30 +1,110 @@ /** * Read-only image viewer with fit-to-window + click-to-zoom toggle. * - * Renders the image directly off the disk via `file://` so we don't need - * to base64-encode it through IPC. clawx's renderer already loads via - * file:// in production so the protocol is allowlisted. + * Image bytes are loaded through the sandboxed `file:readBinary` IPC channel + * and exposed via a Blob URL. Direct `file://` src values fail in dev (Vite + * serves the renderer over http://) and are unreliable across platforms. */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { ZoomIn, ZoomOut } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; +import { LoadingSpinner } from '@/components/common/LoadingSpinner'; +import { readBinaryFile } from '@/lib/api-client'; import { cn } from '@/lib/utils'; +const IMAGE_MAX_BYTES = 50 * 1024 * 1024; + export interface ImageViewerProps { filePath: string; fileName: string; className?: string; } -function toFileUrl(path: string): string { - const norm = path.replace(/\\/g, '/'); - if (norm.startsWith('file://')) return norm; - if (norm.startsWith('/')) return `file://${norm}`; - return `file:///${norm}`; -} +type LoadState = + | { filePath: string; status: 'loading' } + | { filePath: string; status: 'tooLarge'; size?: number } + | { filePath: string; status: 'error'; message: string } + | { filePath: string; status: 'ready'; url: string }; export default function ImageViewer({ filePath, fileName, className }: ImageViewerProps) { + const { t } = useTranslation('chat'); const [zoomed, setZoomed] = useState(false); + const [state, setState] = useState({ filePath, status: 'loading' }); + const currentState: LoadState = state.filePath === filePath + ? state + : { filePath, status: 'loading' }; + + useEffect(() => { + let cancelled = false; + let objectUrl: string | null = null; + + void (async () => { + try { + const res = await readBinaryFile(filePath, { maxBytes: IMAGE_MAX_BYTES }); + if (cancelled) return; + if (!res.ok || !res.data) { + if (res.error === 'tooLarge') { + setState({ filePath, status: 'tooLarge', size: res.size }); + return; + } + setState({ filePath, status: 'error', message: String(res.error ?? 'unknown') }); + return; + } + const cloned = new Uint8Array(res.data.byteLength); + cloned.set(res.data); + objectUrl = URL.createObjectURL(new Blob([cloned], { type: res.mimeType || 'image/png' })); + if (cancelled) { + URL.revokeObjectURL(objectUrl); + return; + } + setState({ filePath, status: 'ready', url: objectUrl }); + } catch (err) { + if (cancelled) return; + setState({ + filePath, + status: 'error', + message: err instanceof Error ? err.message : String(err), + }); + } + })(); + + return () => { + cancelled = true; + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } + }; + }, [filePath]); + + if (currentState.status === 'loading') { + return ( +
+ +
+ ); + } + + if (currentState.status === 'tooLarge') { + return ( +
+ {t('filePreview.errors.tooLarge', 'File too large; preview disabled')} +
+ ); + } + + if (currentState.status === 'error') { + return ( +
+

+ {t('filePreview.image.loadFailed', { + defaultValue: 'Image failed to load: {{error}}', + error: currentState.message, + })} +

+
+ ); + } return (
@@ -41,8 +121,9 @@ export default function ImageViewer({ filePath, fileName, className }: ImageView
{fileName} import('./MonacoViewer')); @@ -437,6 +439,16 @@ export function WorkspaceBrowserBody({ ); } + if (isHtmlPreviewExt(selectedNode.ext)) { + return ( + + ); + } + if (selectedNode.contentType === 'document') { return (
diff --git a/src/pages/Chat/index.tsx b/src/pages/Chat/index.tsx index 62501e7..dc31596 100644 --- a/src/pages/Chat/index.tsx +++ b/src/pages/Chat/index.tsx @@ -19,7 +19,16 @@ import { ChatInput } from './ChatInput'; import { ExecutionGraphCard } from './ExecutionGraphCard'; import { ChatToolbar } from './ChatToolbar'; import { extractImages, extractText, extractThinking, extractToolUse, normalizeMessageRole, stripProcessMessagePrefix } from './message-utils'; -import { buildRunSegmentMessageIndices, deriveTaskSteps, findReplyMessageIndex, getRunSegmentMessages, parseSubagentCompletionInfo, type TaskStep } from './task-visualization'; +import { + buildRunSegmentMessageIndices, + deriveTaskSteps, + findReplyMessageIndex, + getPostTriggerSegmentMessages, + getRunSegmentMessages, + hasActiveStreamingReplyInRun, + parseSubagentCompletionInfo, + type TaskStep, +} from './task-visualization'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { useStickToBottomInstant } from '@/hooks/use-stick-to-bottom-instant'; @@ -361,6 +370,10 @@ export function Chat() { : `${currentSessionKey}:trigger-${idx}`; const nextUserIndex = nextUserMessageIndexes[idx]; const segmentEnd = nextUserIndex === -1 ? messages.length : nextUserIndex; + // Orphans from paginated history are folded into the graph only — they must + // not participate in run lifecycle (hasFinalReply / replyIndex) or a prior + // turn's assistant reply is mistaken for the current run's answer (#1048). + const postTriggerMessages = getPostTriggerSegmentMessages(messages, idx, nextUserIndex); const segmentMessages = getRunSegmentMessages(messages, idx, nextUserIndex, isRunTrigger); const completionInfos = subagentCompletionInfos .slice(idx + 1, segmentEnd) @@ -371,7 +384,7 @@ export function Chat() { // - segment has tool calls but no pure-text final reply yet (server-side // tool execution — Gateway fires phase "end" per tool round which // briefly clears sending, but the run is still in progress) - const hasToolActivity = segmentMessages.some((m) => + const hasToolActivity = postTriggerMessages.some((m) => m.role === 'assistant' && extractToolUse(m).length > 0, ); // Locate the last tool-use message so we only count text messages that @@ -381,14 +394,14 @@ export function Chat() { // flips to false between tool rounds, collapsing the trailing // "Thinking..." indicator during the brief gap before the next stream chunk. let lastToolUseOffset = -1; - for (let i = segmentMessages.length - 1; i >= 0; i -= 1) { - const m = segmentMessages[i]; + for (let i = postTriggerMessages.length - 1; i >= 0; i -= 1) { + const m = postTriggerMessages[i]; if (m.role === 'assistant' && extractToolUse(m).length > 0) { lastToolUseOffset = i; break; } } - const hasFinalReply = segmentMessages.some((m, i) => { + const hasFinalReply = postTriggerMessages.some((m, i) => { if (i <= lastToolUseOffset) return false; if (m.role !== 'assistant') return false; if (extractText(m).trim().length === 0) return false; @@ -408,8 +421,6 @@ export function Chat() { const isLatestOpenRun = isLatestRunSegment && !runError && (sending || pendingFinal || hasAnyStreamContent || (runStillExecutingTools && !!activeRunId)); - const replyIndexOffset = findReplyMessageIndex(segmentMessages, isLatestOpenRun); - const replyIndex = replyIndexOffset === -1 ? null : idx + 1 + replyIndexOffset; const buildSteps = (omitLastStreamingMessageSegment: boolean): TaskStep[] => { let builtSteps = deriveTaskSteps({ @@ -467,6 +478,7 @@ export function Chat() { // `completed` state. // 3. `hasToolActivity` — at least one prior tool_use exists in the // segment, i.e. we're past the first tool round. + // 4. No tool activity yet — plain Q&A; any stream text is the reply. // // Demotion happens the moment a tool_use block appears in the streaming // message (`streamTools.length > 0`) OR a tool transitions back to @@ -481,8 +493,12 @@ export function Chat() { // fixed, the three-signal gate gives the correct bubble placement for // both narration and final reply. const allToolsCompleted = streamingTools.length > 0 && !hasRunningStreamToolStatus; + const canPromoteStreamToBubble = pendingFinal + || allToolsCompleted + || hasToolActivity + || (!hasToolActivity && (hasStreamText || hasStreamImages)); const rawStreamingReplyCandidate = isLatestOpenRun - && (pendingFinal || allToolsCompleted || hasToolActivity) + && canPromoteStreamToBubble && (hasStreamText || hasStreamImages) && streamTools.length === 0 && !hasRunningStreamToolStatus; @@ -499,12 +515,27 @@ export function Chat() { } } + const hasActiveStreamingReply = hasActiveStreamingReplyInRun( + isLatestOpenRun, + hasAnyStreamContent, + streamingReplyText, + ); + const replyIndexOffset = findReplyMessageIndex(postTriggerMessages, hasActiveStreamingReply); + const replyIndex = replyIndexOffset === -1 ? null : idx + 1 + replyIndexOffset; + const segmentAgentId = currentAgentId; const segmentAgentLabel = agents.find((agent) => agent.id === segmentAgentId)?.name || segmentAgentId; const segmentSessionLabel = sessionLabels[currentSessionKey] || currentSessionKey; if (steps.length === 0) { if (isLatestOpenRun && streamingReplyText == null) { + const historyReplyOffset = findReplyMessageIndex(postTriggerMessages, false); + // History can contain the final answer while `sending` is still true + // (blocked chat.send RPC, slow provider). Do not show an empty graph + // that hides the reply behind "Thinking..." (#1048). + if (historyReplyOffset >= 0 && !hasActiveStreamingReply) { + return []; + } return [{ triggerIndex: idx, replyIndex, @@ -548,15 +579,13 @@ export function Chat() { // tool call). This prevents orphan narration bubbles from leaking into // the chat stream once the graph is collapsed. // - // When the run is still streaming (`isLatestOpenRun`) the final reply is - // not yet part of `segmentMessages`, so every assistant message in the - // segment counts as intermediate. For completed runs, we preserve the - // final reply bubble by skipping the message that `findReplyMessageIndex` - // identifies as the answer. - const segmentReplyOffset = findReplyMessageIndex(segmentMessages, isLatestOpenRun); - for (let offset = 0; offset < segmentMessages.length; offset += 1) { + // While the live stream carries the answer, fold assistant history into the + // graph. If the reply is already in history but not streaming, keep it in + // the chat stream (do not pass `isLatestOpenRun` alone — that folds all). + const segmentReplyOffset = findReplyMessageIndex(postTriggerMessages, hasActiveStreamingReply); + for (let offset = 0; offset < postTriggerMessages.length; offset += 1) { if (offset === segmentReplyOffset) continue; - const candidate = segmentMessages[offset]; + const candidate = postTriggerMessages[offset]; if (!candidate || candidate.role !== 'assistant') continue; const hasNarrationText = extractText(candidate).trim().length > 0; const hasThinking = !!extractThinking(candidate); @@ -861,7 +890,11 @@ export function Chat() { {/* Streaming message — render when reply text is separated from graph, OR when there's streaming content without an active graph */} - {shouldRenderStreaming && (streamingReplyText != null || !hasActiveExecutionGraph) && ( + {shouldRenderStreaming && ( + streamingReplyText != null + || !hasActiveExecutionGraph + || (hasStreamText && streamTools.length === 0) + ) && ( 0} message={(() => { diff --git a/src/pages/Chat/task-visualization.ts b/src/pages/Chat/task-visualization.ts index a621058..add9ad5 100644 --- a/src/pages/Chat/task-visualization.ts +++ b/src/pages/Chat/task-visualization.ts @@ -43,6 +43,20 @@ export function findReplyMessageIndex(messages: RawMessage[], hasStreamingReply: return -1; } +/** + * When true, assistant history in the run segment should be folded into the + * execution graph because the live answer is (or will be) shown via streaming. + * When false but the run is still open, a final reply already in history must + * stay visible in the chat stream (history poll can beat stream teardown). + */ +export function hasActiveStreamingReplyInRun( + isLatestOpenRun: boolean, + hasAnyStreamContent: boolean, + streamingReplyText: string | null, +): boolean { + return isLatestOpenRun && (hasAnyStreamContent || streamingReplyText != null); +} + /** * Message indices that belong to an agent run segment (strictly after a run * trigger user message up to the next real user message). Used to fold tool @@ -86,6 +100,20 @@ export function buildRunSegmentMessageIndices( return indices; } +/** + * Messages strictly after the triggering user turn up to the next user. + * Use this for run lifecycle (final reply detection, reply index, open-run + * state) — never count paginated orphan assistants from a prior turn. + */ +export function getPostTriggerSegmentMessages( + messages: RawMessage[], + triggerIndex: number, + nextUserIndex: number, +): RawMessage[] { + const segmentEnd = nextUserIndex === -1 ? messages.length : nextUserIndex; + return messages.slice(triggerIndex + 1, segmentEnd); +} + /** * Slice messages for a user-triggered run, including leading assistant orphans * that belong to the same run but were separated by paginated history. diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 623c4db..9c455c9 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -1936,13 +1936,15 @@ function isRealUserBoundaryMessage(msg: RawMessage): boolean { return blocks.length === 0 || !blocks.every((block) => block.type === 'tool_result' || block.type === 'toolResult'); } -function hasAssistantAfterLastRealUser(messages: RawMessage[]): boolean { - for (let i = messages.length - 1; i >= 0; i -= 1) { - if (isRealUserBoundaryMessage(messages[i])) { - return messages.slice(i + 1).some((m) => m.role === 'assistant'); - } - } - return false; +/** True when the post-user segment has real run output (not a thinking-only stub). */ +function hasMeaningfulAssistantProgressAfterLastUser(messages: RawMessage[]): boolean { + const segment = postUserSegmentMessages(messages); + return segment.some((msg) => { + if (msg.role !== 'assistant') return false; + if (isTerminalAssistantErrorMessage(msg)) return true; + if (hasPendingToolUse(msg) || isToolOnlyMessage(msg)) return true; + return hasNonToolAssistantContent(msg); + }); } function hasAssistantProgressSinceSend(messages: RawMessage[], lastUserMessageAt: number | null): boolean { @@ -1956,7 +1958,7 @@ function hasAssistantProgressSinceSend(messages: RawMessage[], lastUserMessageAt } break; } - return hasAssistantAfterLastRealUser(normalized); + return hasMeaningfulAssistantProgressAfterLastUser(normalized); } function postUserSegmentMessages(filteredMessages: RawMessage[]): RawMessage[] { @@ -2585,7 +2587,7 @@ export const useChatStore = create((set, get) => ({ // (WS disconnect, console-only runs, etc.). Any assistant turn after the // user's message counts as progress so the safety timeout does not emit a // false "No response received" error while tool chains are still running. - if (isSendingNow && hasAssistantAfterLastRealUser(filteredMessages)) { + if (isSendingNow && hasMeaningfulAssistantProgressAfterLastUser(filteredMessages)) { _lastChatEventAt = Date.now(); if (get().error) { set({ error: null }); diff --git a/src/stores/gateway.ts b/src/stores/gateway.ts index 71321f5..7094379 100644 --- a/src/stores/gateway.ts +++ b/src/stores/gateway.ts @@ -214,7 +214,9 @@ function handleGatewayNotification(notification: { method?: string; params?: Rec // honour `'end'` as a hint to refresh history opportunistically. const isPerMessageEnd = phase === 'end'; const isRunCompletion = phase === 'completed' || phase === 'done' || phase === 'finished'; - if (isPerMessageEnd || isRunCompletion) { + const isRunFailure = phase === 'error' || phase === 'failed' || phase === 'aborted' || phase === 'cancelled'; + const isRunTerminal = isRunCompletion || isRunFailure; + if (isPerMessageEnd || isRunTerminal) { import('./chat') .then(({ useChatStore, syncCachedSessionRunIdle }) => { const state = useChatStore.getState(); @@ -231,18 +233,33 @@ function handleGatewayNotification(notification: { method?: string; params?: Rec const matchesActiveRun = runId != null && state.activeRunId != null && String(runId) === state.activeRunId; if (matchesCurrentSession || matchesActiveRun) { - maybeLoadHistory(state, isRunCompletion); + maybeLoadHistory(state, isRunTerminal); } - if (isRunCompletion && resolvedSessionKey && !matchesCurrentSession) { + if (isRunTerminal && resolvedSessionKey && !matchesCurrentSession) { syncCachedSessionRunIdle(resolvedSessionKey); } - if (isRunCompletion && (matchesCurrentSession || matchesActiveRun) && state.sending) { + + if (isRunFailure && (matchesCurrentSession || matchesActiveRun)) { + const errorMessage = String( + data.errorMessage ?? p.errorMessage ?? data.error ?? p.error ?? '', + ).trim(); + if (errorMessage) { + state.handleChatEvent({ + state: 'error', + errorMessage, + runId, + sessionKey: resolvedSessionKey ?? undefined, + }); + } + } + + if (isRunTerminal && (matchesCurrentSession || matchesActiveRun) && state.sending) { useChatStore.setState({ sending: false, activeRunId: null, pendingFinal: false, lastUserMessageAt: null, - error: null, + error: isRunFailure ? state.error : null, }); if (resolvedSessionKey) { syncCachedSessionRunIdle(resolvedSessionKey); diff --git a/tests/unit/chat-history-reply-while-sending.test.tsx b/tests/unit/chat-history-reply-while-sending.test.tsx new file mode 100644 index 0000000..737f2ed --- /dev/null +++ b/tests/unit/chat-history-reply-while-sending.test.tsx @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; + +const { gatewayState, agentsState } = vi.hoisted(() => ({ + gatewayState: { status: { state: 'running', port: 18789 } }, + agentsState: { + agents: [{ id: 'main', name: 'main' }] as Array>, + fetchAgents: vi.fn(), + }, +})); + +vi.mock('@/stores/gateway', () => ({ + useGatewayStore: (selector: (state: typeof gatewayState) => unknown) => selector(gatewayState), +})); + +vi.mock('@/stores/agents', () => ({ + useAgentsStore: (selector: (state: typeof agentsState) => unknown) => selector(agentsState), +})); + +vi.mock('@/lib/host-api', () => ({ + hostApiFetch: vi.fn().mockResolvedValue({ success: true, messages: [] }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record | string) => { + if (typeof params === 'string') return params; + if (key === 'executionGraph.collapsedSummary') { + return `collapsed ${String(params?.toolCount ?? '')} ${String(params?.processCount ?? '')}`.trim(); + } + if (key === 'executionGraph.agentRun') return 'Main execution'; + if (key === 'executionGraph.title') return 'Execution Graph'; + if (key === 'executionGraph.collapseAction') return 'Collapse'; + if (key === 'executionGraph.thinkingLabel') return 'Thinking'; + if (key.startsWith('taskPanel.stepStatus.')) return key.split('.').at(-1) ?? key; + return key; + }, + }), +})); + +vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({ + useStickToBottomInstant: vi.fn(() => ({ + contentRef: { current: null }, + scrollRef: { current: null }, + scrollToBottom: vi.fn(), + isAtBottom: true, + })), +})); + +vi.mock('@/hooks/use-min-loading', () => ({ + useMinLoading: () => false, +})); + +vi.mock('@/pages/Chat/ChatToolbar', () => ({ ChatToolbar: () => null })); +vi.mock('@/pages/Chat/ChatInput', () => ({ ChatInput: () => null })); + +describe('Chat history reply while sending', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('shows assistant reply from history even when sending is still true', async () => { + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + messages: [ + { role: 'user', id: 'u1', content: '你好' }, + { role: 'assistant', id: 'a1', content: [{ type: 'text', text: '你好,我在。' }] }, + ], + loading: false, + error: null, + runError: null, + sending: true, + activeRunId: 'run-1', + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingFinal: false, + lastUserMessageAt: Date.now(), + currentSessionKey: 'agent:main:main', + currentAgentId: 'main', + }); + + const { Chat } = await import('@/pages/Chat'); + render(); + + await waitFor(() => { + expect(screen.getByText('你好,我在。')).toBeTruthy(); + }); + expect(screen.queryByText('Thinking')).toBeNull(); + }); +}); diff --git a/tests/unit/image-viewer.test.tsx b/tests/unit/image-viewer.test.tsx new file mode 100644 index 0000000..fba3445 --- /dev/null +++ b/tests/unit/image-viewer.test.tsx @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import ImageViewer from '@/components/file-preview/ImageViewer'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (_key: string, options?: string | { defaultValue?: string; error?: string }) => { + if (typeof options === 'string') return options; + if (options?.defaultValue && options.error) { + return options.defaultValue.replace('{{error}}', options.error); + } + return options?.defaultValue ?? ''; + }, + }), +})); + +const readBinaryFile = vi.fn(); + +vi.mock('@/lib/api-client', () => ({ + readBinaryFile: (...args: unknown[]) => readBinaryFile(...args), +})); + +describe('ImageViewer', () => { + it('loads image bytes via IPC and renders a blob URL preview', async () => { + const pngBytes = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]); + readBinaryFile.mockResolvedValueOnce({ + ok: true, + data: pngBytes, + mimeType: 'image/png', + size: pngBytes.length, + readOnly: true, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('image-preview')).toBeVisible(); + }); + + const img = screen.getByTestId('image-preview') as HTMLImageElement; + expect(img.src).toMatch(/^blob:/); + expect(readBinaryFile).toHaveBeenCalledWith('/tmp/demo.png', { maxBytes: 50 * 1024 * 1024 }); + }); + + it('shows an error when binary read fails', async () => { + readBinaryFile.mockResolvedValueOnce({ + ok: false, + error: 'notFound', + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Image failed to load: notFound')).toBeVisible(); + }); + }); +}); diff --git a/tests/unit/run-segment-indices.test.ts b/tests/unit/run-segment-indices.test.ts index 7ec6470..07e5b7f 100644 --- a/tests/unit/run-segment-indices.test.ts +++ b/tests/unit/run-segment-indices.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { buildRunSegmentMessageIndices } from '@/pages/Chat/task-visualization'; +import { + buildRunSegmentMessageIndices, + findReplyMessageIndex, + getPostTriggerSegmentMessages, + getRunSegmentMessages, + hasActiveStreamingReplyInRun, +} from '@/pages/Chat/task-visualization'; import type { RawMessage } from '@/stores/chat'; describe('buildRunSegmentMessageIndices', () => { @@ -47,3 +53,51 @@ describe('buildRunSegmentMessageIndices', () => { expect(indices.has(2)).toBe(false); }); }); + +describe('getPostTriggerSegmentMessages vs getRunSegmentMessages', () => { + const isUser = (message: RawMessage) => message.role === 'user'; + + it('keeps lifecycle segment empty while graph segment includes paginated orphans', () => { + const messages: RawMessage[] = [ + { role: 'assistant', content: [{ type: 'text', text: 'prior answer' }] }, + { role: 'user', content: 'follow up' }, + ]; + + expect(getPostTriggerSegmentMessages(messages, 1, -1)).toEqual([]); + expect(getRunSegmentMessages(messages, 1, -1, isUser)).toEqual([ + { role: 'assistant', content: [{ type: 'text', text: 'prior answer' }] }, + ]); + }); + + it('does not attach a prior turn assistant when an earlier user exists in the window', () => { + const messages: RawMessage[] = [ + { role: 'user', content: 'first' }, + { role: 'assistant', content: [{ type: 'text', text: 'first answer' }] }, + { role: 'user', content: 'second' }, + ]; + + expect(getPostTriggerSegmentMessages(messages, 2, -1)).toEqual([]); + expect(getRunSegmentMessages(messages, 2, -1, isUser)).toEqual([]); + }); +}); + +describe('findReplyMessageIndex / hasActiveStreamingReplyInRun', () => { + it('protects a history reply from fold when the run is open but not streaming', () => { + const postTrigger: RawMessage[] = [ + { role: 'assistant', content: [{ type: 'text', text: '你好,我在。' }] }, + ]; + + expect(hasActiveStreamingReplyInRun(true, false, null)).toBe(false); + expect(findReplyMessageIndex(postTrigger, false)).toBe(0); + expect(findReplyMessageIndex(postTrigger, true)).toBe(-1); + }); + + it('folds history when a stream bubble is active', () => { + const postTrigger: RawMessage[] = [ + { role: 'assistant', content: [{ type: 'text', text: 'partial' }] }, + ]; + + expect(hasActiveStreamingReplyInRun(true, true, null)).toBe(true); + expect(findReplyMessageIndex(postTrigger, true)).toBe(-1); + }); +}); diff --git a/tests/unit/workspace-browser-body.test.tsx b/tests/unit/workspace-browser-body.test.tsx new file mode 100644 index 0000000..27c5737 --- /dev/null +++ b/tests/unit/workspace-browser-body.test.tsx @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { WorkspaceBrowserBody } from '@/components/file-preview/WorkspaceBrowserBody'; +import type { WorkspaceTreeNode } from '@/lib/workspace-tree'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (_key: string, options?: string | { defaultValue?: string }) => ( + typeof options === 'string' ? options : options?.defaultValue ?? _key + ), + }), +})); + +const readTextFile = vi.fn(); +const invokeIpc = vi.fn(async () => ({})); + +vi.mock('@/lib/api-client', () => ({ + invokeIpc: (...args: unknown[]) => invokeIpc(...args), + readTextFile: (...args: unknown[]) => readTextFile(...args), + statFile: vi.fn(), +})); + +const htmlNode: WorkspaceTreeNode = { + name: 'dashboard.html', + relPath: 'dashboard.html', + absPath: '/workspace/dashboard.html', + isDir: false, + size: 10_700, + ext: '.html', + mimeType: 'text/html', + contentType: 'document', +}; + +vi.mock('@/lib/workspace-tree', () => ({ + loadWorkspaceTree: vi.fn(async () => ({ + root: { + name: 'workspace', + relPath: '', + absPath: '/workspace', + isDir: true, + children: [htmlNode], + }, + truncated: false, + })), + collectInitialExpanded: vi.fn(() => new Set([''])), + findNode: vi.fn((root: WorkspaceTreeNode, relPath: string) => { + if (relPath === 'dashboard.html') return htmlNode; + return null; + }), +})); + +describe('WorkspaceBrowserBody', () => { + it('renders html files as sandboxed HTML preview instead of raw source', async () => { + readTextFile.mockResolvedValueOnce({ + ok: true, + content: '

Dashboard

', + size: 72, + readOnly: true, + }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('dashboard.html')).toBeVisible(); + }); + + fireEvent.click(screen.getByText('dashboard.html')); + + const frame = await screen.findByTestId('html-preview-frame'); + expect(frame).toBeVisible(); + expect(frame).toHaveAttribute( + 'sandbox', + 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-downloads', + ); + expect(screen.queryByText('')).not.toBeInTheDocument(); + }); +});