fix(host,client): gate the picker affordance on the advertised kind; reject non-absolute browse paths

ds-review-bot round 2. The workspace UI never consulted the advertised
directoryPicker kind: under a browse (or merge-added) backend it still
rendered 'Open local folder…' and called pickDirectory(), which the host
answers with directory-picker-unavailable. The create flow now reads
directoryPickerKind() per menu open and renders the dialog affordance only
under 'dialog' — browse (until its in-app browser UI lands) and unknown
kinds hide the entry, realizing the seam's documented default; a keyless
workspace-flow snapshot pins the hidden entry over the browse fixture.

The browse backend also resolved wire paths, silently rebasing '' or
relative parents under the host process cwd; both primitives now reject
non-absolute explicit paths with their business codes, and the seam JSDoc
carries the contract.
This commit is contained in:
creatixchu
2026-07-28 17:39:15 +08:00
parent 6e299cd55a
commit cd7aa3c7d8
20 changed files with 146 additions and 37 deletions

View File

@@ -254,6 +254,7 @@ export function WorkspaceBrowser({
insertSessionBefore,
createWorkspace,
pickDirectory,
directoryPickerKind,
}: WorkspaceBrowserProps) {
const workspaces = useWorkspaces(state => state.items)
const groupBy = useStore(s => s.groupBy)
@@ -372,6 +373,7 @@ export function WorkspaceBrowser({
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
directoryPickerKind={directoryPickerKind}
onPick={(workspaceId) => {
setWsPickerOpen(false)
startSession(workspaceId)

View File

@@ -5,13 +5,13 @@
* slot registration.
*/
import type { RefObject } from 'react'
import { useCallback, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import {
WorkspaceCreateError,
type WorkspaceId, type WorkspaceListState, type WorkspaceView,
type DirectoryPickerKind, type WorkspaceId, type WorkspaceListState, type WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspacePickerProps } from './contract/slots.ts'
import css from './WorkspacePicker.module.css'
@@ -33,6 +33,8 @@ export interface WorkspaceCreateFlowProps {
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Open the Host's native single-directory picker. */
pickDirectory: () => Promise<string | null>
/** The Host's advertised picker interaction (read per flow open); gates which picking affordance renders. */
directoryPickerKind: () => Promise<DirectoryPickerKind>
/** A real Workspace was picked or created. */
onPick: (workspaceId: WorkspaceId) => void
/** Close the popover (outside click / Escape / post-pick). */
@@ -50,6 +52,7 @@ export function WorkspaceCreateFlow({
useWorkspaces,
createWorkspace,
pickDirectory,
directoryPickerKind,
onPick,
onClose,
}: WorkspaceCreateFlowProps) {
@@ -69,6 +72,22 @@ export function WorkspaceCreateFlow({
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
// The advertised interaction gates the picking affordance: 'dialog' is the
// only kind pickDirectory() can serve, so its entry renders under that kind
// alone; 'browse' (until the in-app browser UI lands) and unknown kinds
// hide the entry, the seam's documented unknown-kind default. Re-read per
// flow open — no cache to go stale across reconnects.
const [dialogPicker, setDialogPicker] = useState(false)
useEffect(() => {
if (!open) return
void directoryPickerKind()
.then((kind) => { setDialogPicker(kind === 'dialog') })
// A failed describe hides the entry too: the same Host that cannot
// answer describe cannot serve pickDirectory. (Post-unmount settlement
// is safe: React 18 no-ops setState on unmounted components.)
.catch(() => { setDialogPicker(false) })
}, [open, directoryPickerKind])
const items: MenuEntry[] = [
...workspaces.map(workspace => ({
id: workspace.workspaceId,
@@ -77,7 +96,9 @@ export function WorkspaceCreateFlow({
disabled: pickingFolder,
})),
...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []),
{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: pickingFolder },
...(dialogPicker
? [{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: pickingFolder }]
: []),
{ id: CREATE_NEW, label: 'Create a new workspace', icon: <IconPlusOutline16 size={16} />, disabled: pickingFolder },
]
@@ -229,6 +250,7 @@ export function WorkspacePicker({
onClose,
createWorkspace,
pickDirectory,
directoryPickerKind,
}: WorkspacePickerProps) {
return (
<WorkspaceCreateFlow
@@ -237,6 +259,7 @@ export function WorkspacePicker({
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
directoryPickerKind={directoryPickerKind}
onPick={onPick}
onClose={onClose}
/>

View File

@@ -13,7 +13,7 @@ import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
// runtime shares below.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type { DirectoryPickerKind, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type { createWorkspaceViewStore } from '../stores.ts'
/**
@@ -44,6 +44,8 @@ export type WorkspaceBrowserInjected = {
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Ask the local Host to open its native single-directory picker. */
pickDirectory: () => Promise<string | null>
/** The Host's advertised picker interaction (read per flow open); gates which picking affordance renders. */
directoryPickerKind: () => Promise<DirectoryPickerKind>
}
/** Full browser props: shell owner share + viewing store + injected actions. */
@@ -62,6 +64,8 @@ export type WorkspacePickerInjected = {
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Ask the local Host to open its native single-directory picker. */
pickDirectory: () => Promise<string | null>
/** The Host's advertised picker interaction (read per flow open); gates which picking affordance renders. */
directoryPickerKind: () => Promise<DirectoryPickerKind>
}
/**

View File

@@ -45,10 +45,12 @@ export function apply(ctx: ClientContext): void {
},
createWorkspace: input => ctx.workspaces.create(input),
pickDirectory: () => ctx.workspaces.pickDirectory(),
directoryPickerKind: () => ctx.workspaces.directoryPickerKind(),
})
const pickerInjected = (): WorkspacePickerInjected => ({
createWorkspace: input => ctx.workspaces.create(input),
pickDirectory: () => ctx.workspaces.pickDirectory(),
directoryPickerKind: () => ctx.workspaces.directoryPickerKind(),
})
// Declaration-aware registration: each owner's declaring apply may activate
// after this one (entry activation order is unconstrained), and a register