diff --git a/docs/module-graph.md b/docs/module-graph.md
index 03f4bee072..31217705ba 100644
--- a/docs/module-graph.md
+++ b/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) |
diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx
index 820ff3d7a3..ef790c8b6a 100644
--- a/packages/client/ui-primitives/src/Modal.tsx
+++ b/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}
>
-
-
-
{title}
-
-
- {description !== undefined && description !== '' && (
-
{description}
+ {headless
+ ? children
+ : (
+ <>
+
+
+
{title}
+
+
+ {description !== undefined && description !== '' && (
+
{description}
+ )}
+ {children !== undefined &&
{children}
}
+
+ {footer !== undefined &&
{footer}
}
+ >
)}
- {children !== undefined &&
{children}
}
-
- {footer !== undefined && {footer}
}
)
diff --git a/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css b/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css
index b936148cd8..14f3e4f2ca 100644
--- a/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css
+++ b/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);
}
diff --git a/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx b/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx
index 0301d9e27f..9454693969 100644
--- a/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx
+++ b/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={(
-
- }
- disabled={listing === null || busy || folderDraft !== null}
- onClick={() => { setFolderDraft('') }}
- >
- {t('browser.newFolder')}
-
-
-
-
-
- )}
+ headless
>
-
{folderDraft !== null && listing !== null && (
@@ -241,6 +225,27 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
{loading &&
{t('browser.loading')}
}
{error !== null &&
{error}
}
+
+ }
+ disabled={listing === null || busy || folderDraft !== null}
+ onClick={() => { setFolderDraft('') }}
+ >
+ {t('browser.newFolder')}
+
+
+
+
+
)
}
diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx
index fb7edd815a..3cacbe44b7 100644
--- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx
+++ b/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 }
diff --git a/packages/client/ui-workspace/tests/directory-browser.spec.tsx b/packages/client/ui-workspace/tests/directory-browser.spec.tsx
index 5b58f0667e..a78527d5f1 100644
--- a/packages/client/ui-workspace/tests/directory-browser.spec.tsx
+++ b/packages/client/ui-workspace/tests/directory-browser.spec.tsx
@@ -147,6 +147,88 @@ describe('DirectoryBrowser', () => {
expect(screen.getAllByRole('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()
+ 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((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((_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() })
diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx
index d400cf0435..0c634fbd60 100644
--- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx
+++ b/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') }),