From ba561db9dde468f0293ec393ac2d6e2e24a17773 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:07:08 +0000 Subject: [PATCH] fix(workspaces): make delete/rename dialogs recoverable and batch kernel deletes The bulk-delete alert only closed inside the mutation's onSuccess and had no error surface, and the rename dialog fired mutate() then closed unconditionally on the next line. When the workspace kernel was slow or errored, users were left with a frozen/disabled dialog or a silently dismissed rename that lost their input. - Delete alert: surface the failure inline, keep the modal open, and relabel the action to "Retry delete" so the user can retry instead of being wedged. Reset the mutation on close so a reopen starts clean. - Rename dialog: await the mutation, close only on success, and keep the dialog open with an inline error and the user's typed value preserved (controlled input) so they can correct and retry in place. - Kernel deleteItems: stream file removals in bounded batches instead of firing every workspace.rm at once, so a multi-item delete doesn't blow the Durable Object memory/storage budget. Generated-By: PostHog Code Task-Id: d66ba4e3-c24d-4160-80d0-8d3b1ff22e2b --- .../components/WorkspaceItemActionDialogs.tsx | 66 +++++++++++++++---- .../workspace-kernel-item-commands.test.ts | 63 ++++++++++++++++++ .../kernel/workspace-kernel-item-commands.ts | 34 +++++++--- 3 files changed, 143 insertions(+), 20 deletions(-) create mode 100644 src/features/workspaces/kernel/workspace-kernel-item-commands.test.ts diff --git a/src/features/workspaces/components/WorkspaceItemActionDialogs.tsx b/src/features/workspaces/components/WorkspaceItemActionDialogs.tsx index 2c3a9331..6e193c32 100644 --- a/src/features/workspaces/components/WorkspaceItemActionDialogs.tsx +++ b/src/features/workspaces/components/WorkspaceItemActionDialogs.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useId } from "react"; +import { type ReactNode, useId, useState } from "react"; import { AlertDialog, @@ -19,7 +19,7 @@ import { DialogHeader, DialogTitle, } from "#/components/ui/dialog"; -import { Field, FieldGroup, FieldLabel } from "#/components/ui/field"; +import { Field, FieldError, FieldGroup, FieldLabel } from "#/components/ui/field"; import { Input } from "#/components/ui/input"; import { getWorkspaceDescendantIds } from "#/features/workspaces/model/tree"; import type { WorkspaceItem } from "#/features/workspaces/model/types"; @@ -27,6 +27,7 @@ import { useDeleteWorkspaceItemsMutation, useRenameWorkspaceItemMutation, } from "#/features/workspaces/use-workspace-kernel-items"; +import { getErrorMessage } from "#/lib/error-message"; export function RenameWorkspaceItemDialog({ item, @@ -53,28 +54,38 @@ function RenameWorkspaceItemDialogContent({ }) { const nameInputId = useId(); const renameWorkspaceItemMutation = useRenameWorkspaceItemMutation(); + // Controlled so a failed rename keeps the user's typed value in place for + // correction/retry instead of React resetting the form back to the old name. + const [nameDraft, setNameDraft] = useState(item.name); return (
{ - const rawName = formData.get("name"); - const name = (typeof rawName === "string" ? rawName : "").trim(); + action={async () => { + const name = nameDraft.trim(); if (!name) { return; } - if (name !== item.name) { - renameWorkspaceItemMutation.mutate({ + if (name === item.name) { + onOpenChange(false); + return; + } + + try { + await renameWorkspaceItemMutation.mutateAsync({ workspaceId: item.workspaceId, itemId: item.id, name, }); - } - onOpenChange(false); + onOpenChange(false); + } catch { + // Keep the dialog open so the error surfaces inline and the + // user can correct the name and retry instead of losing input. + } }} > @@ -82,9 +93,25 @@ function RenameWorkspaceItemDialogContent({ Update the item name shown in this workspace. - + Name - + setNameDraft(event.target.value)} + required + autoFocus + aria-invalid={renameWorkspaceItemMutation.isError || undefined} + /> + {renameWorkspaceItemMutation.isError ? ( + + {getErrorMessage( + renameWorkspaceItemMutation.error, + "Unable to rename workspace item right now.", + )} + + ) : null} @@ -127,6 +154,9 @@ export function DeleteWorkspaceItemsAlert({ onOpenChange={onOpenChange} onOpenChangeComplete={(nextOpen) => { if (!nextOpen) { + // Clear any prior failure so reopening starts from a clean state + // rather than surfacing a stale error and a "Retry delete" label. + deleteWorkspaceItemsMutation.reset(); onClosed?.(); } }} @@ -137,6 +167,15 @@ export function DeleteWorkspaceItemsAlert({ {description} + {deleteWorkspaceItemsMutation.isError ? ( +

+ {getErrorMessage( + deleteWorkspaceItemsMutation.error, + "Unable to delete right now. Please try again.", + )} +

+ ) : null} + Cancel - Delete + {deleteWorkspaceItemsMutation.isError ? "Retry delete" : "Delete"} diff --git a/src/features/workspaces/kernel/workspace-kernel-item-commands.test.ts b/src/features/workspaces/kernel/workspace-kernel-item-commands.test.ts new file mode 100644 index 00000000..725eb14e --- /dev/null +++ b/src/features/workspaces/kernel/workspace-kernel-item-commands.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; + +import { WorkspaceKernelItemCommands } from "#/features/workspaces/kernel/workspace-kernel-item-commands"; +import type { KernelItemRow } from "#/features/workspaces/kernel/workspace-kernel-rows"; + +function makeItemRow(id: string): KernelItemRow { + return { + id, + parent_id: null, + type: "document", + name: id, + color: null, + metadata_json: "{}", + sort_order: 0, + shell_path: `/${id}`, + created_at: 0, + updated_at: 0, + deleted_at: null, + }; +} + +describe("WorkspaceKernelItemCommands.deleteItems", () => { + it("streams file removals in bounded batches instead of all at once", async () => { + const itemIds = Array.from({ length: 9 }, (_, index) => `item-${index}`); + const rowsById = new Map(itemIds.map((id) => [id, makeItemRow(id)])); + + let inFlight = 0; + let maxInFlight = 0; + const rm = vi.fn(async () => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 1)); + inFlight -= 1; + }); + + const store = { + assertActiveItem: (id: string) => rowsById.get(id), + getItemRowIncludingDeleted: (id: string) => rowsById.get(id) ?? null, + getDescendantIds: () => [], + softDeleteItems: vi.fn(), + }; + const relations = { deleteRelationsForItems: vi.fn() }; + const events = { commit: vi.fn(() => ({ id: "event" })) }; + const workspace = { rm }; + + const commands = new WorkspaceKernelItemCommands({ + events: events as never, + relations: relations as never, + sql: (() => []) as never, + store: store as never, + workspace: workspace as never, + workspaceId: () => "workspace", + }); + + const { result } = await commands.deleteItems({ itemIds }); + + expect(rm).toHaveBeenCalledTimes(9); + expect(maxInFlight).toBeLessThanOrEqual(4); + expect(maxInFlight).toBeGreaterThan(1); + expect(result.deletedItemIds).toEqual(itemIds); + expect(store.softDeleteItems).toHaveBeenCalledWith(itemIds, expect.any(Number)); + }); +}); diff --git a/src/features/workspaces/kernel/workspace-kernel-item-commands.ts b/src/features/workspaces/kernel/workspace-kernel-item-commands.ts index dd2575d9..098caee5 100644 --- a/src/features/workspaces/kernel/workspace-kernel-item-commands.ts +++ b/src/features/workspaces/kernel/workspace-kernel-item-commands.ts @@ -37,6 +37,16 @@ import { } from "#/features/workspaces/model/workspace-item-colors"; import type { WorkspaceCommandResult } from "#/features/workspaces/realtime/messages"; +/** + * Number of workspace file removals to run concurrently when deleting items. + * + * A bulk delete can expand into a large number of nested rows. Firing every + * `workspace.rm` at once overwhelms the Durable Object storage budget and trips + * memory-limit / storage-timeout resets, so we stream the removals in bounded + * batches instead of loading them all at once. + */ +const workspaceItemDeleteConcurrency = 4; + export class WorkspaceKernelItemCommands { private readonly events: WorkspaceKernelEventBus; private readonly relations: WorkspaceKernelRelations; @@ -250,14 +260,7 @@ export class WorkspaceKernelItemCommands { this.store.softDeleteItems(deleteIds, Date.now()); this.relations.deleteRelationsForItems(deleteIds); - await Promise.all( - rowsToRemove.map((row) => - this.workspace.rm(row.shell_path, { - recursive: true, - force: true, - }), - ), - ); + await this.removeWorkspaceItemFiles(rowsToRemove); const result = { itemIds: rootIds, deletedItemIds: deleteIds }; const event = this.events.commit({ @@ -342,6 +345,21 @@ export class WorkspaceKernelItemCommands { ); } + private async removeWorkspaceItemFiles(rows: KernelItemRow[]) { + for (let start = 0; start < rows.length; start += workspaceItemDeleteConcurrency) { + const batch = rows.slice(start, start + workspaceItemDeleteConcurrency); + + await Promise.all( + batch.map((row) => + this.workspace.rm(row.shell_path, { + recursive: true, + force: true, + }), + ), + ); + } + } + private commitItemEvent(input: { type: | "workspace.item.renamed"