mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-05-13 16:07:30 +00:00
🐛 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:
parent
8b990d654a
commit
9a6584096b
4 changed files with 75 additions and 43 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue