fix image and html preview (#1050)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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={(() => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
91
tests/unit/chat-history-reply-while-sending.test.tsx
Normal file
91
tests/unit/chat-history-reply-while-sending.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
57
tests/unit/image-viewer.test.tsx
Normal file
57
tests/unit/image-viewer.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
81
tests/unit/workspace-browser-body.test.tsx
Normal file
81
tests/unit/workspace-browser-body.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user