From 3e2f1bbe49b1fb2f0f5ee3c47f0d9e588c217c5f Mon Sep 17 00:00:00 2001 From: Huixin615 Date: Sat, 27 Jun 2026 23:07:12 +0800 Subject: [PATCH] fix(frontend): show filename for presented artifacts missing from thread.values.artifacts (#3198) Closes #3192 Root cause ---------- The artifact preview header is driven by a Radix Select whose renders the label of the matching the current value. The option list was built solely from `artifacts` in ArtifactsContext, which is only synced from `thread.values.artifacts` (chat-box.tsx). Artifacts surfaced via the message-layer `present_files` tool call are never written back to `thread.values.artifacts`, so when such a file is selected its filepath has no matching . Radix then renders an empty trigger and the header filename appears blank, even though the preview body loads correctly. Fix --- Compute `artifactOptions` as a defensive union: if the currently selected filepath is missing from `artifacts`, prepend it so a matching always exists. This keeps the header label in sync with the active file without changing context semantics or coupling the UI to a future auto-discovery mechanism (see existing TODO in chat-box.tsx). Tests ----- Add an e2e case that mocks a thread whose only artifact is delivered via `present_files` (thread.values.artifacts = []) and asserts both the header title and preview content render. All 21 e2e tests pass. --- .../artifacts/artifact-file-detail.tsx | 13 +++- frontend/tests/e2e/artifact-preview.spec.ts | 67 +++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index 97a208968..0f30b1cf2 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -84,6 +84,13 @@ export function ArtifactFileDetail({ } return filepathFromProps; }, [filepathFromProps, isWriteFile]); + const artifactOptions = useMemo(() => { + const list = artifacts ?? []; + if (list.includes(filepath)) { + return list; + } + return [filepath, ...list]; + }, [artifacts, filepath]); const isSkillFile = useMemo(() => { return filepath.endsWith(".skill"); }, [filepath]); @@ -178,9 +185,9 @@ export function ArtifactFileDetail({ - {(artifacts ?? []).map((filepath) => ( - - {getFileName(filepath)} + {artifactOptions.map((option) => ( + + {getFileName(option)} ))} diff --git a/frontend/tests/e2e/artifact-preview.spec.ts b/frontend/tests/e2e/artifact-preview.spec.ts index ea8439d37..8f32cb501 100644 --- a/frontend/tests/e2e/artifact-preview.spec.ts +++ b/frontend/tests/e2e/artifact-preview.spec.ts @@ -5,11 +5,13 @@ import { mockLangGraphAPI } from "./utils/mock-api"; const ARTIFACT_PATH = "/artifact-fixtures/report.html"; const MARKDOWN_ARTIFACT_PATH = "/artifact-fixtures/report.md"; const JSON_ARTIFACT_PATH = "/artifact-fixtures/report.json"; +const PRESENTED_ARTIFACT_PATH = "/mnt/user-data/outputs/presented-report.md"; const IN_PROGRESS_THREAD_ID = "00000000-0000-0000-0000-000000003119"; const COMPLETE_THREAD_ID = "00000000-0000-0000-0000-000000003120"; const MARKDOWN_THREAD_ID = "00000000-0000-0000-0000-000000003121"; const MARKDOWN_ANCHOR_THREAD_ID = "00000000-0000-0000-0000-000000003123"; const JSON_THREAD_ID = "00000000-0000-0000-0000-000000003122"; +const PRESENTED_THREAD_ID = "00000000-0000-0000-0000-000000003123"; function writeFileMessages({ path = ARTIFACT_PATH, @@ -57,6 +59,30 @@ function writeFileMessages({ return messages; } +function presentFilesMessages() { + return [ + { + type: "human", + id: "msg-human-present-file", + content: [{ type: "text", text: "Create a markdown report" }], + }, + { + type: "ai", + id: "msg-ai-present-file", + content: "The report has been written. Now let me present the file.", + tool_calls: [ + { + id: "present-file-artifact", + name: "present_files", + args: { + filepaths: [PRESENTED_ARTIFACT_PATH], + }, + }, + ], + }, + ]; +} + test.describe("Artifact preview stability", () => { test("renders preview iframe for an in-progress write artifact", async ({ page, @@ -235,4 +261,45 @@ test.describe("Artifact preview stability", () => { artifactsPanel.getByText('"中文字段": "测试内容"'), ).toBeVisible(); }); + + test("shows the title for a presented artifact missing from thread artifacts", async ({ + page, + }) => { + mockLangGraphAPI(page, { + threads: [ + { + thread_id: PRESENTED_THREAD_ID, + title: "Presented artifact title fallback", + messages: presentFilesMessages(), + artifacts: [], + }, + ], + }); + await page.route( + `**/api/threads/${PRESENTED_THREAD_ID}/artifacts/mnt/user-data/outputs/presented-report.md`, + (route) => + route.fulfill({ + status: 200, + contentType: "text/markdown", + body: "# Presented Report\n\nGenerated content", + }), + ); + + await page.goto(`/workspace/chats/${PRESENTED_THREAD_ID}`); + + // The file card in the message list shows the basename only. + await expect(page.getByText("presented-report.md")).toBeVisible({ + timeout: 15_000, + }); + await page.getByText("presented-report.md").first().click(); + + const artifactsPanel = page.locator("#artifacts"); + + // 1. Header title should fall back to the current filepath even though + // thread.values.artifacts is empty. + await expect(artifactsPanel.getByText("presented-report.md")).toBeVisible(); + + // 2. Preview content should still render correctly. + await expect(artifactsPanel.getByText("Presented Report")).toBeVisible(); + }); });