Преглед изворни кода

fix(web): align the directory browser with the figma frame and merge the seam tip

The dialog now owns the figma structure through a new headless Modal
mode (mask/card/Escape stay shared): header block with the title and
crumbs 8px apart above the l3 separator (no close chrome — the figma
frame has none), 16px to the level, and the 12px card bottom. The
picker-kind narrowing returns for the merged open describe kind — an
unrecognized advertised kind hides the local-folder entry, now covered
alongside the stale-navigation failure arm and the unmount races.
creatixchu пре 2 месеци
родитељ
комит
2b64341bc0

+ 6 - 5
docs/module-graph.md

@@ -292,10 +292,6 @@ flowchart TD
   pkg_client_ui_slash --> pkg_client_runtime
   pkg_client_ui_slash --> pkg_client_ui_slots
   pkg_client_ui_slash --> pkg_invariants
-  pkg_client_ui_workspace --> pkg_client_runtime
-  pkg_client_ui_workspace --> pkg_client_ui_primitives
-  pkg_client_ui_workspace --> pkg_client_ui_slots
-  pkg_client_ui_workspace --> pkg_invariants
   pkg_helper --> pkg_brand
   pkg_helper --> pkg_invariants
   pkg_helper --> pkg_subprocess
@@ -350,6 +346,11 @@ flowchart TD
   pkg_client_ui_theme --> pkg_client_ui_primitives
   pkg_client_ui_theme --> pkg_client_ui_slots
   pkg_client_ui_theme --> pkg_invariants
+  pkg_client_ui_workspace --> pkg_client_locale
+  pkg_client_ui_workspace --> pkg_client_runtime
+  pkg_client_ui_workspace --> pkg_client_ui_primitives
+  pkg_client_ui_workspace --> pkg_client_ui_slots
+  pkg_client_ui_workspace --> pkg_invariants
   pkg_lsp --> pkg_brand
   pkg_lsp --> pkg_invariants
   pkg_lsp --> pkg_llm
@@ -944,7 +945,6 @@ flowchart TD
 | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
-| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
 | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
 | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
@@ -961,6 +961,7 @@ flowchart TD
 | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
+| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
 | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
 | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |

+ 24 - 14
packages/client/ui-primitives/src/Modal.tsx

@@ -12,13 +12,16 @@ import css from './Modal.module.css'
  * Render a centered modal over a blurred page mask.
  * @param props.open - whether the dialog is showing.
  * @param props.onClose - Escape or mask click.
- * @param props.title - dialog heading.
+ * @param props.title - dialog heading (aria-label in every mode).
  * @param props.description - optional supporting sentence under the title.
  * @param props.children - body (inputs, etc.).
  * @param props.footer - action row (Cancel / Create).
+ * @param props.headless - render children directly in the card (no default
+ * header/close/body chrome) for dialogs whose figma frame owns its own
+ * header structure; mask, card, Escape, and aria-label remain.
  * @returns null when closed; otherwise the overlay tree.
  */
