Fix browser file operations and persist visible save times

Preserve JSON Content-Type when custom idempotency headers are supplied. Expose the latest accepted folder operation timestamp so browser saves remain visible before an agent runs and after all files are deleted.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-07 13:31:59 -05:00
1 parent 23407236b4
commit b4c3bf6961
8 files changed
+46 -6

No files matched your search

+21
View File
@@ -41,6 +41,27 @@ afterEach(() => {
vi.unstubAllGlobals();
});
describe("request headers", () => {
it("keeps JSON content type when a mutation supplies an idempotency header", async () => {
fetchMock.mockResolvedValue(jsonResponse({ applied: true }));
await api.post("/work-folder/operations", { action: "delete", path: "note.md" },
{ headers: { "Idempotency-Key": "delete-note" } });
const request = fetchMock.mock.calls[0]![1] as RequestInit;
const headers = new Headers(request.headers);
expect(headers.get("Content-Type")).toBe("application/json");
expect(headers.get("Idempotency-Key")).toBe("delete-note");
expect(JSON.parse(String(request.body))).toEqual({ action: "delete", path: "note.md" });
});
it("preserves a raw upload's explicit content type", async () => {
fetchMock.mockResolvedValue(jsonResponse({ applied: true }));
const body = new Blob(["file bytes"]);
await api.putRaw("/work-folder/content", body, { headers: { "Idempotency-Key": "upload" } });
const request = fetchMock.mock.calls[0]![1] as RequestInit;
expect(new Headers(request.headers).get("Content-Type")).toBe("application/octet-stream");
expect(request.body).toBe(body);
});
});
describe("tenant-session recovery", () => {
it("keeps concurrent failures pending and schedules one top-level reload", async () => {
const reload = vi.fn();
+1 -1
View File
@@ -51,9 +51,9 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
applyObservabilityHeaders(headers);
const res = await fetch(`${BASE}${path}`, {
headers,
credentials: "include",
...init,
headers,
});
if (!res.ok) {
const errorBody = await res.json().catch(() => null);
+3 -1
View File
@@ -8,14 +8,16 @@ export const workFoldersApi = {
async list(owner: WorkFolderOwner, trash = false) {
const files: WorkFile[] = [];
let cursor: string | null = null;
let lastSavedAt: 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;
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;
return { files, lastSavedAt };
},
upload: (owner: WorkFolderOwner, file: File, filePath: string, operationId: string) =>
api.putRaw(`${base(owner)}/content?${new URLSearchParams({ path: filePath })}`, file,