Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
bc14963
fix: list each duplicate file identity once in the folded file group
TomasPalsson Sep 28, 2026
79f94a7
fix: scope file card dedup to its message and always show the newest …
TomasPalsson Sep 28, 2026
fdb5e75
fix: stack each pptx slide as its own panel-width block
TomasPalsson Sep 28, 2026
3751c83
fix: guard the pptx bootstrap's finalize step against running twice
TomasPalsson Sep 28, 2026
270a3e9
test: prove pptx slide layout fits, refits and falls back cleanly
TomasPalsson Sep 28, 2026
eb3458f
test: prove tied file cards settle once and search places cards per m…
TomasPalsson Sep 28, 2026
5e651e1
fix: pin each pptx slide to its block so wide panels do not shift it
TomasPalsson Sep 28, 2026
b8301e2
fix: keep only the latest attachment for a repeated file within a mes…
TomasPalsson Sep 28, 2026
cd6ae33
Open the newest diagram version from every message's mermaid card
TomasPalsson Sep 28, 2026
11edde6
Reserve the pptx preview's scrollbar gutter so refits settle in one pass
TomasPalsson Sep 28, 2026
79df786
Pick the newest write when a file repeats in a message, keep a linked…
TomasPalsson Sep 29, 2026
50f44c6
Use the same file identity for the folded file-card dedup as the rest…
TomasPalsson Sep 29, 2026
3858716
Key id-less tool artifacts by filepath before filename so two same-na…
TomasPalsson Sep 29, 2026
8eeaf0f
test: describe file card behavior in plain words in test names and co…
TomasPalsson Sep 29, 2026
235936e
docs: say exactly which attachments share the file card key
TomasPalsson Sep 29, 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
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 @@ -179,10 +180,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
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { memo, useEffect, useId, useLayoutEffect, useRef } from 'react';
import { memo, useEffect, useLayoutEffect, useRef } from 'react';
import {
useRecoilCallback,
useRecoilState,
Expand All @@ -9,6 +9,7 @@ import {
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
import type { Artifact } from '~/common';
import { artifactRowKind, isCodeOnlyArtifact } from '~/utils/artifacts';
import useToolArtifactClaim, { isStrictlyNewer } from './claim';
import { displayFilename } from './attachmentTypes';
import { useAttachmentLink } from './LogLink';
import ArtifactRow from './ArtifactRow';
Expand All @@ -24,27 +25,30 @@ interface ToolArtifactCardProps {
*
* Three effects, separately scoped:
*
* 1. **Dedup claim** (`useLayoutEffect`, runs synchronously before
* paint). The same file can appear in multiple tool calls within a
* single message (e.g. the agent reads back what it just wrote) or
* across messages. Each card claims `toolArtifactClaim(artifact.id)`
* with its unique component-instance key on mount; the latest card
* to mount wins, so older duplicates re-render to `null`. The atom
* is family-keyed by artifact id, so claims for unrelated artifacts
* don't trigger re-renders here. Cleanup releases the claim if it's
* still ours so a subsequent re-mount can take it.
* 1. **Dedup claim** (`useToolArtifactClaim`, via `useLayoutEffect`
* inside it, runs synchronously before paint). The same file can
* appear in multiple tool calls within a single message (e.g. the
* agent reads back what it just wrote) or across messages. The
* shared hook scopes the claim to the mounting message (falling
* back to the bare artifact id where no message is known), so the
* same file shows one card per message instead of one card total.
* Within one message the latest card to mount wins, so older
* duplicates re-render to `null`.
*
* 2. **Self-heal registration** subscribes to the per-id selector
* `artifactByIdSelector(artifact.id)` and writes only when the
* entry is missing or the cached content/type/title drifted. The
* entry is missing or the cached content/type/title drifted AND
* this card's version is at least as new (`lastUpdateTime`). The
* panel's `useArtifacts` hook resets `artifactsState` on close, so
* this re-fires deterministically once the slice transitions back
* to `undefined` β€” without the no-deps render-loop pattern. The
* write is also gated on `isMyClaim`, making the registration
* single-writer per id: when two cards exist for the same file
* across turns, only the latest (claim-holder) updates state.
* Without that guard, both cards would observe each other's write
* and trade overwrites in a loop.
* to `undefined` β€” without the no-deps render-loop pattern. A
* strictly newer version always wins regardless of mount order;
* when two versions share a `lastUpdateTime` (no timestamp signal),
* the tie breaks on a SEPARATE global "latest mount" claim kept
* only for this purpose β€” today's semantics, scoped to the id alone
* so it survives across messages. That single-writer tie-break is
* what stops two cards for the same file from observing each
* other's write and trading overwrites in a loop.
*
* 3. **Focus + open on mount** (deps: artifact.id, artifact.type) β€”
* gated on `isSubmitting` captured at first render via a ref AND
Expand All @@ -65,7 +69,6 @@ interface ToolArtifactCardProps {
* of context.
*/
const ToolArtifactCard = memo(({ attachment, artifact }: ToolArtifactCardProps) => {
const claimKey = useId();
const file = attachment as TFile & TAttachmentMetadata;
const fileId = file.file_id;
const setVisible = useSetRecoilState(store.artifactsVisibility);
Expand All @@ -74,9 +77,15 @@ const ToolArtifactCard = memo(({ attachment, artifact }: ToolArtifactCardProps)
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
const currentArtifactId = useRecoilValue(store.currentArtifactId);
const existingEntry = useRecoilValue(store.artifactByIdSelector(artifact.id));
const [claim, setClaim] = useRecoilState(store.toolArtifactClaim(artifact.id));
const { isMyClaim: canRender, claimKey } = useToolArtifactClaim(artifact.id);
// Global (message-independent) claim β€” used only as the registration
// tie-break below, never for the render-null gate. Reuses the same
// `claimKey` as the display claim above so the two never fight over the
// SAME atom entry in the no-message fallback, where both keys collapse
// to `store.toolArtifactClaim(artifact.id)`.
const [globalClaim, setGlobalClaim] = useRecoilState(store.toolArtifactClaim(artifact.id));
const isMyGlobalClaim = globalClaim === claimKey;
const isSelected = artifact.id === currentArtifactId;
const isMyClaim = claim === claimKey;
/* Read+reset on mount only β€” `useRecoilCallback` avoids subscribing
* to the per-file_id flag (no re-renders when other files resolve).
* The deferred-preview hook flips this to `true` on the pending→ready
Expand Down Expand Up @@ -119,35 +128,41 @@ const ToolArtifactCard = memo(({ attachment, artifact }: ToolArtifactCardProps)
}

useLayoutEffect(() => {
// Always (re)claim on mount β€” a later card for the same id displaces
// an earlier one, so the chip migrates to the most recent mention.
setClaim(claimKey);
// Always (re)claim the global slot on mount β€” keeps today's
// latest-mount semantics as the tie-break input for the registration
// effect below, independent of which message a card renders under.
setGlobalClaim(claimKey);
return () => {
// Only release when the claim is still ours; if a sibling already
// took over we don't want to clobber its claim.
setClaim((prev) => (prev === claimKey ? null : prev));
setGlobalClaim((prev) => (prev === claimKey ? null : prev));
};
}, [claimKey, setClaim]);
}, [claimKey, setGlobalClaim]);

useEffect(() => {
// Only the claim-winner writes. Two cards with the same `artifact.id`
// but divergent content (same file_id reused across turns) would
// otherwise see each other's write through `existingEntry`, detect
// drift, and trade overwrites in a loop. Gating on `isMyClaim`
// makes registration single-writer per id.
if (!isMyClaim) {
return;
}
if (
const contentDrifted = !(
existingEntry != null &&
existingEntry.content === artifact.content &&
existingEntry.type === artifact.type &&
existingEntry.title === artifact.title
) {
);
if (!contentDrifted) {
return;
}
// A strictly newer version always wins, regardless of mount order.
// When two versions share a `lastUpdateTime` (no timestamp signal to
// order them), fall back to the global "latest mount" claim so a
// ping-ponging pair still converges on ONE writer instead of trading
// overwrites in a loop.
const isNewerOrTied =
existingEntry == null ||
isStrictlyNewer(artifact, existingEntry) ||
(artifact.lastUpdateTime === existingEntry.lastUpdateTime && isMyGlobalClaim);
if (!isNewerOrTied) {
return;
}
setArtifacts((prev) => ({ ...(prev ?? {}), [artifact.id]: artifact }));
}, [artifact, existingEntry, isMyClaim, setArtifacts]);
}, [artifact, existingEntry, isMyGlobalClaim, setArtifacts]);

useEffect(() => {
if (isCodeOnlyArtifact(artifact.type)) {
Expand Down Expand Up @@ -207,9 +222,9 @@ const ToolArtifactCard = memo(({ attachment, artifact }: ToolArtifactCardProps)
setVisible(true);
};

// Another card with the same artifact id has the active claim β€” render
// nothing here, that row is the canonical trigger for this file.
if (claim != null && !isMyClaim) {
// Another card holds the message-scoped display claim for this file β€”
// render nothing here, that row is the canonical trigger for this file.
if (!canRender) {
return null;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { memo, useId, useLayoutEffect, useMemo, useState } from 'react';
import { memo, useLayoutEffect, useMemo, useState } from 'react';
import { useAtom } from 'jotai';
import { Download } from 'lucide-react';
import { useRecoilState } from 'recoil';
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
import useToolArtifactClaim, { isStrictlyNewer, newestToolArtifactFamily } from './claim';
import { fileToArtifact, TOOL_ARTIFACT_TYPES, toolArtifactKey } from '~/utils/artifacts';
import Mermaid from '~/components/Messages/Content/Mermaid/Mermaid';
import { displayFilename } from './attachmentTypes';
import { useAttachmentLink } from './LogLink';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';

interface ToolMermaidArtifactProps {
attachment: TAttachment;
Expand All @@ -20,27 +20,29 @@ interface ToolMermaidArtifactProps {
* user opens it in the Artifact panel. The compact card keeps the file
* available in chat without rendering the same diagram twice.
*
* Shares the `toolArtifactClaim` dedup atom with `ToolArtifactCard` so
* the same `.mmd` file can't double-render across tool calls / messages.
* Shares `useToolArtifactClaim` with `ToolArtifactCard` so the same
* `.mmd` file dedups identically: one card per message (falling back to
* one card total where no message is known).
*
* A diagram remade in a later turn (same file identity, newer
* `lastUpdateTime`) must still open the newest version from an EARLIER
* message's card. Every mount offers this instance's own artifact to
* `newestToolArtifactFamily`, keyed by file identity; the artifact
* handed to `Mermaid` (which is what gets registered once opened) is
* upgraded to that newest entry when it's ahead of this instance's own
* version, while the inline diagram below keeps rendering `text` β€” this
* message's own source β€” unchanged.
*/
const ToolMermaidArtifact = memo(({ attachment, text }: ToolMermaidArtifactProps) => {
const localize = useLocalize();
const file = attachment as TFile & TAttachmentMetadata;
const claimKey = useId();
const [claim, setClaim] = useRecoilState(store.toolArtifactClaim(toolArtifactKey(file)));
const isMyClaim = claim === claimKey;
const fileKey = toolArtifactKey(file);
const { isMyClaim } = useToolArtifactClaim(fileKey);
/* Once the diagram collapses into its trigger row, that row carries the
* filename and the download itself, so this header would repeat both
* beside it. */
const [isRowMode, setIsRowMode] = useState(false);

useLayoutEffect(() => {
setClaim(claimKey);
return () => {
setClaim((prev) => (prev === claimKey ? null : prev));
};
}, [claimKey, setClaim]);

const { handleDownload } = useAttachmentLink({
href: attachment.filepath ?? '',
filename: attachment.filename ?? '',
Expand All @@ -53,12 +55,32 @@ const ToolMermaidArtifact = memo(({ attachment, text }: ToolMermaidArtifactProps
fileToArtifact({ ...attachment, text }, { preClassifiedType: TOOL_ARTIFACT_TYPES.MERMAID }),
[attachment, text],
);
const [newestArtifact, setNewestArtifact] = useAtom(newestToolArtifactFamily(fileKey));

useLayoutEffect(() => {
if (artifact == null) {
return;
}
setNewestArtifact((current) =>
current == null || isStrictlyNewer(artifact, current) ? artifact : current,
);
}, [artifact, setNewestArtifact]);

if (claim != null && !isMyClaim) {
if (!isMyClaim) {
return null;
}

const visibleFilename = displayFilename(attachment.filename);
// Adopt the shared family's entry unless THIS instance's own artifact is
// strictly newer than it (can't happen once its own mount effect has
// offered it, but guards the pre-effect render). A tied `lastUpdateTime`
// is "not strictly newer" in either direction, so every mounted instance
// converges on whichever version the family already settled on instead
// of each preferring its own on a tie.
const registeredArtifact =
artifact != null && newestArtifact != null && !isStrictlyNewer(artifact, newestArtifact)
? newestArtifact
: artifact;

return (
<div className="my-2 flex w-full flex-col gap-1">
Expand Down Expand Up @@ -95,7 +117,7 @@ const ToolMermaidArtifact = memo(({ attachment, text }: ToolMermaidArtifactProps
{file.file_id ? (
<Mermaid
id={file.file_id}
artifact={artifact ?? undefined}
artifact={registeredArtifact ?? undefined}
onDownload={attachment.filepath ? handleDownload : undefined}
onRowModeChange={setIsRowMode}
rowTitle={attachment.filename ? visibleFilename : undefined}
Expand All @@ -104,7 +126,7 @@ const ToolMermaidArtifact = memo(({ attachment, text }: ToolMermaidArtifactProps
</Mermaid>
) : (
<Mermaid
artifact={artifact ?? undefined}
artifact={registeredArtifact ?? undefined}
onDownload={attachment.filepath ? handleDownload : undefined}
onRowModeChange={setIsRowMode}
rowTitle={attachment.filename ? visibleFilename : undefined}
Expand Down
Loading
Loading