Merge remote-tracking branch 'origin/doc/host-client-group-readmes' into feat/directory-picker

# Conflicts:
#	packages/client/connection/src/client/fixture.ts
#	packages/client/connection/tests/fake-api.ts
#	packages/client/runtime/src/client/workspaces/service.ts
#	packages/client/runtime/tests/fake-api.ts
#	packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx
#	packages/client/ui-workspace/src/client/WorkspacePicker.tsx
#	packages/client/ui-workspace/tests/workspace-picker.spec.tsx
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/host.schema.ts
#	packages/host/apiproxy/src/api/host.ts
#	packages/host/apiproxy/src/api/rpc-map.ts
#	packages/host/apiproxy/src/fetch/client.ts
#	packages/host/apiproxy/src/fetch/handler.ts
#	packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
#	packages/host/apiproxy/tests/client-handler.spec.ts
#	packages/host/apiproxy/tests/fetch-carrier.spec.ts
This commit is contained in:
creatixchu
2026-07-28 21:21:21 +08:00
750 changed files with 15381 additions and 5577 deletions

View File

@@ -129,6 +129,7 @@
reads the shell's class names): the two icon controls stack as 36x36
circles matching the shell's rail rhythm. */
.rail .sectionHeader {
gap: 0;
padding-left: 0;
margin-bottom: 12px;
}
@@ -207,6 +208,13 @@
min-height: 0;
overflow-y: auto;
padding-bottom: 12px;
/* Row trailing content (the relative time, and the hover action buttons
that replace it) sits flush against the row's 8px right padding, so an
overlaid scrollbar covers it. Reserving the gutter keeps the bar beside
the rows instead of on top of them; `stable` holds the reservation when
the list is short enough not to scroll, so expanding a group does not
shift every row left. */
scrollbar-gutter: stable;
}
/* One workspace section: header row + expanded session run. Rows inside

View File

@@ -266,6 +266,7 @@ export function WorkspaceBrowser({
// states; the menu anchors on this button).
const [wsPickerOpen, setWsPickerOpen] = useState(false)
const wsPlusRef = useRef<HTMLButtonElement>(null)
const composingRef = useRef(false)
// Rail search = expand + land in the search box: the flag arms before the
// expand request; once the shell flips wide the input mounts and takes focus.
@@ -359,7 +360,6 @@ export function WorkspaceBrowser({
className={css.iconButton}
aria-label="Create workspace"
onClick={() => {
if (!wide) expandSidebar()
setWsPickerOpen(v => !v)
}}
>
@@ -374,6 +374,8 @@ export function WorkspaceBrowser({
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
directoryPickerKind={directoryPickerKind}
createOnly
side="right"
onPick={(workspaceId) => {
setWsPickerOpen(false)
startSession(workspaceId)
@@ -461,9 +463,12 @@ export function WorkspaceBrowser({
aria-label="Workspace name"
autoFocus
disabled={renaming}
onFocus={(e) => { e.target.select() }}
onChange={(e) => { setRenameDraft(e.target.value); setRenameError(null) }}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (e.key === 'Enter' && !composingRef.current) {
e.preventDefault()
confirmRename()
}

View File

@@ -5,7 +5,7 @@
* slot registration.
*/
import type { RefObject } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
@@ -39,6 +39,12 @@ export interface WorkspaceCreateFlowProps {
onPick: (workspaceId: WorkspaceId) => void
/** Close the popover (outside click / Escape / post-pick). */
onClose: () => void
/** Only show create actions (open folder / create new), hide existing workspaces. */
createOnly?: boolean
/** Menu opening direction relative to the anchor. */
side?: 'bottom' | 'top' | 'right'
/** Currently active workspace (trailing check in the picker list). */
selectedId?: WorkspaceId | undefined
}
/**
@@ -55,6 +61,9 @@ export function WorkspaceCreateFlow({
directoryPickerKind,
onPick,
onClose,
createOnly = false,
side = 'bottom',
selectedId,
}: WorkspaceCreateFlowProps) {
const workspaceSnapshot = useWorkspaces(state => state)
const workspaces = workspaceSnapshot.items
@@ -68,6 +77,7 @@ export function WorkspaceCreateFlow({
const [modalError, setModalError] = useState<string | null>(null)
const [pickingFolder, setPickingFolder] = useState(false)
const [folderConflict, setFolderConflict] = useState(false)
const composingRef = useRef(false)
const normalizedWorkspaceName = workspaceName.trim()
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
@@ -100,19 +110,23 @@ export function WorkspaceCreateFlow({
return () => { stale = true }
}, [open, directoryPickerKind])
const items: MenuEntry[] = [
...workspaces.map(workspace => ({
id: workspace.workspaceId,
label: workspace.title,
icon: <IconFolderClose16 size={16} />,
disabled: pickingFolder,
})),
...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []),
const createEntries: MenuEntry[] = [
...(nativePicker
? [{ 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 },
]
// With workspaces listed, the create actions pin below the scroll region
// (divider + always visible); otherwise they ARE the menu.
const pinCreate = !createOnly && workspaces.length > 0
const items: MenuEntry[] = pinCreate
? workspaces.map(workspace => ({
id: workspace.workspaceId,
label: workspace.title,
icon: <IconFolderClose16 size={16} />,
disabled: pickingFolder,
}))
: createEntries
const closeModal = (): void => {
if (creating) return
@@ -147,7 +161,7 @@ export function WorkspaceCreateFlow({
}
if (id === CREATE_NEW) {
onClose()
setWorkspaceName('workspace')
setWorkspaceName('')
setModalError(null)
setModalKind('create')
return
@@ -182,8 +196,11 @@ export function WorkspaceCreateFlow({
open={open}
anchor={null}
items={items}
{...pinCreate ? { footer: createEntries } : {}}
selectedId={selectedId}
onSelect={handleSelect}
onClose={onClose}
side={side}
portal
getAnchorRect={getAnchorRect}
/>
@@ -227,12 +244,15 @@ export function WorkspaceCreateFlow({
<input
className={css.modalInput}
value={workspaceName}
placeholder="Workspace name"
aria-label="New workspace name"
autoFocus
disabled={creating}
onChange={(event) => { setWorkspaceName(event.target.value); setModalError(null) }}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onKeyDown={(event) => {
if (event.key === 'Enter') {
if (event.key === 'Enter' && !composingRef.current) {
event.preventDefault()
confirmCreate()
}
@@ -258,6 +278,7 @@ export function WorkspacePicker({
open,
anchorRef,
useWorkspaces,
selectedId,
onPick,
onClose,
createWorkspace,
@@ -272,6 +293,7 @@ export function WorkspacePicker({
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
directoryPickerKind={directoryPickerKind}
selectedId={selectedId}
onPick={onPick}
onClose={onClose}
/>

View File

@@ -0,0 +1,48 @@
/**
* WorkspaceBrowser scroll-region style contract, asserted against the CSS text
* on disk: the session list reserves its scrollbar gutter so the scrollbar
* cannot overlay row trailing content, and reserves it whether or not the list
* currently overflows so expanding a group does not shift rows sideways.
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.module.css', import.meta.url)), 'utf8')
/**
* Declarations of one class rule, keyed by property with whitespace collapsed.
* Declaration order and trailing semicolons are normalized away.
* @param className - local class name, without the leading dot.
* @returns the rule's declarations, or undefined when no such rule exists.
*/
function declarations(className: string): Map<string, string> | undefined {
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
const match = new RegExp(String.raw`(^|[\s,}])\.${className}\s*\{([^{}]*)\}`).exec(withoutComments)
if (match === null) return undefined
const found = new Map<string, string>()
// The body group is unconditional in the pattern; the fallback only satisfies
// noUncheckedIndexedAccess.
for (const part of (match[2] ?? '').split(';')) {
const colon = part.indexOf(':')
if (colon === -1) continue
found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' '))
}
return found
}
describe('WorkspaceBrowser.module.css list', () => {
const list = declarations('list')
it('is the scrolling region', () => {
expect(list).toBeDefined()
expect(list!.get('overflow-y')).toBe('auto')
})
it('reserves the scrollbar gutter unconditionally', () => {
// Row trailing content sits flush against the row's right padding, so an
// overlay scrollbar covers it. `stable` keeps the reservation when the list
// is short enough not to scroll, so expanding a group does not shift rows.
expect(list!.get('scrollbar-gutter')).toBe('stable')
})
})

