fix image and html preview (#1050)

This commit is contained in:
paisley
2026-05-20 11:35:53 +08:00
committed by GitHub
parent 0fb460efca
commit fb2d9e312a
10 changed files with 499 additions and 43 deletions

View File

@@ -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<LoadState>({ 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 (
<div className={cn('flex h-full items-center justify-center bg-black/5 dark:bg-black/40', className)}>
<LoadingSpinner />
</div>
);
}
if (currentState.status === 'tooLarge') {
return (
<div className={cn('flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground bg-black/5 dark:bg-black/40', className)}>
{t('filePreview.errors.tooLarge', 'File too large; preview disabled')}
</div>
);
}
if (currentState.status === 'error') {
return (
<div className={cn('flex h-full flex-col items-center justify-center gap-2 px-6 text-center text-sm text-destructive bg-black/5 dark:bg-black/40', className)}>
<p>
{t('filePreview.image.loadFailed', {
defaultValue: 'Image failed to load: {{error}}',
error: currentState.message,
})}
</p>
</div>
);
}
return (
<div className={cn('relative flex h-full w-full items-center justify-center bg-black/5 dark:bg-black/40', className)}>
@@ -41,8 +121,9 @@ export default function ImageViewer({ filePath, fileName, className }: ImageView
</div>
<div className="h-full w-full overflow-auto p-6">
<img
src={toFileUrl(filePath)}
src={currentState.url}
alt={fileName}
data-testid="image-preview"
className={cn(
'mx-auto select-none transition-transform',
zoomed

View File

@@ -14,6 +14,7 @@ import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { cn } from '@/lib/utils';
import { invokeIpc, readTextFile, statFile } from '@/lib/api-client';
import {
isHtmlPreviewExt,
isPdfPreviewExt,
isSheetPreviewExt,
supportsInlineDocumentPreview,
@@ -33,6 +34,7 @@ import {
shouldOfferDirectOpenFallback,
} from './open-file-utils';
import MarkdownPreview from './MarkdownPreview';
import HtmlPreview from './HtmlPreview';
import ImageViewer from './ImageViewer';
const MonacoViewerLazy = lazy(() => import('./MonacoViewer'));
@@ -437,6 +439,16 @@ export function WorkspaceBrowserBody({
);
}
if (isHtmlPreviewExt(selectedNode.ext)) {
return (
<HtmlPreview
source={fileState.content}
filePath={selectedNode.absPath}
fileName={selectedNode.name}
/>
);
}
if (selectedNode.contentType === 'document') {
return (
<div className="h-full overflow-auto">

View File

@@ -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)
) && (
<ChatMessage
suppressToolCards={hasActiveExecutionGraph || runSegmentMessageIndices.size > 0}
message={(() => {

View File

@@ -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.

View File

@@ -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<ChatState>((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 });

View File

@@ -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);

View File

@@ -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<Record<string, unknown>>,
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, unknown> | 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(<Chat />);
await waitFor(() => {
expect(screen.getByText('你好,我在。')).toBeTruthy();
});
expect(screen.queryByText('Thinking')).toBeNull();
});
});

View File

@@ -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(<ImageViewer filePath="/tmp/demo.png" fileName="demo.png" />);
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(<ImageViewer filePath="/tmp/missing.png" fileName="missing.png" />);
await waitFor(() => {
expect(screen.getByText('Image failed to load: notFound')).toBeVisible();
});
});
});

View File

@@ -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);
});
});

View File

@@ -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: '<!doctype html><html><body><h1 id="title">Dashboard</h1></body></html>',
size: 72,
readOnly: true,
});
render(
<WorkspaceBrowserBody
agent={{ id: 'main', name: 'Main Agent', workspace: '/workspace' }}
/>,
);
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('<!doctype html>')).not.toBeInTheDocument();
});
});