diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index c70c3a5ea3..27a5c85aed 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -342,13 +342,19 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, } if (event.key === 'Escape') { event.stopPropagation() + // Cancel also withdraws a navigation the editor already + // launched: its late success must not jump to the + // cancelled path, so the pending request is superseded + // and the view leaves the loading state. + requestSeq.current += 1 + setLoading(false) setPathDraft(null) setError(null) // Editing may have superseded the selection's preview - // request; a selection with no preview and nothing in - // flight would render a half-empty two-pane view, so - // cancel falls back to the single-pane level. - if (child === null && !loading) setSelected(null) + // request; a selection with no preview would render a + // half-empty two-pane view, so cancel falls back to the + // single-pane level. + if (child === null) setSelected(null) } }} /> diff --git a/packages/host/directory-picker-browse/src/client/index.ts b/packages/host/directory-picker-browse/src/client/index.ts index 086c352622..78d5e1c006 100644 --- a/packages/host/directory-picker-browse/src/client/index.ts +++ b/packages/host/directory-picker-browse/src/client/index.ts @@ -7,7 +7,7 @@ * cordis.yml row; no client code branches on a capability kind. The dialog's * copy is locale-registered here — the flow package owns its own strings. */ -import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' +import { deferGroupRegistration } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the SlotMap merge declaring the directory-flow holes. import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -65,19 +65,15 @@ export function apply(ctx: ClientContext): void { t: ctx.locale.bind(LOCALE_NS), }) ctx.effect(() => { - // Constructing the pair can throw halfway (a declared hole already - // occupied registers synchronously): roll the earlier deferral back so - // no live subscription outlives the failed fiber. - const deferred: ReturnType[] = [] - try { - deferred.push(deferRegistration(ctx.slots, 'conversation.hero.workspace.directoryFlow', BrowseDirectoryFlow, () => - ctx.slots.register({ name: 'conversation.hero.workspace.directoryFlow', inject: injected }, BrowseDirectoryFlow))) - deferred.push(deferRegistration(ctx.slots, 'sidebar.workspaces.directoryFlow', BrowseDirectoryFlow, () => - ctx.slots.register({ name: 'sidebar.workspaces.directoryFlow', inject: injected }, BrowseDirectoryFlow))) - } catch (error) { - for (const entry of deferred) entry.dispose() - throw error - } - return () => { for (const entry of deferred) entry.dispose() } + // One occupant, both holes, as a unit: construction or late conflicts + // (holes declared after rival providers activated) roll the whole pair + // back and fail loud — semantics owned by deferGroupRegistration. + const group = deferGroupRegistration( + ctx.slots, + ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const, + BrowseDirectoryFlow, + name => ctx.slots.register({ name, inject: injected }, BrowseDirectoryFlow), + ) + return () => { group.dispose() } }, 'directory-picker-browse: flow registrations') } diff --git a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx index 5c63b00a03..387bcd156d 100644 --- a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx @@ -92,6 +92,43 @@ describe('directory-picker-browse client half', () => { } }) + it('rolls back wholesale and reports loudly when a rival provider wins after deferred activation', async () => { + const b = await bench() + const rejections: unknown[] = [] + const onUnhandled = (reason: unknown): void => { rejections.push(reason) } + process.on('unhandledRejection', onUnhandled) + process.on('uncaughtException', onUnhandled) + try { + // This provider activates BEFORE any hole exists: both deferrals wait. + await b.ctx.plugin({ inject: [...inject], apply }).await() + b.declare() + // A rival occupies both holes ahead of the pending microtask flush. + b.slots.register({ name: HOLES[0] } as never, () => null) + b.slots.register({ name: HOLES[1] } as never, () => null) + await new Promise(resolve => setTimeout(resolve, 20)) + // The rival keeps both holes; this provider rolled back wholesale and + // surfaced the conflict on the fail-loud channel — no partial mix. + for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1) + expect(rejections.map(String).join('\n')).toContain('already has a registration') + + // Non-Error conflicts wrap before the loud rethrow (same channel). + const c = await bench() + await c.ctx.plugin({ inject: [...inject], apply }).await() + const original = c.slots.register.bind(c.slots) + const slotsAny = c.slots as { register: typeof original } + slotsAny.register = ((options: never, component: never) => { + if ((options as { name?: string }).name === HOLES[0]) throw 'string conflict' + return original(options, component) + }) as typeof original + c.declare() + await new Promise(resolve => setTimeout(resolve, 20)) + expect(rejections.map(String).join('\n')).toContain('string conflict') + } finally { + process.off('unhandledRejection', onUnhandled) + process.off('uncaughtException', onUnhandled) + } + }) + it('registers the dialog dictionaries and binds this package namespace', async () => { const b = await bench() b.declare() diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 810245a8ea..2b5cedddfd 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -352,6 +352,24 @@ describe('DirectoryBrowser', () => { expect(screen.getByLabelText('browser.editPath')).toBeTruthy() }) + it('ignores a pending navigation that settles after Escape cancelled the editor', async () => { + const pending: ((listing: DirectoryListing) => void)[] = [] + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + b.listDirectory.mockImplementation(() => + new Promise((settle) => { pending.push(settle) })) + fireEvent.change(input, { target: { value: DOCS } }) + fireEvent.keyDown(input, { key: 'Enter' }) + fireEvent.keyDown(input, { key: 'Escape' }) + // The cancelled navigation settling late must not jump the view to DOCS. + await act(async () => { pending.shift()!(listingFor(DOCS)) }) + expect(screen.queryByText('harness')).toBeNull() + expect(screen.getByText('Documents')).toBeTruthy() + expect(screen.queryByRole('status')).toBeNull() + }) + it('keeps a newer path edit when an older slow navigation settles', async () => { const pending: ((listing: DirectoryListing) => void)[] = [] const b = mount()