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
This commit is contained in:
Lucien 2026-10-13 15:44:20 +08:00 committed by GitHub
parent b7e3d93366
commit c5fd453c37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1035 additions and 34 deletions

View File

@ -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.

View File

@ -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

View File

@ -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({
>
<body>
<ThemeProvider attribute="class" enableSystem disableTransitionOnChange>
{children}
<ArtifactDraftsProvider>{children}</ArtifactDraftsProvider>
</ThemeProvider>
</body>
</html>

View File

@ -19,7 +19,7 @@ export default async function PublicShowcaseLayout({
return (
<I18nProvider initialLocale={locale}>
<AuthProvider initialUser={null}>
<AuthProvider initialUser={null} initialAuthUnknown>
<QueryClientProvider>
<SidebarProvider className="h-screen" defaultOpen={false}>
<SidebarInset className="min-w-0">

View File

@ -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,
}),
);

View File

@ -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<Record<string, ArtifactDraftState>>({});
const [editingPath, setEditingPath] = useState<string | null>(null);
const { setOpen: setSidebarOpen } = useSidebar();
const pathname = usePathname();
const { user } = useAuth();
const { drafts, setDrafts } = useArtifactDrafts(
pathname ?? "",
user?.id ?? null,
);
const hydratedPathRef = useRef<string | null>(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;

View File

@ -28,7 +28,7 @@ export function GatewayOfflineFallback({
children,
}: GatewayOfflineFallbackProps) {
return (
<AuthProvider initialUser={null}>
<AuthProvider initialUser={null} initialAuthUnknown>
{renderBanner && <GatewayOfflineBanner gatewayUnavailable />}
{children}
</AuthProvider>

View File

@ -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<string, ArtifactDraftState>;
type RouteDrafts = Record<string, Drafts>;
type DraftStore = {
owner: string | null;
generation: number;
routes: RouteDrafts;
};
const EMPTY_DRAFTS: Drafts = {};
const useClientLayoutEffect =
typeof window === "undefined" ? useEffect : useLayoutEffect;
const ArtifactDraftsContext = createContext<DraftStore | null>(null);
const ArtifactDraftsSetterContext = createContext<Dispatch<
SetStateAction<DraftStore>
> | 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<DraftStore>({
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 (
<ArtifactDraftsSetterContext.Provider value={setStore}>
<ArtifactDraftsContext.Provider value={store}>
{children}
</ArtifactDraftsContext.Provider>
</ArtifactDraftsSetterContext.Provider>
);
}
// 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<SetStateAction<Drafts>> = 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,
};
}

View File

@ -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<User | null>(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("/");

View File

@ -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",
});
});

View File

@ -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<typeof useAuth>;
let draftState: ReturnType<typeof useArtifactDrafts>;
function Probe() {
auth = useAuth();
draftState = useArtifactDrafts("/workspace/chats/a", auth.user?.id ?? null);
return (
<output>{draftState.drafts[filepath]?.draftContent ?? "no draft"}</output>
);
}
function Workspace() {
return (
<AuthProvider initialUser={user}>
<Probe />
</AuthProvider>
);
}
function App({ children }: { children: ReactNode }) {
return <ArtifactDraftsProvider>{children}</ArtifactDraftsProvider>;
}
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: <Probe /> });
const view = render(
<App>
<Workspace />
</App>,
);
createDraft();
view.rerender(
<App>
<p>Home</p>
</App>,
);
expect(unloadIsPrevented()).toBe(true);
view.rerender(<App>{showcase}</App>);
expect(screen.getByText("no draft")).toBeTruthy();
expect(unloadIsPrevented()).toBe(true);
view.rerender(
<App>
<Workspace />
</App>,
);
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: <Probe /> });
const view = render(
<App>
<Workspace />
</App>,
);
createDraft();
view.rerender(<App>{showcase}</App>);
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(
<App>
<Workspace />
</App>,
);
expect(screen.getByText("no draft")).toBeTruthy();
},
);
});

View File

