diff --git a/CHANGELOG.md b/CHANGELOG.md index 18ecf68e5..8c2c449a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -268,6 +268,7 @@ This section accumulates work toward the **2.1.0** milestone ### Fixed +- **artifacts:** Keep explicit full-file loading scoped to the source thread, so a same-path artifact in another conversation keeps its 1 MiB preview. - **sandbox:** `SandboxAuditMiddleware` no longer blocks ordinary command substitution that only captures output. The rule now judges *position* instead of matching any `$(`: `x=$(curl url)`, `echo $(curl url)`, an argument, and a diff --git a/frontend/src/core/artifacts/hooks.ts b/frontend/src/core/artifacts/hooks.ts index 5dfc494f1..e8c1c2253 100644 --- a/frontend/src/core/artifacts/hooks.ts +++ b/frontend/src/core/artifacts/hooks.ts @@ -18,8 +18,13 @@ export function useArtifactContent({ return filepath.startsWith("write-file:"); }, [filepath]); const { thread, isMock } = useThread(); - const [fullFilepath, setFullFilepath] = useState(null); - const fullContentRequested = fullFilepath === filepath; + const [fullContentSelection, setFullContentSelection] = useState<{ + filepath: string; + threadId: string; + } | null>(null); + const fullContentRequested = + fullContentSelection?.filepath === filepath && + fullContentSelection.threadId === threadId; const content = useMemo(() => { if (isWriteFile) { return loadArtifactContentFromToolCall({ url: filepath, thread }); @@ -54,8 +59,8 @@ export function useArtifactContent({ }, [enabled, isWriteFile, refetch, thread.isLoading]); const loadFullContent = useCallback(() => { - setFullFilepath(filepath); - }, [filepath]); + setFullContentSelection({ filepath, threadId }); + }, [filepath, threadId]); return { content: isWriteFile ? content : data?.content, diff --git a/frontend/tests/unit/core/artifacts/hooks.dom.test.tsx b/frontend/tests/unit/core/artifacts/hooks.dom.test.tsx new file mode 100644 index 000000000..c7b37eae2 --- /dev/null +++ b/frontend/tests/unit/core/artifacts/hooks.dom.test.tsx @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import type { PropsWithChildren } from "react"; + +rs.mock("@/components/workspace/messages/context", () => ({ + useThread: rs.fn(), +})); + +rs.mock("@/core/artifacts/loader", () => ({ + loadArtifactContent: rs.fn(), + loadArtifactContentFromToolCall: rs.fn(), +})); + +import { useThread } from "@/components/workspace/messages/context"; +import { useArtifactContent } from "@/core/artifacts/hooks"; +import { loadArtifactContent } from "@/core/artifacts/loader"; + +const mockedUseThread = rs.mocked(useThread); +const mockedLoadArtifactContent = rs.mocked(loadArtifactContent); +const filepath = "/mnt/user-data/outputs/report.md"; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + + return function QueryWrapper({ children }: PropsWithChildren) { + return ( + {children} + ); + }; +} + +describe("useArtifactContent", () => { + beforeEach(() => { + mockedUseThread.mockReturnValue({ + thread: { isLoading: false, messages: [] }, + isMock: false, + } as never); + mockedLoadArtifactContent.mockImplementation(async ({ full }) => ({ + content: full ? "complete report" : "preview", + url: filepath, + sha256: undefined, + truncated: !full, + previewBytes: full ? 15 : 7, + totalBytes: 15, + })); + }); + + afterEach(() => { + cleanup(); + mockedUseThread.mockReset(); + mockedLoadArtifactContent.mockReset(); + }); + + it("keeps a full-content request scoped to its thread", async () => { + const { result, rerender } = renderHook( + ({ threadId }: { threadId: string }) => + useArtifactContent({ filepath, threadId, enabled: true }), + { + initialProps: { threadId: "thread-a" }, + wrapper: createWrapper(), + }, + ); + + await waitFor(() => { + expect(mockedLoadArtifactContent).toHaveBeenLastCalledWith({ + filepath, + threadId: "thread-a", + isMock: false, + full: false, + }); + }); + + act(() => { + result.current.loadFullContent(); + }); + + await waitFor(() => { + expect(mockedLoadArtifactContent).toHaveBeenLastCalledWith({ + filepath, + threadId: "thread-a", + isMock: false, + full: true, + }); + }); + + rerender({ threadId: "thread-b" }); + + await waitFor(() => { + expect(mockedLoadArtifactContent).toHaveBeenLastCalledWith({ + filepath, + threadId: "thread-b", + isMock: false, + full: false, + }); + }); + }); +});