diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml index 93c78373c6..d576fb10e5 100644 --- a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md -2026-07-27-workspace-registration-deletion.md: 58ae5c4bef2cf1cb0a0158eda5eb37daf2e9703d -2026-07-27-workspace-registration-deletion.zh.md: 7a79a1ccc53a0d4fd7e5ab453239ade955313c6e +2026-07-27-workspace-registration-deletion.md: 8168b0832ca39e6023f6981815ffe758b5695361 +2026-07-27-workspace-registration-deletion.zh.md: b0df6982ac81426a5b0ce2f0e2b0e744212e3f5b diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md index 58ae5c4bef..8168b0832c 100644 --- a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md @@ -28,6 +28,8 @@ Create and delete write a durable `pendingMutation` before their record/order pa `WorkspaceManager` treats both `host/workspace-changed` and `host/workspace-removed` as ordered deltas replayed over an in-flight `workspace.list` response. A successful unary delete removes the row immediately instead of waiting for its own stream echo. Removal is idempotent, and a process-local tombstone rejects late changed frames or stale baseline rows for the never-reused Workspace id. A reconnect still refreshes from `workspace.list`; Session state is never pruned by a Workspace delta. +The delete confirmation remains pending until the React Workspace projection has committed the removed id, so the next create gesture cannot observe one stale list frame. During create, duplicate-name validation is suppressed while the request is pending because the committed `host/workspace-changed` frame may publish the newly created Workspace before its unary response; after failure returns the form to editing, validation uses the latest list again. + ## Confirmation interaction The existing Workspace row menu opens a shared `Modal` before deletion. The text states all three consequences: the Workspace leaves the list, the folder and session logs remain, and its Sessions appear under Ungrouped. While the request is pending, the confirm and Cancel controls are disabled, duplicate confirmation is ignored, and Escape or Close cannot dismiss the operation. Failure keeps the Modal open with the error; Cancel, Escape, and Close before submission never delete. @@ -48,7 +50,7 @@ The menu, Modal, and buttons retain their existing structure and design tokens. ## Verification -Workspace package tests pin successful metadata-only deletion, same-path re-registration, unknown-id idempotence, table-failure rollback, explicit-marker restart recovery, unexplained-corruption rejection, and cache/table invariant behavior. Apiproxy and carrier tests pin the schema, handler, `workspace-not-found`, retained Session/folder, fresh-id re-registration, and committed `host/workspace-removed` frame. Client tests pin unary direct echo, duplicate removal, late changed frames, and deletion racing an in-flight baseline. Component tests pin confirmation, pending-state duplicate suppression, success, failure, Cancel, Escape, and Close. +Workspace package tests pin successful metadata-only deletion, same-path re-registration, unknown-id idempotence, table-failure rollback, explicit-marker restart recovery, unexplained-corruption rejection, and cache/table invariant behavior. Apiproxy and carrier tests pin the schema, handler, `workspace-not-found`, retained Session/folder, fresh-id re-registration, and committed `host/workspace-removed` frame. Client tests pin unary direct echo, duplicate removal, late changed frames, and deletion racing an in-flight baseline. Component tests pin confirmation, projection-settled closing, pending-state duplicate suppression, success-frame-before-unary ordering, failure, Cancel, Escape, and Close. The browser scenario observes every transient alert, slot error, console error, and page error while reusing a deleted title for a different directory. The assembled keyless Web scenario registers an existing temporary project directory, accounts a persisted Session, makes that Session current, confirms deletion in Chromium, and verifies the Workspace group disappears while Ungrouped retains the current Session. It checks the user file and JSONL log before and after deletion and repeats the UI, directory, and log assertions after reload. diff --git a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md index 7a79a1ccc5..b0df6982ac 100644 --- a/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md @@ -28,6 +28,8 @@ Create 与 delete 会在记录/顺序对可能分叉之前写入持久 `pendin `WorkspaceManager` 将 `host/workspace-changed` 与 `host/workspace-removed` 都视为有序增量,并在进行中的 `workspace.list` 响应之上回放。成功的一元删除会立即移除行,无需等待本次操作自己的流回显。移除操作具有幂等性;由于 Workspace id 永不复用,进程本地删除标记会拒绝延迟到达的 changed 帧或陈旧基线行。重连仍从 `workspace.list` 刷新;Workspace 增量绝不会剪除会话状态。 +删除确认框会保持待处理,直到 React Workspace 投影已经提交目标 id 的移除,因此下一次创建操作不会读到一帧陈旧列表。创建请求进行中会暂停重复名称校验,因为已提交的 `host/workspace-changed` 帧可能先于一元响应发布刚创建的 Workspace;如果请求失败并让表单回到可编辑状态,系统会重新使用最新列表执行校验。 + ## 确认交互 现有 Workspace 行菜单会在删除前打开共享 `Modal`。文案明确说明三项后果:Workspace 会从列表中移除,文件夹和会话日志会保留,相关会话会出现在 Ungrouped 下。请求待处理期间,确认与 Cancel 控件均被禁用,重复确认会被忽略,Escape 或 Close 也无法关闭此次操作。失败时 `Modal` 保持打开并显示错误;提交前使用 Cancel、Escape 或 Close 绝不会触发删除。 @@ -48,7 +50,7 @@ Create 与 delete 会在记录/顺序对可能分叉之前写入持久 `pendin ## Verification -Workspace 包测试固定了仅删除元数据的成功路径、同路径重新注册、未知 id 的幂等行为、表操作失败回滚、明确标记的重启恢复、来源不明损坏的拒绝,以及缓存/表不变量行为。Apiproxy 与载体测试固定了 schema、处理器、`workspace-not-found`、保留会话/文件夹、使用新 id 重新注册,以及已提交的 `host/workspace-removed` 帧。客户端测试固定了一元直接回显、重复移除、延迟到达的 changed 帧,以及删除与进行中基线并发的行为。组件测试固定了确认交互、待处理状态下抑制重复提交、成功、失败、Cancel、Escape 与 Close。 +Workspace 包测试固定了仅删除元数据的成功路径、同路径重新注册、未知 id 的幂等行为、表操作失败回滚、明确标记的重启恢复、来源不明损坏的拒绝,以及缓存/表不变量行为。Apiproxy 与载体测试固定了 schema、处理器、`workspace-not-found`、保留会话/文件夹、使用新 id 重新注册,以及已提交的 `host/workspace-removed` 帧。客户端测试固定了一元直接回显、重复移除、延迟到达的 changed 帧,以及删除与进行中基线并发的行为。组件测试固定了确认交互、投影稳定后关闭、待处理状态下抑制重复提交、成功帧先于一元响应、失败、Cancel、Escape 与 Close。浏览器场景会在为不同目录复用已删除名称时,观测每一次瞬时 alert、slot error、console error 与 page error。 组装后的无密钥 Web 场景会注册一个已有临时项目目录,将持久化会话计入账本,把该会话设为当前会话,在 Chromium 中确认删除,并验证 Workspace 分组消失,而 Ungrouped 保留当前会话。该场景在删除前后检查用户文件和 JSONL 日志,并在刷新后重复验证 UI、目录与日志。 diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 3239dcfc12..98a2338064 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -108,6 +108,31 @@ describe('web e2e: workspace management (create / rename / flat view / hover car it('deletes only the Workspace registration and keeps its current Session, folder, and log', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-delete')) + const slotConsoleErrors: string[] = [] + const transientSlotErrors: string[] = [] + page.on('console', (message) => { + if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) { + slotConsoleErrors.push(message.text()) + } + }) + await page.exposeFunction('recordDshSlotError', (key: string) => { + if (!transientSlotErrors.includes(key)) transientSlotErrors.push(key) + }) + await page.evaluate(() => { + const target = window as unknown as { recordDshSlotError(key: string): Promise } + const seen = new Set() + const collect = (): void => { + for (const node of document.querySelectorAll('[data-slot-error]')) { + const key = node.dataset.slotError ?? '' + if (!seen.has(key)) { + seen.add(key) + void target.recordDshSlotError(key) + } + } + } + new MutationObserver(collect).observe(document.documentElement, { childList: true, subtree: true }) + collect() + }) // Register the scaffold's existing project directory through the real UI. await page.getByRole('button', { name: 'Create workspace' }).click() await page.getByRole('menuitem', { name: 'Create workspace' }).hover() @@ -212,6 +237,69 @@ describe('web e2e: workspace management (create / rename / flat view / hover car await stat(logLocation.path) expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0) + expect(transientSlotErrors).toEqual([]) + expect(slotConsoleErrors).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + + it('reuses a deleted title for a different new directory without any transient error surface', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-reuse-title')) + const title = 'same-name' + const oldPath = join(scaffold.workspaceCwd, 'adopted', title) + await mkdir(oldPath, { recursive: true }) + const transientErrors: string[] = [] + const consoleErrors: string[] = [] + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()) + }) + await page.exposeFunction('recordDshTransientWorkspaceError', (message: string) => { + if (!transientErrors.includes(message)) transientErrors.push(message) + }) + await page.evaluate(() => { + const target = window as unknown as { + recordDshTransientWorkspaceError(message: string): Promise + } + const collect = (): void => { + for (const node of document.querySelectorAll('[data-slot-error], [role="alert"]')) { + const message = node.dataset.slotError ?? node.textContent?.trim() ?? '' + if (message !== '') void target.recordDshTransientWorkspaceError(message) + } + } + new MutationObserver(collect).observe(document.documentElement, { childList: true, subtree: true }) + collect() + }) + + await page.getByRole('button', { name: 'Create workspace' }).click() + await page.getByRole('menuitem', { name: 'Create workspace' }).hover() + await page.getByRole('menuitem', { name: 'Use an existing folder' }).click() + const adopt = page.getByRole('dialog', { name: 'Use an existing folder' }) + await adopt.getByLabel('Existing folder path').fill(oldPath) + await adopt.getByRole('button', { name: 'Use folder' }).click() + await expect.poll(() => adopt.count(), { timeout: 10_000 }).toBe(0) + const oldWorkspace = await scaffold.ctx.workspace.resolveByPath(oldPath) + if (oldWorkspace === undefined) throw new Error('old same-name Workspace was not registered') + + const oldRow = page.locator('[role="treeitem"]').filter({ hasText: title }).first() + await oldRow.hover() + await page.getByRole('button', { name: `Workspace actions for ${title}` }).click() + await page.getByRole('menuitem', { name: 'Delete workspace' }).click() + await page.getByRole('dialog', { name: 'Delete workspace' }) + .getByRole('button', { name: 'Delete workspace' }).click() + await expect.poll(() => scaffold.ctx.workspace.get(oldWorkspace.id), { timeout: 10_000 }).toBeUndefined() + + await page.getByRole('button', { name: 'Create workspace' }).click() + await page.getByRole('menuitem', { name: 'Create workspace' }).hover() + await page.getByRole('menuitem', { name: 'Create a new workspace' }).click() + const create = page.getByRole('dialog', { name: 'Create a new workspace' }) + await create.getByLabel('New workspace name').fill(title) + await create.getByRole('button', { name: 'Create workspace' }).click() + await expect.poll(() => create.count(), { timeout: 10_000 }).toBe(0) + const fresh = scaffold.ctx.workspace.list().find(workspace => workspace.title === title) + expect(fresh?.id).toBeDefined() + expect(fresh?.id).not.toBe(oldWorkspace.id) + expect(fresh?.path).toBe(join(scaffold.workspaceCwd, title)) + expect(transientErrors).toEqual([]) + expect(consoleErrors).toEqual([]) expect(tripwire.pageErrors).toEqual([]) }, 90_000) diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index 7179ed9eb9..ce4198cd01 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -133,7 +133,7 @@ export class WorkspaceManager { */ async delete(workspaceId: WorkspaceId): Promise> { const { result } = await this.api.workspace.delete({ workspaceId }) - if (result.ok) this.remove(workspaceId) + if (result.ok) this.remove(workspaceId, true) return result } @@ -224,14 +224,21 @@ export class WorkspaceManager { } /** Remove one id idempotently and retain a tombstone against late echoes. */ - private remove(workspaceId: WorkspaceId): void { + private remove(workspaceId: WorkspaceId, direct = false): void { this.refreshFrames?.push({ type: 'remove', workspaceId }) this.removedIds.add(workspaceId) const items = this.items.filter(item => item.getSnapshot().view?.workspaceId !== workspaceId) - if (items.length === this.items.length) return + if (items.length === this.items.length) { + // The Host frame may have removed the row first but left its batched + // notification pending. A successful unary echo still flushes that + // committed state before the user action resolves. + if (direct) this.notifier.notifyNow() + return + } this.items = items - this.notifier.markDirty() + if (direct) this.notifier.notifyNow() + else this.notifier.markDirty() } private installViews(views: readonly WorkspaceView[]): void { diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 0090164928..c56de93c56 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -307,7 +307,15 @@ export function WorkspaceBrowser({ // unmount that row without tearing down the in-flight confirmation state. const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null) const [deleting, setDeleting] = useState(false) + const [deleteCommittedId, setDeleteCommittedId] = useState(null) const [deleteError, setDeleteError] = useState(null) + useEffect(() => { + if (deleteCommittedId === null + || workspaces.some(workspace => workspace.workspaceId === deleteCommittedId)) return + setDeleting(false) + setDeleteCommittedId(null) + setDeleteTarget(null) + }, [deleteCommittedId, workspaces]) const closeDelete = () => { if (deleting) return setDeleteTarget(null) @@ -317,10 +325,13 @@ export function WorkspaceBrowser({ /* v8 ignore next -- the Modal is absent without a target and its button is disabled while deleting. */ if (deleting || deleteTarget === null) return setDeleting(true) + setDeleteCommittedId(null) setDeleteError(null) deleteWorkspace(deleteTarget.workspaceId).then(() => { - setDeleting(false) - setDeleteTarget(null) + // Keep the confirmation pending until this component has rendered the + // committed list projection without the deleted id. Closing earlier + // exposes one stale React frame to the next Create Workspace gesture. + setDeleteCommittedId(deleteTarget.workspaceId) }).catch((reason: unknown) => { setDeleting(false) setDeleteError(reason instanceof Error ? reason.message : String(reason)) diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 2be39875bc..99ed2831a6 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -60,7 +60,7 @@ export function WorkspaceCreateFlow({ const [creating, setCreating] = useState(false) const [modalError, setModalError] = useState(null) const normalizedWorkspaceName = workspaceName.trim() - const duplicateWorkspaceName = normalizedWorkspaceName !== '' + const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== '' && workspaces.some(workspace => workspace.title === normalizedWorkspaceName) const items: MenuEntry[] = [ diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 1dbe895b74..33abdc1231 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -461,7 +461,7 @@ describe('WorkspaceBrowser', () => { it('confirms Workspace deletion, explains retention, and blocks duplicate submission', async () => { let resolveDelete!: () => void const deleteWorkspace = vi.fn(() => new Promise((resolve) => { resolveDelete = resolve })) - mount({ + const browser = mount({ useWorkspaces: hook(workspaceState([workspace('alpha', ['session'], 'Alpha')])), deleteWorkspace, }) @@ -484,6 +484,11 @@ describe('WorkspaceBrowser', () => { fireEvent.click(screen.getByRole('button', { name: 'Close' })) expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy() await act(async () => { resolveDelete() }) + // RPC success alone does not close: the component waits until its + // useWorkspaces projection has committed the removal, preventing a stale + // duplicate-name frame from leaking into the next create gesture. + expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy() + rerender(browser, { useWorkspaces: hook(workspaceState([])) }) expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull() }) diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index d487fae5f8..a5510178e7 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -35,18 +35,25 @@ function anchor(): { current: HTMLElement } { function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn()) { const onPick = vi.fn() const onClose = vi.fn() - const view = render( + const anchorRef = anchor() + const renderPicker = (nextItems: readonly WorkspaceView[]) => ( , + /> ) - return { view, onPick, onClose, createWorkspace } + const view = render( + renderPicker(items), + ) + return { + view, onPick, onClose, createWorkspace, + rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) }, + } } function chooseCreateItem(name: 'Use an existing folder' | 'Create a new workspace'): void { @@ -106,6 +113,22 @@ describe('WorkspacePicker', () => { expect(b.createWorkspace).not.toHaveBeenCalled() }) + it('does not flash a duplicate alert when the successful create frame arrives before its unary response', async () => { + let resolve!: (workspace: WorkspaceView) => void + const pending = new Promise((settle) => { resolve = settle }) + const created = workspace('fresh', 'same-name') + const b = mount([], vi.fn(() => pending)) + chooseCreateItem('Create a new workspace') + fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } }) + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + + b.rerenderItems([created]) + expect(screen.getByRole('status').textContent).toBe('Creating workspace…') + expect(screen.queryByRole('alert')).toBeNull() + await act(async () => { resolve(created); await pending }) + expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) + }) + it('exposes creation phase and error text while retaining the modal for retry', async () => { let reject!: (reason: unknown) => void const pending = new Promise((_resolve, rejectPromise) => { reject = rejectPromise })