-export function Modal({ open, onClose, title, description, children, footer, className }: {
+export function Modal({ open, onClose, title, description, children, footer, className, headless = false }: {
   open: boolean
   onClose: () => void
   title: string
@@ -26,6 +29,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla
   children?: ReactNode
   footer?: ReactNode
   className?: string
+  headless?: boolean
 }) {
   useEffect(() => {
     if (!open) return
@@ -47,19 +51,25 @@ export function Modal({ open, onClose, title, description, children, footer, cla
         aria-modal="true"
         aria-label={title}
       >
-        <div className={css.content}>
-          <div className={css.header}>
-            <h2 className={css.title}>{title}</h2>
-            <button type="button" className={css.close} aria-label="Close" onClick={onClose}>
-              <IconCloseOutline16 size={14} />
-            </button>
-          </div>
-          {description !== undefined && description !== '' && (
-            <p className={css.description}>{description}</p>
+        {headless
+          ? children
+          : (
+            <>
+              <div className={css.content}>
+                <div className={css.header}>
+                  <h2 className={css.title}>{title}</h2>
+                  <button type="button" className={css.close} aria-label="Close" onClick={onClose}>
+                    <IconCloseOutline16 size={14} />
+                  </button>
+                </div>
+                {description !== undefined && description !== '' && (
+                  <p className={css.description}>{description}</p>
+                )}
+                {children !== undefined && <div className={css.body}>{children}</div>}
+              </div>
+              {footer !== undefined && <div className={css.footer}>{footer}</div>}
+            </>
           )}
-          {children !== undefined && <div className={css.body}>{children}</div>}
-        </div>
-        {footer !== undefined && <div className={css.footer}>{footer}</div>}
       </div>
     </div>
   )

+ 34 - 18
packages/client/ui-workspace/src/client/DirectoryBrowser.module.css

@@ -1,21 +1,40 @@
-/* Directory-browser dialog (figma 802-56979). The shared Modal owns the mask,
- * card, and title row; this module widens the card and rebuilds the figma
- * header/footer separators with bleed margins inside the 24px content column. */
+/* Directory-browser dialog (figma 802-56979). The shared Modal renders
+ * headless here — mask, card, Escape only — and this module owns the figma
+ * frame exactly: header (title + crumbs, l3 separator), one directory level,
+ * and the bordered footer. Card: w600 r24, bottom pad 12, no close chrome. */
 
-.dialog {
+/* Doubled class beats Modal's own .dialog regardless of stylesheet order. */
+.dialog.dialog {
   width: min(600px, 100%);
+  padding: 0 0 12px;
+  gap: 16px;
+}
+
+/* Header block: pl24 pr14 pt22 pb12, 8px between title row and crumb row. */
+.header {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  padding: 22px 14px 12px 24px;
+  border-bottom: 1px solid var(--dsw-alias-border-l3);
+}
+
+.title {
+  display: flex;
+  align-items: flex-end;
+  min-height: 28px;
+  margin: 0;
+  font-size: 16px;
+  line-height: 24px;
+  font-weight: 510;
+  color: var(--dsw-alias-label-primary);
 }
 
-/* Breadcrumb bar sits visually inside the header block: bleed to the card
- * edges, close the header's 12px bottom pad, draw the l3 separator. */
 .crumbBar {
   display: flex;
   align-items: center;
   gap: 4px;
-  min-height: 32px;
-  margin: -12px -24px 0;
-  padding: 0 24px 12px;
-  border-bottom: 1px solid var(--dsw-alias-border-l3);
+  min-height: 20px;
 }
 
 .crumbSeat {
@@ -65,7 +84,7 @@
   box-sizing: border-box;
   flex: 1 1 0;
   min-width: 0;
-  height: 28px;
+  height: 24px;
   padding: 0 8px;
   border: 1px solid var(--dsw-alias-border-l2);
   border-radius: 8px;
@@ -76,12 +95,12 @@
   color: var(--dsw-alias-label-primary);
 }
 
-/* One directory level: 28px rows, r6, folder icon + name + enter chevron. */
+/* One directory level: content column pt16 px24, 28px rows with 2px gaps. */
 .level {
   display: flex;
   flex-direction: column;
   gap: 2px;
-  margin-top: -4px;
+  padding: 16px 24px 0;
   max-height: 320px;
   overflow-y: auto;
 }
@@ -164,15 +183,12 @@
   color: var(--dsw-alias-state-error-primary);
 }
 
-/* Footer: the l3 separator above the action row, New-folder pinned left
- * (bleeds across the card; 12px stays below, matching the figma card pad). */
+/* Footer: l3 separator on top, pt12 px24, New-folder pinned left. */
 .footerBar {
   display: flex;
   align-items: center;
   gap: 8px;
-  width: calc(100% + 48px);
-  margin: 0 -24px -12px;
-  padding: 12px 24px;
+  padding: 12px 24px 0;
   border-top: 1px solid var(--dsw-alias-border-l3);
 }
 

+ 74 - 69
packages/client/ui-workspace/src/client/DirectoryBrowser.tsx

@@ -100,6 +100,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
   }, [open, navigate])
 
   const confirmFolder = (): void => {
+    /* v8 ignore next -- reentry fence: the inline row only renders with a listing and a draft, and the input disables while creating. */
     if (listing === null || folderDraft === null || creatingFolder) return
     const name = folderDraft.trim()
     if (name === '') return
@@ -126,78 +127,61 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
       onClose={onClose}
       title={t('browser.title')}
       className={clsx(css.dialog)}
-      footer={(
-        <div className={css.footerBar}>
-          <Button
-            variant="outline"
-            icon={<IconPlusOutline16 size={14} />}
-            disabled={listing === null || busy || folderDraft !== null}
-            onClick={() => { setFolderDraft('') }}
-          >
-            {t('browser.newFolder')}
-          </Button>
-          <span className={css.footerGap} />
-          <Button variant="outline" className={css.footerAction} disabled={busy} onClick={onClose}>{t('browser.cancel')}</Button>
-          <Button
-            variant="primary"
-            className={css.footerAction}
-            disabled={listing === null || loading || busy}
-            onClick={() => { if (listing !== null) onOpen(listing.path) }}
-          >
-            {t('browser.open')}
-          </Button>
-        </div>
-      )}
+      headless
     >
-      <div className={css.crumbBar}>
-        {pathDraft === null
-          ? (
-            <>
-              {crumbs.map((crumb, index) => (
-                <span key={crumb.path} className={css.crumbSeat}>
-                  {index > 0 && <IconChevronRightOutline14 size={12} className={css.crumbChevron} />}
-                  <button
-                    type="button"
-                    className={css.crumb}
-                    disabled={busy}
-                    onClick={() => { navigate(crumb.path) }}
-                  >
-                    {crumb.name}
-                  </button>
-                </span>
-              ))}
-              {/* The empty zone right of the crumbs is the path-edit affordance. */}
-              <button
-                type="button"
-                className={css.crumbEditZone}
+      <div className={css.header}>
+        <h2 className={css.title}>{t('browser.title')}</h2>
+        <div className={css.crumbBar}>
+          {pathDraft === null
+            ? (
+              <>
+                {crumbs.map((crumb, index) => (
+                  <span key={crumb.path} className={css.crumbSeat}>
+                    {index > 0 && <IconChevronRightOutline14 size={12} className={css.crumbChevron} />}
+                    <button
+                      type="button"
+                      className={css.crumb}
+                      disabled={busy}
+                      onClick={() => { navigate(crumb.path) }}
+                    >
+                      {crumb.name}
+                    </button>
+                  </span>
+                ))}
+                {/* The empty zone right of the crumbs is the path-edit affordance. */}
+                <button
+                  type="button"
+                  className={css.crumbEditZone}
+                  aria-label={t('browser.editPath')}
+                  disabled={listing === null || busy}
+                  /* v8 ignore next -- narrowing guard: the zone disables while the listing is null. */
+                  onClick={() => { if (listing !== null) setPathDraft(listing.path) }}
+                />
+              </>
+            )
+            : (
+              <input
+                className={css.pathInput}
+                value={pathDraft}
                 aria-label={t('browser.editPath')}
-                disabled={listing === null || busy}
-                onClick={() => { if (listing !== null) setPathDraft(listing.path) }}
+                autoFocus
+                disabled={busy}
+                onChange={(event) => { setPathDraft(event.target.value) }}
+                onKeyDown={(event) => {
+                  if (event.key === 'Enter') {
+                    event.preventDefault()
+                    const target = pathDraft.trim()
+                    if (target !== '') navigate(target)
+                  }
+                  if (event.key === 'Escape') {
+                    event.stopPropagation()
+                    setPathDraft(null)
+                    setError(null)
+                  }
+                }}
               />
-            </>
-          )
-          : (
-            <input
-              className={css.pathInput}
-              value={pathDraft}
-              aria-label={t('browser.editPath')}
-              autoFocus
-              disabled={busy}
-              onChange={(event) => { setPathDraft(event.target.value) }}
-              onKeyDown={(event) => {
-                if (event.key === 'Enter') {
-                  event.preventDefault()
-                  const target = pathDraft.trim()
-                  if (target !== '') navigate(target)
-                }
-                if (event.key === 'Escape') {
-                  event.stopPropagation()
-                  setPathDraft(null)
-                  setError(null)
-                }
-              }}
-            />
-          )}
+            )}
+        </div>
       </div>
       <div className={css.level} role="list" aria-label={t('browser.title')}>
         {folderDraft !== null && listing !== null && (
@@ -241,6 +225,27 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
         {loading && <div className={css.status} role="status">{t('browser.loading')}</div>}
         {error !== null && <div className={css.error} role="alert">{error}</div>}
       </div>
+      <div className={css.footerBar}>
+        <Button
+          variant="outline"
+          icon={<IconPlusOutline16 size={14} />}
+          disabled={listing === null || busy || folderDraft !== null}
+          onClick={() => { setFolderDraft('') }}
+        >
+          {t('browser.newFolder')}
+        </Button>
+        <span className={css.footerGap} />
+        <Button variant="outline" className={clsx(css.footerAction)} disabled={busy} onClick={onClose}>{t('browser.cancel')}</Button>
+        <Button
+          variant="primary"
+          className={clsx(css.footerAction)}
+          disabled={listing === null || loading || busy}
+          /* v8 ignore next -- narrowing guard: Open disables while the listing is null. */
+          onClick={() => { if (listing !== null) onOpen(listing.path) }}
+        >
+          {t('browser.open')}
+        </Button>
+      </div>
     </Modal>
   )
 }

+ 4 - 3
packages/client/ui-workspace/src/client/WorkspacePicker.tsx

@@ -76,9 +76,10 @@ export function WorkspaceCreateFlow({
     if (!open) return
     let stale = false
     directoryPickerKind().then(
-      // The wire type is the closed two-kind union today; a fetch failure is
-      // the reachable 'unknown' arm (an unadvertisable host hides the entry).
-      (kind) => { if (!stale) setPickerKind(kind) },
+      // The wire kind is an open string (a merge-added capability advertises
+      // before this client knows it): anything but the two known kinds hides
+      // the entry, as does a fetch failure.
+      (kind) => { if (!stale) setPickerKind(kind === 'dialog' || kind === 'browse' ? kind : 'unknown') },
       () => { if (!stale) setPickerKind('unknown') },
     )
     return () => { stale = true }

+ 82 - 0
packages/client/ui-workspace/tests/directory-browser.spec.tsx

@@ -147,6 +147,88 @@ describe('DirectoryBrowser', () => {
     expect(screen.getAllByRole<HTMLButtonElement>('button', { name: 'browser.open' }).at(-1)!.disabled).toBe(true)
   })
 
+  it('renders the full ancestry when the listing sits outside the home subtree', async () => {
+    const outside: DirectoryListing = {
+      path: '/srv/data',
+      home: HOME,
+      crumbs: [
+        { name: '/', path: '/', hidden: false },
+        { name: 'srv', path: '/srv', hidden: false },
+        { name: 'data', path: '/srv/data', hidden: false },
+      ],
+      entries: [],
+    }
+    mount({ listDirectory: vi.fn(async () => outside) })
+    await waitFor(() => { expect(screen.getByRole('button', { name: 'data' })).toBeTruthy() })
+    expect(screen.getByRole('button', { name: '/' })).toBeTruthy()
+    expect(screen.queryByRole('button', { name: 'browser.home' })).toBeNull()
+  })
+
+  it('folds non-typed failures into readable text (Error message, String otherwise)', async () => {
+    const b = mount({ listDirectory: vi.fn(async () => { throw new Error('socket down') }) })
+    await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('socket down') })
+    b.view.rerender(<DirectoryBrowser {...b.props} open={false} />)
+    const raw = mount({ listDirectory: vi.fn(async () => { throw 'raw failure' }) })
+    await waitFor(() => { expect(screen.getAllByRole('alert').at(-1)!.textContent).toBe('raw failure') })
+    expect(raw.onOpen).not.toHaveBeenCalled()
+  })
+
+  it('cancels the inline folder row with Escape and ignores a blank name', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
+    const input = screen.getByLabelText('browser.newFolder')
+    fireEvent.change(input, { target: { value: '   ' } })
+    fireEvent.keyDown(input, { key: 'Enter' })
+    expect(b.createDirectory).not.toHaveBeenCalled()
+    fireEvent.keyDown(input, { key: 'Escape' })
+    expect(screen.queryByLabelText('browser.newFolder')).toBeNull()
+  })
+
+  it('ignores a blank path draft on Enter', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const input = screen.getByLabelText('browser.editPath')
+    fireEvent.change(input, { target: { value: '   ' } })
+    fireEvent.keyDown(input, { key: 'Enter' })
+    // Only the initial home listing ran; the blank draft navigated nowhere.
+    expect(b.listDirectory).toHaveBeenCalledTimes(1)
+  })
+
+  it('drops a stale listing that resolves after a newer navigation', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    // The next navigation (into Documents) hangs; a Home-crumb jump supersedes it.
+    let resolveSlow!: (value: DirectoryListing) => void
+    const slow = new Promise<DirectoryListing>((settle) => { resolveSlow = settle })
+    b.listDirectory.mockReturnValueOnce(slow)
+    fireEvent.click(screen.getByRole('listitem'))
+    fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
+    await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) })
+    await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') })
+    resolveSlow(listingFor(`${HOME}/Documents`))
+    await new Promise(settle => setTimeout(settle, 0))
+    // The stale Documents listing did not clobber the newer Home level.
+    expect(screen.getByRole('listitem').textContent).toBe('Documents')
+  })
+
+  it('drops a stale failure that rejects after a newer navigation', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    let rejectSlow!: (reason: unknown) => void
+    const slow = new Promise<DirectoryListing>((_settle, fail) => { rejectSlow = fail })
+    b.listDirectory.mockReturnValueOnce(slow)
+    fireEvent.click(screen.getByRole('listitem'))
+    fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
+    await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) })
+    rejectSlow(new Error('too late to matter'))
+    await new Promise(settle => setTimeout(settle, 0))
+    // The superseded failure surfaces no alert over the newer level.
+    expect(screen.queryByRole('alert')).toBeNull()
+    expect(screen.getByRole('listitem').textContent).toBe('Documents')
+  })
+
   it('starts back at home on reopen', async () => {
     const b = mount()
     await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })

