mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 17:44:11 +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 { TFile } from 'librechat-data-provider';
|
||||||
import type { ExtendedFile } from '~/common';
|
import type { ExtendedFile } from '~/common';
|
||||||
import { getFileType, cn } from '~/utils';
|
import { getFileType, cn } from '~/utils';
|
||||||
|
|
@ -8,6 +9,7 @@ const FileContainer = ({
|
||||||
file,
|
file,
|
||||||
overrideType,
|
overrideType,
|
||||||
displayName,
|
displayName,
|
||||||
|
subtitle,
|
||||||
buttonClassName,
|
buttonClassName,
|
||||||
containerClassName,
|
containerClassName,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
|
@ -21,6 +23,15 @@ const FileContainer = ({
|
||||||
* persisted user files leave this undefined and render the raw filename.
|
* persisted user files leave this undefined and render the raw filename.
|
||||||
*/
|
*/
|
||||||
displayName?: string;
|
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;
|
buttonClassName?: string;
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
|
|
@ -49,9 +60,13 @@ const FileContainer = ({
|
||||||
<div className="truncate font-medium" title={visibleName}>
|
<div className="truncate font-medium" title={visibleName}>
|
||||||
{visibleName}
|
{visibleName}
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-text-secondary" title={fileType.title}>
|
{subtitle != null ? (
|
||||||
{fileType.title}
|
subtitle
|
||||||
</div>
|
) : (
|
||||||
|
<div className="truncate text-text-secondary" title={fileType.title}>
|
||||||
|
{fileType.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -25,43 +25,42 @@ import { cn } from '~/utils';
|
||||||
const COLLAPSED_MAX_HEIGHT = 320;
|
const COLLAPSED_MAX_HEIGHT = 320;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Small inline indicator rendered next to a code-execution file chip
|
* Inline subtitle for a code-execution file chip with a pending or
|
||||||
* while its inline preview is in flight or has failed. Three states:
|
* 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
|
* Driven by the polling `useAttachmentPreviewSync` hook for the
|
||||||
* polling `useAttachmentPreviewSync` hook (gated on `isSubmitting`)
|
* post-stream-close gap and by the `attachment` SSE update event
|
||||||
* and by the SSE `attachment` update event when the response stream
|
* while the response stream is still open. For `'ready'` (or
|
||||||
* is still open.
|
* undefined / legacy) status the chip falls back to its default
|
||||||
* - `'failed'`: alert icon + "preview unavailable" with the
|
* subtitle and this component isn't rendered.
|
||||||
* 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(
|
const PreviewStatusSubtitle = memo(
|
||||||
({ status, previewError }: { status: 'pending' | 'failed'; previewError?: string }) => {
|
({ status, previewError }: { status: 'pending' | 'failed'; previewError?: string }) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
if (status === 'pending') {
|
if (status === 'pending') {
|
||||||
return (
|
return (
|
||||||
<div className="mt-1 flex items-center gap-1.5 text-xs text-text-secondary">
|
<div className="flex items-center gap-1.5 truncate text-text-secondary">
|
||||||
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
|
<Loader2 className="h-3 w-3 shrink-0 animate-spin" aria-hidden="true" />
|
||||||
<span>{localize('com_ui_preview_preparing')}</span>
|
<span className="truncate">{localize('com_ui_preview_preparing')}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<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')}
|
title={previewError ?? localize('com_ui_preview_failed')}
|
||||||
>
|
>
|
||||||
<AlertCircle className="h-3 w-3" aria-hidden="true" />
|
<AlertCircle className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||||
<span>{localize('com_ui_preview_failed')}</span>
|
<span className="truncate">{localize('com_ui_preview_failed')}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
PreviewStatusIndicator.displayName = 'PreviewStatusIndicator';
|
PreviewStatusSubtitle.displayName = 'PreviewStatusSubtitle';
|
||||||
|
|
||||||
const FileAttachment = memo(({ attachment }: { attachment: Partial<TAttachment> }) => {
|
const FileAttachment = memo(({ attachment }: { attachment: Partial<TAttachment> }) => {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
@ -75,13 +74,17 @@ const FileAttachment = memo(({ attachment }: { attachment: Partial<TAttachment>
|
||||||
});
|
});
|
||||||
const extension = attachment.filename?.split('.').pop();
|
const extension = attachment.filename?.split('.').pop();
|
||||||
/* Bridge the deferred-preview lifecycle: poll the backend for the
|
/* Bridge the deferred-preview lifecycle: poll the backend for the
|
||||||
* resolved record while pending+isSubmitting, and surface a small
|
* resolved record while the file is still pending, and surface the
|
||||||
* UI indicator. The hook is a no-op for terminal states (legacy
|
* status inline as the chip's subtitle. The hook is a no-op for
|
||||||
* records, ready, failed already-known) so calling it
|
* terminal states (legacy records, ready, failed already-known) so
|
||||||
* unconditionally is cheap. */
|
* calling it unconditionally is cheap. */
|
||||||
const { status: previewStatus, previewError } = useAttachmentPreviewSync(
|
const { status: previewStatus, previewError } = useAttachmentPreviewSync(
|
||||||
attachment as TAttachment,
|
attachment as TAttachment,
|
||||||
);
|
);
|
||||||
|
const subtitle =
|
||||||
|
previewStatus === 'pending' || previewStatus === 'failed' ? (
|
||||||
|
<PreviewStatusSubtitle status={previewStatus} previewError={previewError} />
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => setIsVisible(true), 50);
|
const timer = setTimeout(() => setIsVisible(true), 50);
|
||||||
|
|
@ -109,12 +112,10 @@ const FileAttachment = memo(({ attachment }: { attachment: Partial<TAttachment>
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
overrideType={extension}
|
overrideType={extension}
|
||||||
displayName={displayFilename(attachment.filename)}
|
displayName={displayFilename(attachment.filename)}
|
||||||
|
subtitle={subtitle}
|
||||||
containerClassName="max-w-fit"
|
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"
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ function setup({
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('useAttachmentPreviewSync', () => {
|
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({
|
const ctx = setup({
|
||||||
attachment: makeAttachment({ status: 'pending' }),
|
attachment: makeAttachment({ status: 'pending' }),
|
||||||
isSubmitting: true,
|
isSubmitting: true,
|
||||||
|
|
@ -142,14 +142,20 @@ describe('useAttachmentPreviewSync', () => {
|
||||||
expect(ctx.enabled).toBe(false);
|
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({
|
const ctx = setup({
|
||||||
attachment: makeAttachment({ status: 'pending' }),
|
attachment: makeAttachment({ status: 'pending' }),
|
||||||
isSubmitting: false,
|
isSubmitting: false,
|
||||||
});
|
});
|
||||||
/* User's explicit gate: once the LLM is done, polling stops. The
|
expect(ctx.enabled).toBe(true);
|
||||||
* 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', () => {
|
it('does NOT enable polling when the attachment has no file_id', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
import { useSetRecoilState } from 'recoil';
|
||||||
import type { TAttachment, TFile, TFilePreview } from 'librechat-data-provider';
|
import type { TAttachment, TFile, TFilePreview } from 'librechat-data-provider';
|
||||||
import { useFilePreview } from '~/data-provider';
|
import { useFilePreview } from '~/data-provider';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
@ -39,24 +39,34 @@ interface UseAttachmentPreviewSyncResult {
|
||||||
*
|
*
|
||||||
* Polling is gated on:
|
* Polling is gated on:
|
||||||
* - `attachment.file_id` present (no id → nothing to poll for)
|
* - `attachment.file_id` present (no id → nothing to poll for)
|
||||||
* - Effective status is `'pending'` (terminal states need no work)
|
* - Effective status is `'pending'` (terminal states need no work).
|
||||||
* - Some conversation in the app is submitting (per the user's
|
* `useFilePreview`'s `refetchInterval` returns `false` the moment
|
||||||
* explicit gate: "only query when the preview is still live and
|
* the server reports `ready`/`failed`, so polling auto-terminates
|
||||||
* `isSubmitting` is still true"). When the LLM finishes, polling
|
* within one tick of resolution. Bounded ceiling: the server-side
|
||||||
* stops; the user can refresh on demand from the next interaction.
|
* 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(
|
export default function useAttachmentPreviewSync(
|
||||||
attachment: TAttachment | undefined,
|
attachment: TAttachment | undefined,
|
||||||
): UseAttachmentPreviewSyncResult {
|
): UseAttachmentPreviewSyncResult {
|
||||||
const setAttachmentsMap = useSetRecoilState(store.messageAttachmentsMap);
|
const setAttachmentsMap = useSetRecoilState(store.messageAttachmentsMap);
|
||||||
const isAnySubmitting = useRecoilValue(store.anySubmittingSelector);
|
|
||||||
|
|
||||||
const file = (attachment ?? undefined) as Partial<TFile> | undefined;
|
const file = (attachment ?? undefined) as Partial<TFile> | undefined;
|
||||||
const fileId = file?.file_id;
|
const fileId = file?.file_id;
|
||||||
const baseStatus: 'pending' | 'ready' | 'failed' = file?.status ?? 'ready';
|
const baseStatus: 'pending' | 'ready' | 'failed' = file?.status ?? 'ready';
|
||||||
const messageId = (attachment as Partial<TAttachment> | undefined)?.messageId;
|
const messageId = (attachment as Partial<TAttachment> | undefined)?.messageId;
|
||||||
|
|
||||||
const enabled = !!fileId && baseStatus === 'pending' && isAnySubmitting;
|
const enabled = !!fileId && baseStatus === 'pending';
|
||||||
|
|
||||||
const previewQuery = useFilePreview(fileId, { enabled });
|
const previewQuery = useFilePreview(fileId, { enabled });
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue