Merge remote-tracking branch 'origin/master' into codex/basic-session-search
Conflict resolutions: - `session.list`: master's projection columns fold into the PR's cancellable, batched `listVisibleSessionSummaries`, which `session.search` shares as its visibility baseline; master's goal helpers stay beside it. - Client sessions face: master narrowed `ctx.sessions` to `ISessions`, so the search verb and its protocol-constant bound are declared there and the test-runtime double implements them (recorded, empty page unless a scenario stubs hits). - `WorkspaceBrowser`: master's per-row Rename wiring rides the PR's search results view; the tree keeps the PR's query-free derivations. - `dsh web` bin: the PR's shutdown-handlers-before-readiness order with master's boot-time LAN address snapshot. - `session-query-sqlite`: master's `SCHEMA_VERSION` 7 stands; the PR's bump carried no schema change. - Specs: master wraps assistant/steering message payloads and requires an `application/json` carrier request, so the search fixtures and tests follow. - Web aria goldens keep master's recording plus the PR's search placeholder; the navigation-panes inventory keeps master's terminal-card golden next to the PR's search-results golden.
This commit is contained in:
@@ -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: 84017b65e6ce2447996ef951a9a72a7c1661c376
|
||||
README.zh.md: f4ef5009c2f6f264b798a07078b2ba96276ff0ef
|
||||
README.md: e0c8f7536dda6e1be670e1359ee3f9023de92fba
|
||||
README.zh.md: 226d8633447a9c219fbfa49b108f948151912fca
|
||||
|
||||
@@ -6,7 +6,7 @@ Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar
|
||||
|
||||
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 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.
|
||||
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. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **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. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration.
|
||||
|
||||
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
|
||||
|
||||
@@ -21,5 +21,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **No Session deletion or fork control** — the Session menu's Fork and Delete rows remain visual-only (Rename is wired); Workspace registration deletion does not delete Sessions.
|
||||
- **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
该浏览器通过全局运行时钩子将 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 对象。平铺显示的 **打开本地文件夹…** 操作会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。
|
||||
|
||||
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **没有模糊内容搜索或事件深链接**:内容后端采用字面 token/短语匹配,选择结果会打开 Session,而不是匹配的事件。
|
||||
- **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
|
||||
- **原生文件夹选择依赖本地 Host 载体**:仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。
|
||||
- **没有 Session 删除与 fork 控件**:Session 菜单的 Fork 与 Delete 行仍仅提供视觉效果(Rename 已接线);删除 Workspace 注册记录不会删除 Session。
|
||||
- **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
.list > [role='treeitem'] + [role='treeitem'] {
|
||||
|
||||
@@ -107,12 +107,14 @@ type SessionTreeProps = Pick<
|
||||
onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
|
||||
/** Open the browser-owned delete-confirmation dialog for a real Workspace group. */
|
||||
onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
|
||||
/** Open the browser-owned session rename dialog. */
|
||||
onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void
|
||||
}
|
||||
|
||||
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
||||
function SessionTree({
|
||||
useSessions, startSession, open, workspaces,
|
||||
onRenameRequest, onDeleteRequest, insertSessionBefore,
|
||||
onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore,
|
||||
}: SessionTreeProps) {
|
||||
const list = useSessions(s => s)
|
||||
const current = list.current
|
||||
@@ -207,6 +209,7 @@ function SessionTree({
|
||||
currentId={current}
|
||||
now={now}
|
||||
onOpen={open}
|
||||
onRename={onSessionRename}
|
||||
onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
|
||||
drag={dragProps}
|
||||
/>
|
||||
@@ -221,7 +224,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'>) {
|
||||
function FlatList({ useSessions, open, onSessionRename }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'onSessionRename'>) {
|
||||
const list = useSessions(s => s)
|
||||
const rows = useMemo(() => deriveFlat(list), [list])
|
||||
const now = Date.now()
|
||||
@@ -239,6 +242,7 @@ function FlatList({ useSessions, open }: Pick<SessionTreeProps, 'useSessions' |
|
||||
currentId={list.current}
|
||||
now={now}
|
||||
onOpen={open}
|
||||
onRename={onSessionRename}
|
||||
/* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */
|
||||
onToggle={() => {}}
|
||||
flat
|
||||
@@ -331,13 +335,15 @@ export function WorkspaceBrowser({
|
||||
actions,
|
||||
startSession,
|
||||
open,
|
||||
renameSession,
|
||||
renameWorkspace,
|
||||
deleteWorkspace,
|
||||
insertSessionBefore,
|
||||
createWorkspace,
|
||||
searchSessions,
|
||||
searchResultLimit,
|
||||
pickDirectory,
|
||||
useDirectoryFlow,
|
||||
renderSlot,
|
||||
}: WorkspaceBrowserProps) {
|
||||
const workspaces = useWorkspaces(state => state.items)
|
||||
const groupBy = useStore(s => s.groupBy)
|
||||
@@ -356,6 +362,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.
|
||||
@@ -435,6 +442,39 @@ export function WorkspaceBrowser({
|
||||
})
|
||||
}
|
||||
|
||||
// Session rename dialog (same browser-owned pattern as workspace rename;
|
||||
// sessions have no client-side name-conflict rule — the host normalizes).
|
||||
// Unlike workspace rename, an unchanged title is NOT blocked: confirming
|
||||
// the current automatic title is the gesture that pins it.
|
||||
const [sessionRenameTarget, setSessionRenameTarget] = useState<{ sessionId: SessionNode['id']; currentTitle: string } | null>(null)
|
||||
const [sessionRenameDraft, setSessionRenameDraft] = useState('')
|
||||
const [sessionRenaming, setSessionRenaming] = useState(false)
|
||||
const [sessionRenameError, setSessionRenameError] = useState<string | null>(null)
|
||||
const sessionRenameTrimmed = sessionRenameDraft.trim()
|
||||
const sessionRenameBlocked = sessionRenaming || sessionRenameTrimmed === '' || sessionRenameTarget === null
|
||||
const closeSessionRename = () => {
|
||||
if (sessionRenaming) return
|
||||
setSessionRenameTarget(null)
|
||||
setSessionRenameError(null)
|
||||
}
|
||||
const confirmSessionRename = () => {
|
||||
if (sessionRenameBlocked) return
|
||||
setSessionRenaming(true)
|
||||
setSessionRenameError(null)
|
||||
renameSession(sessionRenameTarget.sessionId, sessionRenameTrimmed).then(() => {
|
||||
setSessionRenaming(false)
|
||||
setSessionRenameTarget(null)
|
||||
}).catch((reason: unknown) => {
|
||||
setSessionRenaming(false)
|
||||
setSessionRenameError(reason instanceof Error ? reason.message : String(reason))
|
||||
})
|
||||
}
|
||||
const onSessionRename = (sessionId: SessionNode['id'], currentTitle: string) => {
|
||||
setSessionRenameTarget({ sessionId, currentTitle })
|
||||
setSessionRenameDraft(currentTitle)
|
||||
setSessionRenameError(null)
|
||||
}
|
||||
|
||||
// Delete dialog is separate from the row so a successful removal can
|
||||
// unmount that row without tearing down the in-flight confirmation state.
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null)
|
||||
@@ -486,7 +526,6 @@ export function WorkspaceBrowser({
|
||||
className={css.iconButton}
|
||||
aria-label="Create workspace"
|
||||
onClick={() => {
|
||||
if (!wide) expandSidebar()
|
||||
setWsPickerOpen(v => !v)
|
||||
}}
|
||||
>
|
||||
@@ -499,7 +538,10 @@ export function WorkspaceBrowser({
|
||||
anchorRef={wsPlusRef}
|
||||
useWorkspaces={useWorkspaces}
|
||||
createWorkspace={createWorkspace}
|
||||
pickDirectory={pickDirectory}
|
||||
useDirectoryFlow={useDirectoryFlow}
|
||||
renderDirectoryFlow={owner => renderSlot('sidebar.workspaces.directoryFlow', owner)}
|
||||
createOnly
|
||||
side="right"
|
||||
onPick={(workspaceId) => {
|
||||
setWsPickerOpen(false)
|
||||
startSession(workspaceId)
|
||||
@@ -560,10 +602,11 @@ export function WorkspaceBrowser({
|
||||
/>
|
||||
)
|
||||
: groupBy === 'flat'
|
||||
? <FlatList useSessions={useSessions} open={open} />
|
||||
? <FlatList useSessions={useSessions} open={open} onSessionRename={onSessionRename} />
|
||||
: (
|
||||
<SessionTree
|
||||
useSessions={useSessions}
|
||||
onSessionRename={onSessionRename}
|
||||
workspaces={workspaces}
|
||||
startSession={startSession}
|
||||
open={open}
|
||||
@@ -598,9 +641,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()
|
||||
}
|
||||
@@ -611,6 +657,37 @@ export function WorkspaceBrowser({
|
||||
)}
|
||||
{renameError !== null && <div className={css.renameError} role="alert">{renameError}</div>}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={sessionRenameTarget !== null}
|
||||
onClose={closeSessionRename}
|
||||
title="Rename session"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" disabled={sessionRenaming} onClick={closeSessionRename}>Cancel</Button>
|
||||
<Button variant="primary" disabled={sessionRenameBlocked} onClick={confirmSessionRename}>Rename</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className={css.renameInput}
|
||||
value={sessionRenameDraft}
|
||||
aria-label="Session name"
|
||||
autoFocus
|
||||
disabled={sessionRenaming}
|
||||
onFocus={(e) => { e.target.select() }}
|
||||
onChange={(e) => { setSessionRenameDraft(e.target.value); setSessionRenameError(null) }}
|
||||
onCompositionStart={() => { composingRef.current = true }}
|
||||
onCompositionEnd={() => { composingRef.current = false }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !composingRef.current) {
|
||||
e.preventDefault()
|
||||
confirmSessionRename()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{sessionRenameError !== null && <div className={css.renameError} role="alert">{sessionRenameError}</div>}
|
||||
</Modal>
|
||||
<Modal
|
||||
open={deleteTarget !== null}
|
||||
onClose={closeDelete}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
* Workspace pick/create flow. WorkspaceCreateFlow is the reusable core
|
||||
* (menu + path/create dialogs) consumed directly by WorkspaceBrowser (same
|
||||
* package) and wrapped by WorkspacePicker for the conversation empty-state
|
||||
* slot registration.
|
||||
* slot registration. Directory picking itself lives in the composed flow
|
||||
* package's slot occupant (see the contract module doc): this core only
|
||||
* opens the flow, adopts the picked path, and owns the error surface.
|
||||
*/
|
||||
import type { RefObject } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
@@ -13,7 +15,8 @@ import {
|
||||
WorkspaceCreateError,
|
||||
type WorkspaceId, type WorkspaceListState, type WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WorkspacePickerProps } from './contract/slots.ts'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts'
|
||||
import css from './WorkspacePicker.module.css'
|
||||
|
||||
const OPEN_LOCAL_FOLDER = '::open-local-folder'
|
||||
@@ -31,12 +34,20 @@ 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>
|
||||
/** Bound occupancy selector hook for this surface's directory-flow hole (empty hides the local-folder entry). */
|
||||
useDirectoryFlow: SnapshotSelectorHook<boolean>
|
||||
/** Render this surface's directory-flow hole with the owner conversation (the entry's narrowed renderSlot). */
|
||||
renderDirectoryFlow: (owner: DirectoryFlowOwnerProps) => ReactNode
|
||||
/** A real Workspace was picked or created. */
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,9 +60,13 @@ export function WorkspaceCreateFlow({
|
||||
anchorRef,
|
||||
useWorkspaces,
|
||||
createWorkspace,
|
||||
pickDirectory,
|
||||
useDirectoryFlow,
|
||||
renderDirectoryFlow,
|
||||
onPick,
|
||||
onClose,
|
||||
createOnly = false,
|
||||
side = 'bottom',
|
||||
selectedId,
|
||||
}: WorkspaceCreateFlowProps) {
|
||||
const workspaceSnapshot = useWorkspaces(state => state)
|
||||
const workspaces = workspaceSnapshot.items
|
||||
@@ -63,23 +78,49 @@ export function WorkspaceCreateFlow({
|
||||
const [workspaceName, setWorkspaceName] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [modalError, setModalError] = useState<string | null>(null)
|
||||
const [flowOpen, setFlowOpen] = useState(false)
|
||||
const [pickingFolder, setPickingFolder] = useState(false)
|
||||
const [folderConflict, setFolderConflict] = useState(false)
|
||||
const composingRef = useRef(false)
|
||||
// One picking interaction at a time: while the flow is open (native chooser
|
||||
// pending, browse dialog up) or its pick is being adopted, every other
|
||||
// menu action stays disabled — a late outcome must not race a concurrent
|
||||
// selection or creation.
|
||||
const flowBusy = flowOpen || pickingFolder
|
||||
const normalizedWorkspaceName = workspaceName.trim()
|
||||
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
|
||||
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
|
||||
|
||||
const items: MenuEntry[] = [
|
||||
...workspaces.map(workspace => ({
|
||||
// The occupied hole gates the picking affordance: with no composed flow the
|
||||
// entry simply is not there (the seam's documented no-flow default). The
|
||||
// framework-bound hook keeps occupancy live: flow plugins activate (and
|
||||
// HMR-reload) independently of this menu's renders.
|
||||
const flowAvailable = useDirectoryFlow(occupied => occupied)
|
||||
// An occupant that unloads mid-interaction leaves nobody to cancel: an
|
||||
// open flow over an empty hole withdraws so the menu actions come back.
|
||||
// flowOpen is a dependency because the flow can also OPEN over an already
|
||||
// empty hole (Choose again after the occupant unloaded with the error
|
||||
// dialog up) — that transition must snap back too, not just occupancy loss.
|
||||
useEffect(() => {
|
||||
if (flowOpen && !flowAvailable) setFlowOpen(false)
|
||||
}, [flowOpen, flowAvailable])
|
||||
const createEntries: MenuEntry[] = [
|
||||
...(flowAvailable
|
||||
? [{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: flowBusy }]
|
||||
: []),
|
||||
{ id: CREATE_NEW, label: 'Create a new workspace', icon: <IconPlusOutline16 size={16} />, disabled: flowBusy },
|
||||
]
|
||||
// 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,
|
||||
})),
|
||||
...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []),
|
||||
{ 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 },
|
||||
]
|
||||
disabled: flowBusy,
|
||||
}))
|
||||
: createEntries
|
||||
|
||||
const closeModal = (): void => {
|
||||
if (creating) return
|
||||
@@ -87,15 +128,10 @@ 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 })
|
||||
/** Adopt a picked directory; failures land in the folder-error dialog (Choose again reopens the flow). */
|
||||
const adoptDirectory = (path: string): Promise<void> =>
|
||||
createWorkspace({ path }).then((workspace) => {
|
||||
setFlowOpen(false)
|
||||
onPick(workspace.workspaceId)
|
||||
}).catch((reason: unknown) => {
|
||||
setFolderConflict(
|
||||
@@ -103,8 +139,33 @@ export function WorkspaceCreateFlow({
|
||||
&& reason.rpcError.code === 'workspace-name-conflict',
|
||||
)
|
||||
setModalError(reason instanceof Error ? reason.message : String(reason))
|
||||
setFlowOpen(false)
|
||||
setModalKind('folder-error')
|
||||
}).finally(() => { setPickingFolder(false) })
|
||||
})
|
||||
|
||||
const openLocalFolder = (): void => {
|
||||
onClose()
|
||||
setModalKind(null)
|
||||
setModalError(null)
|
||||
setFolderConflict(false)
|
||||
setFlowOpen(true)
|
||||
}
|
||||
|
||||
/** Owner side of the flow conversation: adopt keeps the flow open (busy) until the Host answers. */
|
||||
const flowOwner: DirectoryFlowOwnerProps = {
|
||||
open: flowOpen,
|
||||
busy: pickingFolder,
|
||||
onPicked: (path) => {
|
||||
setPickingFolder(true)
|
||||
void adoptDirectory(path).finally(() => { setPickingFolder(false) })
|
||||
},
|
||||
onCancel: () => { setFlowOpen(false) },
|
||||
onError: (message) => {
|
||||
setFlowOpen(false)
|
||||
setFolderConflict(false)
|
||||
setModalError(message)
|
||||
setModalKind('folder-error')
|
||||
},
|
||||
}
|
||||
|
||||
const handleSelect = (id: string): void => {
|
||||
@@ -114,7 +175,7 @@ export function WorkspaceCreateFlow({
|
||||
}
|
||||
if (id === CREATE_NEW) {
|
||||
onClose()
|
||||
setWorkspaceName('workspace')
|
||||
setWorkspaceName('')
|
||||
setModalError(null)
|
||||
setModalKind('create')
|
||||
return
|
||||
@@ -149,12 +210,16 @@ export function WorkspaceCreateFlow({
|
||||
open={open}
|
||||
anchor={null}
|
||||
items={items}
|
||||
{...pinCreate ? { footer: createEntries } : {}}
|
||||
selectedId={selectedId}
|
||||
onSelect={handleSelect}
|
||||
onClose={onClose}
|
||||
side={side}
|
||||
portal
|
||||
getAnchorRect={getAnchorRect}
|
||||
/>
|
||||
{open && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">Loading workspaces…</div>}
|
||||
{renderDirectoryFlow(flowOwner)}
|
||||
<Modal
|
||||
open={modalKind === 'folder-error'}
|
||||
onClose={closeModal}
|
||||
@@ -162,7 +227,9 @@ export function WorkspaceCreateFlow({
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" className={css.modalAction} onClick={closeModal}>Cancel</Button>
|
||||
<Button variant="primary" className={css.modalAction} onClick={openLocalFolder}>Choose again</Button>
|
||||
{/* Retrying needs an occupant to serve the flow; without one the
|
||||
* button would open a flow nobody can answer or cancel. */}
|
||||
<Button variant="primary" className={css.modalAction} disabled={!flowAvailable} onClick={openLocalFolder}>Choose again</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
@@ -194,12 +261,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()
|
||||
}
|
||||
@@ -225,10 +295,12 @@ export function WorkspacePicker({
|
||||
open,
|
||||
anchorRef,
|
||||
useWorkspaces,
|
||||
selectedId,
|
||||
onPick,
|
||||
onClose,
|
||||
createWorkspace,
|
||||
pickDirectory,
|
||||
useDirectoryFlow,
|
||||
renderSlot,
|
||||
}: WorkspacePickerProps) {
|
||||
return (
|
||||
<WorkspaceCreateFlow
|
||||
@@ -236,7 +308,9 @@ export function WorkspacePicker({
|
||||
anchorRef={anchorRef}
|
||||
useWorkspaces={useWorkspaces}
|
||||
createWorkspace={createWorkspace}
|
||||
pickDirectory={pickDirectory}
|
||||
useDirectoryFlow={useDirectoryFlow}
|
||||
renderDirectoryFlow={owner => renderSlot('conversation.hero.workspace.directoryFlow', owner)}
|
||||
selectedId={selectedId}
|
||||
onPick={onPick}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
@@ -7,8 +7,19 @@
|
||||
* consumes the shell's two-fact owner share (wide / expandSidebar).
|
||||
* - WorkspacePicker fills the conversation empty-state hole (menu +
|
||||
* create dialogs shared with the browser).
|
||||
*
|
||||
* Each registration also declares one **directory-flow hole** (`single`
|
||||
* kind): the slot a composed picker package's client half fills with its
|
||||
* picking interaction — a renderless native-chooser driver or an in-app
|
||||
* browsing dialog. ui-workspace owns the trigger (the "Open local folder…"
|
||||
* menu entry, shown only while the hole is occupied) and the adoption
|
||||
* semantics (`createWorkspace({ path })`, the conflict/error dialog, Choose
|
||||
* again); the occupant owns everything between `open` and the picked path.
|
||||
* Two holes exist because the two menu surfaces are independent slot entries
|
||||
* and a hole has exactly one declaring entry — they carry the same owner
|
||||
* contract and the same occupant.
|
||||
*/
|
||||
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { HostObservable, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: pull the owner SlotMap merges into programs that resolve the
|
||||
// runtime shares below.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
@@ -18,12 +29,64 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { createWorkspaceViewStore } from '../stores.ts'
|
||||
|
||||
/**
|
||||
* Owner share of the directory-flow holes: the complete conversation between
|
||||
* the trigger surface and the picking interaction. The occupant reads `open`
|
||||
* to run/render its interaction and reports exactly one outcome per open.
|
||||
*/
|
||||
export interface DirectoryFlowOwnerProps {
|
||||
/** True while a picking interaction is requested; flipping back to false withdraws the request. */
|
||||
open: boolean
|
||||
/** True while the owner adopts a picked path (`createWorkspace` in flight); occupants disable their commit affordances. */
|
||||
busy: boolean
|
||||
/** The operator picked a directory (absolute host path); the owner adopts it. */
|
||||
onPicked: (path: string) => void
|
||||
/** The operator dismissed the interaction; the owner just closes the flow. */
|
||||
onCancel: () => void
|
||||
/** The interaction itself failed (chooser missing, listing denied); the owner shows its error surface. */
|
||||
onError: (message: string) => void
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/** Directory-flow hole under the conversation empty-state picker (declared by the WorkspacePicker entry). */
|
||||
'conversation.hero.workspace.directoryFlow': { kind: 'single'; scope: 'root'; owner: DirectoryFlowOwnerProps }
|
||||
/** Directory-flow hole under the sidebar browsing region (declared by the WorkspaceBrowser entry). */
|
||||
'sidebar.workspaces.directoryFlow': { kind: 'single'; scope: 'root'; owner: DirectoryFlowOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
/** The two directory-flow holes; a flow package's client half registers its one component into both. */
|
||||
export type DirectoryFlowSlotName =
|
||||
| 'conversation.hero.workspace.directoryFlow'
|
||||
| 'sidebar.workspaces.directoryFlow'
|
||||
|
||||
/**
|
||||
* Directory-picking share both trigger surfaces consume. Occupancy rides the
|
||||
* inject face's reserved `hooks` compartment: the renderer binds the source
|
||||
* into the `useDirectoryFlow` selector hook, so an empty hole hides the
|
||||
* "Open local folder…" entry reactively and the surface withdraws an open
|
||||
* flow whose occupant unloaded mid-interaction (nobody is left to cancel).
|
||||
*/
|
||||
export type DirectoryPickingInjected = {
|
||||
hooks: {
|
||||
/** True while this surface's directory-flow hole is occupied. */
|
||||
directoryFlow: HostObservable<boolean>
|
||||
}
|
||||
}
|
||||
|
||||
/** Component-side view of the picking share: the bound occupancy selector hook. */
|
||||
export type DirectoryPickingHooks = {
|
||||
/** Selector hook over this surface's directory-flow occupancy. */
|
||||
useDirectoryFlow: SnapshotSelectorHook<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-private injected share (arrives via the register inject factory).
|
||||
* Data reads use the global framework hooks; these are the Host actions the
|
||||
* browsing region drives.
|
||||
*/
|
||||
export type WorkspaceBrowserInjected = {
|
||||
export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
|
||||
/**
|
||||
* Start a New Session in a Workspace: reuse-or-create its blank session
|
||||
* and open it; with no workspace, clear the selection into the New Session
|
||||
@@ -42,6 +105,8 @@ export type WorkspaceBrowserInjected = {
|
||||
) => Promise<{ items: readonly SessionSearchResultItem[]; hasMore: boolean }>
|
||||
/** Maximum number of merged rows rendered for one search. */
|
||||
searchResultLimit: number
|
||||
/** Rename a Session (explicit user title; resolves on host acceptance). */
|
||||
renameSession: (sessionId: SessionId, title: string) => Promise<void>
|
||||
/** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
|
||||
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
|
||||
/** Delete only a Host Workspace registration; directory and Session logs remain. */
|
||||
@@ -54,26 +119,24 @@ 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. */
|
||||
export type WorkspaceBrowserProps =
|
||||
PropsRuntime<'sidebar.workspaces'>
|
||||
& PropsRenderSlots<'sidebar.workspaces.directoryFlow'>
|
||||
& PropsStore<ReturnType<typeof createWorkspaceViewStore>>
|
||||
& WorkspaceBrowserInjected
|
||||
& Omit<WorkspaceBrowserInjected, 'hooks'>
|
||||
& DirectoryPickingHooks
|
||||
|
||||
/**
|
||||
* Picker-private injected share. Pick semantics remain in the owner's onPick
|
||||
* callback; this callback creates only the real Host Workspace. A type alias
|
||||
* supplies the implicit index signature required by the registry.
|
||||
*/
|
||||
export type WorkspacePickerInjected = {
|
||||
export type WorkspacePickerInjected = DirectoryPickingInjected & {
|
||||
/** 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>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,4 +145,7 @@ export type WorkspacePickerInjected = {
|
||||
* currency, so one composed type serves both registrations.
|
||||
*/
|
||||
export type WorkspacePickerProps =
|
||||
PropsRuntime<'conversation.hero.workspace'> & WorkspacePickerInjected
|
||||
PropsRuntime<'conversation.hero.workspace'>
|
||||
& PropsRenderSlots<'conversation.hero.workspace.directoryFlow'>
|
||||
& Omit<WorkspacePickerInjected, 'hooks'>
|
||||
& DirectoryPickingHooks
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
* the sidebar shell's `sidebar.workspaces` hole (the whole browsing region),
|
||||
* and WorkspacePicker fills the conversation hero's picker hole
|
||||
* (`conversation.hero.workspace` — both hero forms). Both read real Host
|
||||
* Workspaces through the global useWorkspaces hook. Export discipline:
|
||||
* Workspaces through the global useWorkspaces hook, and each declares its
|
||||
* own `single` directory-flow child hole for the composed picker package's
|
||||
* client half (see the contract module doc). Export discipline:
|
||||
* packages/client/AGENTS.md.
|
||||
*/
|
||||
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
|
||||
import { createWorkspaceViewStore } from './stores.ts'
|
||||
@@ -13,6 +17,7 @@ import { WorkspaceBrowser } from './WorkspaceBrowser.tsx'
|
||||
import { WorkspacePicker } from './WorkspacePicker.tsx'
|
||||
|
||||
export type {
|
||||
DirectoryFlowOwnerProps, DirectoryFlowSlotName, DirectoryPickingHooks, DirectoryPickingInjected,
|
||||
WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps,
|
||||
} from './contract/slots.ts'
|
||||
|
||||
@@ -38,6 +43,14 @@ export function apply(ctx: ClientContext): void {
|
||||
if (!result.ok) throw new Error(result.error.message)
|
||||
return result.value
|
||||
}
|
||||
// Stable per-surface occupancy sources (the renderer's hook cache keys by
|
||||
// source identity): true while the surface's directory-flow hole is filled.
|
||||
const flowSource = (hole: 'sidebar.workspaces.directoryFlow' | 'conversation.hero.workspace.directoryFlow'): HostObservable<boolean> => ({
|
||||
getSnapshot: () => ctx.slots.entries(hole).length > 0,
|
||||
subscribe: listener => ctx.slots.subscribe(hole, listener),
|
||||
})
|
||||
const browserFlowSource = flowSource('sidebar.workspaces.directoryFlow')
|
||||
const pickerFlowSource = flowSource('conversation.hero.workspace.directoryFlow')
|
||||
const browserInjected = (): WorkspaceBrowserInjected => ({
|
||||
// Explicit group actions keep their target; unscoped New Session rides
|
||||
// the runtime's shared action (recent-Workspace projection inside).
|
||||
@@ -45,54 +58,54 @@ export function apply(ctx: ClientContext): void {
|
||||
open: (sessionId) => { ctx.sessions.open(sessionId) },
|
||||
searchSessions,
|
||||
searchResultLimit: ctx.sessions.searchResultLimit,
|
||||
renameSession: async (sessionId, title) => {
|
||||
// Row → session-face hop: rename is a per-session verb (ISession), not
|
||||
// a list-service verb; the binding resolves any listed session.
|
||||
const session = ctx.sessions.binding(sessionId)?.session
|
||||
if (session === undefined) throw new Error(`unknown session "${sessionId}"`)
|
||||
const result = await session.rename(title)
|
||||
if (!result.ok) throw new Error(result.error.message)
|
||||
},
|
||||
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
|
||||
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
|
||||
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
|
||||
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
|
||||
},
|
||||
createWorkspace: input => ctx.workspaces.create(input),
|
||||
pickDirectory: () => ctx.workspaces.pickDirectory(),
|
||||
hooks: { directoryFlow: browserFlowSource },
|
||||
})
|
||||
const pickerInjected = (): WorkspacePickerInjected => ({
|
||||
createWorkspace: input => ctx.workspaces.create(input),
|
||||
pickDirectory: () => ctx.workspaces.pickDirectory(),
|
||||
hooks: { directoryFlow: pickerFlowSource },
|
||||
})
|
||||
// Declaration-aware registration: each owner's declaring apply may activate
|
||||
// after this one (entry activation order is unconstrained), and a register
|
||||
// into an undeclared slot throws. Register once the declaration is on the
|
||||
// ledger; the subscription also re-registers after an HMR collapse
|
||||
// re-declares the slot (the cascade disposed our entry with it).
|
||||
// Declaration-aware registration (deferRegistration): each owner's
|
||||
// declaring apply may activate after this one, and a register into an
|
||||
// undeclared slot throws; the deferral also re-registers after an HMR
|
||||
// collapse re-declares the slot. Each registration declares its own
|
||||
// directory-flow child hole in the same call (declaration = render
|
||||
// authorization, one table).
|
||||
ctx.effect(() => {
|
||||
const registrations = [
|
||||
{
|
||||
name: 'sidebar.workspaces' as const,
|
||||
component: WorkspaceBrowser,
|
||||
register: () => ctx.slots.register(
|
||||
{ name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected },
|
||||
const deferred = [
|
||||
deferRegistration(ctx.slots, 'sidebar.workspaces', WorkspaceBrowser, () =>
|
||||
ctx.slots.register(
|
||||
{
|
||||
name: 'sidebar.workspaces',
|
||||
children: { 'sidebar.workspaces.directoryFlow': { kind: 'single', scope: 'root' } },
|
||||
store: createWorkspaceViewStore(),
|
||||
inject: browserInjected,
|
||||
},
|
||||
WorkspaceBrowser,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'conversation.hero.workspace' as const,
|
||||
component: WorkspacePicker,
|
||||
register: () => ctx.slots.register(
|
||||
{ name: 'conversation.hero.workspace', inject: pickerInjected },
|
||||
)),
|
||||
deferRegistration(ctx.slots, 'conversation.hero.workspace', WorkspacePicker, () =>
|
||||
ctx.slots.register(
|
||||
{
|
||||
name: 'conversation.hero.workspace',
|
||||
children: { 'conversation.hero.workspace.directoryFlow': { kind: 'single', scope: 'root' } },
|
||||
inject: pickerInjected,
|
||||
},
|
||||
WorkspacePicker,
|
||||
),
|
||||
},
|
||||
)),
|
||||
]
|
||||
const disposers = new Map<string, () => void>()
|
||||
const tryRegister = (entry: (typeof registrations)[number]): void => {
|
||||
if (ctx.slots.spec(entry.name) === undefined) return
|
||||
if (ctx.slots.entries(entry.name).some(e => e.component === entry.component)) return
|
||||
disposers.set(entry.name, entry.register())
|
||||
}
|
||||
const unsubscribers = registrations.map(entry =>
|
||||
ctx.slots.subscribe(entry.name, () => { tryRegister(entry) }))
|
||||
for (const entry of registrations) tryRegister(entry)
|
||||
return () => {
|
||||
for (const unsubscribe of unsubscribers) unsubscribe()
|
||||
for (const dispose of disposers.values()) dispose()
|
||||
}
|
||||
return () => { for (const entry of deferred) entry.dispose() }
|
||||
}, 'ui-workspace: browser + picker registrations')
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
|
||||
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
|
||||
* time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
|
||||
* except workspace Rename; the session hover card is suppressed while a menu
|
||||
* is open. Workspace Rename/Delete are wired; session actions remain visual-only.
|
||||
* except workspace Rename/Delete and session Rename; the session hover card is
|
||||
* suppressed while a menu is open.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -194,12 +194,14 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
|
||||
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
|
||||
}
|
||||
|
||||
export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle, drag, flat = false }: {
|
||||
export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, onToggle, drag, flat = false }: {
|
||||
node: SessionNode
|
||||
depth: number
|
||||
currentId: string | undefined
|
||||
now: number
|
||||
onOpen: (id: SessionNode['id']) => void
|
||||
/** Open the browser-owned session rename dialog (row menu action). */
|
||||
onRename: (id: SessionNode['id'], currentTitle: string) => void
|
||||
onToggle: (id: SessionNode['id']) => void
|
||||
/** Present only on draggable rows (workspace-group roots outside search). */
|
||||
drag?: RowDragProps | undefined
|
||||
@@ -267,7 +269,10 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle,
|
||||
open={menuOpen}
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
items={SESSION_MENU_ITEMS}
|
||||
onSelect={() => { setMenuOpen(false) }} // Visual-only for now.
|
||||
onSelect={(id) => {
|
||||
setMenuOpen(false)
|
||||
if (id === 'rename') onRename(node.id, row.title) // fork/delete stay visual-only.
|
||||
}}
|
||||
portal
|
||||
closeOnPointerLeave
|
||||
anchor={(
|
||||
@@ -299,6 +304,7 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle,
|
||||
currentId={currentId}
|
||||
now={now}
|
||||
onOpen={onOpen}
|
||||
onRename={onRename}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -14,7 +14,6 @@ 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 () => ({}))
|
||||
@@ -25,14 +24,13 @@ async function bench() {
|
||||
value: { items: [{ sessionId: 'session' as never, snippet: 'match' }], hasMore: false },
|
||||
}))
|
||||
ctx.provide('workspaces', {
|
||||
create, pickDirectory, startSession, rename, insertSessionBefore,
|
||||
create, 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,
|
||||
@@ -95,14 +93,35 @@ 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('declares the two directory-flow holes and reports their occupancy per surface', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots, 'sidebar.workspaces', 'conversation.hero.workspace')
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
// Registration declared the child holes (declaration = render authorization).
|
||||
expect(b.slots.spec('sidebar.workspaces.directoryFlow')).toMatchObject({ kind: 'single' })
|
||||
expect(b.slots.spec('conversation.hero.workspace.directoryFlow')).toMatchObject({ kind: 'single' })
|
||||
|
||||
const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)()
|
||||
const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
|
||||
expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false)
|
||||
expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false)
|
||||
// A flow occupant flips exactly its own surface, and the source notifies.
|
||||
const notified = vi.fn()
|
||||
const unsubscribe = browser.hooks.directoryFlow.subscribe(notified)
|
||||
const dispose = b.slots.register({ name: 'sidebar.workspaces.directoryFlow' } as never, () => null)
|
||||
expect(browser.hooks.directoryFlow.getSnapshot()).toBe(true)
|
||||
expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false)
|
||||
await Promise.resolve()
|
||||
expect(notified).toHaveBeenCalled()
|
||||
dispose()
|
||||
expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false)
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('rejects the browser search callback on a runtime business error', async () => {
|
||||
|
||||
48
packages/client/ui-workspace/tests/browser-styles.spec.ts
Normal file
48
packages/client/ui-workspace/tests/browser-styles.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
118
packages/client/ui-workspace/tests/rename-assembly.spec.tsx
Normal file
118
packages/client/ui-workspace/tests/rename-assembly.spec.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* The session-rename assembly chain on SlotTestRuntime (real apply, real
|
||||
* WorkspaceBrowser occupying the sidebar hole): row menu → rename dialog →
|
||||
* the injected renameSession hop (sessions.binding → ISession.rename) → on
|
||||
* the accepted unary response the dialog closes and the row re-labels from
|
||||
* the list state — no push-frame wait. Previously pinned only by the
|
||||
* assembled-app snapshot (apps/web/tests/session-actions.snapshot.ts); the
|
||||
* verb's wire behavior stays with the runtime package
|
||||
* (session.spec.ts#rename), the dialog's own arms with rows.spec /
|
||||
* workspace-browser.spec.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
|
||||
import type { ISession, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
|
||||
/** Test-owned sidebar shell role: declares and renders the browsing region. */
|
||||
type FrameProps = PropsRenderSlots<'sidebar.workspaces'>
|
||||
function SidebarFrame({ renderSlot }: FrameProps) {
|
||||
return <>{renderSlot('sidebar.workspaces', { wide: true, expandSidebar: () => {} })}</>
|
||||
}
|
||||
|
||||
describe('session rename through the assembled browser', () => {
|
||||
it('renames via the row menu: binding.session.rename fires, the dialog closes, the row re-labels from the list', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const rename = vi.fn<ISession['rename']>(async title => ({
|
||||
ok: true, value: { title: title.trim().replace(/\s+/g, ' '), seq: 7 },
|
||||
}))
|
||||
await runtime.sessions.add({
|
||||
id: SID,
|
||||
summary: { title: '旧标题', displayTitle: '旧标题', cwd: '/w/alpha' },
|
||||
session: { rename },
|
||||
})
|
||||
await runtime.workspaces.update((draft) => {
|
||||
draft.items = [{
|
||||
workspaceId: 'w1' as WorkspaceId, title: 'alpha', path: '/w/alpha',
|
||||
sessionIds: [SID], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}] as never
|
||||
})
|
||||
await runtime.root.declare(
|
||||
{ 'sidebar.workspaces': { kind: 'single', scope: 'root' } } as never,
|
||||
SidebarFrame as never,
|
||||
)
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
const view = runtime.renderRoot()
|
||||
|
||||
// The current session's group auto-expands; open the row's action menu.
|
||||
const row = (await view.findByText('旧标题')).closest('[role="treeitem"]')!
|
||||
fireEvent.click(within(row as HTMLElement).getByLabelText('Session actions for 旧标题'))
|
||||
fireEvent.click(view.getByRole('menuitem', { name: 'Rename', hidden: true }))
|
||||
|
||||
// The dialog seeds from the current title; submit a padded value.
|
||||
const input = await view.findByLabelText('Session name') as HTMLInputElement
|
||||
expect(input.value).toBe('旧标题')
|
||||
fireEvent.change(input, { target: { value: ' 分叉 实验记录 ' } })
|
||||
fireEvent.click(view.getByRole('button', { name: 'Rename' }))
|
||||
|
||||
// The injected hop reached the session face with the edge-trimmed draft
|
||||
// (the dialog trims edges; interior normalization is host-side).
|
||||
await waitFor(() => { expect(rename).toHaveBeenCalledWith('分叉 实验记录') })
|
||||
// Acceptance closes the dialog without any push-frame wait.
|
||||
await waitFor(() => { expect(view.queryByLabelText('Session name')).toBeNull() })
|
||||
// The manager lands the unary echo in the list store (its own package
|
||||
// tests own that hop); the row re-labels from list state alone.
|
||||
await runtime.sessions.updateSummary(SID, { displayTitle: '分叉 实验记录', title: '分叉 实验记录' })
|
||||
await view.findByText('分叉 实验记录')
|
||||
expect(view.queryByText('旧标题')).toBeNull()
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('a rejected rename keeps the dialog open with the error surfaced', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const rename = vi.fn<ISession['rename']>(async () => ({
|
||||
ok: false, error: { code: 'internal', message: 'title write failed', details: {} },
|
||||
}))
|
||||
await runtime.sessions.add({
|
||||
id: SID,
|
||||
summary: { title: '旧标题', displayTitle: '旧标题', cwd: '/w/alpha' },
|
||||
session: { rename },
|
||||
})
|
||||
await runtime.workspaces.update((draft) => {
|
||||
draft.items = [{
|
||||
workspaceId: 'w1' as WorkspaceId, title: 'alpha', path: '/w/alpha',
|
||||
sessionIds: [SID], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}] as never
|
||||
})
|
||||
await runtime.root.declare(
|
||||
{ 'sidebar.workspaces': { kind: 'single', scope: 'root' } } as never,
|
||||
SidebarFrame as never,
|
||||
)
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
const view = runtime.renderRoot()
|
||||
await runtime.flush()
|
||||
|
||||
const row = (await view.findByText('旧标题')).closest('[role="treeitem"]')!
|
||||
fireEvent.click(within(row as HTMLElement).getByLabelText('Session actions for 旧标题'))
|
||||
fireEvent.click(view.getByRole('menuitem', { name: 'Rename', hidden: true }))
|
||||
const input = await view.findByLabelText('Session name')
|
||||
fireEvent.change(input, { target: { value: '新名' } })
|
||||
fireEvent.click(view.getByRole('button', { name: 'Rename' }))
|
||||
|
||||
// Failure: the injected hop rethrows the business error; the dialog
|
||||
// stays open with the alert and the row keeps its title.
|
||||
const alert = await view.findByRole('alert')
|
||||
expect(alert.textContent).toContain('title write failed')
|
||||
expect(view.getByLabelText('Session name')).toBeTruthy()
|
||||
expect(view.getByText('旧标题')).toBeTruthy()
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
@@ -87,7 +87,8 @@ describe('workspace browser rows', () => {
|
||||
const onOpen = vi.fn()
|
||||
const onToggle = vi.fn()
|
||||
const view = render(
|
||||
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen} onToggle={onToggle} />,
|
||||
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen}
|
||||
onRename={vi.fn()} onToggle={onToggle} />,
|
||||
)
|
||||
|
||||
const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
|
||||
@@ -107,7 +108,8 @@ describe('workspace browser rows', () => {
|
||||
view.rerender(
|
||||
<SessionNodeItem
|
||||
node={{ ...parent, children: [], expanded: false, running: false }}
|
||||
depth={1} currentId={undefined} now={0} onOpen={onOpen} onToggle={onToggle}
|
||||
depth={1} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={vi.fn()} onToggle={onToggle}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
|
||||
@@ -154,19 +156,29 @@ describe('workspace browser rows', () => {
|
||||
expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('session row menu opens without opening the session and closes on selection', () => {
|
||||
it('session row menu opens without opening the session and dispatches rename', () => {
|
||||
const onOpen = vi.fn()
|
||||
const onRename = vi.fn()
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'One', children: [], hasChildren: false,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen} onToggle={vi.fn()} />)
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={onRename} onToggle={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' }))
|
||||
// Rename dispatches with the current display title (dialog prefill).
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(onRename).toHaveBeenCalledWith(node.id, 'One')
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
// Fork and Delete stay visual-only.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete session' }))
|
||||
expect(onRename).toHaveBeenCalledOnce()
|
||||
// Escape closes without selecting (Menu onClose path).
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
@@ -178,7 +190,8 @@ describe('workspace browser rows', () => {
|
||||
id: sid('p'), title: 'Parent', children: [], hasChildren: true,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} flat />)
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} flat />)
|
||||
expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull()
|
||||
})
|
||||
|
||||
@@ -189,7 +202,8 @@ describe('workspace browser rows', () => {
|
||||
id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
|
||||
expanded: false, running: true, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()} onToggle={vi.fn()} />)
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} />)
|
||||
const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
@@ -215,7 +229,8 @@ describe('workspace browser rows', () => {
|
||||
id: sid('s1'), title: 'Quiet', children: [], hasChildren: false,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} />)
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} />)
|
||||
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByText('Idle')).toBeTruthy()
|
||||
@@ -232,7 +247,8 @@ describe('workspace browser rows', () => {
|
||||
}
|
||||
const inactive = dragProps()
|
||||
const { rerender } = render(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
|
||||
)
|
||||
const row = screen.getByRole('treeitem')
|
||||
stubRect(row)
|
||||
@@ -249,7 +265,8 @@ describe('workspace browser rows', () => {
|
||||
|
||||
const active = dragProps({ active: true, marker: 'before' })
|
||||
rerender(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={active} />,
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} drag={active} />,
|
||||
)
|
||||
stubRect(screen.getByRole('treeitem'))
|
||||
// Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
|
||||
@@ -262,7 +279,8 @@ describe('workspace browser rows', () => {
|
||||
|
||||
const after = dragProps({ active: true, marker: 'after' })
|
||||
rerender(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={after} />,
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} drag={after} />,
|
||||
)
|
||||
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createWorkspaceViewStore } from '../src/client/stores.ts'
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({
|
||||
id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }),
|
||||
id: sid(id), displayTitle: id, running: false, waitingApproval: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }),
|
||||
})
|
||||
const list = (...items: SessionSummary[]): SessionListState => ({
|
||||
ids: items.map(item => item.id),
|
||||
|
||||
@@ -15,7 +15,7 @@ beforeEach(() => { localStorage.clear() })
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
const summary = (id: string, updatedAt: number, overrides: Partial<SessionSummary> = {}): SessionSummary => ({
|
||||
id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...overrides,
|
||||
id: sid(id), displayTitle: id, running: false, waitingApproval: false, blank: false, updatedAt, ...overrides,
|
||||
})
|
||||
const sessionState = (items: readonly SessionSummary[], overrides: Partial<SessionListState> = {}): SessionListState => ({
|
||||
ids: items.map(item => item.id),
|
||||
@@ -57,11 +57,13 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
open: vi.fn(),
|
||||
searchSessions: vi.fn(async () => ({ items: [], hasMore: false })),
|
||||
searchResultLimit: 20,
|
||||
renameSession: vi.fn(async () => {}),
|
||||
renameWorkspace: vi.fn(async () => {}),
|
||||
deleteWorkspace: vi.fn(async () => {}),
|
||||
insertSessionBefore: vi.fn(async () => {}),
|
||||
createWorkspace: vi.fn(async () => workspace('created', [])),
|
||||
pickDirectory: vi.fn(async () => null),
|
||||
useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }),
|
||||
renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ? <div data-testid="directory-flow" /> : null)) as never,
|
||||
...overrides,
|
||||
}
|
||||
const view = render(<WorkspaceBrowser {...props} />)
|
||||
@@ -442,25 +444,21 @@ 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', () => {
|
||||
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()
|
||||
// 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()
|
||||
})
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DirectoryFlowOwnerProps } from '../src/client/contract/slots.ts'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -35,14 +37,49 @@ function anchor(): { current: HTMLElement } {
|
||||
return { current: element }
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe occupant of the directory-flow hole: records the latest owner
|
||||
* conversation so tests drive onPicked/onCancel/onError like a composed flow
|
||||
* package would, and renders a marker element while the flow is open.
|
||||
*/
|
||||
function flowProbe() {
|
||||
const probe: { owner: DirectoryFlowOwnerProps | undefined } = { owner: undefined }
|
||||
const renderSlot = ((_name: string, owner: DirectoryFlowOwnerProps) => {
|
||||
probe.owner = owner
|
||||
return owner.open ? <div data-testid="directory-flow" data-busy={owner.busy} /> : null
|
||||
}) as never
|
||||
return { probe, renderSlot }
|
||||
}
|
||||
|
||||
/** Manual occupancy source bound like the renderer would: flip() drives the hook like a real registration change. */
|
||||
function occupancySource(initial = true) {
|
||||
let occupied = initial
|
||||
const listeners = new Set<() => void>()
|
||||
const useDirectoryFlow = bindSnapshotSelector({
|
||||
getSnapshot: () => occupied,
|
||||
subscribe: (listener: () => void) => {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
})
|
||||
return {
|
||||
useDirectoryFlow,
|
||||
flip: (next: boolean) => {
|
||||
occupied = next
|
||||
for (const listener of [...listeners]) listener()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function mount(
|
||||
items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
|
||||
createWorkspace = vi.fn(),
|
||||
pickDirectory = vi.fn(async () => null as string | null),
|
||||
occupancy = occupancySource(),
|
||||
) {
|
||||
const onPick = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const anchorRef = anchor()
|
||||
const { probe, renderSlot } = flowProbe()
|
||||
const renderPicker = (nextItems: readonly WorkspaceView[]) => (
|
||||
<WorkspacePicker
|
||||
open
|
||||
@@ -52,14 +89,15 @@ function mount(
|
||||
onPick={onPick}
|
||||
onClose={onClose}
|
||||
createWorkspace={createWorkspace}
|
||||
pickDirectory={pickDirectory}
|
||||
useDirectoryFlow={occupancy.useDirectoryFlow}
|
||||
renderSlot={renderSlot}
|
||||
/>
|
||||
)
|
||||
const view = render(
|
||||
renderPicker(items),
|
||||
)
|
||||
return {
|
||||
view, onPick, onClose, createWorkspace, pickDirectory,
|
||||
view, onPick, onClose, createWorkspace, probe, occupancy,
|
||||
rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) },
|
||||
}
|
||||
}
|
||||
@@ -87,65 +125,75 @@ describe('WorkspacePicker', () => {
|
||||
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
|
||||
})
|
||||
|
||||
it('opens a native directory picker, adopts its path, and selects the returned Workspace', async () => {
|
||||
it('opens the composed directory flow, adopts its picked path, and selects the returned Workspace', async () => {
|
||||
const created = { ...workspace('adopted'), path: '/tmp/project', title: 'project' }
|
||||
const createWorkspace = vi.fn(async () => created)
|
||||
const pickDirectory = vi.fn(async () => '/tmp/project')
|
||||
const b = mount([], createWorkspace, pickDirectory)
|
||||
const b = mount([], createWorkspace)
|
||||
expect(screen.queryByTestId('directory-flow')).toBeNull()
|
||||
chooseItem('Open local folder…')
|
||||
expect(pickDirectory).toHaveBeenCalledOnce()
|
||||
await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) })
|
||||
expect(b.onClose).toHaveBeenCalled()
|
||||
expect(screen.getByTestId('directory-flow')).toBeTruthy()
|
||||
await act(async () => { b.probe.owner!.onPicked('/tmp/project') })
|
||||
expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' })
|
||||
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
|
||||
// Successful adoption withdraws the flow request.
|
||||
expect(screen.queryByTestId('directory-flow')).toBeNull()
|
||||
})
|
||||
|
||||
it('treats native picker cancellation as a silent no-op', async () => {
|
||||
const b = mount([], vi.fn(), vi.fn(async () => null))
|
||||
it('treats flow cancellation as a silent no-op', () => {
|
||||
const b = mount([])
|
||||
chooseItem('Open local folder…')
|
||||
await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() })
|
||||
act(() => { b.probe.owner!.onCancel() })
|
||||
expect(screen.queryByTestId('directory-flow')).toBeNull()
|
||||
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)
|
||||
it('shows a name conflict and retries by reopening the flow', async () => {
|
||||
const createWorkspace = vi.fn(async () => {
|
||||
throw new WorkspaceCreateError({
|
||||
code: 'workspace-name-conflict', message: 'project already exists', details: { name: 'project' },
|
||||
})
|
||||
})
|
||||
const b = mount([], createWorkspace, pickDirectory)
|
||||
const b = mount([], createWorkspace)
|
||||
chooseItem('Open local folder…')
|
||||
await act(async () => { b.probe.owner!.onPicked('/one/project') })
|
||||
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.')
|
||||
// The failed adoption withdrew the flow; Choose again reopens it.
|
||||
expect(b.probe.owner!.open).toBe(false)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose again' }))
|
||||
await waitFor(() => { expect(pickDirectory).toHaveBeenCalledTimes(2) })
|
||||
expect(b.probe.owner!.open).toBe(true)
|
||||
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))
|
||||
it('disables every menu action from flow open through adoption, and reports busy to the flow', async () => {
|
||||
let resolve!: (workspace: WorkspaceView) => void
|
||||
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
|
||||
const created = workspace('adopted')
|
||||
const b = mount([workspace('alpha', 'Alpha')], vi.fn(() => pending))
|
||||
chooseItem('Open local folder…')
|
||||
// The flow is open but nothing is picked yet: a chooser pending on the
|
||||
// host display must already block concurrent workspace actions.
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Alpha' }).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true)
|
||||
act(() => { b.probe.owner!.onPicked('/tmp/project') })
|
||||
expect(b.probe.owner!.busy).toBe(true)
|
||||
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 })
|
||||
await act(async () => { resolve(created); await pending })
|
||||
expect(b.probe.owner!.busy).toBe(false)
|
||||
})
|
||||
|
||||
it('reports non-Error native picker failures', async () => {
|
||||
const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' }))
|
||||
it('shows the flow-reported failure in the folder-error surface', () => {
|
||||
const b = mount([])
|
||||
chooseItem('Open local folder…')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert').textContent).toBe('picker unavailable')
|
||||
})
|
||||
act(() => { b.probe.owner!.onError('no chooser installed') })
|
||||
expect(screen.getByRole('alert').textContent).toBe('no chooser installed')
|
||||
expect(screen.queryByTestId('directory-flow')).toBeNull()
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -205,6 +253,8 @@ describe('WorkspacePicker', () => {
|
||||
it('reports non-Error creation failures', async () => {
|
||||
const b = mount([], vi.fn(async () => { throw 'permission denied' }))
|
||||
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')
|
||||
@@ -213,10 +263,12 @@ describe('WorkspacePicker', () => {
|
||||
})
|
||||
|
||||
it('waits to show its menu until an optional anchor is available', () => {
|
||||
const { renderSlot } = flowProbe()
|
||||
render(
|
||||
<WorkspacePicker
|
||||
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
|
||||
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
@@ -226,12 +278,54 @@ describe('WorkspacePicker', () => {
|
||||
const state: WorkspaceListState = {
|
||||
...workspaceState([]), phase: 'pending', state: 'loading', baselinesReady: false,
|
||||
}
|
||||
const { renderSlot } = flowProbe()
|
||||
render(
|
||||
<WorkspacePicker
|
||||
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
|
||||
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('status').textContent).toBe('Loading workspaces…')
|
||||
})
|
||||
|
||||
it('hides the folder entry while the directory-flow hole is empty', () => {
|
||||
mount([], vi.fn(), occupancySource(false))
|
||||
expect(screen.getByRole('menuitem', { name: 'Create a new workspace' })).toBeTruthy()
|
||||
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the folder entry when a flow package activates after the first paint', () => {
|
||||
const b = mount([], vi.fn(), occupancySource(false))
|
||||
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
|
||||
// Registration changes flow through the subscription, no re-render needed.
|
||||
act(() => { b.occupancy.flip(true) })
|
||||
expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps Choose again inert while the flow occupant is gone, and snaps back a flow opened over an empty hole', async () => {
|
||||
const b = mount([], vi.fn(async () => { throw new Error('adoption failed') }))
|
||||
chooseItem('Open local folder…')
|
||||
await act(async () => { b.probe.owner!.onPicked('/one/project') })
|
||||
await waitFor(() => { expect(screen.getByRole('dialog', { name: 'Couldn’t open folder' })).toBeTruthy() })
|
||||
// The occupant unloads while the error dialog is up: retrying would open
|
||||
// a flow nobody can serve or cancel, so the button goes inert.
|
||||
act(() => { b.occupancy.flip(false) })
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Choose again' }).disabled).toBe(true)
|
||||
// Cancel stays the way out, and the menu actions are usable again.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('withdraws an open flow when its occupant unloads, re-enabling the menu actions', () => {
|
||||
const b = mount([])
|
||||
chooseItem('Open local folder…')
|
||||
expect(screen.getByTestId('directory-flow')).toBeTruthy()
|
||||
// The flow plugin unloads mid-interaction (HMR): nobody is left to
|
||||
// cancel, so the owner withdraws and the actions come back.
|
||||
act(() => { b.occupancy.flip(false) })
|
||||
expect(b.probe.owner!.open).toBe(false)
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(false)
|
||||
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user