From 7c8a17c6506ee0a8655b6b0802effe116274ec45 Mon Sep 17 00:00:00 2001 From: Huixin615 Date: Sat, 27 Jun 2026 23:06:26 +0800 Subject: [PATCH] fix(frontend): preserve artifacts during streaming updates (#3791) --- .../components/workspace/chats/chat-box.tsx | 17 +++- frontend/src/core/threads/types.ts | 2 +- .../tests/e2e/artifact-stream-state.spec.ts | 96 +++++++++++++++++++ 3 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 frontend/tests/e2e/artifact-stream-state.spec.ts diff --git a/frontend/src/components/workspace/chats/chat-box.tsx b/frontend/src/components/workspace/chats/chat-box.tsx index a57aa522c..628ed3211 100644 --- a/frontend/src/components/workspace/chats/chat-box.tsx +++ b/frontend/src/components/workspace/chats/chat-box.tsx @@ -44,13 +44,20 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); useEffect(() => { + const threadArtifacts = Array.isArray(thread.values.artifacts) + ? thread.values.artifacts + : undefined; + if (threadIdRef.current !== threadId) { threadIdRef.current = threadId; deselect(); + setArtifacts([]); } // Update artifacts from the current thread - setArtifacts(thread.values.artifacts); + if (threadArtifacts) { + setArtifacts(threadArtifacts); + } // DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now. // if ( @@ -64,9 +71,9 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && autoSelectFirstArtifact ) { - if (thread?.values?.artifacts?.length > 0) { + if (threadArtifacts && threadArtifacts.length > 0) { setAutoSelectFirstArtifact(false); - selectArtifact(thread.values.artifacts[0]!); + selectArtifact(threadArtifacts[0]!); } } }, [ @@ -149,7 +156,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ - {thread.values.artifacts?.length === 0 ? ( + {artifacts.length === 0 ? ( } title="No artifact selected" @@ -163,7 +170,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
diff --git a/frontend/src/core/threads/types.ts b/frontend/src/core/threads/types.ts index 2fffc85fd..72ff9aa27 100644 --- a/frontend/src/core/threads/types.ts +++ b/frontend/src/core/threads/types.ts @@ -5,7 +5,7 @@ import type { Todo } from "../todos"; export interface AgentThreadState extends Record { title: string; messages: Message[]; - artifacts: string[]; + artifacts?: string[]; todos?: Todo[]; } diff --git a/frontend/tests/e2e/artifact-stream-state.spec.ts b/frontend/tests/e2e/artifact-stream-state.spec.ts new file mode 100644 index 000000000..2768bae6c --- /dev/null +++ b/frontend/tests/e2e/artifact-stream-state.spec.ts @@ -0,0 +1,96 @@ +import { expect, test, type Route } from "@playwright/test"; + +import { mockLangGraphAPI } from "./utils/mock-api"; + +const THREAD_ID = "00000000-0000-0000-0000-000000003788"; +const RUN_ID = "00000000-0000-0000-0000-000000003789"; +const ARTIFACT_PATH = "/artifact-fixtures/report.md"; +const THREAD_MESSAGES = [ + { + type: "human", + id: "msg-human-artifact", + content: [{ type: "text", text: "Create a markdown report" }], + }, + { + type: "ai", + id: "msg-ai-artifact", + content: "Created a markdown report.", + }, +]; + +function streamWithoutArtifacts(route: Route) { + const events = [ + { + event: "metadata", + data: { run_id: RUN_ID, thread_id: THREAD_ID }, + }, + { + event: "values", + data: { + messages: [ + { + type: "human", + id: "msg-human-artifact-follow-up", + content: [{ type: "text", text: "Continue" }], + }, + { + type: "ai", + id: "msg-ai-artifact-follow-up", + content: "Updated response while the artifact list is omitted.", + }, + ], + }, + }, + ]; + + return route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: events + .map((event) => { + return `event: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`; + }) + .join(""), + }); +} + +test("keeps artifact trigger after stream values omit artifacts", async ({ + page, +}) => { + mockLangGraphAPI(page, { + threads: [ + { + thread_id: THREAD_ID, + title: "Artifact stream state", + artifacts: [ARTIFACT_PATH], + messages: THREAD_MESSAGES, + }, + ], + }); + + await page.route("**/api/langgraph/threads/*/runs/stream", (route) => { + return streamWithoutArtifacts(route); + }); + + await page.goto(`/workspace/chats/${THREAD_ID}`); + + const artifactTrigger = page.getByRole("button", { name: /artifacts/i }); + await expect(artifactTrigger).toBeVisible({ timeout: 15_000 }); + + const textarea = page.getByPlaceholder(/how can i assist you/i); + await textarea.fill("Continue"); + await textarea.press("Enter"); + + await expect( + page.getByText("Updated response while the artifact list is omitted."), + ).toBeVisible({ timeout: 10_000 }); + await expect(artifactTrigger).toBeVisible(); + + await artifactTrigger.click(); + + const artifactsPanel = page.locator("#artifacts"); + await expect(artifactsPanel.getByText("report.md")).toBeVisible(); + await artifactsPanel.getByText("report.md").click(); + + await expect(artifactsPanel.getByRole("combobox")).toContainText("report.md"); +});