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.
This commit is contained in:
creatixchu
2026-07-28 17:33:19 +08:00
parent ee5deee0de
commit 2b64341bc0
7 changed files with 276 additions and 109 deletions

View File

@@ -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) |

View File

@@ -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>
)

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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 }

View File

@@ -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() })

View File

@@ -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') }),