Merge remote-tracking branch 'origin/master' into codex/basic-session-search

# Conflicts:
#	apps/web/tests/snapshots/question-composer/answered.expected.md
#	packages/client/connection/tests/fake-api.ts
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/tests/fake-api.ts
#	packages/client/ui-workspace/README.i18n.yaml
#	packages/client/ui-workspace/README.md
#	packages/client/ui-workspace/README.zh.md
#	packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx
#	packages/client/ui-workspace/tests/apply.spec.ts
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/README.zh.md
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/index.ts
#	packages/host/apiproxy/src/api/sessions.schema.ts
#	packages/host/apiproxy/src/fetch/client.ts
#	packages/host/apiproxy/src/fetch/handler.ts
#	packages/host/apiproxy/tests/fetch-carrier.spec.ts
#	packages/host/apiproxy/tests/rpc-schemas.spec.ts
#	packages/session-query/session-query/tests/search-helpers.spec.ts
This commit is contained in:
Hypatia May
2026-07-28 09:27:28 +08:00
1288 changed files with 40508 additions and 19164 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
README.md: 751aa67e4f8425a0e0f9819f6ddb664d116ebe66
README.zh.md: 4c4180d351578acbb308057858bf45e67ed0a8e0
README.md: 84017b65e6ce2447996ef951a9a72a7c1661c376
README.zh.md: f4ef5009c2f6f264b798a07078b2ba96276ff0ef

View File

@@ -2,11 +2,11 @@
English | [中文](README.zh.md)
Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and creation modals.
Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and creation flow.
The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace create/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. The flat **Open local folder...** action delegates to the Host's native single-directory picker, adopts a returned path through the object layer, and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped.
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
@@ -22,4 +22,4 @@ None; this package neither assembles nor sends a provider request.
- **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event.
- **No Session deletion control** — the existing Session menu row remains visual-only; Workspace registration deletion does not delete Sessions.
- **Existing-folder entry is manual path input only** — Host creation failures are shown in the modal.
- **Native folder selection depends on the local Host carrier** — fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal.

View File

@@ -2,11 +2,11 @@
[English](README.md) | 中文
共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot两个表层使用同一套 Workspace 菜单和创建模态框
共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot两个表层使用同一套 Workspace 菜单和创建流程
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 创建/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL将查询限制在传输 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair并保留现有的防抖与取消行为。每次新查询都会中止前一个请求内容搜索失败时元数据匹配项仍会显示同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace取消操作不会显示提示发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
@@ -22,4 +22,4 @@
- **没有模糊内容搜索或事件深链接**:内容后端采用字面 token短语匹配选择结果会打开 Session而不是匹配的事件。
- **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
- **现有文件夹入口仅支持手动输入路径**Host 创建失败会显示在模态框中
- **原生文件夹选择依赖本地 Host 载体**:仅使用 fixture测试前置数据的部署或远程浏览器部署无法打开本地操作系统对话框模态框会显示平台故障并允许重试

View File

