🐛 fix: stuck "Preparing preview…" + inline the chip subtitle

Two related fixes for a stuck-spinner bug a user reported in manual
testing of PR #12957.

**Stuck spinner (the bug)**
The deferred preview render can complete a few seconds AFTER the SSE
stream closes (typical case: PPTX render finishes ~3s after the LLM
emits FINAL). When that happens, the SSE update is silently dropped
(`isStreamWritable` returns false on a closed stream) and polling is
the only recovery path.

The earlier polling gate was `status === 'pending' && isAnySubmitting`,
which mirrored the original design intent ("only query while the LLM
is still generating"). But `isAnySubmitting` flips false the moment
the model emits FINAL — milliseconds before the deferred render
commits. Polling never runs, the chip stays "Preparing preview…"
forever even though the DB has `status: 'ready'` with valid HTML.

Drop the `isAnySubmitting` part of the gate. `useFilePreview`'s
`refetchInterval` is already a function-form that returns `false` on
the first terminal response, so polling auto-stops within one tick of
resolution. The server-side render ceiling (60s) plus the lazy sweep
in the preview endpoint cap the worst case to ~24 polls per pending
attachment. Polling itself never blocks UX — the gate's purpose was
"don't waste cycles", and capping by terminal status is the correct
expression of that.

**Inline the chip subtitle (the visual)**
The previous design rendered "Preparing preview…" as a loose-feeling
spinner+text BELOW the file chip. The chip itself looked done while a
floating annotation said it wasn't.

`FileContainer` gains an optional `subtitle?: ReactNode` prop that
overrides the default file-type label. `Attachment.tsx` passes a
`PreviewStatusSubtitle` (spinner + "Preparing preview…" / alert +
"Preview unavailable") into that slot when the file's preview is
pending or failed. The chip footprint stays identical to its `'ready'`
form — just the second row swaps from "PowerPoint Presentation" to
the status indicator. No floating element, no layout shift.

Tests: regression test pinning down "polling stays enabled after the
LLM finishes" so a future revert can't reintroduce the stuck-spinner
bug. Existing FileContainer tests pass unchanged (subtitle override
is opt-in). 522 frontend tests pass; lint clean.
This commit is contained in:
Danny Avila 2026-05-05 22:37:10 -04:00
parent 8b990d654a
commit 9a6584096b
4 changed files with 75 additions and 43 deletions

View file

@ -1,3 +1,4 @@
import type { ReactNode } from 'react';
import type { TFile } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import { getFileType, cn } from '~/utils';
@ -8,6 +9,7 @@ const FileContainer = ({
file,
overrideType,
displayName,
subtitle,
buttonClassName,
containerClassName,
onDelete,
@ -21,6 +23,15 @@ const FileContainer = ({
* persisted user files leave this undefined and render the raw filename.
*/
displayName?: string;
/**
* Optional override for the subtitle line (defaults to the file
* type's localized title e.g. "PowerPoint Presentation"). Used by
* the deferred-preview flow to surface "Preparing preview…" /
* "Preview unavailable" inline within the chip rather than as a
* loose-feeling annotation below it. Pass a ReactNode so callers
* can include icons (spinner, alert) alongside the text.
*/
subtitle?: ReactNode;
buttonClassName?: string;
containerClassName?: string;
onDelete?: () => void;
@ -49,9 +60,13 @@ const FileContainer = ({
<div className="truncate font-medium" title={visibleName}>
{visibleName}
</div>
<div className="truncate text-text-secondary" title={fileType.title}>
{fileType.title}
</div>
{subtitle != null ? (
subtitle
) : (
<div className="truncate text-text-secondary" title={fileType.title}>
{fileType.title}
</div>
)}
</div>
</div>
</div>

View file

@ -25,43 +25,42 @@ 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:
* Inline subtitle for a code-execution file chip with a pending or
* failed preview render. Renders into the chip's existing subtitle
* slot (replacing the file-type label), so the chip footprint stays
* identical to its `'ready'` form just the second row swaps from
* "PowerPoint Presentation" (or whatever the type label is) to a
* spinner + "Preparing preview…" or an alert + "Preview unavailable".
*
* - `'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.
* Driven by the polling `useAttachmentPreviewSync` hook for the
* post-stream-close gap and by the `attachment` SSE update event
* while the response stream is still open. For `'ready'` (or
* undefined / legacy) status the chip falls back to its default
* subtitle and this component isn't rendered.
*/
const PreviewStatusIndicator = memo(
const PreviewStatusSubtitle = memo(
({ status, previewError }: { status: 'pending' | 'failed'; previewError?: string }) => {
const localize = useLocalize();
if (status === 'pending') {
return (
<div className="mt-1 flex items-center gap-1.5 text-xs text-text-secondary">
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
<span>{localize('com_ui_preview_preparing')}</span>
<div className="flex items-center gap-1.5 truncate text-text-secondary">
<Loader2 className="h-3 w-3 shrink-0 animate-spin" aria-hidden="true" />
<span className="truncate">{localize('com_ui_preview_preparing')}</span>
</div>
);
}
return (
<div
className="mt-1 flex items-center gap-1.5 text-xs text-text-secondary"
className="flex items-center gap-1.5 truncate text-text-secondary"
title={previewError ?? localize('com_ui_preview_failed')}
>
<AlertCircle className="h-3 w-3" aria-hidden="true" />
<span>{localize('com_ui_preview_failed')}</span>
<AlertCircle className="h-3 w-3 shrink-0" aria-hidden="true" />
<span className="truncate">{localize('com_ui_preview_failed')}</span>
</div>
);
},
);
PreviewStatusIndicator.displayName = 'PreviewStatusIndicator';
PreviewStatusSubtitle.displayName = 'PreviewStatusSubtitle';
const FileAttachment = memo(({ attachment }: { attachment: Partial<TAttachment> }) => {
const [isVisible, setIsVisible] = useState(false);
@ -75,13 +74,17 @@ const FileAttachment = memo(({ attachment }: { attachment: Partial<TAttachment>
});
const extension = attachment.filename?.split('.').pop();
/* Bridge the deferred-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. */
* resolved record while the file is still pending, and surface the
* status inline as the chip's subtitle. 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,
);
const subtitle =
previewStatus === 'pending' || previewStatus === 'failed' ? (
<PreviewStatusSubtitle status={previewStatus} previewError={previewError} />
) : undefined;
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), 50);
@ -109,12 +112,10 @@ const FileAttachment = memo(({ attachment }: { attachment: Partial<TAttachment>
onClick={handleDownload}
overrideType={extension}
displayName={displayFilename(attachment.filename)}
subtitle={subtitle}
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') && (
<PreviewStatusIndicator status={previewStatus} previewError={previewError} />
)}
</div>
);
});

View file

@ -118,7 +118,7 @@ function setup({
}
describe('useAttachmentPreviewSync', () => {
it('enables polling only when status=pending AND a conversation is submitting', () => {
it('enables polling whenever status=pending (no longer gated on isSubmitting)', () => {
const ctx = setup({
attachment: makeAttachment({ status: 'pending' }),
isSubmitting: true,
@ -142,14 +142,20 @@ describe('useAttachmentPreviewSync', () => {
expect(ctx.enabled).toBe(false);
});
it('does NOT enable polling when no conversation is submitting (LLM finished)', () => {
it('STILL enables polling for pending records even after the LLM has finished generating', () => {
/* Regression for the stuck-spinner bug: the deferred render can
* complete a few seconds AFTER the SSE stream closes. With the
* earlier `isAnySubmitting` gate, polling stopped the moment the
* model finished and the resolved-but-not-yet-emitted state would
* never reach the UI. Polling now runs on `status === 'pending'`
* alone; `useFilePreview`'s `refetchInterval` auto-stops on the
* first terminal response, and the server-side render ceiling +
* lazy sweep cap the worst case. */
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);
expect(ctx.enabled).toBe(true);
});
it('does NOT enable polling when the attachment has no file_id', () => {

View file

@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useSetRecoilState } from 'recoil';
import type { TAttachment, TFile, TFilePreview } from 'librechat-data-provider';
import { useFilePreview } from '~/data-provider';
import store from '~/store';
@ -39,24 +39,34 @@ interface UseAttachmentPreviewSyncResult {
*
* 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.
* - Effective status is `'pending'` (terminal states need no work).
* `useFilePreview`'s `refetchInterval` returns `false` the moment
* the server reports `ready`/`failed`, so polling auto-terminates
* within one tick of resolution. Bounded ceiling: the server-side
* render timeout is 60s, so a stuck pending record gets ~24 polls
* max before the lazy sweep in the preview endpoint forces it to
* `'failed'`.
*
* NOTE: an earlier version of this hook also gated on `isAnySubmitting`
* (the LLM still generating). That gate was removed because the
* deferred render can complete *after* the SSE stream closes when it
* does, the SSE update is silently dropped, and polling is the only
* recovery path. With the gate in place, the chip would stay stuck on
* "Preparing preview…" forever (until manual refresh) for any render
* that landed even seconds after submission ended. The polling itself
* doesn't block UX; the user can keep messaging regardless.
*/
export default function useAttachmentPreviewSync(
attachment: TAttachment | undefined,
): UseAttachmentPreviewSyncResult {
const setAttachmentsMap = useSetRecoilState(store.messageAttachmentsMap);
const isAnySubmitting = useRecoilValue(store.anySubmittingSelector);
const file = (attachment ?? undefined) as Partial<TFile> | undefined;
const fileId = file?.file_id;
const baseStatus: 'pending' | 'ready' | 'failed' = file?.status ?? 'ready';
const messageId = (attachment as Partial<TAttachment> | undefined)?.messageId;
const enabled = !!fileId && baseStatus === 'pending' && isAnySubmitting;
const enabled = !!fileId && baseStatus === 'pending';
const previewQuery = useFilePreview(fileId, { enabled });