+ 52 - 0
packages/client/ui-workspace/tests/workspace-picker.spec.tsx

@@ -280,6 +280,58 @@ describe('WorkspacePicker', () => {
     expect(b.onPick).not.toHaveBeenCalled()
   })
 
+  it('drops a picker-kind failure that lands after unmount', async () => {
+    let rejectKind!: (reason: unknown) => void
+    const pending = new Promise<'dialog'>((_settle, fail) => { rejectKind = fail })
+    const b = mount([], vi.fn(), vi.fn(), { directoryPickerKind: vi.fn(() => pending) })
+    b.view.unmount()
+    await act(async () => {
+      rejectKind(new Error('gone'))
+      await pending.catch(() => {})
+    })
+    expect(b.onPick).not.toHaveBeenCalled()
+  })
+
+  it('drops a picker-kind resolution that lands after unmount', async () => {
+    let resolveKind!: (kind: 'dialog') => void
+    const pending = new Promise<'dialog'>((settle) => { resolveKind = settle })
+    const b = mount([], vi.fn(), vi.fn(), { directoryPickerKind: vi.fn(() => pending) })
+    b.view.unmount()
+    await act(async () => {
+      resolveKind('dialog')
+      await pending
+    })
+    expect(b.onPick).not.toHaveBeenCalled()
+  })
+
+  it('reports a browse adoption failure thrown as a plain string', async () => {
+    const b = mount([], vi.fn(async () => { throw 'disk detached' }), vi.fn(), {
+      directoryPickerKind: vi.fn(async () => 'browse' as const),
+    })
+    await chooseLocalFolder()
+    await waitFor(() => { expect(screen.getByRole('dialog', { name: 'browser.title' })).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.open' }))
+    await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('disk detached') })
+    expect(b.onPick).not.toHaveBeenCalled()
+  })
+
+  it('reports a native picker Error by its message', async () => {
+    const b = mount([], vi.fn(), vi.fn(async () => { throw new Error('no chooser installed') }))
+    await chooseLocalFolder()
+    await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('no chooser installed') })
+    expect(b.createWorkspace).not.toHaveBeenCalled()
+  })
+
+  it('hides the local-folder entry for an unrecognized advertised kind', async () => {
+    mount([], vi.fn(), vi.fn(), {
+      directoryPickerKind: vi.fn(async () => 'electron-native'),
+    })
+    await waitFor(() => {
+      expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
+    })
+    expect(screen.getByRole('menuitem', { name: 'Create a new workspace' })).toBeTruthy()
+  })
+
   it('hides the local-folder entry when the picker kind is unknown', async () => {
     mount([], vi.fn(), vi.fn(), {
       directoryPickerKind: vi.fn(async () => { throw new Error('unreachable host') }),