diff --git a/doc/sandbox-work-folders.md b/doc/sandbox-work-folders.md index 6f5b0963c3..4e39225aeb 100644 --- a/doc/sandbox-work-folders.md +++ b/doc/sandbox-work-folders.md @@ -79,6 +79,9 @@ are unchanged. CLI state is separate from the four shared collections. A change of task, agent, responsible user, or project cannot reuse a sandbox with another binding. +The cache inspector shows “Waiting for first save” until an active run has a +completed checkpoint. Missing or unavailable sync status is never labeled saved. + ## Storage and synchronization Postgres stores company/owner bindings, paths, executable bits, current object diff --git a/ui/src/components/WorkFolderBrowser.test.tsx b/ui/src/components/WorkFolderBrowser.test.tsx index d872086dee..ed1ed61d54 100644 --- a/ui/src/components/WorkFolderBrowser.test.tsx +++ b/ui/src/components/WorkFolderBrowser.test.tsx @@ -10,16 +10,32 @@ vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompan const owner = { companyId: "company", scope: "task" as const, ownerId: "task" }; const key = ["work-folders", owner.companyId, owner.scope, owner.ownerId]; -function render(statuses: WorkFolderSyncStatus[], lastOperationAt: string | null, readOnly = false) { +function render(statuses: WorkFolderSyncStatus[], lastOperationAt: string | null, readOnly = false, queryState?: "loading" | "error") { const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); client.setQueryData([...key, "files", false], { files: [], lastOperationAt }); client.setQueryData([...key, "sync"], statuses); + if (queryState === "loading") client.removeQueries({ queryKey: [...key, "sync"] }); + if (queryState === "error") client.getQueryCache().find({ queryKey: [...key, "sync"] })! + .setState({ status: "error", error: new Error("Save service unavailable") }); return renderToStaticMarkup(); } const checkpoint: WorkFolderSyncStatus = { runId: "run", state: "saved", active: false, lastSavedAt: "2026-09-07T12:00:00.000Z", error: null, refreshRequested: false }; describe("work folder save feedback", () => { + it("does not claim a save before the first checkpoint", () => { + const pending = render([{ ...checkpoint, state: "starting", active: true, lastSavedAt: null }], null, true); + expect(pending).toContain('role="status">Waiting for first save'); + expect(pending).not.toContain("Last agent save"); + expect(render([], null, true)).toContain('role="status">No saved files'); + }); + it("does not report success while save status is unknown", () => { + expect(render([], null, true, "loading")).toContain('role="status">Loading save status…'); + const unavailable = render([checkpoint], null, true, "error"); + expect(unavailable).toContain('role="status">Save status unavailable'); + expect(unavailable).toContain("Last agent save"); + expect(unavailable).toContain("Save service unavailable"); + }); it("keeps inspection free of controls that mutate the cache or refresh the sandbox", () => { const html = render([checkpoint], null, true); for (const action of ["Upload", "Create folder", "Refresh sandbox", "Delete", "Restore", "Purge"]) expect(html).not.toContain(action); diff --git a/ui/src/components/WorkFolderBrowser.tsx b/ui/src/components/WorkFolderBrowser.tsx index 2675a58bd3..2040c6143e 100644 --- a/ui/src/components/WorkFolderBrowser.tsx +++ b/ui/src/components/WorkFolderBrowser.tsx @@ -75,6 +75,15 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH const lastSaved = statuses.map((status) => status.lastSavedAt) .filter((value): value is string => Boolean(value)).sort().at(-1); const lastOperation = filesQuery.data?.lastOperationAt; + const saveLabel = mutation.isPending ? "Saving…" + : mutation.isError ? "Save failed" + : syncQuery.isError || filesQuery.isError ? "Save status unavailable" + : !exampleFiles && (syncQuery.isPending || filesQuery.isPending) ? "Loading save status…" + : saving ? "Saving…" + : failures.length > 0 ? "Run save failed" + : !lastSaved && statuses.some((status) => status.active) ? "Waiting for first save" + : lastSaved || lastOperation || files.length > 0 ? "Saved" + : "No saved files"; const disabled = mutation.isPending || Boolean(exampleFiles); return { setTrash(value === "trash"); setSelectedPath(null); setCheckedFiles(new Set()); }} className={cn("flex min-h-0 flex-col gap-3", fillHeight && "flex-1")}>
@@ -89,7 +98,7 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH {!trash && canManageTrash && checkedPaths.length > 0 && } {!readOnly && } - {saving ? "Saving…" : mutation.isError ? "Save failed" : failures.length > 0 ? "Run save failed" : "Saved"} + {saveLabel} {lastSaved && Last agent save {new Date(lastSaved).toLocaleTimeString()}} {lastOperation && (!lastSaved || lastOperation > lastSaved) && Files updated {new Date(lastOperation).toLocaleTimeString()}}
diff --git a/ui/storybook/fixtures/workFolders.ts b/ui/storybook/fixtures/workFolders.ts index f90402e6fe..3b6a4a6fa1 100644 --- a/ui/storybook/fixtures/workFolders.ts +++ b/ui/storybook/fixtures/workFolders.ts @@ -8,6 +8,7 @@ import type { export type WorkFolderScenario = | "saved" | "saving" + | "waiting" | "failed" | "olderFailure" | "multipleFailures" @@ -84,7 +85,7 @@ export function createWorkFolderFixture( }); contents.set(path, blob); } - if (scenario !== "empty") { + if (scenario !== "empty" && scenario !== "waiting") { add( "README.md", `# ${workFolderLabels[scope as WorkFolderScope]}\n\nFiles available to this ${scope}'s sandbox runs.\n\n## Current work\n\n- Review the launch brief\n- Keep the original uploads\n- Share the final report\n`, @@ -151,13 +152,13 @@ export function createWorkFolderFixture( runId: "run-storybook", agentId: workFolderOwners.agent.ownerId, state: - scenario === "saving" + scenario === "waiting" ? "starting" : scenario === "saving" ? "saving" : scenario === "failed" ? "failed" : "saved", - lastSavedAt: savedAt, - active: scenario === "saving" || scenario === "failed", + lastSavedAt: scenario === "waiting" ? null : savedAt, + active: scenario === "waiting" || scenario === "saving" || scenario === "failed", refreshRequested: false, error: scenario === "failed" diff --git a/ui/storybook/stories/work-folders.stories.tsx b/ui/storybook/stories/work-folders.stories.tsx index 33e3814f5d..947c24a808 100644 --- a/ui/storybook/stories/work-folders.stories.tsx +++ b/ui/storybook/stories/work-folders.stories.tsx @@ -31,6 +31,7 @@ const meta = { options: [ "saved", "saving", + "waiting", "failed", "olderFailure", "multipleFailures", @@ -100,6 +101,7 @@ export const LargeFilePreview: Story = { }; export const EmptyFolder: Story = { args: { scenario: "empty" } }; export const Loading: Story = { args: { scenario: "loading" } }; +export const WaitingForFirstSave: Story = { args: { scenario: "waiting" } }; export const Saving: Story = { args: { scenario: "saving" } }; export const SaveFailed: Story = { args: { scenario: "failed" } }; export const SharedFolderWithEarlierFailure: Story = { args: { scope: "agent", scenario: "olderFailure" } };