@ -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 (
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en-US">
<ArtifactsProvider>{children}</ArtifactsProvider>
<ArtifactDraftsProvider>
<ArtifactsProvider key={mocks.pathname}>{children}</ArtifactsProvider>
</ArtifactDraftsProvider>
</I18nProvider>
</QueryClientProvider>
);
@ -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(<div />);
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(<div />);
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(<div />);
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(<div />);
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<Response>((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(<div />);
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") }],

View File

@ -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 (
<>
<output data-testid="draft">{draft?.draftContent ?? "no draft"}</output>
<button
onClick={() =>
setDrafts({
[filepath]: {
filepath,
baselineContent: "original",
draftContent: mocks.pathname.endsWith("/b")
? "B edit"
: "unsaved edit",
baselineSha256: "a".repeat(64),
conflict: false,
},
})
}
>
Edit
</button>
<button onClick={() => setDrafts({})}>Discard</button>
<button
onClick={() =>
setDrafts((current) => ({
...current,
[filepath]: {
...current[filepath]!,
baselineContent: current[filepath]!.draftContent,
baselineSha256: "b".repeat(64),
},
}))
}
>
Save
</button>
</>
);
}
function AccountWorkspace({
show = true,
routeKey = "stable",
}: {
show?: boolean;
routeKey?: string;
}) {
useArtifactDraftOwner(mocks.user?.id ?? null);
return (
<>
{show ? (
<ArtifactsProvider key={routeKey}>
<Probe />
</ArtifactsProvider>
) : null}
</>
);
}
function Workspace(props: {
show?: boolean;
workspace?: boolean;
routeKey?: string;
}) {
return (
<ArtifactDraftsProvider>
{props.workspace === false ? (
<p>Home</p>
) : (
<AccountWorkspace {...props} />
)}
</ArtifactDraftsProvider>
);
}
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(<Workspace />);
fireEvent.click(screen.getByText("Edit"));
mocks.pathname = "/";
view.rerender(<Workspace workspace={false} />);
const event = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
mocks.pathname = "/workspace/chats/a";
view.rerender(<Workspace />);
expect(screen.getByTestId("draft").textContent).toBe("unsaved edit");
});
it("keeps separate drafts through A-B-A navigation", () => {
const view = render(<Workspace />);
fireEvent.click(screen.getByText("Edit"));
mocks.pathname = "/workspace/chats/b";
view.rerender(<Workspace />);
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(<Workspace />);
expect(screen.getByTestId("draft").textContent).toBe("unsaved edit");
mocks.pathname = "/workspace/chats/b";
view.rerender(<Workspace />);
expect(screen.getByTestId("draft").textContent).toBe("B edit");
});
it("retains a draft when the route-local provider unmounts", () => {
const view = render(<Workspace />);
fireEvent.click(screen.getByText("Edit"));
view.rerender(<Workspace show={false} />);
view.rerender(<Workspace routeKey="replacement" />);
expect(screen.getByTestId("draft").textContent).toBe("unsaved edit");
});
it("does not show the previous account's draft on the same route", () => {
const view = render(<Workspace />);
fireEvent.click(screen.getByText("Edit"));
mocks.user = { id: "account-b" };
view.rerender(<Workspace />);
expect(screen.getByTestId("draft").textContent).toBe("no draft");
mocks.user = { id: "account-a" };
view.rerender(<Workspace />);
expect(screen.getByTestId("draft").textContent).toBe("no draft");
});
it("clears account drafts on logout and removes their unload warning", () => {
const view = render(<Workspace />);
fireEvent.click(screen.getByText("Edit"));
mocks.user = null;
view.rerender(<Workspace />);
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(<Workspace />);
expect(screen.getByTestId("draft").textContent).toBe("no draft");
});
it("warns about dirty drafts when no artifact page is mounted", () => {
const view = render(<Workspace />);
fireEvent.click(screen.getByText("Edit"));
view.rerender(<Workspace show={false} />);
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(<Workspace />);
fireEvent.click(screen.getByText("Edit"));
const stored = Object.values(window.sessionStorage).join("");
expect(stored).not.toContain("unsaved edit");
view.unmount();
render(<Workspace />);
expect(screen.getByTestId("draft").textContent).toBe("no draft");
});
it("clears the unload warning after Save and does not revive discarded drafts", () => {
const view = render(<Workspace />);
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(<Workspace show={false} />);
view.rerender(<Workspace />);
expect(screen.getByTestId("draft").textContent).toBe("no draft");
});
});

View File

@ -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<typeof useAuth>;
let draftState: ReturnType<typeof useArtifactDrafts>;
let authConsumerRenders = 0;
function AuthOnlyProbe() {
useAuth();
authConsumerRenders++;
return null;
}
function Probe() {
auth = useAuth();
draftState = useArtifactDrafts("/workspace/chats/a", auth.user?.id ?? null);
return (
<output>{draftState.drafts[filepath]?.draftContent ?? "no draft"}</output>
);
}
function App({
home = false,
offline = false,
user = accountA,
}: {
home?: boolean;
offline?: boolean;
user?: User;
}) {
return (
<ArtifactDraftsProvider>
{home ? (
<p>Home</p>
) : offline ? (
<GatewayOfflineFallback>
<Probe />
</GatewayOfflineFallback>
) : (
<AuthProvider initialUser={user}>
<AuthOnlyProbe />
<Probe />
</AuthProvider>
)}
</ArtifactDraftsProvider>
);
}
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(<App />);
act(() => draftState.setDrafts({ [filepath]: dirtyDraft }));
view.rerender(<App offline />);
// 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(<App />);
expect(screen.getByText("account A private edit")).toBeTruthy();
});
it("does not rerender unrelated auth consumers on draft edits", () => {
render(<App />);
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(<App />);
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(<App />);
act(() => draftState.setDrafts({ [filepath]: dirtyDraft }));
view.rerender(<App offline />);
act(() => auth.applyUser(null));
view.rerender(<App />);
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(<App />);
act(() => draftState.setDrafts({ [filepath]: dirtyDraft }));
view.rerender(<App offline />);
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(<App />);
act(() => draftState.setDrafts({ [filepath]: dirtyDraft }));
view.rerender(<App offline />);
act(() => auth.applyUser(accountB));
expect(screen.getByText("no draft")).toBeTruthy();
view.rerender(<App />);
expect(screen.getByText("no draft")).toBeTruthy();
});
it("still clears the retained draft when logging out during an outage", async () => {
const view = render(<App />);
act(() => draftState.setDrafts({ [filepath]: dirtyDraft }));
view.rerender(<App offline />);
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(<App />);
expect(screen.getByText("no draft")).toBeTruthy();
});
it("retains drafts when the auth layout unmounts for home and returns", () => {
const view = render(<App />);
act(() => draftState.setDrafts({ [filepath]: dirtyDraft }));
view.rerender(<App home />);
const event = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
view.rerender(<App />);
expect(screen.getByText("account A private edit")).toBeTruthy();
});
it("clears drafts when another account enters from home", () => {
const view = render(<App />);
act(() => draftState.setDrafts({ [filepath]: dirtyDraft }));
view.rerender(<App home />);
view.rerender(<App user={accountB} />);
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(<App />);
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(<App />);
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();
});
});