From 756530c2b8d23b24ba0713b8ff6f002a4c5e8ddf Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 29 Apr 2026 21:53:10 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=A9=B9=20fix:=20Polish=20code-execution?= =?UTF-8?q?=20attachment=20UX=20(#12870)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🧹 chore: Strip code-execution boilerplate from tool output The bash executor in `@librechat/agents` appends two kinds of noise to every successful run: 1. Trailing `Note:` paragraphs β€” long behavioral hints repeating rules already in the system prompt ("Files from previous executions are automatically available...", "Files in 'Available files' are inputs..."). Re-stating these on every tool call adds ~50 tokens of waste per call, which compounds across long agent traces. 2. Per-file `| ` suffixes on every line of `Generated files:` / `Available files (...):`. The two section headers already convey the new-vs-known distinction; the per-file annotations are redundant *and* phrased inconsistently ("downloaded by the user" vs. "displayed to the user" vs. "known to the user"). Strip both in a small `cleanCodeToolOutput` helper invoked from `packages/api/src/agents/handlers.ts` for every tool listed in `CODE_EXECUTION_TOOLS`. Non-code-execution tools pass through unchanged. The cleaning happens *after* tool resolution but *before* downstream consumers (model context, SSE forwarding, persistence) see the content, so subsequent model turns get the lean output. * 🩹 fix: Polish code-execution attachment rendering Three rough edges visible in code-interpreter conversations: 1. **Sandbox-internal `.dirkeep` placeholders leak as file chips.** The bash executor creates `.dirkeep` inside any new directory so the stateless container preserves the folder across executions. After `sanitizeArtifactPath`'s `_` prefix and 6-hex collision suffix it surfaces as `_.dirkeep-` β€” a 0-byte chip with no value to the user, sometimes hiding the real artifact behind it. New `isInternalSandboxArtifact` helper filters them out of every routing path (`Attachment`, `AttachmentGroup`, `LogContent`). 2. **The `-` collision suffix is visible in chip labels.** The suffix is collision-avoidance machinery; users only need to see the canonical name. New `displayFilename` strips it for display while leaving the on-disk `attachment.filename` untouched so downloads resolve. Applied across `FileContainer`, `ToolArtifactCard`, `ToolMermaidArtifact`, and `LogContent`'s text-attachment label path. 3. **0-byte / placeholder files outrank real artifacts in render order.** Bucket sort by salience (non-empty before empty) sinks stragglers to the bottom. Stable sort preserves arrival order for peers. Added regression tests cover the new helpers, the dirkeep filter across buckets, and the within-bucket salience ordering. * 🩹 fix: Don't auto-open artifact panel on history navigation Navigating to a previous conversation full of code-execution artifacts would auto-open the side panel and focus the most-recent artifact β€” the same code path that fires for fresh streaming artifacts. Users expect that "auto-open" behavior only when an artifact arrives via SSE, not when they revisit an old chat. Two-part gate: 1. `ToolArtifactCard`'s focus effect captures `isSubmitting` at first render via a ref. A card mounted *during* a stream means a new artifact arrived β†’ steal panel focus (legacy behavior). A card mounted while `isSubmitting === false` is part of conversation history β†’ leave focus alone. 2. `Presentation`'s panel-render condition gains `currentArtifactId != null`. With (1) keeping `currentArtifactId` null on history load, the panel stops rendering at all on navigation β€” even if `artifactsVisibility` was left `true` by a prior conversation. User clicks on a chip to re-open (the click handler is unchanged and unconditional). Test seeds `isSubmittingFamily(0)` per case: existing tests opt into streaming (default `true`) so legacy auto-focus assertions still hold; new tests for history-load opt into `streaming: false` and verify no auto-focus + click-to-open still works. * 🩹 fix: Force panel visible on streaming artifact arrival The previous commit gated `setCurrentArtifactId` on `isSubmitting` but left `artifactsVisibility` untouched. When a user had explicitly closed the panel earlier in the session, a fresh SSE artifact would set `currentArtifactId` (so the chip read "click to close") but `Presentation`'s render condition still required `visibility === true` β€” net effect: the card claimed to be open, the panel stayed hidden. Streaming arrivals now also call `setVisible(true)`, which is the explicit "auto-open when first created" behavior the user asked for. History mounts (`isSubmitting === false`) still leave both focus and visibility alone, so navigating to an old conversation does not re-open the panel. Two regression tests added: one asserts streaming flips visibility on even when seeded false, the other asserts history mounts leave a seeded-false visibility alone. * 🧹 chore: Tighten code-execution attachment polish per audit feedback Resolves the eight actionable findings from the comprehensive audit: - Scope `displayFilename` out of `FileContainer`: opt-in via a new `displayName` prop. User-uploaded chips (input area, persisted message files) keep their raw filename, eliminating the false-positive class where `report-abc123.pdf` was silently rewritten to `report.pdf`. Code-execution artifact paths in `Attachment.tsx` explicitly compute the de-suffixed name and pass it through. - Tighten `TRAILING_NOTES_PATTERN` to anchor on the two known boilerplate openings (`Files from previous executions`, `Files in "Available files"`), so a user-authored `Note:` line preceded by a blank line in stdout no longer gets eaten along with everything after it. - `ToolMermaidArtifact`: compute `visibleFilename` once and reuse for title, content, and the download `aria-label` (was using the raw `attachment.filename` for the aria-label, creating a screen-reader inconsistency). - `ToolArtifactCard`: read `isSubmittingFamily(0)` once via a non-subscribing `useRecoilCallback`, instead of subscribing for the full lifetime to a value the ref only ever needs at first render. - Extract `bySalience` and `byEntrySalience` comparators from `attachmentTypes.ts`, replacing the ten duplicated sort lambdas in `Attachment.tsx` and `LogContent.tsx`. - Treat `attachmentSalience({ bytes: undefined })` as neutral (`0`) rather than empty (`1`); only an explicit `bytes === 0` sinks. Stops non-code-exec sources (web-search inline results, files where the schema omits the byte count) from silently sinking past real content. - Pin the click-history test to the panel-open button by name instead of relying on `getByRole('button', { pressed: false })`, which matched by DOM order. - Add the missing blank line between adjacent `it(...)` blocks. - Drop the verbose narrating comments in `FileContainer` along with the removed `displayFilename` import. Adds three regression tests for the new behavior (FileContainer raw filename, artifact-context displayName flow, user-authored `Note:` line preserved through cleanup) and updates the salience test for the new neutral-undefined semantics. * 🧹 chore: Drop redundant `@testing-library/jest-dom` import in FileContainer spec `client/test/setupTests.js` already imports the matchers globally for every Jest test in the client workspace, so the explicit import here was dead code. Removing it brings the spec in line with the broader convention used by `ArtifactRouting.test.tsx`, `LogContent.test.tsx`, and `attachmentTypes.test.ts`. * πŸ›‘οΈ fix: Narrow `.dirkeep`/`.gitkeep` filter to the sandbox-specific form `isInternalSandboxArtifact` was filtering bare `.dirkeep` / `.gitkeep` along with the post-sanitization form. Bare versions never originate from the bash executor (the dotfile rewrite + disambiguator step in `sanitizeArtifactPath` always produces `_.dirkeep-<6 hex>`), so the only real-world source of a bare `.gitkeep` is project scaffolding the user uploaded β€” silently hiding it from every attachment bucket meant the file disappeared with no way to surface or download it. Tightening to `^_\.(?:dirkeep|gitkeep)-[0-9a-f]{6}$` keeps the sandbox-placeholder filter intact while letting user-uploaded markers render normally. Tests inverted accordingly: bare forms now expected to render; only the post-sanitization form is filtered. * 🩹 fix: Address comprehensive-review findings on attachment helpers Five findings from the latest pass: - **MAJOR β€” `displayFilename` false-positive on extensionless 6-hex.** The previous regex `/-[0-9a-f]{6}(?=\.[^.]+$|$)/` stripped any leaf ending in `-XXXXXX` regardless of context, so a user-named `build-a1b2c3` (script-emitted hash artifact, no extension) lost its tail and rendered as `build`. Split into two narrower patterns: `COLLISION_SUFFIX_BEFORE_EXT` only matches when followed by an extension; `SANITIZED_DOTFILE_TRAILING_SUFFIX` only fires when the leaf starts with `_.` AND ends with `-XXXXXX` β€” the unambiguous fingerprint of `sanitizeArtifactPath`'s dotfile rewrite. - **MINOR β€” `isInternalSandboxArtifact` filter too aggressive.** `(file.bytes ?? 0) > 0` treated undefined bytes as zero, falling through to the regex check. Tightened to `file.bytes !== 0`: only an *explicit* zero counts as the empty-placeholder shape worth hiding. Non-code-exec sources without `bytes` populated render normally now. - **MINOR β€” `getValue()` could throw on a degenerate atom state.** Switched the snapshot read in `ToolArtifactCard` to `valueMaybe() ?? false` so a transient error / loading state on the upstream selector doesn't crash card mount. The `false` default is the right history-fallback (don't auto-open if we can't classify). - **NIT β€” `attachmentSalience` / `bySalience` over-broad signature.** Removed the test-only `{ bytes?: number }` arm; functions now accept `TAttachment` directly. The internal `bytes` read still goes through a cast since not every TAttachment branch declares it. Tests updated to use the existing `baseAttachment(...)` helper. - **MINOR β€” Missing regression test for extensionless 6-hex.** Added `'build-a1b2c3'` and `'out/blob-deadbe'` cases that pin the preservation behavior, plus an `isInternalSandboxArtifact` test that asserts undefined-bytes attachments are not filtered. * 🩹 fix: Make code-file artifacts click-to-open only Removes mount-time auto-open from `ToolArtifactCard`. Streaming arrivals no longer hijack the panel β€” even a freshly-emitted SSE artifact registers silently in `artifactsState` and waits for the user to click. Combined with `Presentation`'s `currentArtifactId != null` render gate, the panel stays closed across history navigation, page reload, and SSE arrival. Click is the only path that opens the panel. `handleOpen` is unchanged: first click focuses + reveals, second click on the same chip closes. Dropped: - `useRecoilCallback` snapshot read of `isSubmittingFamily(0)` - `mountedDuringStreamRef` ref + lazy-init block - The whole focus + visibility effect (was effect 3) - `useRef` import (now unused) Tests: - `ArtifactRouting.test.tsx` rewritten to exercise the click path: registers-on-mount-without-focus, click-to-open-then-close, multi- card-no-auto-focus, click-when-visibility-was-false. The streaming state is no longer seeded; both `renderWith` and `renderWithProbe` collapsed back to plain `RecoilRoot`. - `LogContent.test.tsx` flips its panel-routing assertions from `pressed: true` (which asserted auto-focus) to `pressed: false` with a chip-title check (which asserts the panel card rendered but stayed unfocused). * Revert "🩹 fix: Make code-file artifacts click-to-open only" This reverts commit 67615312878d58da1f9ea3cd40da5ea454d9e699. * 🩹 fix: Exclude CODE bucket from streaming auto-open Narrows the previous-commit revert: rich-preview artifacts (HTML, React, Markdown, plain text) keep the legacy SSE auto-open UX, but the CODE bucket (`.py`, `.js`, `.cpp`, `Dockerfile`, `Makefile`, …) stays click-to-open even on streaming. Source-code artifacts are typically supporting helpers the agent emits alongside a richer deliverable (a Python script that builds the actual `.html` output, for example). Auto-opening every helper's panel each time it gets written would shove the panel in front of the user every tool call. The user explicitly opens a code chip when they want to inspect it. Implementation: - Focus+open effect skips early when `artifact.type === CODE`. - `artifact.type` added to the dep array so the gate re-evaluates if the type ever changes (it shouldn't, but the dep is honest). - JSDoc updated to call out the carve-out. Tests: - New `does NOT auto-open a streaming CODE artifact (test.py is click-to-open)` β€” seeds isSubmitting=true, mounts a `.py`, asserts the artifact registers but currentArtifactId stays null. - New `clicking a CODE artifact focuses it even though it skipped auto-open` β€” confirms the click path still surfaces a `.py`. - All 25 prior auto-open tests for HTML/React/Markdown/plain-text buckets still pass unchanged: those types continue to auto-open on streaming. * 🧹 chore: Address two NITs from the audit-fix follow-up review - **NIT #1 (conf 60)**: Add a test for the dotfile-with-extension intersection (`_.config-abcdef.txt` β†’ `.config.txt`). Both halves of the path were tested separately β€” extension-anchored suffix stripping and `_.` underscore restoration β€” but the combination wasn't pinned. Adds `expect(displayFilename('_.config-abcdef.txt')) .toBe('.config.txt')`. - **NIT #2 (conf 25)**: Tighten the cast in `attachmentSalience` from the anonymous `{ bytes?: number }` shape to the concrete `TFile & TAttachmentMetadata` (the actual TAttachment branch that declares `bytes`). Same runtime behavior; a future retype of `TFile.bytes` will now surface here at compile time instead of being silently papered over. * 🩹 fix: Stop stripping `-<6 hex>` suffixes from non-dotfile filenames Codex's repeated P2 was correct: the `COLLISION_SUFFIX_BEFORE_EXT` regex stripped any `-<6 hex>` immediately before an extension regardless of context. That collapsed legitimate user-named files like `report-deadbe.csv` and `report-beef01.csv` onto the same chip label `report.csv`, silently merging distinct files in the UI. The structural truth: only the dotfile shape (`_.foo-XXXXXX`) carries an unambiguous discriminator (the leading `_.` that `sanitizeArtifactPath` adds when rewriting a leading dot). The extension-only case (`name-.ext`) has no such discriminator β€” we can't distinguish a sanitized `report 1.csv` (which became `report_1-.csv`) from a user-named `report-deadbe.csv` from the filename alone. Recovering the non-dotfile case cleanly would require a backend `wasSanitized` metadata flag we don't have. Without it, the safer choice is to leave non-dotfile names alone β€” uglier when the file *was* sanitized, but never collapses distinct files onto a shared label. Changes: - Drop `COLLISION_SUFFIX_BEFORE_EXT`. Replace `SANITIZED_DOTFILE_TRAILING_SUFFIX` with a unified `SANITIZED_DOTFILE_PATTERN` that handles both extensionless and with-extension dotfile shapes in one regex. - Simplify `displayFilename` to a single match + reconstruct path. - Update tests: drop the broad-stripping assertion (`output-deadbe.csv` β†’ `output.csv`), add explicit codex-regression cases (`report-deadbe.csv` and `report-beef01.csv` preserve unchanged), document the deliberate non-recovery for sanitized non-dotfiles, update the AttachmentGroupβ†’FileContainer integration test to reflect the narrower stripping (non-dotfile `archive-deadbe.zip` passes through; new dotfile `_.config-abcdef.zip` β†’ `.config.zip` exercises the recoverable path). * 🩹 fix: Scope code-tool annotation stripping to file-list sections Codex was right: the previous global `.replace` would mutate any line ending in one of the three annotation phrases β€” even legitimate stdout. A user script doing `echo "foo | File is already downloaded by the user"` had its output silently scrubbed before being fed back into model context. New `FILE_SECTION_PATTERN` captures `Generated files:` / `Available files (...)` blocks (header + lines starting with `- /`). Annotation stripping now only runs *within* the captured file-list section via a nested `.replace`, so: - Inside the section: per-file `| ` suffixes still get stripped (line-per-file β‰₯ 4 files form, inline `, ` comma-separated ≀ 3 files form β€” both already covered by existing patterns). - Outside the section: stdout, stderr, blank lines, the trailing `Note:` paragraphs (handled by their own pattern), and any user text that coincidentally contains an annotation phrase pass through unchanged. Tests: - New `does NOT mutate stdout that legitimately contains an annotation phrase outside a file-list section` pins the codex regression: three coincidental phrases in stdout, no `Generated files:` header, all three preserved verbatim. - New `strips annotations inside a file-list section but preserves identical phrases in stdout above it` covers the mixed case where the same phrase appears in both stdout and a file listing β€” stdout survives, listing gets cleaned, exactly one occurrence remains. - All 9 prior tests still pass (file-section stripping behavior unchanged for both line-per-file and inline-comma layouts). --- .../Chat/Input/Files/FileContainer.tsx | 14 +- .../Files/__tests__/FileContainer.spec.tsx | 56 ++++ .../Messages/Content/Parts/Attachment.tsx | 24 ++ .../Messages/Content/Parts/LogContent.tsx | 27 +- .../Content/Parts/ToolArtifactCard.tsx | 96 +++++- .../Content/Parts/ToolMermaidArtifact.tsx | 9 +- .../Parts/__tests__/ArtifactRouting.test.tsx | 285 +++++++++++++++++- .../Parts/__tests__/LogContent.test.tsx | 16 +- .../Parts/__tests__/attachmentTypes.test.ts | 184 ++++++++++- .../Messages/Content/Parts/attachmentTypes.ts | 137 +++++++++ client/src/components/Chat/Presentation.tsx | 16 +- packages/api/src/agents/cleanup.spec.ts | 172 +++++++++++ packages/api/src/agents/cleanup.ts | 103 +++++++ packages/api/src/agents/handlers.ts | 17 +- 14 files changed, 1120 insertions(+), 36 deletions(-) create mode 100644 client/src/components/Chat/Input/Files/__tests__/FileContainer.spec.tsx create mode 100644 packages/api/src/agents/cleanup.spec.ts create mode 100644 packages/api/src/agents/cleanup.ts diff --git a/client/src/components/Chat/Input/Files/FileContainer.tsx b/client/src/components/Chat/Input/Files/FileContainer.tsx index 5a22fef256..a4b902fac4 100644 --- a/client/src/components/Chat/Input/Files/FileContainer.tsx +++ b/client/src/components/Chat/Input/Files/FileContainer.tsx @@ -7,6 +7,7 @@ import RemoveFile from './RemoveFile'; const FileContainer = ({ file, overrideType, + displayName, buttonClassName, containerClassName, onDelete, @@ -14,12 +15,19 @@ const FileContainer = ({ }: { file: Partial; overrideType?: string; + /** + * Optional pre-computed label for the chip. Callers in code-execution + * artifact contexts pass the de-suffixed name; upload chips and + * persisted user files leave this undefined and render the raw filename. + */ + displayName?: string; buttonClassName?: string; containerClassName?: string; onDelete?: () => void; onClick?: React.MouseEventHandler; }) => { const fileType = getFileType(overrideType ?? file.type); + const visibleName = displayName ?? file.filename ?? ''; return (
-
- {file.filename} +
+ {visibleName}
{fileType.title} diff --git a/client/src/components/Chat/Input/Files/__tests__/FileContainer.spec.tsx b/client/src/components/Chat/Input/Files/__tests__/FileContainer.spec.tsx new file mode 100644 index 0000000000..3291eb5b8c --- /dev/null +++ b/client/src/components/Chat/Input/Files/__tests__/FileContainer.spec.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import type { TFile } from 'librechat-data-provider'; +import FileContainer from '../FileContainer'; + +jest.mock('~/utils', () => ({ + cn: (...classes: Array) => classes.filter(Boolean).join(' '), + getFileType: () => ({ paths: [], color: '', title: 'Plain' }), +})); + +jest.mock('../FilePreview', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../RemoveFile', () => ({ + __esModule: true, + default: () =>
)} diff --git a/client/src/components/Chat/Messages/Content/Parts/ToolArtifactCard.tsx b/client/src/components/Chat/Messages/Content/Parts/ToolArtifactCard.tsx index cae6d197ff..3e3fa14524 100644 --- a/client/src/components/Chat/Messages/Content/Parts/ToolArtifactCard.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/ToolArtifactCard.tsx @@ -1,9 +1,17 @@ -import { memo, useEffect, useId, useLayoutEffect } from 'react'; +import { memo, useEffect, useId, useLayoutEffect, useRef } from 'react'; import { Download } from 'lucide-react'; -import { useRecoilState, useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil'; +import { + useRecoilCallback, + useRecoilState, + useRecoilValue, + useResetRecoilState, + useSetRecoilState, +} from 'recoil'; import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider'; import type { Artifact } from '~/common'; import FilePreview from '~/components/Chat/Input/Files/FilePreview'; +import { TOOL_ARTIFACT_TYPES } from '~/utils/artifacts'; +import { displayFilename } from './attachmentTypes'; import { useAttachmentLink } from './LogLink'; import { useLocalize } from '~/hooks'; import { cn, getFileType } from '~/utils'; @@ -41,15 +49,23 @@ interface ToolArtifactCardProps { * Without that guard, both cards would observe each other's write * and trade overwrites in a loop. * - * 3. **Focus on mount** (deps: artifact.id). A freshly-mounted card - * means a new artifact has arrived; we steal panel focus to match - * the legacy streaming-artifact UX where the latest artifact - * auto-opens. Cards that re-render with the same artifact don't - * refire this, so user clicks on older cards aren't overridden. - * - * Visibility is intentionally not toggled. The Recoil default is `true`, - * which auto-opens the panel on first registration; a user who has - * explicitly closed the panel keeps it closed until they click. + * 3. **Focus + open on mount** (deps: artifact.id, artifact.type) β€” + * gated on `isSubmitting` captured at first render via a ref AND + * on `artifact.type !== CODE`. A card mounted *during* streaming + * for a rich-preview bucket (HTML, React, Markdown, plain text) + * steals panel focus and forces `artifactsVisibility = true` so + * the panel auto-opens β€” matching the legacy SSE auto-open UX. + * A card mounted while `isSubmitting === false` is part of + * conversation history (page load, back-navigation) and must not + * steal focus β€” `Presentation`'s render condition gates on + * `currentArtifactId != null`, so leaving both alone keeps the + * panel closed on history load. The CODE bucket (`.py`, `.js`, + * `Dockerfile`, …) is click-to-open *even on streaming*: source + * files are typically supporting scripts the agent emits alongside + * a richer deliverable, and shoving the panel in front of the + * user every time a helper script gets written is disruptive. + * Click-to-open via `handleOpen` works for every bucket regardless + * of context. */ const ToolArtifactCard = memo(({ attachment, artifact }: ToolArtifactCardProps) => { const localize = useLocalize(); @@ -63,6 +79,30 @@ const ToolArtifactCard = memo(({ attachment, artifact }: ToolArtifactCardProps) const [claim, setClaim] = useRecoilState(store.toolArtifactClaim(artifact.id)); const isSelected = artifact.id === currentArtifactId; const isMyClaim = claim === claimKey; + /** + * Captured at first render via a non-subscribing snapshot read so the + * downstream effect doesn't re-fire (and the component doesn't + * re-render) every time `isSubmittingFamily(0)` flips. Cards that mount + * mid-stream stay "fresh" for the rest of their lifetime; cards that + * mount post-stream stay "history" even if the user sends a new + * message while this card stays mounted. + */ + const readInitialIsSubmitting = useRecoilCallback( + ({ snapshot }) => + () => + // `valueMaybe()` returns `undefined` if the atom is in an error + // or loading state instead of throwing β€” defensive against an + // upstream selector failure surfacing during card mount. The + // `?? false` default is correct because a card we can't classify + // as streaming is one we should treat as history (don't steal + // focus / open the panel). + snapshot.getLoadable(store.isSubmittingFamily(0)).valueMaybe() ?? false, + [], + ); + const mountedDuringStreamRef = useRef(null); + if (mountedDuringStreamRef.current === null) { + mountedDuringStreamRef.current = readInitialIsSubmitting(); + } useLayoutEffect(() => { // Always (re)claim on mount β€” a later card for the same id displaces @@ -96,8 +136,29 @@ const ToolArtifactCard = memo(({ attachment, artifact }: ToolArtifactCardProps) }, [artifact, existingEntry, isMyClaim, setArtifacts]); useEffect(() => { + if (!mountedDuringStreamRef.current) { + // Card mounted as part of conversation history β€” leave focus and + // visibility alone so the side panel doesn't auto-open on navigation. + return; + } + if (artifact.type === TOOL_ARTIFACT_TYPES.CODE) { + // Source-code artifacts (`.py`, `.js`, `.cpp`, `Dockerfile`, …) are + // click-to-open only. They're typically supporting scripts the + // agent emits alongside a richer deliverable; auto-opening them + // would shove the panel in front of the user every time a tool + // call writes a helper file. The rich-preview buckets (HTML, + // React, Markdown, plain text) keep the legacy auto-open UX so + // an HTML deliverable still surfaces immediately. + return; + } + // Streaming arrival: focus the new artifact AND force the panel + // visible. Without `setVisible(true)`, a session where the user had + // previously closed the panel (visibility=false) would surface the + // selection in the chip ("click to close") but never actually open + // β€” `Presentation` gates rendering on visibility. setCurrentArtifactId(artifact.id); - }, [artifact.id, setCurrentArtifactId]); + setVisible(true); + }, [artifact.id, artifact.type, setCurrentArtifactId, setVisible]); const file = attachment as TFile & TAttachmentMetadata; const { handleDownload } = useAttachmentLink({ @@ -130,6 +191,11 @@ const ToolArtifactCard = memo(({ attachment, artifact }: ToolArtifactCardProps) const actionLabel = isSelected ? localize('com_ui_click_to_close') : localize('com_ui_artifact_click'); + const visibleFilename = displayFilename(attachment.filename); + // The artifact's stored `title` mirrors the on-disk `filename` for + // tool artifacts, so re-derive the user-facing label rather than + // showing the collision-suffixed name. + const visibleTitle = displayFilename(artifact.title); return (
@@ -149,8 +215,8 @@ const ToolArtifactCard = memo(({ attachment, artifact }: ToolArtifactCardProps)
-
- {artifact.title} +
+ {visibleTitle}
{actionLabel}
@@ -160,7 +226,7 @@ const ToolArtifactCard = memo(({ attachment, artifact }: ToolArtifactCardProps)