Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
bf36075
fix: list each duplicate file identity once in the folded file group
TomasPalsson Sep 28, 2026
05036a1
fix: scope file card dedup to its message and always show the newest …
TomasPalsson Sep 28, 2026
206f546
fix: stack each pptx slide as its own panel-width block
TomasPalsson Sep 28, 2026
ccacdaf
fix: guard the pptx bootstrap's finalize step against running twice
TomasPalsson Sep 28, 2026
abc2afd
test: prove pptx slide layout fits, refits and falls back cleanly
TomasPalsson Sep 28, 2026
1d9b25f
test: prove tied file cards settle once and search places cards per m…
TomasPalsson Sep 28, 2026
7c2486b
fix: pin each pptx slide to its block so wide panels do not shift it
TomasPalsson Sep 28, 2026
cd66c1a
fix: keep only the latest attachment for a repeated file within a mes…
TomasPalsson Sep 28, 2026
2ab35a9
Open the newest diagram version from every message's mermaid card
TomasPalsson Sep 28, 2026
74125d3
Reserve the pptx preview's scrollbar gutter so refits settle in one pass
TomasPalsson Sep 28, 2026
755a580
Pick the newest write when a file repeats in a message, keep a linked…
TomasPalsson Sep 29, 2026
77000bc
Use the same file identity for the folded file-card dedup as the rest…
TomasPalsson Sep 29, 2026
bbc29d7
Key id-less tool artifacts by filepath before filename so two same-na…
TomasPalsson Sep 29, 2026
62d1d9e
test: describe file card behavior in plain words in test names and co…
TomasPalsson Sep 29, 2026
4ffa5e3
docs: say exactly which attachments share the file card key
TomasPalsson Sep 29, 2026
73c4001
Merge branch 'dev' into fix/pptx-slide-scroll-file-cards
TomasPalsson Sep 29, 2026
cb7bfeb
Merge branch 'dev' into fix/pptx-slide-scroll-file-cards
TomasPalsson Sep 29, 2026
d225663
🧾 feat: Add Office Preview Setting and File Shell Contract
TomasPalsson Sep 29, 2026
b644402
📄 feat: Build Storage-Backed Office Preview Shells for Large Decks an…
TomasPalsson Sep 29, 2026
6945325
📄 feat: Fill Stored Office Preview Shells from File Storage in the Ar…
TomasPalsson Sep 29, 2026
5b626ad
🩹 fix: Keep Office Shell Previews Per Artifact and Out of the Code Ed…
TomasPalsson Sep 29, 2026
722b32f
🩹 fix: Show a Short Notice When a Large Office Preview Drops Its Fall…
TomasPalsson Sep 29, 2026
15c2f8c
📄 feat: Route Large Office Files to Storage-Backed Preview Shells and…
TomasPalsson Sep 29, 2026
71f9f2e
🩹 fix: Move Office Preview Failure Labeling Out of the Legacy Process…
TomasPalsson Sep 29, 2026
ccce6be
🩹 fix: Skip Base64 Encoding When Building Office File Shells
TomasPalsson Sep 29, 2026
b2b396d
🧪 test: Cover Office Shell Head Integrity, Size and Configured Limits
TomasPalsson Sep 29, 2026
c9551f0
🧪 test: Prove the Office Shell Size Check Strips the Fallback
TomasPalsson Sep 29, 2026
b00b09c
Merge branch 'dev' into feat/storage-backed-office-previews
TomasPalsson Sep 29, 2026
ff4f723
Merge branch 'dev' into feat/storage-backed-office-previews
TomasPalsson Sep 30, 2026
548d84c
Merge branch 'dev' into feat/storage-backed-office-previews
TomasPalsson Sep 30, 2026
b3a3b6a
Merge branch 'dev' into feat/storage-backed-office-previews
TomasPalsson Sep 30, 2026
18a4d2d
🔀 chore: Merge dev into feat/storage-backed-office-previews
TomasPalsson Oct 1, 2026
e459a19
🔀 chore: Merge dev into feat/storage-backed-office-previews
TomasPalsson Oct 1, 2026
0c98c7f
🔀 chore: Merge dev into feat/storage-backed-office-previews
TomasPalsson Oct 1, 2026
aa8f878
🔀 chore: Merge dev into feat/storage-backed-office-previews
TomasPalsson Oct 2, 2026
623cc29
🔀 chore: Merge dev into feat/storage-backed-office-previews
TomasPalsson Oct 2, 2026
4bb7939
🔀 chore: Merge dev into feat/storage-backed-office-previews
TomasPalsson Oct 4, 2026
731e31e
🔀 chore: Merge dev into feat/storage-backed-office-previews
TomasPalsson Oct 5, 2026
9cfe7b6
🔀 chore: Merge dev into feat/storage-backed-office-previews
TomasPalsson Oct 6, 2026
70a3c1c
Merge remote-tracking branch 'upstream/dev' into HEAD
TomasPalsson Oct 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions api/server/services/Files/Code/process.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const {
codeServerHttpAgent,
codeServerHttpsAgent,
extractCodeArtifactText,
officePreviewFailure,
extractCodeArtifactRawText,
extractCodeArtifactInspectionText,
prepareCodeOutputBufferForInspection,
Expand Down Expand Up @@ -433,12 +434,13 @@ const finalizePreview = async ({
category,
file_id,
previewRevision,
officePreview,
}) => {
let text = null;
let previewError;
try {
text = await withTimeout(
extractCodeArtifactText(buffer, leafName, mimeType, category),
extractCodeArtifactText(buffer, leafName, mimeType, category, officePreview),
PREVIEW_FINALIZE_TIMEOUT_MS,
`Preview extraction exceeded ${PREVIEW_FINALIZE_TIMEOUT_MS}ms`,
);
Expand All @@ -458,7 +460,7 @@ const finalizePreview = async ({
const failed = text == null;
const status = failed ? 'failed' : 'ready';
if (failed && !previewError) {
previewError = 'parser-error';
previewError = officePreviewFailure(buffer.length, leafName, mimeType, officePreview);
}
try {
/* Conditional update: commit only if `previewRevision` still
Expand Down
68 changes: 68 additions & 0 deletions api/server/services/Files/Code/process.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const mockExtractCodeArtifactInspectionText = jest.fn(async () => ({
complete: false,
}));
const mockExtractCodeArtifactText = jest.fn(async () => null);
const mockOfficePreviewFailure = jest.fn(() => 'parser-error');
const mockExecuteWorkspaceTool = jest.fn();
const mockGetExtractedTextFormat = jest.fn((_name, _mime, text) => (text == null ? null : 'text'));
/* `hasOfficeHtmlPath` gates the persist-then-render split: when true, processCodeOutput
Expand Down Expand Up @@ -160,6 +161,7 @@ jest.mock('@librechat/api', () => {
extractCodeArtifactRawText: (...args) => mockExtractCodeArtifactRawText(...args),
extractCodeArtifactInspectionText: (...args) => mockExtractCodeArtifactInspectionText(...args),
extractCodeArtifactText: (...args) => mockExtractCodeArtifactText(...args),
officePreviewFailure: (...args) => mockOfficePreviewFailure(...args),
getBoundedCodeOutputByteLimit: (configured) =>
typeof configured === 'number' && Number.isFinite(configured) && configured > 0
? Math.min(configured, 64 * 1024 * 1024)
Expand Down Expand Up @@ -1722,6 +1724,7 @@ describe('Code Process', () => {
* The `hasOfficeHtmlPath` mock is the gate. Other tests keep it
* at `false` (legacy single-phase path); we flip it on here. */
const { updateFile } = require('~/models');
const pptxMime = 'application/vnd.openxmlformats-officedocument.presentationml.presentation';

beforeEach(() => {
mockHasOfficeHtmlPath.mockReturnValue(true);
Expand Down Expand Up @@ -1810,6 +1813,71 @@ describe('Code Process', () => {
);
});

it('finalize() stores the failure label the helper returns for the buffer size, name, type and setting', async () => {
mockAxios.mockResolvedValue({ data: Buffer.alloc(100) });
determineFileType.mockResolvedValue({
mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
});
mockExtractCodeArtifactText.mockResolvedValueOnce(null);
mockOfficePreviewFailure.mockReturnValueOnce('too-large');

const { finalize } = await processCodeOutput({ ...baseParams, name: 'deck.pptx' });
await finalize();

expect(mockOfficePreviewFailure).toHaveBeenCalledWith(
100,
'deck.pptx',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
expect.objectContaining({ enabled: true }),
);
expect(updateFile).toHaveBeenCalledWith(
expect.objectContaining({ status: 'failed', previewError: 'too-large' }),
{ previewRevision: 'mock-uuid-1234' },
);
});

it('finalize() passes the resolved officePreview setting to the extractor', async () => {
mockAxios.mockResolvedValue({ data: Buffer.alloc(100) });
determineFileType.mockResolvedValue({
mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
});
mockExtractCodeArtifactText.mockResolvedValueOnce('<html></html>');

const { finalize } = await processCodeOutput({ ...baseParams, name: 'deck.pptx' });
await finalize();

expect(mockExtractCodeArtifactText).toHaveBeenCalledWith(
expect.any(Buffer),
'deck.pptx',
expect.any(String),
expect.any(String),
expect.objectContaining({ enabled: true, fileSizeLimit: 25 * 1024 * 1024 }),
);
});

it('finalize() hands a yaml fileSizeLimit in megabytes to the extractor and the failure label in bytes', async () => {
mockAxios.mockResolvedValue({ data: Buffer.alloc(100) });
determineFileType.mockResolvedValue({ mime: pptxMime });
mockExtractCodeArtifactText.mockResolvedValueOnce(null);
const req = {
...mockReq,
config: { ...mockReq.config, fileConfig: { officePreview: { fileSizeLimit: 5 } } },
};
const merged = { enabled: true, fileSizeLimit: 5 * 1024 * 1024 };

const { finalize } = await processCodeOutput({ ...baseParams, req, name: 'deck.pptx' });
await finalize();

expect(mockExtractCodeArtifactText).toHaveBeenCalledWith(
expect.any(Buffer),
'deck.pptx',
pptxMime,
expect.any(String),
merged,
);
expect(mockOfficePreviewFailure).toHaveBeenCalledWith(100, 'deck.pptx', pptxMime, merged);
});

it('finalize() transitions to failed with previewError:timeout when the outer timeout rejects', async () => {
/* The passthrough `withTimeout` mock at the file scope returns
* its inner promise unchanged, so the only way the catch branch
Expand Down
1 change: 1 addition & 0 deletions client/src/components/Artifacts/ArtifactTabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ jest.mock('~/data-provider', () => ({
useGetStartupConfig: (options: unknown) => mockUseGetStartupConfig(options),
useGetSharedStartupConfig: (shareId: unknown, options: unknown) =>
mockUseGetSharedStartupConfig(shareId, options),
useFilePreviewBlob: () => ({ refetch: jest.fn() }),
}));

jest.mock('~/hooks/Artifacts/useArtifactProps', () => ({
Expand Down
99 changes: 97 additions & 2 deletions client/src/components/Artifacts/SandboxArtifactTabs.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import { render } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import { OFFICE_DOC_DATA_SLOT, OFFICE_FILE_SHELL_MARKER } from 'librechat-data-provider';
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
import type { Artifact } from '~/common';
import SandboxArtifactTabs from './SandboxArtifactTabs';
Expand All @@ -12,11 +13,15 @@ interface PreviewProps {
}

const mockPreview = jest.fn((_props: PreviewProps) => null);
const mockEditor = jest.fn((_props: { artifact: Artifact }) => null);
const mockRefetch = jest.fn();
let mockUseRealShell = false;
let mockCurrentCode: string | undefined;
let mockShell: { content: string | undefined; isLoading: boolean } | undefined;
let mockCodeArtifactId: string | undefined;

jest.mock('./ArtifactCodeEditor', () => ({
ArtifactCodeEditor: () => null,
ArtifactCodeEditor: (props: { artifact: Artifact }) => mockEditor(props),
}));

jest.mock('./ArtifactPreview', () => ({
Expand All @@ -28,13 +33,27 @@ jest.mock('~/Providers/EditorContext', () => ({
mockCodeArtifactId === artifactId ? mockCurrentCode : undefined,
}));

jest.mock('~/hooks/Artifacts/useOfficeFileShell', () => ({
__esModule: true,
default: (artifact: Artifact) =>
mockShell ??
(mockUseRealShell
? jest.requireActual('~/hooks/Artifacts/useOfficeFileShell').default(artifact)
: { content: artifact.content, isLoading: false }),
}));

jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));

jest.mock('~/Providers', () => ({
useShareContext: () => ({ shareId: undefined }),
}));

jest.mock('~/data-provider', () => ({
useGetStartupConfig: () => ({ data: {} }),
useGetSharedStartupConfig: () => ({ data: {} }),
useFilePreviewBlob: () => ({ refetch: mockRefetch }),
}));

const previewRef = {
Expand Down Expand Up @@ -134,3 +153,79 @@ describe('SandboxArtifactTabs SVG preview', () => {
expect(call?.currentCode).toBe('<p>edited</p>');
});
});

describe('SandboxArtifactTabs office file shell', () => {
const shellArtifact: Artifact = {
id: 'deck-1',
type: 'text/html',
title: 'Deck',
content: '<p>shell</p>',
lastUpdateTime: 1,
download: { file_id: 'file-1', user: 'user-1' },
};

beforeEach(() => {
mockCurrentCode = undefined;
mockPreview.mockClear();
});

afterEach(() => {
mockShell = undefined;
});

it('shows the preparing state instead of the preview while the shell is filled', () => {
mockShell = { content: '<p>shell</p>', isLoading: true };
renderTabs(shellArtifact);
expect(screen.getByText('com_ui_preview_preparing')).toBeInTheDocument();
expect(mockPreview).not.toHaveBeenCalled();
});

it('previews the filled document once loading ends', () => {
mockShell = { content: '<p>filled</p>', isLoading: false };
renderTabs(shellArtifact);
expect(screen.queryByText('com_ui_preview_preparing')).not.toBeInTheDocument();
expect(lastFiles()['index.html']).toBe('<p>filled</p>');
});
});

describe('SandboxArtifactTabs with a stored office shell', () => {
const shell = `<html><head>${OFFICE_FILE_SHELL_MARKER}</head><body>${OFFICE_DOC_DATA_SLOT}</body></html>`;
const deck: Artifact = {
id: 'deck-2',
type: 'text/html',
title: 'Deck',
content: shell,
lastUpdateTime: 1,
download: { file_id: 'file-2', user: 'user-1' },
};

beforeEach(() => {
mockUseRealShell = true;
mockCurrentCode = undefined;
mockPreview.mockClear();
mockEditor.mockClear();
mockRefetch.mockReset();
});

afterEach(() => {
mockUseRealShell = false;
});

it('fills only the preview and leaves the editor on the stored shell', async () => {
mockRefetch.mockResolvedValue({ data: new Blob(['ABC']) });
const { rerender } = renderTabs(deck);
expect(screen.getByText('com_ui_preview_preparing')).toBeInTheDocument();
expect(mockPreview).not.toHaveBeenCalled();

await waitFor(() => expect(mockPreview).toHaveBeenCalled());
expect(lastFiles()['index.html']).toContain('QUJD');
expect(lastFiles()['index.html']).not.toContain(OFFICE_DOC_DATA_SLOT);

rerender(
<Tabs.Root value="code">
<SandboxArtifactTabs artifact={deck} previewRef={previewRef} />
</Tabs.Root>,
);
expect(mockEditor.mock.calls.at(-1)?.[0].artifact.content).toBe(shell);
});
});
25 changes: 23 additions & 2 deletions client/src/components/Artifacts/SandboxArtifactTabs.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,34 @@
import { useMemo, useRef } from 'react';
import { Spinner } from '@librechat/client';
import * as Tabs from '@radix-ui/react-tabs';
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
import type { editor } from 'monaco-editor';
import type { Artifact } from '~/common';
import { useGetSharedStartupConfig, useGetStartupConfig } from '~/data-provider';
import useOfficeFileShell from '~/hooks/Artifacts/useOfficeFileShell';
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
import { useArtifactCode } from '~/Providers/EditorContext';
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
import { ArtifactPreview } from './ArtifactPreview';
import { useShareContext } from '~/Providers';
import { useLocalize } from '~/hooks';

export default function SandboxArtifactTabs({
artifact,
artifact: storedArtifact,
previewRef,
isSharedConvo,
}: {
artifact: Artifact;
previewRef: React.MutableRefObject<SandpackPreviewRef>;
isSharedConvo?: boolean;
}) {
const localize = useLocalize();
const { content, isLoading } = useOfficeFileShell(storedArtifact);
const previewArtifact = useMemo(
() => ({ ...storedArtifact, content }),
[storedArtifact, content],
);
const artifact = storedArtifact;
const { shareId } = useShareContext();
const shouldUseSharedConfig =
isSharedConvo === true && typeof shareId === 'string' && shareId.length > 0;
Expand All @@ -34,7 +44,9 @@ export default function SandboxArtifactTabs({
* another artifact displaced is just as much this artifact's text. */
const editedCode = useArtifactCode(artifact.id);

const { files, fileKey, template, sharedProps, deriveFiles } = useArtifactProps({ artifact });
const { files, fileKey, template, sharedProps, deriveFiles } = useArtifactProps({
artifact: previewArtifact,
});

/* An artifact whose preview entry is derived from its source needs the whole
* set rebuilt from the editor text; `ArtifactPreview` can only swap the file
Expand All @@ -44,6 +56,15 @@ export default function SandboxArtifactTabs({
[deriveFiles, editedCode, files],
);

if (isLoading) {
return (
<div className="text-text-secondary flex h-full w-full items-center justify-center gap-2 text-sm">
<Spinner size={16} />
{localize('com_ui_preview_preparing')}
</div>
);
}

return (
<div className="flex h-full w-full flex-col">
<Tabs.Content
Expand Down
23 changes: 18 additions & 5 deletions client/src/components/Chat/Messages/Content/Parts/Attachment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ import {
renderAttachmentKey,
} from './attachmentTypes';
import { useLocalize, useAttachmentPreviewSync, useExpandCollapse } from '~/hooks';
import FileContainer from '~/components/Chat/Input/Files/FileContainer';
import { fileToArtifact, TOOL_ARTIFACT_TYPES } from '~/utils/artifacts';
import FileContainer from '~/components/Chat/Input/Files/FileContainer';
import Image from '~/components/Chat/Messages/Content/Image';
import { ROW_GLYPH_SLOT, TOOL_ROW_CLASSES } from '../rows';
import ToolMermaidArtifact from './ToolMermaidArtifact';
import ToolArtifactCard from './ToolArtifactCard';
import { useAttachmentLink } from './LogLink';
import { fileIdentity } from '~/utils/map';
import { cn } from '~/utils';

const COLLAPSED_MAX_HEIGHT = 320;
Expand Down Expand Up @@ -176,10 +177,22 @@ const FileAttachmentGroup = memo(({ attachments }: { attachments: TAttachment[]
const panelId = useId();
const [isExpanded, setIsExpanded] = useState(false);
const { style: expandStyle, ref: expandRef } = useExpandCollapse(isExpanded);
const visibleAttachments = useMemo(
() => attachments.filter((attachment) => Boolean(attachment.filepath)),
[attachments],
);
const visibleAttachments = useMemo(() => {
// Same file identity can arrive twice (e.g. two tool calls touching
// one file in a message) — keep the last occurrence so the folded
// row lists it once instead of listing the same name twice.
const byIdentity = new Map<string, TAttachment>();
let unidentifiedCount = 0;
for (const attachment of attachments) {
if (!attachment.filepath) {
continue;
}
const key = fileIdentity(attachment) ?? `__unidentified-${unidentifiedCount++}`;
byIdentity.delete(key);
byIdentity.set(key, attachment);
}
return Array.from(byIdentity.values());
}, [attachments]);
const count = visibleAttachments.length;
const summary = useMemo(() => {
const names = visibleAttachments.map((attachment) => displayFilename(attachment.filename));
Expand Down
Loading