From fc05cd88a816227d159bf3ba8c4e78897c12b157 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 7 Sep 2026 14:38:28 -0500 Subject: [PATCH] fix: preserve native target bindings and distinguish checkpoint status Keep the execution target identity through workspace realization, restore its saved home for finalization, and distinguish warm setup reuse from replacement dependency recovery. Label direct file updates separately from agent checkpoints. Co-Authored-By: Paperclip --- doc/sandbox-work-folders.md | 13 +++++-- packages/shared/src/work-folders.ts | 3 +- ...ment-execution-target-capabilities.test.ts | 13 ++++++- .../environment-run-orchestrator.test.ts | 11 +++++- .../__tests__/sandbox-work-folders.test.ts | 19 +++++++--- server/src/__tests__/work-folders.test.ts | 10 ++--- server/src/routes/openapi.ts | 2 +- .../services/environment-execution-target.ts | 2 + .../services/environment-run-orchestrator.ts | 7 ++-- server/src/services/sandbox-work-folders.ts | 9 ++--- server/src/services/work-folders.ts | 2 +- ui/src/api/work-folders.ts | 6 +-- ui/src/components/WorkFolderBrowser.test.tsx | 38 +++++++++++++++++++ ui/src/components/WorkFolderBrowser.tsx | 8 +++- 14 files changed, 111 insertions(+), 32 deletions(-) create mode 100644 ui/src/components/WorkFolderBrowser.test.tsx diff --git a/doc/sandbox-work-folders.md b/doc/sandbox-work-folders.md index 759af6a5c4..9f216853a0 100644 --- a/doc/sandbox-work-folders.md +++ b/doc/sandbox-work-folders.md @@ -7,7 +7,10 @@ Set `PAPERCLIP_DEPLOYED_STACK_MANIFEST` to a JSON manifest matching and `PAPERCLIP_DEPLOYED_STACK_EVIDENCE` to an absolute output directory. The harness never launches a local server. It checks the deployed commit and adapter inventory, exercises scoped file APIs, and starts real sandbox tasks -for every configured profile. Missing profiles fail the inventory gate. +for every configured profile, including cold/warm Git state and real timed saves. +The maintainer-approved matrix is Codex, Claude, OpenCode, and Pi in both runner +generations (11 CLI/ACP profiles). Only Cursor, Gemini, Grok, and Kimi are deferred +for this campaign. Missing required profiles fail the inventory gate. Credentials are not recorded in Playwright reports. These API checks supplement the required browser walkthrough, two real 180-second intervals, and recovery scenarios; passing them alone is not staging acceptance. @@ -94,7 +97,9 @@ URL. Names derive from repository names, with stable workspace-ID suffixes on collisions. Initial clones use existing Git credentials and starting-ref policy; the primary clone also honors the task's configured branch. Warm starts never reset branches, clean edits, or rerun completed setup. Added repositories are -prepared at the next startup; removed bindings retain saved work. +prepared at the next startup; removed bindings retain saved work. Replacement +sandboxes rerun project setup to restore excluded dependencies; an existing +warm checkout keeps both its completed setup and reusable caches. A complete repository checkpoint includes Git objects, refs, HEAD and index, tracked working files, and nonignored untracked files. It excludes dependencies @@ -110,7 +115,9 @@ directories are not supported by this checkpoint format. The task, agent, project, and current-user pages expose a Files dialog using the shared file tree and viewer. It supports uploads, folder creation, previews, -downloads, deletion, trash restore/purge, and sync state with the last save time. +downloads, deletion, trash restore/purge, and sync state with the last agent save +time. Direct file-operation timestamps are labeled separately as “Files updated”; +a delete, restore, or idempotent receipt is not presented as an agent checkpoint. All routes start at `/api/companies/:companyId/work-folders/:scope/:ownerId`: diff --git a/packages/shared/src/work-folders.ts b/packages/shared/src/work-folders.ts index 297f8c5e9e..3e1ba33c27 100644 --- a/packages/shared/src/work-folders.ts +++ b/packages/shared/src/work-folders.ts @@ -27,7 +27,8 @@ export interface WorkFolderListing { owner: WorkFolderOwner; files: WorkFile[]; nextCursor: string | null; - lastSavedAt: string | null; + /** Last accepted file operation, including deletion, restore, and retries. */ + lastOperationAt: string | null; } export interface WorkFolderSyncStatus { diff --git a/server/src/__tests__/environment-execution-target-capabilities.test.ts b/server/src/__tests__/environment-execution-target-capabilities.test.ts index 29a660e0a1..3d7313f7a9 100644 --- a/server/src/__tests__/environment-execution-target-capabilities.test.ts +++ b/server/src/__tests__/environment-execution-target-capabilities.test.ts @@ -47,6 +47,7 @@ async function buildSandboxTarget(input: { snapshot: EffectiveExecutionCapabilities | null; supportsSync: boolean; config?: Record; + leaseMetadata?: Record; // Reject the capability resolution to exercise the fail-closed error path. rejectResolution?: boolean; }) { @@ -82,7 +83,7 @@ async function buildSandboxTarget(input: { adapterType: "codex_local", environment: { id: "env-1", driver: "sandbox", config: { provider: "daytona" } }, leaseId: "lease-1", - leaseMetadata: { remoteCwd: "/work" }, + leaseMetadata: { remoteCwd: "/work", ...input.leaseMetadata }, lease: { id: "lease-1", leasePolicy: "reuse_by_environment", metadata: { remoteCwd: "/work", marker: "preserved" } } as never, environmentRuntime, }); @@ -285,6 +286,16 @@ describe("effective snapshot gates the sync decision", () => { expect(target.remoteCwd).toBe("/work"); }); + it("restores the host-bound sync home when reconstructing a target from its saved lease", async () => { + const { target, environmentRuntime } = await buildSandboxTarget({ snapshot: FULL_GRANT, supportsSync: true, + leaseMetadata: { workFolderHome: "/home/daytona" } }); + expect(target.workFolderHome).toBe("/home/daytona"); + await target.runner!.syncOut!([]); + expect(environmentRuntime.syncOut).toHaveBeenLastCalledWith(expect.objectContaining({ + lease: expect.objectContaining({ metadata: expect.objectContaining({ remoteCwd: "/home/daytona" }) }), + })); + }); + it("omits the native sync hooks when the snapshot removes a sync verb", async () => { // The snapshot verified inbound sync but not outbound sync. The runner // exposes the sync hooks both-or-neither, so it keeps the base64 fallback. diff --git a/server/src/__tests__/environment-run-orchestrator.test.ts b/server/src/__tests__/environment-run-orchestrator.test.ts index 01bb0442a9..f49d2d69e7 100644 --- a/server/src/__tests__/environment-run-orchestrator.test.ts +++ b/server/src/__tests__/environment-run-orchestrator.test.ts @@ -287,11 +287,14 @@ describe("environmentRunOrchestrator — realizeForRun", () => { }); it("uses an in-place authoritative root on the adapter execution target", async () => { - mockResolveEnvironmentExecutionTarget.mockResolvedValue({ + const executionTarget = { kind: "remote", transport: "sandbox", remoteCwd: "/copied/workspace", - }); + workFolderHome: undefined as string | undefined, + runner: { syncOut: async () => executionTarget.workFolderHome }, + }; + mockResolveEnvironmentExecutionTarget.mockResolvedValue(executionTarget); const runtime = makeMockRuntime({ realizeWorkspace: vi.fn().mockResolvedValue({ cwd: "/app", @@ -323,6 +326,10 @@ describe("environmentRunOrchestrator — realizeForRun", () => { outboundRestorePaths: [], }, })); + expect(result.executionTarget).toBe(executionTarget); + if (result.executionTarget?.kind !== "remote" || result.executionTarget.transport !== "sandbox") throw new Error("Expected sandbox target"); + result.executionTarget.workFolderHome = "/home/daytona"; + await expect(result.executionTarget.runner!.syncOut!([])).resolves.toBe("/home/daytona"); }); it("realization failure: runtime.realizeWorkspace throws → EnvironmentRunError with code workspace_realization_failed", async () => { diff --git a/server/src/__tests__/sandbox-work-folders.test.ts b/server/src/__tests__/sandbox-work-folders.test.ts index cc515fe64a..a7d4e25c2b 100644 --- a/server/src/__tests__/sandbox-work-folders.test.ts +++ b/server/src/__tests__/sandbox-work-folders.test.ts @@ -37,11 +37,12 @@ describe("shared sandbox work-folder lifecycle", () => { const source = path.join(root, name); await exec("git", ["init", source]); await fs.writeFile(path.join(source, "tracked"), "initial\n"); + await fs.writeFile(path.join(source, ".gitignore"), "node_modules/\n"); await fs.symlink("tracked", path.join(source, "link")); await exec("git", ["-C", source, "add", "."]); await exec("git", ["-C", source, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "initial"]); await db.insert(projectWorkspaces).values({ companyId, projectId, name, repoUrl: source, sourceType: "git_repo", isPrimary: name === "repo-one", - setupCommand: "printf 'initialized\\n' >> .setup-count" }); + setupCommand: "mkdir -p node_modules/acceptance && printf ready > node_modules/acceptance/installed && printf 'initialized\\n' >> .setup-count" }); } }, 60_000); afterAll(async () => { @@ -87,15 +88,19 @@ describe("shared sandbox work-folder lifecycle", () => { }, 120_000); it("reuses clones and restores saved unpushed work, staged changes, and task files after losing the sandbox", async () => { + const repositoryTaskId = randomUUID(); + await db.insert(issues).values({ id: repositoryTaskId, companyId, projectId, title: "Repository recovery", assigneeAgentId: agentId }); + const task = { taskId: repositoryTaskId }; const home = path.join(root, "sandbox"); const leaseId = randomUUID(); - const first = await prepare(home, leaseId); + const first = await prepare(home, leaseId, leaseId, null, task); expect(first.home).toBe(home); expect(first.manifest.repositories).toHaveLength(2); expect(first.primaryRepo).toBe(path.join(home, "repos/repo-one")); await fs.writeFile(path.join(home, "task/report.md"), "durable task file"); const repo = first.primaryRepo; expect(await fs.readFile(path.join(repo, ".setup-count"), "utf8")).toBe("initialized\n"); + await fs.writeFile(path.join(repo, "node_modules/acceptance/warm-cache"), "reusable"); await fs.writeFile(path.join(repo, "tracked"), "committed\n"); await exec("git", ["-C", repo, "add", "."]); await exec("git", ["-C", repo, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "unpushed"]); @@ -105,13 +110,17 @@ describe("shared sandbox work-folder lifecycle", () => { await fs.writeFile(path.join(repo, "tracked"), "unstaged\n"); await fs.writeFile(path.join(repo, "untracked"), "untracked\n"); await first.stop(); active.splice(active.indexOf(first), 1); - const warm = await prepare(home, randomUUID(), leaseId); + const warm = await prepare(home, randomUUID(), leaseId, null, task); expect(await fs.readFile(path.join(repo, ".setup-count"), "utf8")).toBe("initialized\n"); + expect(await fs.readFile(path.join(repo, "node_modules/acceptance/warm-cache"), "utf8")).toBe("reusable"); expect(await fs.readFile(path.join(repo, "tracked"), "utf8")).toBe("unstaged\n"); await warm.stop(); active.splice(active.indexOf(warm), 1); await fs.rm(home, { recursive: true }); - const restored = await prepare(path.join(root, "replacement"), randomUUID()); - expect(await fs.readFile(path.join(restored.primaryRepo, ".setup-count"), "utf8")).toBe("initialized\n"); + const replacementId = randomUUID(); + const restored = await prepare(path.join(root, "replacement"), replacementId, replacementId, null, task); + expect(await fs.readFile(path.join(restored.primaryRepo, ".setup-count"), "utf8")).toBe("initialized\ninitialized\n"); + expect(await fs.readFile(path.join(restored.primaryRepo, "node_modules/acceptance/installed"), "utf8")).toBe("ready"); + await expect(fs.stat(path.join(restored.primaryRepo, "node_modules/acceptance/warm-cache"))).rejects.toMatchObject({ code: "ENOENT" }); expect(await fs.readFile(path.join(restored.home, "task/report.md"), "utf8")).toBe("durable task file"); expect((await exec("git", ["-C", restored.primaryRepo, "rev-parse", "HEAD"])).stdout.trim()).toBe(expectedHead); expect((await exec("git", ["-C", restored.primaryRepo, "show", ":tracked"])).stdout).toBe("staged\n"); diff --git a/server/src/__tests__/work-folders.test.ts b/server/src/__tests__/work-folders.test.ts index a51ac82230..d20ef577b7 100644 --- a/server/src/__tests__/work-folders.test.ts +++ b/server/src/__tests__/work-folders.test.ts @@ -57,17 +57,17 @@ describe("durable work folders", () => { expect(await textContent(f, "memory.md")).toBe("second"); await expect(svc.write(f, { ...first, body: Buffer.from("different") })).rejects.toMatchObject({ status: 409 }); }); - it("retains the last accepted save time after all files are removed", async () => { + it("retains the last accepted operation time after all files are removed", async () => { const f = await folder(); - expect((await svc.list(f)).lastSavedAt).toBeNull(); + expect((await svc.list(f)).lastOperationAt).toBeNull(); await svc.write(f, { path: "note", body: Buffer.from("saved"), operationId: "write" }); - const saved = (await svc.list(f)).lastSavedAt; + const saved = (await svc.list(f)).lastOperationAt; expect(saved).toEqual(expect.any(String)); await svc.remove(f, "note", "delete"); const listing = await svc.list(f); expect(listing.files).toEqual([]); - expect(Date.parse(listing.lastSavedAt!)).toBeGreaterThanOrEqual(Date.parse(saved!)); - expect((await svc.list(f, { trash: true })).lastSavedAt).toBe(listing.lastSavedAt); + expect(Date.parse(listing.lastOperationAt!)).toBeGreaterThanOrEqual(Date.parse(saved!)); + expect((await svc.list(f, { trash: true })).lastOperationAt).toBe(listing.lastOperationAt); }); it("retains a deleted copy after the same path is recreated", async () => { const f = await folder(); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 6cfb0fb557..3c2c450bf3 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -5276,7 +5276,7 @@ const workFolderErrors = { 400: r.badRequest, 401: r.unauthorized, 404: r.notFou registry.registerPath({ method: "get", path: workFolderPath, tags: ["work-folders"], summary: "List scoped sandbox files or recoverable trash", description: "User files require the owning user or an authorized run acting for that user. Company access alone does not grant access.", request: { params: workFolderParams, query: z.object({ trash: z.enum(["true", "false"]).optional(), cursor: z.uuid().optional(), limit: z.coerce.number().int().min(1).max(1000).optional() }) }, - responses: { ...workFolderErrors, 200: r.ok(z.object({ id: z.uuid(), owner: workFolderParams, files: z.array(workFileResponse), nextCursor: z.string().nullable(), lastSavedAt: z.string().nullable() })) }, + responses: { ...workFolderErrors, 200: r.ok(z.object({ id: z.uuid(), owner: workFolderParams, files: z.array(workFileResponse), nextCursor: z.string().nullable(), lastOperationAt: z.string().nullable() })) }, }); registry.registerPath({ method: "get", path: `${workFolderPath}/content`, tags: ["work-folders"], summary: "Download a scoped file", request: { params: workFolderParams, query: z.object({ path: z.string() }) }, diff --git a/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts index 8166e5f925..281f772aaa 100644 --- a/server/src/services/environment-execution-target.ts +++ b/server/src/services/environment-execution-target.ts @@ -372,6 +372,8 @@ export async function resolveEnvironmentExecutionTarget(input: { providerKey: parsed.config.provider, shellCommand, remoteCwd, + ...(typeof input.leaseMetadata?.workFolderHome === "string" + ? { workFolderHome: input.leaseMetadata.workFolderHome } : {}), enableSandboxDuplexBridge, runnerLifecyclePolicy: parsed.config.runnerLifecycleMode === "warm" diff --git a/server/src/services/environment-run-orchestrator.ts b/server/src/services/environment-run-orchestrator.ts index 0c08deb714..7b900b965b 100644 --- a/server/src/services/environment-run-orchestrator.ts +++ b/server/src/services/environment-run-orchestrator.ts @@ -543,13 +543,14 @@ export function environmentRunOrchestrator( : [], }; if (executionTarget) { - executionTarget = { - ...executionTarget, + // Runner callbacks retain this host-owned target. Keep its identity so + // the later work-folder binding reaches native file-sync callbacks. + Object.assign(executionTarget, { ...(executionTarget.kind === "remote" && realizationMode === "in_place" ? { remoteCwd: authoritativeRoot } : {}), workspaceRealization: workspaceTargetMetadata, - } as AdapterExecutionTarget; + }); } } catch (err) { throw new EnvironmentRunError( diff --git a/server/src/services/sandbox-work-folders.ts b/server/src/services/sandbox-work-folders.ts index a583e9ac0b..8d007e025b 100644 --- a/server/src/services/sandbox-work-folders.ts +++ b/server/src/services/sandbox-work-folders.ts @@ -227,14 +227,12 @@ export async function prepareSandboxWorkFolders(input: { const root = path.posix.join(paths.repos!, binding.name); const probe = await target.runner!.execute({ command: "git", args: ["-C", root, "rev-parse", "--git-dir"], bypassSession: true, timeoutMs: 10_000 }); const freshCheckout = probe.exitCode !== 0; - let restoredCheckout = false; if (freshCheckout) { // Publish the checkout directory only after every restore object or // clone step completes. An interrupted attempt cannot masquerade as a // reusable checkout merely because it contains a .git directory. const temporary = path.posix.join(staging, `repo-${binding.id}-${randomUUID()}`); const restored = await repositories.restore(binding, temporary, staging); - restoredCheckout = restored; if (!restored) { const auth = await resolveGitAuth(workspace.repoUrl!); const result = await target.runner!.execute({ command: "git", args: [...(auth?.configArgs ?? []), "clone", "--no-hardlinks", @@ -265,9 +263,10 @@ export async function prepareSandboxWorkFolders(input: { } await transport.moveRoot(temporary, root); } - // A complete checkpoint already contains the setup's durable outputs. - // Replacing the sandbox must not repeat completed project setup. - if ((!binding.setupComplete || (freshCheckout && !restoredCheckout)) && workspace.setupCommand) { + // Warm checkouts retain completed setup. A replacement only restores + // durable repository files, so setup must recreate ignored dependencies + // and caches that are deliberately outside the checkpoint guarantee. + if ((!binding.setupComplete || freshCheckout) && workspace.setupCommand) { const setup = await target.runner!.execute({ command: "sh", args: ["-c", workspace.setupCommand], cwd: root, bypassSession: true, timeoutMs: 300_000 }); if (setup.exitCode !== 0 || setup.timedOut) throw new Error(`Repository ${binding.name} setup failed`); } diff --git a/server/src/services/work-folders.ts b/server/src/services/work-folders.ts index 050565385e..5c955c4fa3 100644 --- a/server/src/services/work-folders.ts +++ b/server/src/services/work-folders.ts @@ -44,7 +44,7 @@ export function workFolderService(db: Db, storage: StorageProvider) { const [saved] = await db.select({ at: max(workFileOperations.createdAt) }).from(workFileOperations) .where(and(eq(workFileOperations.companyId, folder.companyId), eq(workFileOperations.folderId, folder.id))); return { id: folder.id, owner: { companyId: folder.companyId, scope: folder.scope, ownerId: folder.ownerId }, - lastSavedAt: saved?.at?.toISOString() ?? null, + lastOperationAt: saved?.at?.toISOString() ?? null, files: rows.slice(0, limit).map(workFileDto), nextCursor: rows.length > limit ? rows[limit - 1]!.id : null }; } diff --git a/ui/src/api/work-folders.ts b/ui/src/api/work-folders.ts index 51d87f3c3a..df4b35487c 100644 --- a/ui/src/api/work-folders.ts +++ b/ui/src/api/work-folders.ts @@ -8,16 +8,16 @@ export const workFoldersApi = { async list(owner: WorkFolderOwner, trash = false) { const files: WorkFile[] = []; let cursor: string | null = null; - let lastSavedAt: string | null = null; + let lastOperationAt: string | null = null; do { const query = new URLSearchParams({ trash: String(trash), limit: "1000", ...(cursor ? { cursor } : {}) }); const page: WorkFolderListing = await api.get(`${base(owner)}?${query}`); files.push(...page.files); - if (page.lastSavedAt && (!lastSavedAt || page.lastSavedAt > lastSavedAt)) lastSavedAt = page.lastSavedAt; + if (page.lastOperationAt && (!lastOperationAt || page.lastOperationAt > lastOperationAt)) lastOperationAt = page.lastOperationAt; cursor = page.nextCursor; } while (cursor && files.length < 100_000); if (cursor) throw new Error("This folder is too large to display in one view"); - return { files, lastSavedAt }; + return { files, lastOperationAt }; }, upload: (owner: WorkFolderOwner, file: File, filePath: string, operationId: string) => api.putRaw(`${base(owner)}/content?${new URLSearchParams({ path: filePath })}`, file, diff --git a/ui/src/components/WorkFolderBrowser.test.tsx b/ui/src/components/WorkFolderBrowser.test.tsx new file mode 100644 index 0000000000..9919cf4ba1 --- /dev/null +++ b/ui/src/components/WorkFolderBrowser.test.tsx @@ -0,0 +1,38 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import type { WorkFolderSyncStatus } from "@paperclipai/shared"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { WorkFolderBrowser } from "./WorkFolderBrowser"; + +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) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + client.setQueryData([...key, "files", false], { files: [], lastOperationAt }); + client.setQueryData([...key, "sync"], statuses); + 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("labels direct file operations separately from agent checkpoints", () => { + const html = render([checkpoint], "2026-09-07T12:01:00.000Z"); + expect(html).toContain('role="status">Saved'); + expect(html).toContain("Last agent save"); + expect(html).toContain("Files updated"); + const manualOnly = render([], "2026-09-07T12:01:00.000Z"); + expect(manualOnly).toContain("Files updated"); + expect(manualOnly).not.toContain("Last agent save"); + }); + it("keeps the last successful checkpoint visible during a failed or pending save", () => { + const failed = render([{ ...checkpoint, state: "failed", error: "Working copy retained" }], null); + expect(failed).toContain('role="status">Save failed'); + expect(failed).toContain("Last agent save"); + expect(failed).toContain("Working copy retained"); + const saving = render([{ ...checkpoint, state: "saving", active: true }], null); + expect(saving).toContain('role="status">Saving…'); + expect(saving).toContain("Last agent save"); + }); +}); diff --git a/ui/src/components/WorkFolderBrowser.tsx b/ui/src/components/WorkFolderBrowser.tsx index b001ce6fef..28761bac8e 100644 --- a/ui/src/components/WorkFolderBrowser.tsx +++ b/ui/src/components/WorkFolderBrowser.tsx @@ -71,8 +71,10 @@ export function WorkFolderBrowser({ owner, exampleFiles }: { owner: WorkFolderOw const statuses = syncQuery.data ?? []; const failed = statuses.find((status) => status.state === "failed"); const saving = mutation.isPending || statuses.some((status) => status.state === "saving"); - const lastSaved = [...statuses.map((status) => status.lastSavedAt), filesQuery.data?.lastSavedAt] + const lastSaved = statuses.map((status) => status.lastSavedAt) .filter((value): value is string => Boolean(value)).sort().at(-1); + const lastOperation = filesQuery.data?.lastOperationAt; + const saveFailed = Boolean(failed) || mutation.isError; const disabled = mutation.isPending || Boolean(exampleFiles); return
@@ -83,7 +85,9 @@ export function WorkFolderBrowser({ owner, exampleFiles }: { owner: WorkFolderOw }} /> - {saving ? "Saving…" : failed ? "Save failed" : lastSaved ? `Saved ${new Date(lastSaved).toLocaleTimeString()}` : "Saved files"} + {saving ? "Saving…" : saveFailed ? "Save failed" : "Saved"} + {lastSaved && Last agent save {new Date(lastSaved).toLocaleTimeString()}} + {lastOperation && (!lastSaved || lastOperation > lastSaved) && Files updated {new Date(lastOperation).toLocaleTimeString()}}
{!trash &&
setDirectory(event.target.value)} placeholder="Root folder" />