From ba8a845f343c3f69cb8d3ef9bd9bcc4b63a2a68a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 5 May 2026 12:24:04 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=96=A5=EF=B8=8F=20feat:=20frontend=20two-?= =?UTF-8?q?phase=20preview=20consumer=20(polling=20+=20UI=20states)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the React side to the new lifecycle so the user sees what's happening with their file while phase-2 extraction runs in the background and after the response stream closes. - `useAttachmentHandler` upserts by `file_id` (was append-only) so the phase-2 SSE update event merges over the pending placeholder in place. Lightweight attachments without a `file_id` (web_search / file_search citations) keep the legacy append path. - `useFilePreview(file_id)` React Query hook with `refetchInterval: (data) => data?.status === 'pending' ? 2500 : false` so polling auto-stops on the first terminal response without the caller having to flip `enabled`. - `useAttachmentPreviewSync(attachment)` bridges polled data into `messageAttachmentsMap`. Polling enabled iff `status === 'pending' && isAnySubmitting` — per the design ask: active polling while the LLM is still generating, then quiet. Process-restart and post-stream cases are covered by polling on the next interaction. - `Attachment.tsx` renders a small `PreviewStatusIndicator` (spinner + "Preparing preview…" for pending, alert icon + "Preview unavailable" for failed) inside `FileAttachment`. Download button stays fully functional in both states. Two new English locale keys. - Data-provider scaffolding: `TFilePreview` type, `endpoints.filePreview`, `dataService.getFilePreview`, `QueryKeys.filePreview`. --- .../Messages/Content/Parts/Attachment.tsx | 53 +++- client/src/data-provider/Files/queries.ts | 38 +++ .../useAttachmentPreviewSync.spec.tsx | 235 ++++++++++++++++++ client/src/hooks/Files/index.ts | 1 + .../hooks/Files/useAttachmentPreviewSync.ts | 108 ++++++++ .../__tests__/useAttachmentHandler.spec.tsx | 148 +++++++++++ client/src/hooks/SSE/useAttachmentHandler.ts | 20 ++ client/src/locales/en/translation.json | 2 + packages/data-provider/src/api-endpoints.ts | 5 + packages/data-provider/src/data-service.ts | 16 ++ packages/data-provider/src/keys.ts | 1 + 11 files changed, 626 insertions(+), 1 deletion(-) create mode 100644 client/src/hooks/Files/__tests__/useAttachmentPreviewSync.spec.tsx create mode 100644 client/src/hooks/Files/useAttachmentPreviewSync.ts create mode 100644 client/src/hooks/SSE/__tests__/useAttachmentHandler.spec.tsx diff --git a/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx b/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx index 121f619847..f7370b960e 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx @@ -1,4 +1,5 @@ import { memo, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { Loader2, AlertCircle } from 'lucide-react'; import { Tools } from 'librechat-data-provider'; import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider'; import type { ToolArtifactType } from '~/utils/artifacts'; @@ -18,11 +19,50 @@ import Image from '~/components/Chat/Messages/Content/Image'; import ToolMermaidArtifact from './ToolMermaidArtifact'; import ToolArtifactCard from './ToolArtifactCard'; import { useAttachmentLink } from './LogLink'; -import { useLocalize } from '~/hooks'; +import { useLocalize, useAttachmentPreviewSync } from '~/hooks'; import { cn } from '~/utils'; const COLLAPSED_MAX_HEIGHT = 320; +/** + * Small inline indicator rendered next to a code-execution file chip + * while its inline preview is in flight or has failed. Three states: + * + * - `'pending'`: tiny spinner + "preparing preview…". Driven by the + * polling `useAttachmentPreviewSync` hook (gated on `isSubmitting`) + * and by the SSE `attachment` update event when the response stream + * is still open. + * - `'failed'`: alert icon + "preview unavailable" with the + * backend's machine-readable `previewError` as a tooltip + * (`'timeout'`, `'parser-error'`, `'orphaned'`, …). The download + * chip itself remains fully functional. + * - `'ready'` (or undefined): nothing rendered — the chip looks + * exactly as it did before this PR. + */ +const PreviewStatusIndicator = memo( + ({ status, previewError }: { status: 'pending' | 'failed'; previewError?: string }) => { + const localize = useLocalize(); + if (status === 'pending') { + return ( +
+
+ ); + } + return ( +
+
+ ); + }, +); +PreviewStatusIndicator.displayName = 'PreviewStatusIndicator'; + const FileAttachment = memo(({ attachment }: { attachment: Partial }) => { const [isVisible, setIsVisible] = useState(false); const file = attachment as TFile & TAttachmentMetadata; @@ -34,6 +74,14 @@ const FileAttachment = memo(({ attachment }: { attachment: Partial source: file.source, }); const extension = attachment.filename?.split('.').pop(); + /* Bridge the two-phase preview lifecycle: poll the backend for the + * resolved record while pending+isSubmitting, and surface a small + * UI indicator. The hook is a no-op for terminal states (legacy + * records, ready, failed already-known) so calling it + * unconditionally is cheap. */ + const { status: previewStatus, previewError } = useAttachmentPreviewSync( + attachment as TAttachment, + ); useEffect(() => { const timer = setTimeout(() => setIsVisible(true), 50); @@ -64,6 +112,9 @@ const FileAttachment = memo(({ attachment }: { attachment: Partial containerClassName="max-w-fit" buttonClassName="bg-surface-secondary hover:cursor-pointer hover:bg-surface-hover active:bg-surface-secondary focus:bg-surface-hover hover:border-border-heavy active:border-border-heavy" /> + {(previewStatus === 'pending' || previewStatus === 'failed') && ( + + )} ); }); diff --git a/client/src/data-provider/Files/queries.ts b/client/src/data-provider/Files/queries.ts index 9a213d5ad1..6430889908 100644 --- a/client/src/data-provider/Files/queries.ts +++ b/client/src/data-provider/Files/queries.ts @@ -105,3 +105,41 @@ export const useCodeOutputDownload = (url = ''): QueryObserverResult => }, ); }; + +/** + * Poll the lifecycle of an inline file preview while phase-2 HTML + * extraction runs in the background. + * + * Caller wires `enabled` to `(attachment.status === 'pending') && isSubmitting` + * so the query is dormant for terminal-status records and stops the + * moment the assistant finishes generating. Once enabled, React Query's + * `refetchInterval` runs at 2.5s while the server keeps returning + * `'pending'`, then auto-stops on the first `'ready'`/`'failed'` + * response (returning `false` from the function form disables the + * interval). Idle by default. + * + * Cache key: `[QueryKeys.filePreview, file_id]`. Sibling components + * watching the same `file_id` get a single shared poller. + */ +export const useFilePreview = ( + file_id: string | undefined, + config?: UseQueryOptions, +): QueryObserverResult => { + return useQuery( + [QueryKeys.filePreview, file_id], + () => dataService.getFilePreview(file_id ?? ''), + { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + retry: false, + /* Function form: poll only while the server says 'pending'. + * Returning `false` disables the interval, so a single 'ready' + * or 'failed' response naturally terminates polling without the + * caller having to also flip `enabled`. */ + refetchInterval: (data) => (data?.status === 'pending' ? 2500 : false), + ...config, + enabled: !!file_id && (config?.enabled ?? true), + }, + ); +}; diff --git a/client/src/hooks/Files/__tests__/useAttachmentPreviewSync.spec.tsx b/client/src/hooks/Files/__tests__/useAttachmentPreviewSync.spec.tsx new file mode 100644 index 0000000000..6c0aa40538 --- /dev/null +++ b/client/src/hooks/Files/__tests__/useAttachmentPreviewSync.spec.tsx @@ -0,0 +1,235 @@ +/** + * Coverage for `useAttachmentPreviewSync` — the bridge between the + * two-phase code-execution preview lifecycle and the attachment cache. + * + * Behavior under test: + * 1. Polling enables only when `attachment.status === 'pending'` AND + * some conversation is submitting (per the user's explicit gate). + * 2. On a terminal poll response (ready/failed), the resolved record + * is upserted into `messageAttachmentsMap` by `file_id`. + * 3. The hook's returned `status` reflects the polled value once + * it arrives, not just the prop snapshot. + * + * The underlying `useFilePreview` hook is mocked here — its own + * polling cadence and refetchInterval semantics are React Query's + * concern, not this hook's. + */ + +import { renderHook } from '@testing-library/react'; +import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil'; +import type { ReactNode } from 'react'; +import type { TAttachment, TFilePreview } from 'librechat-data-provider'; +import store from '~/store'; + +const mockUseFilePreview = jest.fn(); +jest.mock('~/data-provider', () => ({ + useFilePreview: (...args: unknown[]) => mockUseFilePreview(...args), +})); + +import useAttachmentPreviewSync from '../useAttachmentPreviewSync'; + +const wrapper = ({ children }: { children: ReactNode }) => {children}; + +const messageId = 'msg-1'; +const fileId = 'fid-1'; + +function makeAttachment(overrides: Partial = {}): TAttachment { + return { + file_id: fileId, + filename: 'data.xlsx', + filepath: '/uploads/data.xlsx', + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + messageId, + toolCallId: 'tc-1', + text: null, + textFormat: null, + status: 'pending', + ...overrides, + } as unknown as TAttachment; +} + +/** Read messageAttachmentsMap and bridge it out via a mutable ref. */ +function setup({ + attachment, + isSubmitting, + preview, + isFetching = false, +}: { + attachment: TAttachment; + isSubmitting: boolean; + preview?: TFilePreview; + isFetching?: boolean; +}) { + mockUseFilePreview.mockReset(); + mockUseFilePreview.mockReturnValue({ data: preview, isFetching }); + const ref: { current: Record } = { current: {} }; + let lastEnabled: boolean | undefined; + /* Spy on the second arg of useFilePreview to assert the gate. */ + mockUseFilePreview.mockImplementation((_id: unknown, opts: { enabled?: boolean }) => { + lastEnabled = opts?.enabled; + return { data: preview, isFetching }; + }); + + const Bridge = () => { + /* Seed the messageAttachmentsMap with the test attachment so the + * hook's upsert path has something to find. Also expose setters + * for tests that simulate `isAnySubmitting` toggling — the + * selector reads `conversationKeysAtom` × `isSubmittingFamily(key)` + * so we have to populate both for the selector to fire. */ + const setMap = useSetRecoilState(store.messageAttachmentsMap); + if (Object.keys(ref.current).length === 0) { + setMap({ [messageId]: [attachment] }); + } + const setKeys = useSetRecoilState(store.conversationKeysAtom); + setKeys([0]); + const setSubmitting = useSetRecoilState(store.isSubmittingFamily(0)); + setSubmitting(isSubmitting); + const map = useRecoilValue(store.messageAttachmentsMap); + ref.current = map; + return null; + }; + + const { result } = renderHook( + () => { + return useAttachmentPreviewSync(attachment); + }, + { + wrapper: ({ children }: { children: ReactNode }) => + wrapper({ + children: ( + <> + + {children} + + ), + }), + }, + ); + + return { + result, + get map() { + return ref.current; + }, + get enabled() { + return lastEnabled; + }, + }; +} + +describe('useAttachmentPreviewSync', () => { + it('enables polling only when status=pending AND a conversation is submitting', () => { + const ctx = setup({ + attachment: makeAttachment({ status: 'pending' }), + isSubmitting: true, + }); + expect(ctx.enabled).toBe(true); + }); + + it('does NOT enable polling when status is already ready', () => { + const ctx = setup({ + attachment: makeAttachment({ status: 'ready' }), + isSubmitting: true, + }); + expect(ctx.enabled).toBe(false); + }); + + it('does NOT enable polling when status is already failed', () => { + const ctx = setup({ + attachment: makeAttachment({ status: 'failed', previewError: 'timeout' }), + isSubmitting: true, + }); + expect(ctx.enabled).toBe(false); + }); + + it('does NOT enable polling when no conversation is submitting (LLM finished)', () => { + const ctx = setup({ + attachment: makeAttachment({ status: 'pending' }), + isSubmitting: false, + }); + /* User's explicit gate: once the LLM is done, polling stops. The + * frontend can refetch on demand from the next interaction. */ + expect(ctx.enabled).toBe(false); + }); + + it('does NOT enable polling when the attachment has no file_id', () => { + const noId = { ...makeAttachment({ status: 'pending' }) } as Partial; + delete (noId as { file_id?: string }).file_id; + const ctx = setup({ + attachment: noId as TAttachment, + isSubmitting: true, + }); + expect(ctx.enabled).toBe(false); + }); + + it('returns ready when no preview and no status (legacy back-compat)', () => { + const legacy = makeAttachment(); + delete (legacy as Partial).status; + const ctx = setup({ attachment: legacy as TAttachment, isSubmitting: false }); + expect(ctx.result.current.status).toBe('ready'); + }); + + it('upserts the resolved preview into messageAttachmentsMap when phase-2 reports ready', () => { + const ctx = setup({ + attachment: makeAttachment({ status: 'pending' }), + isSubmitting: true, + preview: { + file_id: fileId, + status: 'ready', + text: 'final
', + textFormat: 'html', + }, + }); + const updated = ctx.map[messageId]?.[0] as TAttachment & { text?: string }; + expect(updated.status).toBe('ready'); + expect(updated.text).toBe('final
'); + expect(ctx.result.current.status).toBe('ready'); + }); + + it('upserts the resolved preview when phase-2 reports failed (with previewError)', () => { + const ctx = setup({ + attachment: makeAttachment({ status: 'pending' }), + isSubmitting: true, + preview: { + file_id: fileId, + status: 'failed', + previewError: 'parser-error', + }, + }); + const updated = ctx.map[messageId]?.[0] as TAttachment & { previewError?: string }; + expect(updated.status).toBe('failed'); + expect(updated.previewError).toBe('parser-error'); + expect(ctx.result.current.status).toBe('failed'); + expect(ctx.result.current.previewError).toBe('parser-error'); + }); + + it('does NOT upsert while the polled status is still pending', () => { + const ctx = setup({ + attachment: makeAttachment({ status: 'pending' }), + isSubmitting: true, + preview: { file_id: fileId, status: 'pending' }, + }); + /* Map should be unchanged from the initial seed — no patch. */ + const list = ctx.map[messageId] ?? []; + expect(list).toHaveLength(1); + expect((list[0] as TAttachment & { status?: string }).status).toBe('pending'); + }); + + it('reports isPolling true when the query is fetching and the gate is open', () => { + const ctx = setup({ + attachment: makeAttachment({ status: 'pending' }), + isSubmitting: true, + isFetching: true, + }); + expect(ctx.result.current.isPolling).toBe(true); + }); + + it('reports isPolling false when the query is not fetching', () => { + const ctx = setup({ + attachment: makeAttachment({ status: 'pending' }), + isSubmitting: true, + isFetching: false, + }); + expect(ctx.result.current.isPolling).toBe(false); + }); +}); diff --git a/client/src/hooks/Files/index.ts b/client/src/hooks/Files/index.ts index 499572f0e0..7fa4df5a17 100644 --- a/client/src/hooks/Files/index.ts +++ b/client/src/hooks/Files/index.ts @@ -1,3 +1,4 @@ +export { default as useAttachmentPreviewSync } from './useAttachmentPreviewSync'; export { default as useDeleteFilesFromTable } from './useDeleteFilesFromTable'; export { default as useSetFilesToDelete } from './useSetFilesToDelete'; export { default as useFileHandling, useFileHandlingNoChatContext } from './useFileHandling'; diff --git a/client/src/hooks/Files/useAttachmentPreviewSync.ts b/client/src/hooks/Files/useAttachmentPreviewSync.ts new file mode 100644 index 0000000000..0134b018ed --- /dev/null +++ b/client/src/hooks/Files/useAttachmentPreviewSync.ts @@ -0,0 +1,108 @@ +import { useEffect } from 'react'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import type { TAttachment, TFile, TFilePreview } from 'librechat-data-provider'; +import { useFilePreview } from '~/data-provider'; +import store from '~/store'; + +interface UseAttachmentPreviewSyncResult { + /** + * Effective lifecycle status: `'pending'` while phase-2 HTML + * extraction is in flight, `'ready'` once it completes successfully + * (or for legacy / non-office files that never had a status), and + * `'failed'` when extraction errored or hit the 60s ceiling. Drives + * UI state (spinner, badge, etc.) without callers needing to read + * the attachment shape directly. + */ + status: 'pending' | 'ready' | 'failed'; + /** Short machine-readable failure reason from the backend. */ + previewError?: string; + /** True while React Query is actively polling the preview endpoint. */ + isPolling: boolean; +} + +/** + * Bridge the two-phase code-execution preview lifecycle to the + * attachment cache. + * + * Phase-1 (handled in callbacks.js + processCodeOutput) emits the + * attachment record at `status: 'pending'` immediately so the agent's + * response stops blocking on extraction. Phase-2 runs in the + * background; if the SSE stream is still open when it lands, an + * `attachment` update event arrives and the SSE handler upserts by + * `file_id`. If the stream has already closed (the model finished + * generating before phase-2 resolved), this hook covers the gap by + * polling `GET /api/files/:file_id/preview` and writing the resolved + * record back into `messageAttachmentsMap` — which triggers + * re-classification through `artifactTypeForAttachment`, so the file + * chip transitions from a plain download to the rich preview card + * (or to a download-with-error state) without remounting. + * + * Polling is gated on: + * - `attachment.file_id` present (no id → nothing to poll for) + * - Effective status is `'pending'` (terminal states need no work) + * - Some conversation in the app is submitting (per the user's + * explicit gate: "only query when the preview is still live and + * `isSubmitting` is still true"). When the LLM finishes, polling + * stops; the user can refresh on demand from the next interaction. + */ +export default function useAttachmentPreviewSync( + attachment: TAttachment | undefined, +): UseAttachmentPreviewSyncResult { + const setAttachmentsMap = useSetRecoilState(store.messageAttachmentsMap); + const isAnySubmitting = useRecoilValue(store.anySubmittingSelector); + + const file = (attachment ?? undefined) as Partial | undefined; + const fileId = file?.file_id; + const baseStatus: 'pending' | 'ready' | 'failed' = file?.status ?? 'ready'; + const messageId = (attachment as Partial | undefined)?.messageId; + + const enabled = !!fileId && baseStatus === 'pending' && isAnySubmitting; + + const previewQuery = useFilePreview(fileId, { enabled }); + + /* Effective status: prefer the polled record once it arrives, since + * the SSE handler may have already moved the cache forward and the + * `attachment` prop will catch up on the next render anyway. */ + const polled = previewQuery.data as TFilePreview | undefined; + const effectiveStatus: 'pending' | 'ready' | 'failed' = polled?.status ?? baseStatus; + const previewError = polled?.previewError ?? file?.previewError; + + /* On a terminal poll response (ready or failed), upsert into the + * shared attachments map. Mirrors the SSE handler's by-file_id + * upsert (`useAttachmentHandler`) — the attachment object is + * patched in place so siblings sharing the same atom re-render + * with the resolved data and `artifactTypeForAttachment` re-runs + * its empty-text gate, transitioning the file chip into a panel + * artifact card. */ + useEffect(() => { + if (!polled || polled.status === 'pending' || !messageId || !fileId) { + return; + } + setAttachmentsMap((prevMap) => { + const messageAttachments = + (prevMap as Record)[messageId] || []; + const existingIndex = messageAttachments.findIndex( + (a) => (a as Partial).file_id === fileId, + ); + if (existingIndex < 0) { + return prevMap; + } + const existing = messageAttachments[existingIndex] as Partial & TAttachment; + const merged = [...messageAttachments]; + merged[existingIndex] = { + ...existing, + status: polled.status, + text: polled.text ?? existing.text ?? null, + textFormat: polled.textFormat ?? existing.textFormat ?? null, + previewError: polled.previewError, + } as TAttachment; + return { ...prevMap, [messageId]: merged }; + }); + }, [polled, fileId, messageId, setAttachmentsMap]); + + return { + status: effectiveStatus, + previewError, + isPolling: enabled && previewQuery.isFetching, + }; +} diff --git a/client/src/hooks/SSE/__tests__/useAttachmentHandler.spec.tsx b/client/src/hooks/SSE/__tests__/useAttachmentHandler.spec.tsx new file mode 100644 index 0000000000..42f211ba52 --- /dev/null +++ b/client/src/hooks/SSE/__tests__/useAttachmentHandler.spec.tsx @@ -0,0 +1,148 @@ +/** + * Coverage for the upsert-by-file_id behavior in `useAttachmentHandler`. + * + * Two-phase code-execution flow: phase-1 emits an attachment with + * `status: 'pending'`; phase-2 emits the same `file_id` again with + * `status: 'ready'` (and resolved `text`/`textFormat`) or + * `'failed'` (with `previewError`). The handler MUST merge over the + * pending placeholder in place — appending would render the artifact + * twice in the UI (once stuck pending, once resolved). + * + * Lightweight attachments without a `file_id` (web_search citations, + * file_search results) keep the legacy append-only behavior so two + * unrelated citations both show up. + */ + +import { renderHook, act } from '@testing-library/react'; +import { RecoilRoot, useRecoilValue } from 'recoil'; +import { QueryClient } from '@tanstack/react-query'; +import { Tools } from 'librechat-data-provider'; +import type { ReactNode } from 'react'; +import type { TAttachment, EventSubmission } from 'librechat-data-provider'; +import useAttachmentHandler from '../useAttachmentHandler'; +import store from '~/store'; + +const wrapper = ({ children }: { children: ReactNode }) => {children}; + +const submission = {} as EventSubmission; +const messageId = 'msg-1'; + +function makeAttachment(overrides: Partial): TAttachment { + return { + file_id: 'fid-1', + filename: 'data.xlsx', + filepath: '/uploads/data.xlsx', + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + messageId, + toolCallId: 'tc-1', + text: null, + textFormat: null, + status: 'pending', + ...overrides, + } as unknown as TAttachment; +} + +/* Co-mount the handler and a reader of the messageAttachmentsMap atom in + * the same RecoilRoot so each act() shows the post-write state. The + * `attachmentsMap` ref is mutated by the reader on every render, so + * callers read it after their `act()` to assert. */ +function setup() { + const queryClient = new QueryClient(); + const ref: { current: Record } = { current: {} }; + const { result } = renderHook( + () => { + const handler = useAttachmentHandler(queryClient); + const map = useRecoilValue(store.messageAttachmentsMap); + ref.current = map; + return handler; + }, + { wrapper }, + ); + return { + handle: (data: TAttachment) => act(() => result.current({ data, submission })), + get list(): TAttachment[] { + return ref.current[messageId] ?? []; + }, + }; +} + +describe('useAttachmentHandler upsert-by-file_id', () => { + it('appends a new attachment when no record with the same file_id exists', () => { + const ctx = setup(); + ctx.handle(makeAttachment({ status: 'pending' })); + expect(ctx.list).toHaveLength(1); + expect(ctx.list[0]).toMatchObject({ file_id: 'fid-1', status: 'pending' }); + }); + + it('upserts in place when a second event arrives for the same file_id (phase-1 → phase-2 ready)', () => { + const ctx = setup(); + ctx.handle(makeAttachment({ status: 'pending' })); + ctx.handle(makeAttachment({ status: 'ready', text: '
', textFormat: 'html' })); + /* Critical: still ONE attachment, not two. The phase-2 event + * patches the pending record in place. */ + expect(ctx.list).toHaveLength(1); + expect(ctx.list[0]).toMatchObject({ + file_id: 'fid-1', + status: 'ready', + text: '
', + textFormat: 'html', + }); + }); + + it('upserts a failed phase-2 over the pending placeholder', () => { + const ctx = setup(); + ctx.handle(makeAttachment({ status: 'pending' })); + ctx.handle(makeAttachment({ status: 'failed', previewError: 'timeout' })); + expect(ctx.list).toHaveLength(1); + expect(ctx.list[0]).toMatchObject({ status: 'failed', previewError: 'timeout' }); + }); + + it('keeps multiple distinct file_ids as separate entries', () => { + const ctx = setup(); + ctx.handle(makeAttachment({ file_id: 'fid-A' })); + ctx.handle(makeAttachment({ file_id: 'fid-B' })); + expect(ctx.list).toHaveLength(2); + expect(ctx.list.map((a) => (a as { file_id: string }).file_id).sort()).toEqual([ + 'fid-A', + 'fid-B', + ]); + }); + + it('appends (does NOT merge) attachments with no file_id', () => { + /* Lightweight attachments like file_search citations and web_search + * results don't carry file_id. The handler must keep its legacy + * append behavior for them — merging would lose distinct citations + * and is unnecessary because they're never the target of a phase-2 + * update. */ + const ctx = setup(); + const noFileId = { + messageId, + toolCallId: 'tc-1', + type: Tools.web_search, + } as unknown as TAttachment; + ctx.handle(noFileId); + ctx.handle(noFileId); + expect(ctx.list).toHaveLength(2); + }); + + it('preserves fields from the first event when the second omits them', () => { + /* Phase-2 update only carries the deltas (text, status, textFormat). + * Fields set in phase-1 (filename, type, etc.) must survive the + * merge — the second event uses spread-over-existing semantics. */ + const ctx = setup(); + ctx.handle(makeAttachment({ status: 'pending', filename: 'phase1-name.xlsx' })); + ctx.handle({ + file_id: 'fid-1', + messageId, + status: 'ready', + text: 'final', + textFormat: 'html', + } as unknown as TAttachment); + expect(ctx.list).toHaveLength(1); + expect(ctx.list[0]).toMatchObject({ + filename: 'phase1-name.xlsx', + status: 'ready', + text: 'final', + }); + }); +}); diff --git a/client/src/hooks/SSE/useAttachmentHandler.ts b/client/src/hooks/SSE/useAttachmentHandler.ts index 06261f4af6..d4a03c3d41 100644 --- a/client/src/hooks/SSE/useAttachmentHandler.ts +++ b/client/src/hooks/SSE/useAttachmentHandler.ts @@ -52,6 +52,26 @@ export default function useAttachmentHandler(queryClient?: QueryClient) { setAttachmentsMap((prevMap) => { const messageAttachments = (prevMap as Record)[messageId] || []; + /* Upsert by `file_id` rather than always appending. The two-phase + * code-execution flow emits the same attachment twice: once at + * phase-1 with `status: 'pending'` and `text: null`, then again + * at phase-2 with `status: 'ready'` (and text/textFormat) or + * `'failed'` (with previewError). The second event must merge + * over the first in place — appending would render the artifact + * card twice, once stuck pending and once resolved. Attachments + * without a `file_id` (lightweight types like web_search / + * file_search citations) keep the legacy append behavior. */ + const fileId = (data as Partial).file_id; + if (fileId) { + const existingIndex = messageAttachments.findIndex( + (a) => (a as Partial).file_id === fileId, + ); + if (existingIndex > -1) { + const merged = [...messageAttachments]; + merged[existingIndex] = { ...messageAttachments[existingIndex], ...data }; + return { ...prevMap, [messageId]: merged }; + } + } return { ...prevMap, [messageId]: [...messageAttachments, data], diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index efeaced119..60d68b8d79 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1304,6 +1304,8 @@ "com_ui_prev": "Prev", "com_ui_prev_result": "Previous result", "com_ui_preview": "Preview", + "com_ui_preview_failed": "Preview unavailable", + "com_ui_preview_preparing": "Preparing preview…", "com_ui_preview_unavailable": "Preview not available for this file type", "com_ui_privacy_policy": "Privacy policy", "com_ui_privacy_policy_url": "Privacy Policy URL", diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 992d4a78a6..8bd38427fe 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -284,6 +284,11 @@ export const fileUpload = () => `${BASE_URL}/api/files`; export const fileDelete = () => `${BASE_URL}/api/files`; export const fileDownload = (userId: string, fileId: string) => `${BASE_URL}/api/files/download/${userId}/${fileId}`; +/* Two-phase code-execution preview lifecycle endpoint. Returns + * `{ status, text?, textFormat?, previewError? }` so the frontend can + * poll while phase-2 HTML extraction is in flight. See PR #12952. */ +export const filePreview = (fileId: string) => + `${BASE_URL}/api/files/${encodeURIComponent(fileId)}/preview`; export const fileConfig = () => `${BASE_URL}/api/files/config`; export const agentFiles = (agentId: string) => `${BASE_URL}/api/files/agent/${agentId}`; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 1e118c6f07..45f6ca6fdb 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -402,6 +402,22 @@ export const getFiles = (): Promise => { return request.get(endpoints.files()); }; +/** + * Poll the lifecycle of an inline file preview. Returns the smallest + * shape needed to drive the UI: + * - `status` always present (defaults to `'ready'` server-side for + * legacy records that pre-date the field). + * - `text` and `textFormat` only when `status === 'ready'` and text + * was extracted (preserves the HTML-or-null security contract). + * - `previewError` only when `status === 'failed'`. + * + * Called from `useFilePreview` with React Query's `refetchInterval` + * gated on `status === 'pending'` and `isSubmitting`. See PR #12952. + */ +export const getFilePreview = (fileId: string): Promise => { + return request.get(endpoints.filePreview(fileId)); +}; + export const getAgentFiles = (agentId: string): Promise => { return request.get(endpoints.agentFiles(agentId)); }; diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index 2200558cf1..36f4286617 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -34,6 +34,7 @@ export enum QueryKeys { assistantDocs = 'assistantDocs', agentDocs = 'agentDocs', fileDownload = 'fileDownload', + filePreview = 'filePreview', voices = 'voices', customConfigSpeech = 'customConfigSpeech', prompts = 'prompts',