From c5fd453c37004190ecffb54af18dc7a99aca99c0 Mon Sep 17 00:00:00 2001 From: Lucien <164857476+dakjdakd@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:44:20 +0800 Subject: [PATCH] fix(artifacts): preserve account-scoped drafts across navigation (#6700) * fix(artifacts): preserve account-scoped drafts across navigation * docs(artifacts): keep draft lifecycle guidance within its budget * fix(artifacts): retain drafts through unknown auth and isolate typing updates * fix(artifacts): preserve drafts when visiting public showcases * fix(artifacts): preserve edits after pending save --- README.md | 9 + frontend/src/AGENTS.md | 2 +- frontend/src/app/layout.tsx | 3 +- .../src/app/showcase/[thread_id]/layout.tsx | 2 +- .../artifacts/artifact-file-detail.tsx | 31 ++- .../workspace/artifacts/context.tsx | 23 +- .../workspace/gateway-offline-fallback.tsx | 2 +- frontend/src/core/artifacts/draft-context.tsx | 115 +++++++++ frontend/src/core/auth/AuthProvider.tsx | 17 +- .../e2e/artifact-draft-navigation.spec.ts | 126 +++++++++ .../app/showcase-layout-drafts.dom.test.tsx | 151 +++++++++++ .../artifact-file-detail-editing.dom.test.tsx | 135 +++++++++- .../artifacts/context-drafts.dom.test.tsx | 211 +++++++++++++++ .../core/artifacts/draft-context.dom.test.tsx | 242 ++++++++++++++++++ 14 files changed, 1035 insertions(+), 34 deletions(-) create mode 100644 frontend/src/core/artifacts/draft-context.tsx create mode 100644 frontend/tests/e2e/artifact-draft-navigation.spec.ts create mode 100644 frontend/tests/unit/app/showcase-layout-drafts.dom.test.tsx create mode 100644 frontend/tests/unit/components/workspace/artifacts/context-drafts.dom.test.tsx create mode 100644 frontend/tests/unit/core/artifacts/draft-context.dom.test.tsx diff --git a/README.md b/README.md index 3eec9f46cc..acab87c753 100644 --- a/README.md +++ b/README.md @@ -2332,6 +2332,15 @@ After each run, DeerFlow records a workspace change summary for the run-owned `w 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. When a completed response successfully presents between 2 and 50 files, its final file card also offers one ZIP download. Archive membership comes from the terminal delivery receipt rather than browser-supplied paths, and the ZIP contains the current file versions, which may have changed since the response. The currently selected formal artifact is refreshed once when the run finishes so edits become visible without a manual reload. Existing UTF-8 text artifacts under `/mnt/user-data/outputs` can also be edited and explicitly saved from the panel on Unix and Windows while the thread is idle; saves use content revisions to prevent overwriting agent changes. Source previews also recognize extensionless `Dockerfile` and `Makefile` artifacts by their file names. Unknown file types, including names such as `constructor` and `__proto__`, retain the download fallback. +Unsaved artifact edits survive switching chats, workspace pages, or visiting the +home page and returning in the same browser tab. Drafts stay in memory for the +current account until saved or discarded; changing accounts or signing out clears them. +Public showcase visits and unavailable Gateway auth probes retain hidden drafts +until authentication is known. Recovering as another account or confirming logout +clears the retained content. +Refreshing or closing the tab warns while any chat has unsaved edits. Draft file +contents are not stored in browser storage and do not survive a reload. + Artifact content revisions refresh when an output file is atomically replaced, even if its size and modification time are preserved. Refreshing the preview then provides the new revision for saving; an older preview still requires a reload. diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index 9f8108c230..8e770d97bd 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -28,7 +28,7 @@ Language-map membership checks only own properties; inherited names such as 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. Formal artifact content is refreshed once when the run finishes; transient `write-file:` previews remain message-driven. - The detail view exposes explicit editing only for an already-opened formal UTF-8 text artifact under `/mnt/user-data/outputs`. Drafts stay in provider memory until Save so switching right-side panels cannot discard them, render in Markdown/HTML preview, and are protected from remote refreshes by the loaded SHA-256 revision. Saving is disabled during an active run; a changed revision preserves the draft and surfaces a conflict instead of overwriting agent output. + UTF-8 outputs under `/mnt/user-data/outputs` are editable while idle. Root drafts survive SPA/home/showcase routes and unknown auth; confirmed account change/logout clears them. Owner/generation fences reject stale updates. Auth binds through a stable setter context, outside the typing state subscription. Drafts preview in Markdown/HTML and warn on unload off-route. SHA-256 conflicts preserve edits. Layout, draft-context and editor DOM tests cover this. Regular artifact text loads request at most the first 1 MiB through an HTTP byte range. A truncated preview must stay lightweight and expose an explicit full-file action; do not mount CodeMirror for that artifact until the user diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index ac8adf33b4..4222e62572 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -3,6 +3,7 @@ import "@/styles/globals.css"; import { type Metadata } from "next"; import { ThemeProvider } from "@/components/theme-provider"; +import { ArtifactDraftsProvider } from "@/core/artifacts/draft-context"; import { DEFAULT_LOCALE } from "@/core/i18n/locale"; export const metadata: Metadata = { @@ -21,7 +22,7 @@ export default function RootLayout({ > - {children} + {children} diff --git a/frontend/src/app/showcase/[thread_id]/layout.tsx b/frontend/src/app/showcase/[thread_id]/layout.tsx index 7bf94bfb22..7ac79c3965 100644 --- a/frontend/src/app/showcase/[thread_id]/layout.tsx +++ b/frontend/src/app/showcase/[thread_id]/layout.tsx @@ -19,7 +19,7 @@ export default async function PublicShowcaseLayout({ return ( - + diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index b44d7ec322..a9465576ec 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -321,24 +321,33 @@ export function ArtifactFileDetail({ return; } + const submittedDraftContent = activeDraft.draftContent; + const submittedBaselineSha256 = activeDraft.baselineSha256; setIsSaving(true); try { const result = await updateArtifactContent({ threadId, filepath, - content: activeDraft.draftContent, - expectedSha256: activeDraft.baselineSha256, + content: submittedDraftContent, + expectedSha256: submittedBaselineSha256, }); - const savedContent = activeDraft.draftContent; setDrafts((current) => ({ ...current, - [filepath]: { - filepath, - baselineContent: savedContent, - baselineSha256: result.sha256, - draftContent: savedContent, - conflict: false, - }, + [filepath]: (() => { + const currentDraft = current[filepath] ?? activeDraft; + const changedSinceSubmit = + currentDraft.draftContent !== submittedDraftContent || + currentDraft.baselineSha256 !== submittedBaselineSha256; + return { + ...currentDraft, + baselineContent: submittedDraftContent, + baselineSha256: result.sha256, + draftContent: changedSinceSubmit + ? currentDraft.draftContent + : submittedDraftContent, + conflict: false, + }; + })(), })); queryClient.setQueryData( ["artifact", filepathFromProps, threadId, isMock, fullContentRequested], @@ -348,7 +357,7 @@ export function ArtifactFileDetail({ | undefined, ) => ({ ...current, - content: savedContent, + content: submittedDraftContent, sha256: result.sha256, }), ); diff --git a/frontend/src/components/workspace/artifacts/context.tsx b/frontend/src/components/workspace/artifacts/context.tsx index 694c789b6a..0cc423c553 100644 --- a/frontend/src/components/workspace/artifacts/context.tsx +++ b/frontend/src/components/workspace/artifacts/context.tsx @@ -12,7 +12,9 @@ import { } from "react"; import { useSidebar } from "@/components/ui/sidebar"; +import { useArtifactDrafts } from "@/core/artifacts/draft-context"; import type { ArtifactDraftState } from "@/core/artifacts/editing"; +import { useAuth } from "@/core/auth/AuthProvider"; import { env } from "@/env"; export interface ArtifactsContextType { @@ -90,10 +92,14 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) { env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true", ); const [autoOpen, setAutoOpen] = useState(true); - const [drafts, setDrafts] = useState>({}); const [editingPath, setEditingPath] = useState(null); const { setOpen: setSidebarOpen } = useSidebar(); const pathname = usePathname(); + const { user } = useAuth(); + const { drafts, setDrafts } = useArtifactDrafts( + pathname ?? "", + user?.id ?? null, + ); const hydratedPathRef = useRef(null); useEffect(() => { @@ -107,25 +113,10 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) { setOpen(persisted?.open ?? env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"); setAutoOpen(true); setAutoSelect(!persisted?.selectedArtifact); - setDrafts({}); setEditingPath(null); hydratedPathRef.current = pathname; }, [pathname]); - useEffect(() => { - const hasUnsavedDrafts = Object.values(drafts).some( - (draft) => draft.draftContent !== draft.baselineContent, - ); - if (!hasUnsavedDrafts) { - return; - } - const handleBeforeUnload = (event: BeforeUnloadEvent) => { - event.preventDefault(); - }; - window.addEventListener("beforeunload", handleBeforeUnload); - return () => window.removeEventListener("beforeunload", handleBeforeUnload); - }, [drafts]); - useEffect(() => { if (!pathname || hydratedPathRef.current !== pathname) { return; diff --git a/frontend/src/components/workspace/gateway-offline-fallback.tsx b/frontend/src/components/workspace/gateway-offline-fallback.tsx index 03ff0e2170..324cfc42e6 100644 --- a/frontend/src/components/workspace/gateway-offline-fallback.tsx +++ b/frontend/src/components/workspace/gateway-offline-fallback.tsx @@ -28,7 +28,7 @@ export function GatewayOfflineFallback({ children, }: GatewayOfflineFallbackProps) { return ( - + {renderBanner && } {children} diff --git a/frontend/src/core/artifacts/draft-context.tsx b/frontend/src/core/artifacts/draft-context.tsx new file mode 100644 index 0000000000..173514e6e6 --- /dev/null +++ b/frontend/src/core/artifacts/draft-context.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { + createContext, + type Dispatch, + type ReactNode, + type SetStateAction, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useState, +} from "react"; + +import type { ArtifactDraftState } from "@/core/artifacts/editing"; + +type Drafts = Record; +type RouteDrafts = Record; +type DraftStore = { + owner: string | null; + generation: number; + routes: RouteDrafts; +}; +const EMPTY_DRAFTS: Drafts = {}; +const useClientLayoutEffect = + typeof window === "undefined" ? useEffect : useLayoutEffect; + +const ArtifactDraftsContext = createContext(null); +const ArtifactDraftsSetterContext = createContext +> | null>(null); + +// RootLayout survives chat navigation and workspace layout replacement. +// File bodies stay in React memory, never in browser storage or a module cache. +export function ArtifactDraftsProvider({ children }: { children: ReactNode }) { + const [store, setStore] = useState({ + owner: null, + generation: 0, + routes: {}, + }); + const hasUnsavedDrafts = Object.values(store.routes).some((drafts) => + Object.values(drafts).some( + (draft) => draft.draftContent !== draft.baselineContent, + ), + ); + + useEffect(() => { + if (!hasUnsavedDrafts) return; + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + }; + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [hasUnsavedDrafts]); + + return ( + + + {children} + + + ); +} + +// Every AuthProvider (including login pages) clears drafts when its owner +// changes. Unmounting an auth layout to visit home deliberately retains them. +export function useArtifactDraftOwner(owner: string | null | undefined) { + const setStore = useContext(ArtifactDraftsSetterContext); + useClientLayoutEffect(() => { + // An unavailable auth probe is not a logout or account change. + if (owner === undefined) return; + setStore?.((current) => + current.owner === owner + ? current + : { owner, generation: current.generation + 1, routes: {} }, + ); + }, [owner, setStore]); +} + +export function useArtifactDrafts(pathname: string, owner: string | null) { + const store = useContext(ArtifactDraftsContext); + const setStore = useContext(ArtifactDraftsSetterContext); + if (!store || !setStore) { + throw new Error("ArtifactsProvider must be inside ArtifactDraftsProvider"); + } + const generation = store.generation; + const setDrafts: Dispatch> = useCallback( + (update) => { + setStore((current) => { + // A save/refetch completing after an account change cannot repopulate + // another account's store with the previous owner's file content. + if (current.owner !== owner || current.generation !== generation) + return current; + const drafts = + typeof update === "function" + ? update(current.routes[pathname] ?? EMPTY_DRAFTS) + : update; + return { + ...current, + routes: { ...current.routes, [pathname]: drafts }, + }; + }); + }, + [generation, owner, pathname, setStore], + ); + // Never expose the previous owner's drafts, including the render before + // AuthProvider's layout effect binds the new owner. + return { + drafts: + store.owner === owner + ? (store.routes[pathname] ?? EMPTY_DRAFTS) + : EMPTY_DRAFTS, + setDrafts, + }; +} diff --git a/frontend/src/core/auth/AuthProvider.tsx b/frontend/src/core/auth/AuthProvider.tsx index 3fee616580..daaacda83f 100644 --- a/frontend/src/core/auth/AuthProvider.tsx +++ b/frontend/src/core/auth/AuthProvider.tsx @@ -10,6 +10,8 @@ import React, { type ReactNode, } from "react"; +import { useArtifactDraftOwner } from "@/core/artifacts/draft-context"; + import { isStaticWebsiteOnly } from "../static-mode"; import { @@ -49,6 +51,8 @@ export const AuthDisabledContext = createContext(false); interface AuthProviderProps { children: ReactNode; initialUser: User | null; + /** The Gateway could not determine authentication; null is not a confirmed logout. */ + initialAuthUnknown?: boolean; } /** @@ -59,8 +63,14 @@ interface AuthProviderProps { * - initialUser comes from server-side guard, avoiding client flicker * - Provides logout and refresh capabilities */ -export function AuthProvider({ children, initialUser }: AuthProviderProps) { +export function AuthProvider({ + children, + initialUser, + initialAuthUnknown = false, +}: AuthProviderProps) { const [user, setUser] = useState(initialUser); + const [authKnown, setAuthKnown] = useState(!initialAuthUnknown); + useArtifactDraftOwner(authKnown ? (user?.id ?? null) : undefined); const [isLoading, setIsLoading] = useState(false); const router = useRouter(); const pathname = usePathname(); @@ -75,6 +85,7 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) { */ const applyUser = useCallback((next: User | null) => { setUser(next); + setAuthKnown(true); }, []); /** @@ -93,8 +104,10 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) { if (res.ok) { const data = await res.json(); setUser(data); + setAuthKnown(true); } else if (res.status === 401) { setUser(null); + setAuthKnown(true); const code = await res .json() .then((data) => parseAuthError(data).code) @@ -113,6 +126,7 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) { } catch (err) { console.error("Failed to refresh user:", err); setUser(null); + setAuthKnown(false); } finally { setIsLoading(false); } @@ -132,6 +146,7 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) { const logout = useCallback(async () => { // Immediately clear local state to prevent UI flicker setUser(null); + setAuthKnown(true); if (staticMode) { router.push("/"); diff --git a/frontend/tests/e2e/artifact-draft-navigation.spec.ts b/frontend/tests/e2e/artifact-draft-navigation.spec.ts new file mode 100644 index 0000000000..19a58ea60a --- /dev/null +++ b/frontend/tests/e2e/artifact-draft-navigation.spec.ts @@ -0,0 +1,126 @@ +import { createHash } from "node:crypto"; + +import { expect, test, type Page } from "@playwright/test"; + +import { mockLangGraphAPI } from "./utils/mock-api"; + +const threadA = "00000000-0000-0000-0000-000000006696"; +const threadB = "00000000-0000-0000-0000-000000006697"; +const filepath = "/mnt/user-data/outputs/navigation-report.txt"; +const original = "Original saved report"; +const draft = "My draft survives chat navigation"; + +async function navigateToChat(page: Page, threadId: string) { + const link = page.locator(`a[href="/workspace/chats/${threadId}"]`).first(); + if (!(await link.isVisible())) { + await page.getByRole("button", { name: "Toggle Sidebar" }).first().click(); + } + await link.click(); + await expect(page).toHaveURL(new RegExp(`/workspace/chats/${threadId}$`)); +} + +test("retains drafts through chat navigation and keeps Save and Discard final", async ({ + page, +}) => { + mockLangGraphAPI(page, { + threads: [ + { + thread_id: threadA, + title: "Draft navigation A", + artifacts: [filepath], + messages: [ + { type: "human", id: "draft-human", content: "Write a report" }, + { + type: "ai", + id: "draft-ai", + content: "Here is the report", + tool_calls: [ + { + id: "draft-present", + name: "present_files", + args: { filepaths: [filepath] }, + }, + ], + }, + ], + }, + { thread_id: threadB, title: "Draft navigation B", messages: [] }, + ], + }); + let saved = original; + let putCount = 0; + const sha256 = () => createHash("sha256").update(saved).digest("hex"); + await page.route( + `**/api/threads/${threadA}/artifacts${filepath}`, + (route) => { + if (route.request().method() === "PUT") { + const body = route.request().postDataJSON() as { + content: string; + expected_sha256: string; + }; + expect(body.expected_sha256).toBe(sha256()); + saved = body.content; + putCount++; + return route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + path: filepath, + sha256: sha256(), + size: Buffer.byteLength(saved), + }), + }); + } + return route.fulfill({ + contentType: "text/plain; charset=utf-8", + headers: { ETag: `"${sha256()}"` }, + body: saved, + }); + }, + ); + + await page.goto(`/workspace/chats/${threadA}`); + await page + .getByText("navigation-report.txt", { exact: true }) + .first() + .click(); + const panel = page.locator("#artifacts"); + await expect(panel.locator(".cm-content")).toHaveText(original); + await panel.getByRole("button", { name: "Edit", exact: true }).click(); + await panel.locator('.cm-content[contenteditable="true"]').fill(draft); + await expect(panel.getByText("Unsaved", { exact: true })).toHaveCount(1); + await page.screenshot({ + path: "output/playwright/artifact-draft-before-navigation.png", + }); + + await navigateToChat(page, threadB); + await navigateToChat(page, threadA); + await expect(panel.locator(".cm-content")).toHaveText(draft); + await expect(panel.getByText("Unsaved", { exact: true })).toHaveCount(1); + await panel.getByRole("button", { name: "Edit", exact: true }).click(); + await panel.getByRole("button", { name: "Save", exact: true }).click(); + await expect( + panel.getByRole("button", { name: "Save", exact: true }), + ).toBeDisabled(); + expect(putCount).toBe(1); + expect(saved).toBe(draft); + + await navigateToChat(page, threadB); + await navigateToChat(page, threadA); + await expect(panel.locator(".cm-content")).toHaveText(draft); + await expect(panel.getByText("Unsaved", { exact: true })).toHaveCount(0); + await panel.getByRole("button", { name: "Edit", exact: true }).click(); + await panel + .locator('.cm-content[contenteditable="true"]') + .fill("Discard this edit"); + page.once("dialog", (dialog) => dialog.accept()); + await panel + .getByRole("button", { name: "Discard changes", exact: true }) + .click(); + await navigateToChat(page, threadB); + await navigateToChat(page, threadA); + await expect(panel.locator(".cm-content")).toHaveText(draft); + await expect(panel.getByText("Unsaved", { exact: true })).toHaveCount(0); + await page.screenshot({ + path: "output/playwright/artifact-draft-after-save-return.png", + }); +}); diff --git a/frontend/tests/unit/app/showcase-layout-drafts.dom.test.tsx b/frontend/tests/unit/app/showcase-layout-drafts.dom.test.tsx new file mode 100644 index 0000000000..43c882c70c --- /dev/null +++ b/frontend/tests/unit/app/showcase-layout-drafts.dom.test.tsx @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, it, rs } from "@rstest/core"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; + +rs.mock("next/navigation", () => ({ + useRouter: () => ({ push: rs.fn() }), + usePathname: () => "/showcase/demo", +})); +rs.mock("@/env", () => ({ + env: { NEXT_PUBLIC_STATIC_WEBSITE_ONLY: "false" }, +})); +rs.mock("@/core/i18n/server", () => ({ + detectLocaleServer: async () => "en", +})); +// Keep the actual layout and AuthProvider; unrelated UI wrappers are inert. +rs.mock("@/core/i18n/context", () => ({ + I18nProvider: ({ children }: { children: ReactNode }) => children, +})); +rs.mock("@/components/query-client-provider", () => ({ + QueryClientProvider: ({ children }: { children: ReactNode }) => children, +})); +rs.mock("@/components/ui/sidebar", () => ({ + SidebarProvider: ({ children }: { children: ReactNode }) => children, + SidebarInset: ({ children }: { children: ReactNode }) => children, +})); +rs.mock("@/components/workspace/chats/chat-providers", () => ({ + ChatProviders: ({ children }: { children: ReactNode }) => children, +})); +rs.mock("sonner", () => ({ Toaster: () => null })); + +import PublicShowcaseLayout from "@/app/showcase/[thread_id]/layout"; +import { + ArtifactDraftsProvider, + useArtifactDrafts, +} from "@/core/artifacts/draft-context"; +import { AuthProvider, useAuth } from "@/core/auth/AuthProvider"; +import type { User } from "@/core/auth/types"; + +const user: User = { + id: "account-a", + email: "a@example.com", + system_role: "admin", + needs_setup: false, + oauth_provider: null, +}; +const filepath = "/mnt/user-data/outputs/report.txt"; +let auth: ReturnType; +let draftState: ReturnType; + +function Probe() { + auth = useAuth(); + draftState = useArtifactDrafts("/workspace/chats/a", auth.user?.id ?? null); + return ( + {draftState.drafts[filepath]?.draftContent ?? "no draft"} + ); +} + +function Workspace() { + return ( + + + + ); +} + +function App({ children }: { children: ReactNode }) { + return {children}; +} + +function createDraft() { + act(() => + draftState.setDrafts({ + [filepath]: { + filepath, + baselineContent: "original", + baselineSha256: "a".repeat(64), + draftContent: "private workspace edit", + conflict: false, + }, + }), + ); +} + +function unloadIsPrevented() { + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + return event.defaultPrevented; +} + +afterEach(() => { + cleanup(); + rs.restoreAllMocks(); +}); + +describe("showcase layout draft ownership", () => { + it("retains hidden workspace drafts and the unload warning through home and showcase", async () => { + const showcase = await PublicShowcaseLayout({ children: }); + const view = render( + + + , + ); + createDraft(); + view.rerender( + +

Home

+
, + ); + expect(unloadIsPrevented()).toBe(true); + view.rerender({showcase}); + expect(screen.getByText("no draft")).toBeTruthy(); + expect(unloadIsPrevented()).toBe(true); + view.rerender( + + + , + ); + expect(screen.getByText("private workspace edit")).toBeTruthy(); + expect(unloadIsPrevented()).toBe(true); + }); + + it.each(["anonymous verdict", "logout"])( + "still clears retained drafts on a confirmed %s from showcase", + async (verdict) => { + const showcase = await PublicShowcaseLayout({ children: }); + const view = render( + + + , + ); + createDraft(); + view.rerender({showcase}); + expect(unloadIsPrevented()).toBe(true); + if (verdict === "logout") { + rs.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(null, { status: 204 }), + ); + await act(async () => auth.logout()); + } else { + act(() => auth.applyUser(null)); + } + expect(unloadIsPrevented()).toBe(false); + view.rerender( + + + , + ); + expect(screen.getByText("no draft")).toBeTruthy(); + }, + ); +}); diff --git a/frontend/tests/unit/components/workspace/artifacts/artifact-file-detail-editing.dom.test.tsx b/frontend/tests/unit/components/workspace/artifacts/artifact-file-detail-editing.dom.test.tsx index f07c8263bb..122c6f3019 100644 --- a/frontend/tests/unit/components/workspace/artifacts/artifact-file-detail-editing.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/artifacts/artifact-file-detail-editing.dom.test.tsx @@ -10,6 +10,7 @@ import { import { useState, type PropsWithChildren } from "react"; const mocks = rs.hoisted(() => ({ + pathname: "/workspace/chats/artifact-editing-test", artifactContent: { content: undefined as string | undefined, url: undefined as string | undefined, @@ -35,7 +36,7 @@ rs.mock("@/components/ui/sidebar", () => ({ useSidebar: () => ({ setOpen: mocks.setSidebarOpen }), })); rs.mock("next/navigation", () => ({ - usePathname: () => "/workspace/chats/artifact-editing-test", + usePathname: () => mocks.pathname, })); rs.mock("@/core/auth/AuthProvider", () => ({ useAuth: () => ({ user: null }), @@ -76,6 +77,7 @@ rs.mock("@/components/workspace/code-editor", () => ({ import { ArtifactFileDetail } from "@/components/workspace/artifacts/artifact-file-detail"; import { ArtifactsProvider } from "@/components/workspace/artifacts/context"; import { fetch as apiFetch } from "@/core/api/fetcher"; +import { ArtifactDraftsProvider } from "@/core/artifacts/draft-context"; import { I18nProvider } from "@/core/i18n/context"; const filepath = "/mnt/user-data/outputs/large.txt"; @@ -84,6 +86,7 @@ const prefix = "a".repeat(1024 * 1024); const fullContent = prefix + "\nComplete file tail\n"; const revision = "a".repeat(64); const mockedFetch = rs.mocked(apiFetch); +let resolvePendingSave: (() => void) | undefined; function Wrapper({ children }: PropsWithChildren) { const [queryClient] = useState( @@ -92,7 +95,9 @@ function Wrapper({ children }: PropsWithChildren) { return ( - {children} + + {children} + ); @@ -129,6 +134,7 @@ function save() { } beforeEach(() => { + mocks.pathname = "/workspace/chats/artifact-editing-test"; window.sessionStorage.clear(); Object.assign(mocks.artifactContent, { content: prefix, @@ -150,6 +156,7 @@ beforeEach(() => { { headers: { "Content-Type": "application/json" } }, ), ); + resolvePendingSave = undefined; }); afterEach(() => { @@ -159,6 +166,130 @@ afterEach(() => { }); describe("ArtifactFileDetail complete editing baseline", () => { + it("retains a dirty draft and marks a remote conflict after A-B-A navigation", () => { + loadFull(); + const { rerender } = renderDetail(); + fireEvent.click(screen.getByRole("button", { name: "Edit" })); + fireEvent.change(source(), { target: { value: "retained draft" } }); + mocks.pathname = "/workspace/chats/other"; + rerender(
); + Object.assign(mocks.artifactContent, { + content: "agent regenerated the report", + sha256: "c".repeat(64), + }); + mocks.pathname = "/workspace/chats/artifact-editing-test"; + rerender(detail()); + expect(source().value).toBe("retained draft"); + expect(screen.getByText("Changed remotely")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Edit" })); + expect(save().disabled).toBe(true); + expect(mockedFetch).not.toHaveBeenCalled(); + }); + + it("restores an actual editor draft after the chat provider is replaced", () => { + loadFull(); + const { rerender } = renderDetail(); + fireEvent.click(screen.getByRole("button", { name: "Edit" })); + fireEvent.change(source(), { + target: { value: "retained after navigation" }, + }); + mocks.pathname = "/workspace/chats/other"; + rerender(
); + mocks.pathname = "/workspace/chats/artifact-editing-test"; + rerender(detail()); + expect(source().value).toBe("retained after navigation"); + fireEvent.click(screen.getByRole("button", { name: "Edit" })); + expect(save().disabled).toBe(false); + }); + + it("does not resurrect discarded changes after navigating away and back", () => { + loadFull(); + const { rerender } = renderDetail(); + fireEvent.click(screen.getByRole("button", { name: "Edit" })); + fireEvent.change(source(), { target: { value: "discarded edit" } }); + rs.spyOn(window, "confirm").mockReturnValue(true); + fireEvent.click(screen.getByRole("button", { name: "Discard changes" })); + mocks.pathname = "/workspace/chats/other"; + rerender(
); + mocks.pathname = "/workspace/chats/artifact-editing-test"; + rerender(detail()); + expect(source().value).toBe(fullContent); + fireEvent.click(screen.getByRole("button", { name: "Edit" })); + expect(save().disabled).toBe(true); + }); + + it("keeps a successful save clean after navigating away and back", async () => { + loadFull(); + const { rerender } = renderDetail(); + fireEvent.click(screen.getByRole("button", { name: "Edit" })); + fireEvent.change(source(), { target: { value: "saved edit" } }); + fireEvent.click(save()); + await waitFor(() => expect(save().disabled).toBe(true)); + Object.assign(mocks.artifactContent, { + content: "saved edit", + sha256: "b".repeat(64), + }); + mocks.pathname = "/workspace/chats/other"; + rerender(
); + mocks.pathname = "/workspace/chats/artifact-editing-test"; + rerender(detail()); + expect(source().value).toBe("saved edit"); + fireEvent.click(screen.getByRole("button", { name: "Edit" })); + expect(save().disabled).toBe(true); + expect(screen.queryByText("Unsaved")).toBeNull(); + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + }); + + it("keeps edits made after a save across a remount", async () => { + loadFull(); + const { rerender } = renderDetail(); + fireEvent.click(screen.getByRole("button", { name: "Edit" })); + fireEvent.change(source(), { target: { value: "first save" } }); + + mockedFetch.mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePendingSave = () => + resolve( + new Response( + JSON.stringify({ + path: filepath, + sha256: "b".repeat(64), + size: "first save".length, + }), + { headers: { "Content-Type": "application/json" } }, + ), + ); + }), + ); + fireEvent.click(save()); + await waitFor(() => expect(mockedFetch).toHaveBeenCalledOnce()); + + // Leaving the chat unmounts the detail while the original PUT is pending. + rerender(
); + rerender(detail()); + fireEvent.change(source(), { target: { value: "newer edit" } }); + + resolvePendingSave?.(); + await waitFor(() => expect(source().value).toBe("newer edit")); + expect(save().disabled).toBe(false); + + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + + fireEvent.click(save()); + await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(2)); + expect(mockedFetch.mock.calls[1]?.[1]).toMatchObject({ + body: JSON.stringify({ + content: "newer edit", + expected_sha256: "b".repeat(64), + }), + }); + }); + it.each([ ["truncated preview", { content: prefix, truncated: true }], ["failed refetch", { error: new Error("Artifact refetch failed") }], diff --git a/frontend/tests/unit/components/workspace/artifacts/context-drafts.dom.test.tsx b/frontend/tests/unit/components/workspace/artifacts/context-drafts.dom.test.tsx new file mode 100644 index 0000000000..2a5b3bf9b4 --- /dev/null +++ b/frontend/tests/unit/components/workspace/artifacts/context-drafts.dom.test.tsx @@ -0,0 +1,211 @@ +import { afterEach, describe, expect, it, rs } from "@rstest/core"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; + +const mocks = rs.hoisted(() => ({ + pathname: "/workspace/chats/a", + user: { id: "account-a" } as { id: string } | null, +})); + +rs.mock("next/navigation", () => ({ usePathname: () => mocks.pathname })); +rs.mock("@/components/ui/sidebar", () => ({ + useSidebar: () => ({ setOpen: rs.fn() }), +})); +rs.mock("@/core/auth/AuthProvider", () => ({ + useAuth: () => ({ user: mocks.user }), +})); +rs.mock("@/env", () => ({ + env: { NEXT_PUBLIC_STATIC_WEBSITE_ONLY: "false" }, +})); + +import { + ArtifactsProvider, + useArtifacts, +} from "@/components/workspace/artifacts/context"; +import { + ArtifactDraftsProvider, + useArtifactDraftOwner, +} from "@/core/artifacts/draft-context"; + +const filepath = "/mnt/user-data/outputs/report.txt"; + +function Probe() { + const { drafts, setDrafts } = useArtifacts(); + const draft = drafts[filepath]; + return ( + <> + {draft?.draftContent ?? "no draft"} + + + + + ); +} + +function AccountWorkspace({ + show = true, + routeKey = "stable", +}: { + show?: boolean; + routeKey?: string; +}) { + useArtifactDraftOwner(mocks.user?.id ?? null); + return ( + <> + {show ? ( + + + + ) : null} + + ); +} + +function Workspace(props: { + show?: boolean; + workspace?: boolean; + routeKey?: string; +}) { + return ( + + {props.workspace === false ? ( +

Home

+ ) : ( + + )} +
+ ); +} + +afterEach(() => { + cleanup(); + window.sessionStorage.clear(); + mocks.pathname = "/workspace/chats/a"; + mocks.user = { id: "account-a" }; +}); + +describe("artifact draft navigation", () => { + it("keeps drafts and the unload warning across the workspace-to-home exit", () => { + const view = render(); + fireEvent.click(screen.getByText("Edit")); + mocks.pathname = "/"; + view.rerender(); + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + mocks.pathname = "/workspace/chats/a"; + view.rerender(); + expect(screen.getByTestId("draft").textContent).toBe("unsaved edit"); + }); + it("keeps separate drafts through A-B-A navigation", () => { + const view = render(); + fireEvent.click(screen.getByText("Edit")); + mocks.pathname = "/workspace/chats/b"; + view.rerender(); + expect(screen.getByTestId("draft").textContent).toBe("no draft"); + fireEvent.click(screen.getByText("Edit")); + expect(screen.getByTestId("draft").textContent).toBe("B edit"); + mocks.pathname = "/workspace/chats/a"; + view.rerender(); + expect(screen.getByTestId("draft").textContent).toBe("unsaved edit"); + mocks.pathname = "/workspace/chats/b"; + view.rerender(); + expect(screen.getByTestId("draft").textContent).toBe("B edit"); + }); + + it("retains a draft when the route-local provider unmounts", () => { + const view = render(); + fireEvent.click(screen.getByText("Edit")); + view.rerender(); + view.rerender(); + expect(screen.getByTestId("draft").textContent).toBe("unsaved edit"); + }); + + it("does not show the previous account's draft on the same route", () => { + const view = render(); + fireEvent.click(screen.getByText("Edit")); + mocks.user = { id: "account-b" }; + view.rerender(); + expect(screen.getByTestId("draft").textContent).toBe("no draft"); + mocks.user = { id: "account-a" }; + view.rerender(); + expect(screen.getByTestId("draft").textContent).toBe("no draft"); + }); + + it("clears account drafts on logout and removes their unload warning", () => { + const view = render(); + fireEvent.click(screen.getByText("Edit")); + mocks.user = null; + view.rerender(); + expect(screen.getByTestId("draft").textContent).toBe("no draft"); + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + mocks.user = { id: "account-a" }; + view.rerender(); + expect(screen.getByTestId("draft").textContent).toBe("no draft"); + }); + + it("warns about dirty drafts when no artifact page is mounted", () => { + const view = render(); + fireEvent.click(screen.getByText("Edit")); + view.rerender(); + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + }); + + it("does not keep file contents in browser storage or across root unmount", () => { + const view = render(); + fireEvent.click(screen.getByText("Edit")); + const stored = Object.values(window.sessionStorage).join(""); + expect(stored).not.toContain("unsaved edit"); + view.unmount(); + render(); + expect(screen.getByTestId("draft").textContent).toBe("no draft"); + }); + + it("clears the unload warning after Save and does not revive discarded drafts", () => { + const view = render(); + fireEvent.click(screen.getByText("Edit")); + const dirty = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(dirty); + expect(dirty.defaultPrevented).toBe(true); + fireEvent.click(screen.getByText("Save")); + const saved = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(saved); + expect(saved.defaultPrevented).toBe(false); + fireEvent.click(screen.getByText("Discard")); + view.rerender(); + view.rerender(); + expect(screen.getByTestId("draft").textContent).toBe("no draft"); + }); +}); diff --git a/frontend/tests/unit/core/artifacts/draft-context.dom.test.tsx b/frontend/tests/unit/core/artifacts/draft-context.dom.test.tsx new file mode 100644 index 0000000000..37564d9ff9 --- /dev/null +++ b/frontend/tests/unit/core/artifacts/draft-context.dom.test.tsx @@ -0,0 +1,242 @@ +import { afterEach, describe, expect, it, rs } from "@rstest/core"; +import { act, cleanup, render, screen } from "@testing-library/react"; + +rs.mock("next/navigation", () => ({ + useRouter: () => ({ push: rs.fn() }), + usePathname: () => "/workspace/chats/a", +})); +rs.mock("@/env", () => ({ + env: { NEXT_PUBLIC_STATIC_WEBSITE_ONLY: "false" }, +})); +rs.mock("@/components/workspace/gateway-offline-banner", () => ({ + GatewayOfflineBanner: () => null, +})); + +import { GatewayOfflineFallback } from "@/components/workspace/gateway-offline-fallback"; +import { + ArtifactDraftsProvider, + useArtifactDrafts, +} from "@/core/artifacts/draft-context"; +import { AuthProvider, useAuth } from "@/core/auth/AuthProvider"; +import type { User } from "@/core/auth/types"; + +const accountA: User = { + id: "account-a", + email: "a@example.com", + system_role: "admin", + needs_setup: false, + oauth_provider: null, +}; +const accountB: User = { ...accountA, id: "account-b", email: "b@example.com" }; +const filepath = "/mnt/user-data/outputs/report.txt"; +const dirtyDraft = { + filepath, + baselineContent: "original", + baselineSha256: "a".repeat(64), + draftContent: "account A private edit", + conflict: false, +}; +let auth: ReturnType; +let draftState: ReturnType; +let authConsumerRenders = 0; + +function AuthOnlyProbe() { + useAuth(); + authConsumerRenders++; + return null; +} + +function Probe() { + auth = useAuth(); + draftState = useArtifactDrafts("/workspace/chats/a", auth.user?.id ?? null); + return ( + {draftState.drafts[filepath]?.draftContent ?? "no draft"} + ); +} + +function App({ + home = false, + offline = false, + user = accountA, +}: { + home?: boolean; + offline?: boolean; + user?: User; +}) { + return ( + + {home ? ( +

Home

+ ) : offline ? ( + + + + ) : ( + + + + + )} +
+ ); +} + +afterEach(() => { + cleanup(); + rs.restoreAllMocks(); +}); + +describe("artifact drafts with real authentication", () => { + it("keeps the authenticated draft and unload warning during gateway-offline navigation", () => { + const view = render(); + act(() => draftState.setDrafts({ [filepath]: dirtyDraft })); + view.rerender(); + // Unknown auth must not expose the previous account's file contents. + expect(screen.getByText("no draft")).toBeTruthy(); + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + act(() => auth.applyUser(accountA)); + expect(screen.getByText("account A private edit")).toBeTruthy(); + view.rerender(); + expect(screen.getByText("account A private edit")).toBeTruthy(); + }); + + it("does not rerender unrelated auth consumers on draft edits", () => { + render(); + const initialRenders = authConsumerRenders; + for (const draftContent of ["a", "ab", "abc"]) { + act(() => + draftState.setDrafts({ [filepath]: { ...dirtyDraft, draftContent } }), + ); + expect(screen.getByText(draftContent)).toBeTruthy(); + } + expect(authConsumerRenders).toBe(initialRenders); + }); + + it("retains drafts after a failed auth probe and clears them on a definite 401", async () => { + render(); + act(() => draftState.setDrafts({ [filepath]: dirtyDraft })); + const fetch = rs + .spyOn(globalThis, "fetch") + .mockRejectedValue(new Error("Gateway unavailable")); + rs.spyOn(console, "error").mockImplementation(rs.fn()); + await act(async () => auth.refreshUser()); + const dirty = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(dirty); + expect(dirty.defaultPrevented).toBe(true); + fetch.mockResolvedValue( + new Response(JSON.stringify({ detail: "expired" }), { status: 401 }), + ); + await act(async () => auth.refreshUser()); + const signedOut = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(signedOut); + expect(signedOut.defaultPrevented).toBe(false); + act(() => auth.applyUser(accountA)); + expect(screen.getByText("no draft")).toBeTruthy(); + }); + + it("clears retained drafts on a confirmed anonymous verdict after an outage", () => { + const view = render(); + act(() => draftState.setDrafts({ [filepath]: dirtyDraft })); + view.rerender(); + act(() => auth.applyUser(null)); + view.rerender(); + expect(screen.getByText("no draft")).toBeTruthy(); + }); + + it.each([ + ["HTTP 503", 503, "{}"], + ["malformed success JSON", 200, "invalid JSON"], + ])( + "retains drafts when an offline auth probe returns %s", + async (_name, status, body) => { + const view = render(); + act(() => draftState.setDrafts({ [filepath]: dirtyDraft })); + view.rerender(); + rs.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(body, { status }), + ); + rs.spyOn(console, "error").mockImplementation(rs.fn()); + await act(async () => auth.refreshUser()); + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + act(() => auth.applyUser(accountA)); + expect(screen.getByText("account A private edit")).toBeTruthy(); + }, + ); + + it("clears the previous account's drafts when an outage resolves as another user", () => { + const view = render(); + act(() => draftState.setDrafts({ [filepath]: dirtyDraft })); + view.rerender(); + act(() => auth.applyUser(accountB)); + expect(screen.getByText("no draft")).toBeTruthy(); + view.rerender(); + expect(screen.getByText("no draft")).toBeTruthy(); + }); + + it("still clears the retained draft when logging out during an outage", async () => { + const view = render(); + act(() => draftState.setDrafts({ [filepath]: dirtyDraft })); + view.rerender(); + rs.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(null, { status: 204 }), + ); + await act(async () => auth.logout()); + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + view.rerender(); + expect(screen.getByText("no draft")).toBeTruthy(); + }); + + it("retains drafts when the auth layout unmounts for home and returns", () => { + const view = render(); + act(() => draftState.setDrafts({ [filepath]: dirtyDraft })); + view.rerender(); + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + view.rerender(); + expect(screen.getByText("account A private edit")).toBeTruthy(); + }); + + it("clears drafts when another account enters from home", () => { + const view = render(); + act(() => draftState.setDrafts({ [filepath]: dirtyDraft })); + view.rerender(); + view.rerender(); + expect(screen.getByText("no draft")).toBeTruthy(); + act(() => auth.applyUser(accountA)); + expect(screen.getByText("no draft")).toBeTruthy(); + }); + + it("ignores a previous owner's late save/refetch callback", () => { + render(); + act(() => draftState.setDrafts({ [filepath]: dirtyDraft })); + const oldSetDrafts = draftState.setDrafts; + act(() => auth.applyUser(accountB)); + act(() => oldSetDrafts({ [filepath]: dirtyDraft })); + expect(screen.getByText("no draft")).toBeTruthy(); + act(() => auth.applyUser(accountA)); + act(() => oldSetDrafts({ [filepath]: dirtyDraft })); + expect(screen.getByText("no draft")).toBeTruthy(); + }); + + it("clears draft content and the warning immediately on logout", async () => { + render(); + act(() => draftState.setDrafts({ [filepath]: dirtyDraft })); + rs.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(null, { status: 204 }), + ); + await act(async () => auth.logout()); + expect(screen.getByText("no draft")).toBeTruthy(); + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + act(() => auth.applyUser(accountA)); + expect(screen.getByText("no draft")).toBeTruthy(); + }); +});