From c4bf919895ebe92a691fe50b3b5d3cc0d619c35f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 17:56:03 +0800 Subject: [PATCH] fix(host,client): default-export the picker seam; invalidate stale kind reads ds-review-bot round 3. The seam package broke the service-package export contract (named export only), so the config catalog filed it under Other libraries and default imports failed; it now default-exports DirectoryPicker like every abstract seam, and the regenerated catalog lists it as one. The picker-kind effect also let a settlement from a superseded flow open leak into the current one (close/reopen mid-describe, or a reconnect that swaps the backend): the read now resets the affordance on every open and a cleanup-toggled flag discards obsolete settlements, both directions pinned by jsdom races. --- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- .../src/client/WorkspacePicker.tsx | 14 ++++-- .../tests/workspace-picker.spec.tsx | 46 +++++++++++++++++++ packages/host/directory-picker/src/index.ts | 2 + 5 files changed, 60 insertions(+), 6 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f9b4e9f8bf..0697f56dfe 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2211,6 +2211,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) +- `@deepseek-ai/dsh-host-directory-picker` — abstract `DirectoryPicker` ([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) @@ -2234,7 +2235,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) - `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) -- `@deepseek-ai/dsh-host-directory-picker` ([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts)) - `@deepseek-ai/dsh-llm-mock-server` ([`packages/support/llm-mock-server/src/index.ts`](../packages/support/llm-mock-server/src/index.ts)) - `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 397e7320d5..3b47e405f0 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -481,7 +481,7 @@ Abstract directory-picking service. Subclass, implement `capability()`, and load abstract capability(): DirectoryPickerCapability ``` -Source: [`packages/host/directory-picker/src/index.ts:118`](../../packages/host/directory-picker/src/index.ts) +Source: [`packages/host/directory-picker/src/index.ts:120`](../../packages/host/directory-picker/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 8cef0af8f9..2e65fc6b72 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -80,12 +80,18 @@ export function WorkspaceCreateFlow({ const [dialogPicker, setDialogPicker] = useState(false) useEffect(() => { if (!open) return + // Reset before each read: a reconnect can change the composed backend, so + // a previous open's answer must not leak into this one; and a settlement + // from a superseded open (flow closed, or a newer read started) is + // discarded via the cleanup-toggled flag. + setDialogPicker(false) + let stale = false void directoryPickerKind() - .then((kind) => { setDialogPicker(kind === 'dialog') }) + .then((kind) => { if (!stale) setDialogPicker(kind === 'dialog') }) // A failed describe hides the entry too: the same Host that cannot - // answer describe cannot serve pickDirectory. (Post-unmount settlement - // is safe: React 18 no-ops setState on unmounted components.) - .catch(() => { setDialogPicker(false) }) + // answer describe cannot serve pickDirectory. + .catch(() => { if (!stale) setDialogPicker(false) }) + return () => { stale = true } }, [open, directoryPickerKind]) const items: MenuEntry[] = [ diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index c7f8fd9765..9ca5304fa4 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -268,4 +268,50 @@ describe('WorkspacePicker', () => { ) expect(directoryPickerKind).not.toHaveBeenCalled() }) + + /** Render the picker with an owner-controlled `open` and a scripted kind read. */ + function togglable(directoryPickerKind: () => Promise) { + const anchorRef = anchor() + const props = (open: boolean) => ( + + ) + const view = render(props(true)) + return { setOpen: (open: boolean) => { view.rerender(props(open)) } } + } + + it('discards a kind settlement from a superseded flow open', async () => { + let resolveFirst!: (kind: string) => void + const first = new Promise((settle) => { resolveFirst = settle }) + const directoryPickerKind = vi.fn<() => Promise>() + .mockImplementationOnce(() => first) + .mockImplementation(async () => 'browse') + const t = togglable(directoryPickerKind) + // Close while the first read is in flight, then let it answer 'dialog': + // the settlement is stale and must not leak into the next open. + t.setOpen(false) + await act(async () => { resolveFirst('dialog') }) + t.setOpen(true) + await screen.findByRole('menuitem', { name: 'Create a new workspace' }) + await waitFor(() => { expect(directoryPickerKind).toHaveBeenCalledTimes(2) }) + expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() + }) + + it('discards a stale describe failure after a newer open already answered', async () => { + let rejectFirst!: (reason: Error) => void + const first = new Promise((_settle, reject) => { rejectFirst = reject }) + const directoryPickerKind = vi.fn<() => Promise>() + .mockImplementationOnce(() => first) + .mockImplementation(async () => 'dialog') + const t = togglable(directoryPickerKind) + t.setOpen(false) + t.setOpen(true) + await screen.findByRole('menuitem', { name: 'Open local folder…' }) + // The superseded read failing late must not hide the freshly shown entry. + await act(async () => { rejectFirst(new Error('late loss')); await first.catch(() => {}) }) + expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy() + }) }) diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 322dc4551c..1c4f5e32fb 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -128,3 +128,5 @@ export abstract class DirectoryPicker extends Service { */ abstract capability(): DirectoryPickerCapability } + +export default DirectoryPicker