fix: show pending and unknown work folder save state

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-08 14:08:10 -05:00
parent 30ed3cbc99
commit ebe1fc8b65
5 changed files with 37 additions and 6 deletions

View File

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

View File

@ -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(<MemoryRouter initialEntries={["/STG/issues/task"]}><QueryClientProvider client={client}><TooltipProvider><WorkFolderBrowser owner={owner} readOnly={readOnly} /></TooltipProvider></QueryClientProvider></MemoryRouter>);
}
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);

View File

@ -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 <Tabs value={trash ? "trash" : "files"} onValueChange={(value) => { setTrash(value === "trash"); setSelectedPath(null); setCheckedFiles(new Set()); }} className={cn("flex min-h-0 flex-col gap-3", fillHeight && "flex-1")}>
<div className="flex shrink-0 flex-wrap items-center gap-2">
@ -89,7 +98,7 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH
</TabsList>
{!trash && canManageTrash && checkedPaths.length > 0 && <Button variant="outline" size="sm" disabled={disabled} onClick={() => mutation.mutate({ type: "deleteSelected", paths: checkedPaths })}><Trash2 aria-hidden />Move {checkedPaths.length} {checkedPaths.length === 1 ? "file" : "files"} to trash</Button>}
{!readOnly && <Button variant="outline" size="sm" disabled={disabled || !statuses.some((status) => status.active)} onClick={() => mutation.mutate({ type: "refresh" })}><RefreshCw aria-hidden />Refresh sandbox</Button>}
<span className="text-xs text-muted-foreground" role="status">{saving ? "Saving…" : mutation.isError ? "Save failed" : failures.length > 0 ? "Run save failed" : "Saved"}</span>
<span className="text-xs text-muted-foreground" role="status">{saveLabel}</span>
{lastSaved && <span className="text-xs text-muted-foreground">Last agent save {new Date(lastSaved).toLocaleTimeString()}</span>}
{lastOperation && (!lastSaved || lastOperation > lastSaved) && <span className="text-xs text-muted-foreground">Files updated {new Date(lastOperation).toLocaleTimeString()}</span>}
</div>

View File

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

View File

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