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={( -
- - - - -
- )} + headless > -
- {pathDraft === null - ? ( - <> - {crumbs.map((crumb, index) => ( - - {index > 0 && } - - - ))} - {/* The empty zone right of the crumbs is the path-edit affordance. */} - + + ))} + {/* The empty zone right of the crumbs is the path-edit affordance. */} +
{folderDraft !== null && listing !== null && ( @@ -241,6 +225,27 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, {loading &&
{t('browser.loading')}
} {error !== null &&
{error}
}
+
+ + + + +
) } 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') }),