@@ -23,7 +23,10 @@ import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.t
import { WorkspaceCreateFlow } from './WorkspacePicker.tsx'
import css from './WorkspaceBrowser.module.css'
/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */
/**
* Column slide length (--ds-transition-duration-slow): rail-search focus waits it out —
* focus() forces a synchronous layout and would jank the slide.
*/
const EXPAND_SLIDE_MS = 300
/** Pause between the latest keystroke and a Host content-search request. */
const SEARCH_DEBOUNCE_MS = 250
@@ -49,7 +52,7 @@ const GROUP_BY_ITEMS = [
/** Immutable membership toggle for the local expansion arrays. */
function toggled(list: readonly string[], key: string): string[] {
return list.includes(key) ? list.filter((k) => k !== key) : [...list, key]
return list.includes(key) ? list.filter(k => k !== key) : [...list, key]
}
/** Group-by strategy menu; own open state so it resets with the wide chrome. */
@@ -78,7 +81,7 @@ function GroupByMenu({ groupBy, onPick }: {
type="button"
className={clsx(css.iconButton, css.wide)}
aria-label="Group by"
onClick={() => { setOpen((v) => !v) }}
onClick={() => { setOpen(v => !v) }}
>
<IconPersonalizationOutline16 />
</button>
@@ -111,7 +114,7 @@ function SessionTree({
useSessions, startSession, open, workspaces,
onRenameRequest, onDeleteRequest, insertSessionBefore,
}: SessionTreeProps) {
const list = useSessions((s) => s)
const list = useSessions(s => s)
const current = list.current
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
@@ -123,7 +126,7 @@ function SessionTree({
?? UNGROUPED_KEY
useEffect(() => {
if (current === undefined || currentGroup === undefined) return
setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup]))
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
}, [current, currentGroup])
const groups = useMemo(
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions }),
@@ -144,22 +147,22 @@ function SessionTree({
<div key={group.key} className={css.groupSection}>
<ProjectRowItem
group={group}
onToggle={() => { setExpandedProjects((l) => toggled(l, group.key)) }}
onToggle={() => { setExpandedProjects(l => toggled(l, group.key)) }}
onCreate={() => {
if (group.workspaceId !== undefined) startSession(group.workspaceId)
}}
actions={group.workspaceId === undefined
? undefined
: {
rename: () => {
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
},
delete: () => {
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
},
}}
rename: () => {
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
},
delete: () => {
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
},
}}
/>
{group.sessions.map((node, index) => {
// Draggable: real-workspace group roots. The drag
@@ -204,7 +207,7 @@ function SessionTree({
currentId={current}
now={now}
onOpen={open}
onToggle={(id) => { setExpandedSessions((l) => toggled(l, id)) }}
onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
drag={dragProps}
/>
)
@@ -219,7 +222,7 @@ function SessionTree({
/** The flat "In one list" body: every session a top-level row, newest-first. */
function FlatList({ useSessions, open }: Pick<SessionTreeProps, 'useSessions' | 'open'>) {
const list = useSessions((s) => s)
const list = useSessions(s => s)
const rows = useMemo(() => deriveFlat(list), [list])
const now = Date.now()
return (
@@ -268,7 +271,7 @@ function SearchResults({
remote: RemoteSearchState
resultLimit: number
}) {
const list = useSessions((s) => s)
const list = useSessions(s => s)
const currentRemote = remote.query === query
? remote
: { query, status: 'loading' as const, items: [], hasMore: false }
@@ -334,6 +337,7 @@ export function WorkspaceBrowser({
createWorkspace,
searchSessions,
searchResultLimit,
pickDirectory,
}: WorkspaceBrowserProps) {
const workspaces = useWorkspaces(state => state.items)
const groupBy = useStore(s => s.groupBy)
@@ -419,7 +423,7 @@ export function WorkspaceBrowser({
setRenameError(null)
}
const confirmRename = () => {
if (renameBlocked || renameTarget === null) return
if (renameBlocked) return
setRenaming(true)
setRenameError(null)
renameWorkspace(renameTarget.workspaceId, renameTrimmed).then(() => {
@@ -495,6 +499,7 @@ export function WorkspaceBrowser({
anchorRef={wsPlusRef}
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
onPick={(workspaceId) => {
setWsPickerOpen(false)
startSession(workspaceId)
@@ -545,18 +550,18 @@ export function WorkspaceBrowser({
<div className={css.listArea}>
{wide && (normalizedQuery !== ''
? (
<SearchResults
useSessions={useSessions}
open={open}
workspaces={workspaces}
query={normalizedQuery}
remote={remoteSearch}
resultLimit={searchResultLimit}
/>
)
<SearchResults
useSessions={useSessions}
open={open}
workspaces={workspaces}
query={normalizedQuery}
remote={remoteSearch}
resultLimit={searchResultLimit}
/>
)
: groupBy === 'flat'
? <FlatList useSessions={useSessions} open={open} />
: (
: (
<SessionTree
useSessions={useSessions}
workspaces={workspaces}
@@ -618,7 +623,7 @@ export function WorkspaceBrowser({
<Button variant="outline" disabled={deleting} onClick={closeDelete}>Cancel</Button>
<Button
variant="outline"
className={css.deleteAction!}
className={css.deleteAction}
disabled={deleting}
onClick={confirmDelete}
>

View File

@@ -9,15 +9,17 @@ import { useCallback, useState } from 'react'
import {
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { WorkspaceId, WorkspaceListState, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import {
WorkspaceCreateError,
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'
const CREATE_WORKSPACE = '::create-workspace'
const USE_EXISTING = '::use-existing'
const OPEN_LOCAL_FOLDER = '::open-local-folder'
const CREATE_NEW = '::create-new'
type ModalKind = 'path' | 'create' | null
type ModalKind = 'create' | 'folder-error' | null
/** Core flow props: the owner supplies popover control and pick semantics. */
export interface WorkspaceCreateFlowProps {
@@ -29,6 +31,8 @@ export interface WorkspaceCreateFlowProps {
useWorkspaces: <S>(selector: (state: WorkspaceListState) => S) => S
/** Create or adopt a real Host Workspace. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Open the Host's native single-directory picker. */
pickDirectory: () => Promise<string | null>
/** A real Workspace was picked or created. */
onPick: (workspaceId: WorkspaceId) => void
/** Close the popover (outside click / Escape / post-pick). */
@@ -45,6 +49,7 @@ export function WorkspaceCreateFlow({
anchorRef,
useWorkspaces,
createWorkspace,
pickDirectory,
onPick,
onClose,
}: WorkspaceCreateFlowProps) {
@@ -55,30 +60,25 @@ export function WorkspaceCreateFlow({
[anchorRef],
)
const [modalKind, setModalKind] = useState<ModalKind>(null)
const [pathDraft, setPathDraft] = useState('')
const [workspaceName, setWorkspaceName] = useState('')
const [creating, setCreating] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
const [pickingFolder, setPickingFolder] = useState(false)
const [folderConflict, setFolderConflict] = useState(false)
const normalizedWorkspaceName = workspaceName.trim()
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
const items: MenuEntry[] = [
...workspaces.map(workspace => ({
id: workspace.workspaceId as string,
id: workspace.workspaceId,
label: workspace.title,
icon: <IconFolderClose16 size={16} />,
disabled: pickingFolder,
})),
...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []),
{
id: CREATE_WORKSPACE,
label: 'Create workspace',
icon: <IconPlusOutline16 size={16} />,
submenu: [
{ id: USE_EXISTING, label: 'Use an existing folder' },
{ id: CREATE_NEW, label: 'Create a new workspace' },
],
},
{ 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 },
]
const closeModal = (): void => {
@@ -87,12 +87,29 @@ export function WorkspaceCreateFlow({
setModalError(null)
}
const openLocalFolder = (): void => {
onClose()
setModalKind(null)
setModalError(null)
setFolderConflict(false)
setPickingFolder(true)
void pickDirectory().then(async (path) => {
if (path === null) return
const workspace = await createWorkspace({ path })
onPick(workspace.workspaceId)
}).catch((reason: unknown) => {
setFolderConflict(
reason instanceof WorkspaceCreateError
&& reason.rpcError.code === 'workspace-name-conflict',
)
setModalError(reason instanceof Error ? reason.message : String(reason))
setModalKind('folder-error')
}).finally(() => { setPickingFolder(false) })
}
const handleSelect = (id: string): void => {
if (id === USE_EXISTING) {
onClose()
setPathDraft('')
setModalError(null)
setModalKind('path')
if (id === OPEN_LOCAL_FOLDER) {
openLocalFolder()
return
}
if (id === CREATE_NEW) {
@@ -120,11 +137,6 @@ export function WorkspaceCreateFlow({
})
}
const confirmPath = (): void => {
const path = pathDraft.trim()
if (path !== '') create({ path })
}
const confirmCreate = (): void => {
if (normalizedWorkspaceName !== '' && !duplicateWorkspaceName) {
create({ name: normalizedWorkspaceName })
@@ -144,40 +156,21 @@ export function WorkspaceCreateFlow({
/>
{open && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">Loading workspaces</div>}
<Modal
open={modalKind === 'path'}
open={modalKind === 'folder-error'}
onClose={closeModal}
title="Use an existing folder"
title={folderConflict ? 'A workspace with this name already exists' : 'Couldnt open folder'}
footer={(
<>
<Button variant="outline" className={css.modalAction!} disabled={creating} onClick={closeModal}>Cancel</Button>
<Button
variant="primary"
className={css.modalAction!}
disabled={creating || pathDraft.trim() === ''}
onClick={confirmPath}
>
Use folder
</Button>
<Button variant="outline" className={css.modalAction} onClick={closeModal}>Cancel</Button>
<Button variant="primary" className={css.modalAction} onClick={openLocalFolder}>Choose again</Button>
</>
)}
>
<input
className={css.modalInput}
value={pathDraft}
aria-label="Existing folder path"
autoFocus
disabled={creating}
placeholder="/path/to/project"
onChange={(event) => { setPathDraft(event.target.value) }}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
confirmPath()
}
}}
/>
{creating && <div className={css.modalStatus} role="status">Creating workspace</div>}
{modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>}
<div className={css.modalError} role="alert">
{folderConflict
? 'Choose a folder with a different name.'
: modalError}
</div>
</Modal>
<Modal
open={modalKind === 'create'}
@@ -186,10 +179,10 @@ export function WorkspaceCreateFlow({
description="The name is used for both the workspace and its new folder."
footer={(
<>
<Button variant="outline" className={css.modalAction!} disabled={creating} onClick={closeModal}>Cancel</Button>
<Button variant="outline" className={css.modalAction} disabled={creating} onClick={closeModal}>Cancel</Button>
<Button
variant="primary"
className={css.modalAction!}
className={css.modalAction}
disabled={creating || normalizedWorkspaceName === '' || duplicateWorkspaceName}
onClick={confirmCreate}
>
@@ -235,6 +228,7 @@ export function WorkspacePicker({
onPick,
onClose,
createWorkspace,
pickDirectory,
}: WorkspacePickerProps) {
return (
<WorkspaceCreateFlow
@@ -242,6 +236,7 @@ export function WorkspacePicker({
anchorRef={anchorRef}
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
onPick={onPick}
onClose={onClose}
/>

View File

@@ -54,6 +54,8 @@ export type WorkspaceBrowserInjected = {
insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise<void>
/** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Ask the local Host to open its native single-directory picker. */
pickDirectory: () => Promise<string | null>
}
/** Full browser props: shell owner share + viewing store + injected actions. */
@@ -69,7 +71,9 @@ export type WorkspaceBrowserProps =
*/
export type WorkspacePickerInjected = {
/** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace(input: { name: string } | { path: string }): Promise<WorkspaceView>
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Ask the local Host to open its native single-directory picker. */
pickDirectory: () => Promise<string | null>
}
/**

View File

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

View File

@@ -227,37 +227,37 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle,
onDragStart={drag === undefined
? undefined
: (e) => {
e.dataTransfer.effectAllowed = 'move'
drag.start()
}}
e.dataTransfer.effectAllowed = 'move'
drag.start()
}}
onDragEnd={drag?.end}
onDragOver={drag === undefined
? undefined
: (e) => {
if (!drag.active) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
drag.hover(rowHalf(e))
}}
if (!drag.active) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
drag.hover(rowHalf(e))
}}
onDrop={drag === undefined
? undefined
: (e) => {
if (!drag.active) return
e.preventDefault()
drag.drop(rowHalf(e))
}}
if (!drag.active) return
e.preventDefault()
drag.drop(rowHalf(e))
}}
>
{row.hasChildren && !flat
? (
<button
type="button"
className={css.twist}
aria-label={row.expanded ? 'Collapse' : 'Expand'}
onClick={(e) => { e.stopPropagation(); onToggle(node.id) }}
>
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
</button>
)
<button
type="button"
className={css.twist}
aria-label={row.expanded ? 'Collapse' : 'Expand'}
onClick={(e) => { e.stopPropagation(); onToggle(node.id) }}
>
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
</button>
)
: null}
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
<span className={css.title}>{row.title}</span>

View File

@@ -14,6 +14,7 @@ async function bench() {
path: 'name' in input ? `/projects/${input.name}` : input.path,
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
}))
const pickDirectory = vi.fn(async () => '/tmp/picked')
const startSession = vi.fn()
const rename = vi.fn(async () => ({}))
const insertSessionBefore = vi.fn(async () => ({}))
@@ -24,13 +25,14 @@ async function bench() {
value: { items: [{ sessionId: 'session' as never, snippet: 'match' }], hasMore: false },
}))
ctx.provide('workspaces', {
create, startSession, rename, insertSessionBefore,
create, pickDirectory, startSession, rename, insertSessionBefore,
} as never)
ctx.provide('sessions', { open, clear, search, searchResultLimit: 20 } as never)
return {
ctx,
slots: ctx.get('slots') as SlotsService,
create,
pickDirectory,
startSession,
rename,
insertSessionBefore,
@@ -93,10 +95,14 @@ describe('ui-workspace apply', () => {
expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2')
await browser.createWorkspace({ name: 'project' })
expect(b.create).toHaveBeenCalledWith({ name: 'project' })
await browser.pickDirectory()
expect(b.pickDirectory).toHaveBeenCalledOnce()
const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
await picker.createWorkspace({ path: '/tmp/project' })
expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' })
await picker.pickDirectory()
expect(b.pickDirectory).toHaveBeenCalledTimes(2)
})
it('rejects the browser search callback on a runtime business error', async () => {

View File

@@ -16,7 +16,7 @@ function stubRect(row: HTMLElement): void {
row.getBoundingClientRect = () => ({
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34,
x: 0, y: 100, toJSON: () => ({}),
} as DOMRect)
})
}
function dragProps(overrides: Partial<RowDragProps> = {}): RowDragProps {

View File

@@ -32,7 +32,9 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
items, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
recentWorkspaceId: items[0]?.workspaceId,
})
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
function hook<T>(snapshot: T) {
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
}
/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void {
@@ -59,6 +61,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
deleteWorkspace: vi.fn(async () => {}),
insertSessionBefore: vi.fn(async () => {}),
createWorkspace: vi.fn(async () => workspace('created', [])),
pickDirectory: vi.fn(async () => null),
...overrides,
}
const view = render(<WorkspaceBrowser {...props} />)
@@ -340,9 +343,9 @@ describe('WorkspaceBrowser', () => {
const searchSessions = vi.fn((query: string, _signal: AbortSignal) => query === 'first'
? first
: Promise.resolve({
items: [{ sessionId: sid('second-hit'), snippet: 'second excerpt' }],
hasMore: false,
}))
items: [{ sessionId: sid('second-hit'), snippet: 'second excerpt' }],
hasMore: false,
}))
mount({
useSessions: hook(sessionState([
summary('first-hit', 2, { displayTitle: 'Old result' }),
@@ -475,7 +478,7 @@ describe('WorkspaceBrowser', () => {
const [one, , three] = rows as [HTMLElement, HTMLElement, HTMLElement]
three.getBoundingClientRect = () => ({
top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}),
} as DOMRect)
})
const dataTransfer = { effectAllowed: '', dropEffect: '' }
fireEvent.dragStart(one, { dataTransfer })
// Drop on the top half of "three": insert one before three.
@@ -488,7 +491,7 @@ describe('WorkspaceBrowser', () => {
fireEvent.dragStart(one, { dataTransfer })
one.getBoundingClientRect = () => ({
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}),
} as DOMRect)
})
fireDrag(one, 'dragOver', 105)
fireDrag(one, 'drop', 105)
expect(insertSessionBefore).toHaveBeenCalledTimes(1)
@@ -514,7 +517,7 @@ describe('WorkspaceBrowser', () => {
const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement
two.getBoundingClientRect = () => ({
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
} as DOMRect)
})
fireDrag(two, 'drop', 155)
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('two'))
})
@@ -531,7 +534,7 @@ describe('WorkspaceBrowser', () => {
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
two.getBoundingClientRect = () => ({
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
} as DOMRect)
})
const dataTransfer = { effectAllowed: '', dropEffect: '' }
fireEvent.dragStart(one, { dataTransfer })
fireEvent.dragEnd(one)
@@ -560,7 +563,7 @@ describe('WorkspaceBrowser', () => {
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
two.getBoundingClientRect = () => ({
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
} as DOMRect)
})
const dataTransfer = { effectAllowed: '', dropEffect: '' }
fireEvent.dragStart(one, { dataTransfer })
fireDrag(two, 'drop', 180)
@@ -582,13 +585,13 @@ describe('WorkspaceBrowser', () => {
const input = screen.getByLabelText<HTMLInputElement>('Workspace name')
expect(input.value).toBe('Alpha')
// Unchanged and blank names stay blocked.
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
fireEvent.change(input, { target: { value: ' ' } })
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
// A duplicate of another workspace's title shows the inline conflict.
fireEvent.change(input, { target: { value: ' Beta ' } })
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Beta” already exists.')
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
fireEvent.change(input, { target: { value: 'Gamma' } })
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Gamma')
@@ -651,13 +654,13 @@ describe('WorkspaceBrowser', () => {
expect(dialog.textContent).toContain('folder and session logs will be kept')
expect(dialog.textContent).toContain('sessions will appear under Ungrouped')
const confirm = screen.getByRole('button', { name: 'Delete workspace' }) as HTMLButtonElement
const confirm = screen.getByRole<HTMLButtonElement>('button', { name: 'Delete workspace' })
fireEvent.click(confirm)
fireEvent.click(confirm)
expect(deleteWorkspace).toHaveBeenCalledOnce()
expect(deleteWorkspace).toHaveBeenCalledWith(wid('alpha'))
expect(confirm.disabled).toBe(true)
expect((screen.getByRole('button', { name: 'Cancel' }) as HTMLButtonElement).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Cancel' }).disabled).toBe(true)
expect(screen.getByRole('status').textContent).toBe('Deleting workspace…')
fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.click(screen.getByRole('button', { name: 'Close' }))

View File

@@ -4,6 +4,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
import type {
SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client'
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
afterEach(cleanup)
@@ -15,7 +16,9 @@ function workspace(id: string, title = id): WorkspaceView {
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
}
}
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
function hook<T>(snapshot: T) {
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
}
const sessions: SessionListState = {
ids: [], byId: {}, current: undefined, phase: 'ready',
}
@@ -32,7 +35,11 @@ function anchor(): { current: HTMLElement } {
return { current: element }
}
function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn()) {
function mount(
items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
createWorkspace = vi.fn(),
pickDirectory = vi.fn(async () => null as string | null),
) {
const onPick = vi.fn()
const onClose = vi.fn()
const anchorRef = anchor()
@@ -45,20 +52,19 @@ function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
onPick={onPick}
onClose={onClose}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
/>
)
const view = render(
renderPicker(items),
)
return {
view, onPick, onClose, createWorkspace,
view, onPick, onClose, createWorkspace, pickDirectory,
rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) },
}
}
function chooseCreateItem(name: 'Use an existing folder' | 'Create a new workspace'): void {
const parent = screen.getByRole('menuitem', { name: 'Create workspace' })
fireEvent.mouseEnter(parent.parentElement as HTMLElement)
function chooseItem(name: 'Open local folder' | 'Create a new workspace'): void {
fireEvent.click(screen.getByRole('menuitem', { name }))
}
@@ -73,7 +79,7 @@ describe('WorkspacePicker', () => {
const created = workspace('new', 'New')
const createWorkspace = vi.fn(async () => created)
const b = mount([], createWorkspace)
chooseCreateItem('Create a new workspace')
chooseItem('Create a new workspace')
const input = screen.getByLabelText('New workspace name')
fireEvent.change(input, { target: { value: 'project-one' } })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
@@ -81,34 +87,81 @@ describe('WorkspacePicker', () => {
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
})
it('adopts an existing path through the same immediate create action', async () => {
const created = workspace('adopted')
it('opens a native directory picker, adopts its path, and selects the returned Workspace', async () => {
const created = { ...workspace('adopted'), path: '/tmp/project', title: 'project' }
const createWorkspace = vi.fn(async () => created)
const b = mount([], createWorkspace)
chooseCreateItem('Use an existing folder')
const input = screen.getByLabelText('Existing folder path')
fireEvent.keyDown(input, { key: 'ArrowRight' })
fireEvent.keyDown(input, { key: 'Enter' })
expect(createWorkspace).not.toHaveBeenCalled()
fireEvent.change(input, { target: { value: ' /tmp/project ' } })
fireEvent.keyDown(input, { key: 'Enter' })
const pickDirectory = vi.fn(async () => '/tmp/project')
const b = mount([], createWorkspace, pickDirectory)
chooseItem('Open local folder')
expect(pickDirectory).toHaveBeenCalledOnce()
await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) })
expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' })
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
})
it('treats native picker cancellation as a silent no-op', async () => {
const b = mount([], vi.fn(), vi.fn(async () => null))
chooseItem('Open local folder…')
await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() })
expect(b.createWorkspace).not.toHaveBeenCalled()
expect(b.onPick).not.toHaveBeenCalled()
expect(screen.queryByRole('dialog')).toBeNull()
})
it('shows a name conflict and retries through the native picker', async () => {
const pickDirectory = vi.fn()
.mockResolvedValueOnce('/one/project')
.mockResolvedValueOnce(null)
const createWorkspace = vi.fn(async () => {
throw new WorkspaceCreateError({
code: 'workspace-name-conflict', message: 'project already exists', details: { name: 'project' },
})
})
const b = mount([], createWorkspace, pickDirectory)
chooseItem('Open local folder…')
await waitFor(() => {
expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy()
})
expect(screen.getByRole('alert').textContent).toBe('Choose a folder with a different name.')
fireEvent.click(screen.getByRole('button', { name: 'Choose again' }))
await waitFor(() => { expect(pickDirectory).toHaveBeenCalledTimes(2) })
expect(b.onPick).not.toHaveBeenCalled()
})
it('disables the folder action while the native picker is already open', async () => {
let resolve!: (path: string | null) => void
const pending = new Promise<string | null>((settle) => { resolve = settle })
const b = mount([], vi.fn(), vi.fn(() => pending))
chooseItem('Open local folder…')
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Open local folder…' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true)
fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' }))
expect(b.pickDirectory).toHaveBeenCalledTimes(1)
await act(async () => { resolve(null); await pending })
})
it('reports non-Error native picker failures', async () => {
const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' }))
chooseItem('Open local folder…')
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toBe('picker unavailable')
})
expect(b.createWorkspace).not.toHaveBeenCalled()
})
it('closes a creation modal when the user cancels', () => {
mount([])
chooseCreateItem('Create a new workspace')
chooseItem('Create a new workspace')
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('dialog')).toBeNull()
})
it('blocks a create-new name already present in the Workspace list', () => {
const b = mount([workspace('alpha', 'Alpha')])
chooseCreateItem('Create a new workspace')
chooseItem('Create a new workspace')
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } })
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.')
expect((screen.getByRole('button', { name: 'Create workspace' }) as HTMLButtonElement).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Create workspace' }).disabled).toBe(true)
fireEvent.keyDown(screen.getByLabelText('New workspace name'), { key: 'Enter' })
expect(b.createWorkspace).not.toHaveBeenCalled()
})
@@ -118,7 +171,7 @@ describe('WorkspacePicker', () => {
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
const created = workspace('fresh', 'same-name')
const b = mount([], vi.fn(() => pending))
chooseCreateItem('Create a new workspace')
chooseItem('Create a new workspace')
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
@@ -134,7 +187,7 @@ describe('WorkspacePicker', () => {
const pending = new Promise<WorkspaceView>((_resolve, rejectPromise) => { reject = rejectPromise })
const createWorkspace = vi.fn(() => pending)
const b = mount([], createWorkspace)
chooseCreateItem('Create a new workspace')
chooseItem('Create a new workspace')
const input = screen.getByLabelText('New workspace name')
fireEvent.keyDown(input, { key: 'ArrowRight' })
fireEvent.change(input, { target: { value: 'broken' } })
@@ -151,7 +204,7 @@ describe('WorkspacePicker', () => {
it('reports non-Error creation failures', async () => {
const b = mount([], vi.fn(async () => { throw 'permission denied' }))
chooseCreateItem('Create a new workspace')
chooseItem('Create a new workspace')
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied')
@@ -163,7 +216,7 @@ describe('WorkspacePicker', () => {
render(
<WorkspacePicker
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
/>,
)
expect(screen.queryByRole('menu')).toBeNull()
@@ -176,7 +229,7 @@ describe('WorkspacePicker', () => {
render(
<WorkspacePicker
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
/>,
)
expect(screen.getByRole('status').textContent).toBe('Loading workspaces…')