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 (
+
+
+ {localize('com_ui_preview_preparing')}
+
+ );
+ }
+ return (
+
+
+
{localize('com_ui_preview_failed')}
+
+ );
+ },
+);
+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: '',
+ textFormat: 'html',
+ },
+ });
+ const updated = ctx.map[messageId]?.[0] as TAttachment & { text?: string };
+ expect(updated.status).toBe('ready');
+ expect(updated.text).toBe('');
+ 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',