diff --git a/README.md b/README.md index eb1827822..0ee1781cc 100644 --- a/README.md +++ b/README.md @@ -926,6 +926,8 @@ Image bytes loaded for a vision-model call are transient: DeerFlow removes the h After each run, DeerFlow records a workspace change summary for the run-owned `workspace` and `outputs` directories. The Web UI shows a compact "files changed" badge on the assistant turn; opening it reveals created, modified, and deleted files with text diffs when safe to display. Uploads are excluded because they are user inputs, not agent-generated changes. Large, binary, or sensitive-looking files are shown as metadata only. +Files presented through `present_files` remain part of the thread's artifact state, and the Web UI restores the artifact panel and selected document after a page refresh. + With `AioSandboxProvider`, shell execution runs inside isolated containers. With `LocalSandboxProvider`, file tools still map to per-thread directories on the host, but host `bash` is disabled by default because it is not a secure isolation boundary. Re-enable host bash only for fully trusted local workflows. Host bash commands have a wall-clock timeout, and long-lived processes should be started in the background with output redirected to a workspace log. `AioSandboxProvider` normally detects thread-data mounts from its backend: local diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 97af44bc9..0d0309f06 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -68,6 +68,7 @@ The frontend is a stateful chat application. Users create **threads** (conversat 1. Optional composer helpers such as `core/input-polish` can rewrite the local draft before submission, and `core/voice-input` can transcribe browser microphone input into that same local draft; confirmed user input then flows to thread hooks (`core/threads/hooks.ts`) → LangGraph SDK streaming 2. Stream events update thread state (messages, artifacts, todos, goal). The main thread stream uses the LangGraph SDK's `throttle: true` mode so updates received in the same macrotask coalesce before React is notified; do not replace it with a numeric delay without validating the SDK's trailing-debounce behavior on a continuous stream. File-tool artifact auto-open work must run in an effect with timer cleanup; never schedule timers while rendering streamed `write_file` or `str_replace` updates. + `ThreadState.artifacts` remains the authoritative artifact list. The artifacts provider persists only thread-scoped panel UI state (`open`, selected path, and a refresh bootstrap cache) in session storage; an initial empty stream value must not overwrite that restored state before history finishes loading. 3. `useThreadHistory` loads persisted conversation pages from `GET /api/threads/{id}/messages/page`, preserving the backend's thread-global event `seq`; rendering overlays checkpoint/live copies at their matching canonical identities (a summarized checkpoint may contain a protected early input plus a recent tail). Context-compaction rescue diffs every retained visible identity rather than slicing at the first anchor, and keeps a run-scoped ledger of committed visible messages so replacement updates and repeated rolling checkpoint windows cannot erase an already displayed step. The resolver suppresses checkpoint/transient prefixes whose canonical position is still behind an unloaded cursor page instead of collapsing that unknown gap before a recent anchor, then adds optimistic messages without timestamp re-sorting. History invalidation preserves already-loaded pages so their established ordering positions are not discarded. 4. Stop actions call the LangGraph SDK stream stop path; `core/threads/hooks.ts` invalidates current-thread, thread-history, token-usage, and sidebar/search caches immediately and schedules one follow-up refetch because SDK stop may finish via abort + fire-and-forget cancel before backend title finalization commits 5. TanStack Query manages server state; localStorage stores user settings diff --git a/frontend/src/components/workspace/artifacts/context.tsx b/frontend/src/components/workspace/artifacts/context.tsx index af9b19ab9..33a236930 100644 --- a/frontend/src/components/workspace/artifacts/context.tsx +++ b/frontend/src/components/workspace/artifacts/context.tsx @@ -1,7 +1,10 @@ +import { usePathname } from "next/navigation"; import { createContext, useCallback, useContext, + useEffect, + useRef, useState, type ReactNode, } from "react"; @@ -27,6 +30,46 @@ const ArtifactsContext = createContext( undefined, ); +const ARTIFACTS_STORAGE_PREFIX = "deerflow:artifacts:v1"; + +type PersistedArtifactsState = { + artifacts: string[]; + selectedArtifact: string | null; + open: boolean; +}; + +function storageKey(pathname: string) { + return `${ARTIFACTS_STORAGE_PREFIX}:${encodeURIComponent(pathname)}`; +} + +function readPersistedState(pathname: string): PersistedArtifactsState | null { + try { + const raw = window.sessionStorage.getItem(storageKey(pathname)); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw) as Partial; + if ( + !Array.isArray(parsed.artifacts) || + !parsed.artifacts.every((artifact) => typeof artifact === "string") || + !( + parsed.selectedArtifact === null || + typeof parsed.selectedArtifact === "string" + ) || + typeof parsed.open !== "boolean" + ) { + return null; + } + return { + artifacts: parsed.artifacts, + selectedArtifact: parsed.selectedArtifact, + open: parsed.open, + }; + } catch { + return null; + } +} + interface ArtifactsProviderProps { children: ReactNode; } @@ -40,6 +83,36 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) { ); const [autoOpen, setAutoOpen] = useState(true); const { setOpen: setSidebarOpen } = useSidebar(); + const pathname = usePathname(); + const hydratedPathRef = useRef(null); + + useEffect(() => { + if (!pathname) { + return; + } + + const persisted = readPersistedState(pathname); + setArtifacts(persisted?.artifacts ?? []); + setSelectedArtifact(persisted?.selectedArtifact ?? null); + setOpen(persisted?.open ?? env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"); + setAutoOpen(true); + setAutoSelect(!persisted?.selectedArtifact); + hydratedPathRef.current = pathname; + }, [pathname]); + + useEffect(() => { + if (!pathname || hydratedPathRef.current !== pathname) { + return; + } + try { + window.sessionStorage.setItem( + storageKey(pathname), + JSON.stringify({ artifacts, selectedArtifact, open }), + ); + } catch { + // Browser storage can be disabled or full; panel state must keep working. + } + }, [artifacts, open, pathname, selectedArtifact]); const select = useCallback( (artifact: string, autoSelect = false) => { diff --git a/frontend/src/components/workspace/chats/chat-box.tsx b/frontend/src/components/workspace/chats/chat-box.tsx index 077603f5f..2963296eb 100644 --- a/frontend/src/components/workspace/chats/chat-box.tsx +++ b/frontend/src/components/workspace/chats/chat-box.tsx @@ -72,11 +72,12 @@ const ChatBox: React.FC<{ if (threadIdRef.current !== threadId) { threadIdRef.current = threadId; deselect(); - setArtifacts([]); } // Update artifacts from the current thread - if (threadArtifacts) { + // An empty initial state must not erase artifacts restored by the provider + // before the persisted thread state has arrived. + if (threadArtifacts && threadArtifacts.length > 0) { setArtifacts(threadArtifacts); } diff --git a/frontend/tests/e2e/artifact-preview.spec.ts b/frontend/tests/e2e/artifact-preview.spec.ts index 7d078c949..e7845ec25 100644 --- a/frontend/tests/e2e/artifact-preview.spec.ts +++ b/frontend/tests/e2e/artifact-preview.spec.ts @@ -13,6 +13,7 @@ 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"; +const PERSISTED_PANEL_THREAD_ID = "00000000-0000-0000-0000-000000003125"; const PDF_THREAD_ID = "00000000-0000-0000-0000-000000003124"; function writeFileMessages({ @@ -326,6 +327,46 @@ test.describe("Artifact preview stability", () => { await expect(artifactsPanel.getByText("Presented Report")).toBeVisible(); }); + test("restores the artifact panel and selected file after a page refresh", async ({ + page, + }) => { + mockLangGraphAPI(page, { + threads: [ + { + thread_id: PERSISTED_PANEL_THREAD_ID, + title: "Persisted artifact panel", + messages: presentFilesMessages(), + artifacts: [MARKDOWN_ARTIFACT_PATH], + }, + ], + }); + await page.route( + `**/api/threads/${PERSISTED_PANEL_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/${PERSISTED_PANEL_THREAD_ID}`); + await expect(page.getByText("presented-report.md")).toBeVisible({ + timeout: 15_000, + }); + await page.getByText("presented-report.md").first().click(); + + const artifactsPanel = page.locator("#artifacts"); + await expect(artifactsPanel.getByText("Presented Report")).toBeVisible(); + + await page.reload(); + + await expect(page.getByTestId("artifact-trigger")).toBeVisible(); + await expect( + page.locator("#artifacts").getByText("Presented Report"), + ).toBeVisible(); + }); + test("renders sandboxed iframe for a browser-previewable non-code file (urlOfArtifact path)", async ({ page, }) => {