View File

@@ -264,25 +264,23 @@ describe('WorkspaceBrowser', () => {
}
})
it('rail create-workspace expands the shell and opens the picker; wide toggles in place', () => {
it('rail create-workspace toggles the create-only picker in place, without expanding', async () => {
const expandSidebar = vi.fn()
const b = mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
expect(expandSidebar).toHaveBeenCalledTimes(1)
rerender(b, { wide: true })
// The picker menu is open (anchored on the +); picking starts a session.
fireEvent.click(screen.getByRole('menuitem', { name: 'alpha' }))
expect(b.props.startSession).toHaveBeenCalledWith(wid('alpha'))
expect(screen.queryByRole('menu')).toBeNull()
// Wide toggle: open and close without expand requests.
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
expect(screen.getByRole('menu')).toBeTruthy()
expect(expandSidebar).not.toHaveBeenCalled()
// Flush the advertised-kind read that gates the local-folder entry.
await act(async () => {})
// createOnly: existing workspaces are not listed, only the create actions.
expect(screen.queryByRole('menuitem', { name: 'alpha' })).toBeNull()
expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy()
// Toggle: open and close in place.
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
expect(screen.queryByRole('menu')).toBeNull()
expect(expandSidebar).toHaveBeenCalledTimes(1)
// Escape closes the picker through its own onClose.
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
expect(screen.getByRole('menu')).toBeTruthy()
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
})

View File

@@ -209,6 +209,8 @@ describe('WorkspacePicker', () => {
it('reports non-Error creation failures', async () => {
const b = mount([], vi.fn(async () => { throw 'permission denied' }))
await chooseItem('Create a new workspace')
// The name field starts empty (no prefill); a name is required to submit.
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'broken' } })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied')