mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into web-e2e-interactions
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
2026-07-25-session-list-browsing-and-manual-order.md: 586995bf459aeaee88672863977f7acf2a7061a3
|
||||
2026-07-25-session-list-browsing-and-manual-order.zh.md: 432d5167a57d30bc04a0b4faf213e4341f07bd2f
|
||||
@@ -0,0 +1,60 @@
|
||||
# Agent Note: Session List Browsing and Manual Workspace Order
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-25-session-list-browsing-and-manual-order.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
[Workspace UI Complete Product Flow](2026-07-25-workspace-ui-product-flow.md) shipped the first form of the grouped session list and explicitly scoped out operations such as Rename and drag ordering. The design file (figma 239-10458 and its companion screens) has since filled in those interactions: the list must switch to an ungrouped flat view, session rows need a hover detail card and an action menu, workspaces need renaming, and sessions need manual ordering inside their group.
|
||||
|
||||
Two existing mechanisms stood in the way. First, the host durably promoted the active session to the front of its workspace account on every `session/event` (activity pinning), so any manual order would be scrambled by the next activity — two ordering authorities cannot coexist. Second, the browsing area was split across two packages: ui-sidebar owned the list, search, and header rows while ui-workspace only borrowed a picker slot for its popover; every new workspace-domain dialog required cross-package wiring, and ownership grew more twisted with each one.
|
||||
|
||||
## Decision
|
||||
|
||||
### Flat view and viewing state
|
||||
|
||||
The group-by menu offers two modes, WorkSpace / In one list. Flat mode renders every session (fork children included) as a top-level row, strictly newest-first by `updatedAt`, with no parent/child adjacency; the Intent placeholder renders as the first row. The mode choice persists in the browser (`dsh.workspace.view`) across reloads.
|
||||
|
||||
### Row interactions
|
||||
|
||||
- Session rows show a detail card after a 500ms hover dwell (full title / relative time / status line; the status line has only running/idle until the wire grows a status field). The card and the row menu are mutually exclusive: no card while a menu is open or a drag is in flight.
|
||||
- Session-row … menu: Rename / Fork session / Delete session, visual-only this iteration; workspace-header … menu: Rename (wired) / Delete workspace (visual-only). Menus close when the pointer leaves them.
|
||||
- Supporting primitives: `Menu` gains label entries, danger rows, and `closeOnPointerLeave`; a new `HoverCard` (portaled placement, open delay, disabled guard).
|
||||
|
||||
### workspace.rename
|
||||
|
||||
`workspace.rename({ workspaceId, title })`: the title is trimmed and must be non-blank; both the same-title no-op and the duplicate check evaluate inside the host's serialized workspace-creation chain (shared with create, so concurrent create/rename cannot interleave a duplicate or an out-of-order fake success), and a conflict returns `workspace-name-conflict`. Durability goes through `setTitle`'s mutate path, and the `domain/changed` listener broadcasts the `host/workspace-changed` frame automatically. The UI is a standard modal with a client-side duplicate pre-check.
|
||||
|
||||
### Manual order: insertSessionBefore replaces activity pinning
|
||||
|
||||
The `session/event` → `touchSession` activity-pinning chain is deleted wholesale; the workspace account order is now manually owned — new sessions prepend at attach, and explicit reordering goes through `workspace.insertSessionBefore({ workspaceId, sessionId, beforeSessionId? })` (DOM insertBefore semantics: with an anchor it inserts before it, omitted appends to the end). The entity throws a typed `WorkspaceMoveInvalidError` only for unaccounted session/anchor ids; the handler maps exactly that to the business code `workspace-move-invalid`, while storage failures stay internal.
|
||||
|
||||
The UI is HTML5 drag on root rows inside a group (workspace grouping only, outside search; fork children ride with their parent and are not draggable). Order authority stays entirely host-side: drop only sends the RPC, the client performs zero local reordering, and the view refreshes from the response upsert and the changed frame; a failed move changes nothing. The client's upsert rejects snapshots older (`updatedAt`) than the installed projection so a late unary response cannot roll back a newer frame.
|
||||
|
||||
### Shell/region split
|
||||
|
||||
ui-sidebar shrinks to the column-geometry shell: brand row, fold state machine, New Session, Settings, and one `sidebar.workspaces` hole; the shell↔region contract is two facts, `{ wide, expandSidebar }`. ui-workspace fully owns the browsing region (section header, search, grouped tree and flat list, every workspace dialog, drag) plus its groupBy store; the rail-state search/new-workspace icons belong to the region too and request shell expansion via `expandSidebar()`. The picker splits into the core `WorkspaceCreateFlow` (composed directly inside the region) and the thin `WorkspacePicker` wrapper (still filling ui-conversation's hero slot); the old `sidebar.workspace` picker slot and its declaration-aware deferral are deleted with it.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep activity pinning; treat drag as a transient adjustment** — the manual order would be scrambled by the next session activity, making it a fiction; two coexisting ordering authorities cannot be explained to the user. A middle ground — freeze pinning per workspace after the first drag — adds a state tier with murkier semantics; deleting outright is cleaner.
|
||||
|
||||
**Numeric index in the reorder payload** — `{ index }` drifts during the drag window: after the host prepends a new session (e.g. Intent materialization) the same index points at a different row. Anchor-style insertBefore is naturally immune to prepends and filtered projections.
|
||||
|
||||
**Optimistic reordering on drop** — client-first reordering needs failure rollback, one more entangled state in the object layer; local/LAN round-trips are millisecond-scale, so waiting for the host response is imperceptible. With a single order authority (trust the host completely), the frontend never invents an order.
|
||||
|
||||
**Keep the rename dialog in ui-sidebar (smallest change)** — that is the problem itself: workspace-domain dialogs scattered in a borrowed slot, with each addition (the Delete confirmation is coming) repeating the cross-package wiring. Review first considered moving only the rename modal; the ruling was to give the whole browsing region to ui-workspace and leave the shell geometry-only.
|
||||
|
||||
**Keep parent/child adjacency in flat mode** — contradicts strict recency (a child newer than its parent's sibling cannot slot adjacently), and the flat view's purpose is dropping the hierarchy; flattening fully and disabling drag in flat mode (no persistence carrier) is more consistent.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Manual order is the sole authority over the workspace account: an order the user arranges is never scrambled by activity; the cost is losing float-to-top-on-activity, whose signal now rides the row status dot and time label. The `WorkspaceView.sessionIds` wire contract is reworded to the manual-order semantics.
|
||||
- The two-fact shell/region contract funnels every future workspace-domain feature (Delete confirmation, cross-group moves, Ungrouped adoption) into the single ui-workspace package; ui-sidebar no longer evolves with session-list features.
|
||||
- Flat mode supports neither reordering nor a create-in-workspace entry point (switching back to grouped view is required) — an accepted scope reduction.
|
||||
- Wiring the three session-menu items and workspace Delete, and growing the wire status enum, remain future iterations.
|
||||
|
||||
## Testing
|
||||
|
||||
Package-level suites cover the derivations (deriveGroups/deriveFlat), row components, both apply registrations and passthroughs, host entity move semantics, and the rename/insertSessionBefore RPC implementations with their fixture stubs; the `apps/web` keyless snapshots regress the assembled application; delivery acceptance additionally runs a 12-item playwright (chromium headless) checklist (grouped default, flat switch and persistence, hover-card appearance and suppression, both menus, the full rename chain, drag persistence) and drives the real host over the wire for rename success / duplicate rejection / `workspace-move-invalid`.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Agent Note: Session List Browsing and Manual Workspace Order
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-25-session-list-browsing-and-manual-order.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
[Workspace UI 完整产品流](2026-07-25-workspace-ui-product-flow.md)交付了分组 session 列表的首个形态,并把 Rename、拖拽排序等操作明确划出当期范围。设计稿(figma 239-10458 及关联画面)随后补齐了这些交互:列表要能切换成不分组的平铺视图、session 行悬停要出详情卡与操作菜单、workspace 要能改名、组内 session 要能手动排序。
|
||||
|
||||
两条既有机制挡在前面。其一,host 在每条 `session/event` 上把活跃 session durable 地提到 workspace 账本最前(活动置顶),任何手动排序都会被下一次活动打乱——两种排序权威不可调和。其二,浏览区域被劈在两个包里:ui-sidebar 拥有列表、搜索和组头行,ui-workspace 只借一个 picker 坑放弹层;每加一个 workspace 域的对话框都要跨包接线,归属越来越拧。
|
||||
|
||||
## Decision
|
||||
|
||||
### 平铺视图与浏览态
|
||||
|
||||
group-by 菜单提供 WorkSpace / In one list 两种模式。平铺模式把所有 session(含 fork 子)一律作为顶层行,严格按 `updatedAt` 新→旧排序,不保持父子相邻;Intent 占位行渲染在列表首行。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。
|
||||
|
||||
### 行交互
|
||||
|
||||
- session 行悬停 500ms 出详情卡(全名/相对时间/状态行;状态本期只有 running/idle 两态,枚举扩展待 wire 增补 status 字段)。卡片与行菜单互斥:菜单开启或拖拽进行中不出卡。
|
||||
- session 行 … 菜单:Rename / Fork session / Delete session,本期纯视觉;workspace 组头 … 菜单:Rename(已接线)/ Delete workspace(纯视觉)。菜单鼠标移出即关。
|
||||
- 支撑件:`Menu` 新增 label 条目、danger 行、`closeOnPointerLeave`;新增 `HoverCard`(portal 定位、开启延时、disabled 守卫)。
|
||||
|
||||
### workspace.rename
|
||||
|
||||
`workspace.rename({ workspaceId, title })`:title trim 后非空;同名 no-op 与重名查重都在 host 的 workspace 创建串行链内求值(与 create 共链,并发 create/rename 不能穿插出重名或乱序假成功),冲突回 `workspace-name-conflict`。落盘经 `setTitle` 的 mutate 通道,`domain/changed` 监听自动广播 `host/workspace-changed` 帧。UI 为标准 Modal,client 侧另做重名预检。
|
||||
|
||||
### 手动排序:insertSessionBefore 取代活动置顶
|
||||
|
||||
`session/event` → `touchSession` 活动置顶链整体删除;workspace 账本序改为纯手动拥有——新 session attach 时前插,显式重排走 `workspace.insertSessionBefore({ workspaceId, sessionId, beforeSessionId? })`(DOM insertBefore 语义:锚给了插锚前,缺省 append 到末尾)。实体只对不在账的 session/锚抛类型化的 `WorkspaceMoveInvalidError`,handler 仅把它映射为业务码 `workspace-move-invalid`,存储故障保持 internal。
|
||||
|
||||
UI 为组内 root 行的 HTML5 拖拽(仅 workspace 分组、非搜索态;fork 子随父不单独拖)。顺序权威完全在 host:drop 只发 RPC,client 零本地重排,视图靠响应体 upsert 与 changed 帧刷新;失败即无事发生。client 的 upsert 拒绝比已装载投影更旧(`updatedAt`)的快照,防迟到的一元响应回滚更新的帧。
|
||||
|
||||
### 壳/区域切分
|
||||
|
||||
ui-sidebar 缩为列几何壳:品牌行、折叠状态机、New Session、Settings,以及一个 `sidebar.workspaces` 洞;壳与区域的契约只有两个事实 `{ wide, expandSidebar }`。ui-workspace 全权拥有浏览区域(section header、搜索、分组树与平铺、全部 workspace 对话框、拖拽)及其 groupBy store;rail 态的搜索/新建图标也归区域,经 `expandSidebar()` 请求壳展开。picker 拆为核心件 `WorkspaceCreateFlow`(区域内直接组件组合)与薄包装 `WorkspacePicker`(继续填 ui-conversation 的 hero 坑);原 `sidebar.workspace` picker 坑与声明感知延迟注册随之删除。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留活动置顶、拖拽仅作临时调整** —— 手动序在下一次 session 活动即被打乱,形同虚设;两种排序权威并存无法向用户解释。也考虑过「拖过一次即冻结该 workspace 的活动置顶」的折中,状态多一档、语义更难讲,直接删除更干净。
|
||||
|
||||
**排序报文用数字下标** —— `{ index }` 在拖拽窗口期会漂移:host 前插新 session(如 Intent 材料化)后同一下标指向别的行。锚点式 insertBefore 对前插与过滤投影天然免疫。
|
||||
|
||||
**drop 后乐观重排** —— client 先行重排需失败回滚,对象层多一块纠缠态;本地/局域网往返毫秒级,等 host 响应的简单方案肉眼无感。顺序权威单一化(完全信 host)后,前端永不发明顺序。
|
||||
|
||||
**rename 对话框留在 ui-sidebar(最小改动)** —— 正是问题本身:workspace 域的对话框散落在借来的坑里,每加一个(Delete 确认框将至)都重演跨包接线。评审中先议了「只挪 rename Modal」的中间态,最终裁定整个浏览区域归 ui-workspace,壳只留几何。
|
||||
|
||||
**平铺模式保持父子相邻成组** —— 与「严格按时间」矛盾(子新于兄则插不进相邻位),且平铺本意就是取消层级;拉平并禁用平铺下的拖拽(无持久化载体)更一致。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 手动序是唯一的 workspace 账本序权威:用户排好的顺序不再被活动打乱;代价是「最近活跃浮到最上」的行为消失,活跃感知转由行内状态点与时间标签承担。`WorkspaceView.sessionIds` 的 wire 契约随之改为手动序措辞。
|
||||
- 壳/区域两事实契约把 workspace 域的后续功能(Delete 确认、跨组移动、Ungrouped 收编)全部收进 ui-workspace 单包;ui-sidebar 不再随 session 列表功能演进。
|
||||
- 平铺模式不支持排序与分组入口(建到指定 workspace 需切回分组视图),是拍板接受的范围收窄。
|
||||
- session 菜单三项与 workspace Delete 的功能接线、状态枚举扩 wire,留待后续迭代。
|
||||
|
||||
## Testing
|
||||
|
||||
包级用例覆盖派生(deriveGroups/deriveFlat)、行组件、两处 apply 注册与透传、host 实体移位语义、rename/insertSessionBefore 的 RPC 实现与 fixture 桩;`apps/web` keyless snapshot 回归覆盖装配后的应用;交付验收另以 playwright(chromium headless)过 12 项清单(分组默认、平铺切换与持久化、hover 卡出现与抑制、双菜单、rename 全链、拖拽落盘),并对真 host 直打 wire 验证 rename 成功/重名拒绝/`workspace-move-invalid` 三径。
|
||||
@@ -94,10 +94,10 @@ it('projects initial and revised durable titles through the built nine-plugin fi
|
||||
})
|
||||
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
const projectCount = await within(tree).findByText('4 sessions')
|
||||
const projectRow = projectCount.closest<HTMLElement>('[role="treeitem"]')
|
||||
if (projectRow === null) throw new Error('fixture project row missing')
|
||||
fireEvent.click(projectRow)
|
||||
// The fixture Intent selects the workspace, so the current-group effect
|
||||
// already expanded it; clicking the header would now collapse (the twist
|
||||
// stays live since intent stopped forcing expansion).
|
||||
await within(tree).findByText('4 sessions')
|
||||
|
||||
const initialLabel = 'Fixture 历史会话'
|
||||
const initialRowLabel = await screen.findByText(initialLabel)
|
||||
|
||||
@@ -2009,15 +2009,6 @@ get(id: WorkspaceId): Workspace | undefined
|
||||
*/
|
||||
list(): Workspace[]
|
||||
|
||||
/**
|
||||
* Move one accounted, cwd-validated session to the front of its workspace.
|
||||
* Ungrouped sessions and candidates filtered by the header check are
|
||||
* no-ops. The owning workspace's relative position never changes.
|
||||
* @param sessionId - Session whose activity was observed.
|
||||
* @returns resolution after the possible record write.
|
||||
*/
|
||||
async touchSession(sessionId: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Resolve by canonical directory path without creating or mutating a
|
||||
* workspace. A missing path rejects during `realpath`; an existing unowned
|
||||
@@ -2028,9 +2019,7 @@ async touchSession(sessionId: SessionId): Promise<void>
|
||||
async resolveByPath(path: string): Promise<Workspace | undefined>
|
||||
```
|
||||
|
||||
Types: [SessionId](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/workspace/workspace/src/index.ts:75`](../../packages/workspace/workspace/src/index.ts)
|
||||
Source: [`packages/workspace/workspace/src/index.ts:78`](../../packages/workspace/workspace/src/index.ts)
|
||||
|
||||
## Inherited `ctx` members (cordis core + loader/hmr/timer)
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace`](../packages/workspace/workspace), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
|
||||
@@ -641,6 +641,59 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
emitHost({ type: 'host/workspace-changed', workspace: { ...created } })
|
||||
return ok(request, { workspace: { ...created }, created: true })
|
||||
},
|
||||
rename: (request) => {
|
||||
const { workspaceId, title } = request.payload
|
||||
const workspace = workspaces.find(w => w.workspaceId === workspaceId)
|
||||
if (workspace === undefined) {
|
||||
return err(request, {
|
||||
code: 'workspace-not-found',
|
||||
message: `no workspace ${workspaceId}`,
|
||||
details: { workspaceId },
|
||||
})
|
||||
}
|
||||
const trimmed = title.trim()
|
||||
if (trimmed !== workspace.title) {
|
||||
if (workspaces.some(w => w.workspaceId !== workspaceId && w.title === trimmed)) {
|
||||
return err(request, {
|
||||
code: 'workspace-name-conflict',
|
||||
message: `workspace name '${trimmed}' is already in use`,
|
||||
details: { name: trimmed },
|
||||
})
|
||||
}
|
||||
workspace.title = trimmed
|
||||
workspace.updatedAt = new Date().toISOString()
|
||||
emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } })
|
||||
}
|
||||
return ok(request, { workspace: { ...workspace } })
|
||||
},
|
||||
insertSessionBefore: (request) => {
|
||||
const { workspaceId, sessionId, beforeSessionId } = request.payload
|
||||
const workspace = workspaces.find(w => w.workspaceId === workspaceId)
|
||||
if (workspace === undefined) {
|
||||
return err(request, {
|
||||
code: 'workspace-not-found',
|
||||
message: `no workspace ${workspaceId}`,
|
||||
details: { workspaceId },
|
||||
})
|
||||
}
|
||||
if (!workspace.sessionIds.includes(sessionId)
|
||||
|| (beforeSessionId !== undefined && !workspace.sessionIds.includes(beforeSessionId))) {
|
||||
return err(request, {
|
||||
code: 'workspace-move-invalid',
|
||||
message: `session or anchor is not accounted by workspace ${workspaceId}`,
|
||||
details: { workspaceId, sessionId, ...beforeSessionId === undefined ? {} : { beforeSessionId } },
|
||||
})
|
||||
}
|
||||
const without = workspace.sessionIds.filter(id => id !== sessionId)
|
||||
const at = beforeSessionId === undefined ? without.length : without.indexOf(beforeSessionId)
|
||||
const sessionIds = [...without.slice(0, at), sessionId, ...without.slice(at)]
|
||||
if (!sessionIds.every((id, index) => id === workspace.sessionIds[index])) {
|
||||
workspace.sessionIds = sessionIds
|
||||
workspace.updatedAt = new Date().toISOString()
|
||||
emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } })
|
||||
}
|
||||
return ok(request, { workspace: { ...workspace } })
|
||||
},
|
||||
},
|
||||
events: {
|
||||
async *mux(_request, signal) {
|
||||
@@ -757,6 +810,8 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'host.describe': return this.api.host.describe(request)
|
||||
case 'workspace.list': return this.api.workspace.list(request)
|
||||
case 'workspace.create': return this.api.workspace.create(request)
|
||||
case 'workspace.rename': return this.api.workspace.rename(request)
|
||||
case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,12 @@ export class FakeApiClient implements IApiClient {
|
||||
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
|
||||
created: true,
|
||||
}))),
|
||||
rename: (payload: unknown) => this.record('workspace.rename', payload, Promise.resolve(ok({
|
||||
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
|
||||
}))),
|
||||
insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({
|
||||
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
|
||||
}))),
|
||||
}
|
||||
|
||||
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
|
||||
|
||||
@@ -311,6 +311,60 @@ describe('createFixtureApi', () => {
|
||||
expect(rootPath.result.value.workspace.title).toBe('/')
|
||||
})
|
||||
|
||||
it('workspace.rename covers not-found, conflict, no-op, and the changed frame', async () => {
|
||||
const api = createFixtureApi()
|
||||
const abort = new AbortController()
|
||||
const seen: HostFrame[] = []
|
||||
const consuming = (async () => {
|
||||
for await (const envelope of api.events.host(req({}), abort.signal)) {
|
||||
seen.push(envelope.payload)
|
||||
if (seen.length >= 2) abort.abort()
|
||||
}
|
||||
})()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
const wsid = 'fx-ws-fixture' as WorkspaceId
|
||||
const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' }))
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } })
|
||||
|
||||
await api.workspace.create(req({ name: 'occupied' }))
|
||||
const conflict = await api.workspace.rename(req({ workspaceId: wsid, title: ' occupied ' }))
|
||||
expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace-name-conflict', details: { name: 'occupied' } } })
|
||||
|
||||
const noop = await api.workspace.rename(req({ workspaceId: wsid, title: ' fixture ' }))
|
||||
if (!noop.result.ok) throw new Error('no-op rename failed')
|
||||
expect(noop.result.value.workspace.title).toBe('fixture')
|
||||
|
||||
const renamed = await api.workspace.rename(req({ workspaceId: wsid, title: 'renamed' }))
|
||||
if (!renamed.result.ok) throw new Error('rename failed')
|
||||
expect(renamed.result.value.workspace.title).toBe('renamed')
|
||||
await consuming
|
||||
// Only the create and the effective rename emit frames; the no-op stays silent.
|
||||
expect(seen.map(f => f.type)).toEqual(['host/workspace-changed', 'host/workspace-changed'])
|
||||
})
|
||||
|
||||
it('workspace.insertSessionBefore moves, appends, no-ops, and rejects invalid ids', async () => {
|
||||
const api = createFixtureApi()
|
||||
const wsid = 'fx-ws-fixture' as WorkspaceId
|
||||
const missing = await api.workspace.insertSessionBefore(req({ workspaceId: 'fx-ws-void' as WorkspaceId, sessionId: sid('fx-alpha') }))
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } })
|
||||
const ghost = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-ghost') }))
|
||||
expect(ghost.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { sessionId: 'fx-ghost' } } })
|
||||
const badAnchor = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha'), beforeSessionId: sid('fx-ghost') }))
|
||||
expect(badAnchor.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { beforeSessionId: 'fx-ghost' } } })
|
||||
|
||||
const moved = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-gamma'), beforeSessionId: sid('fx-beta') }))
|
||||
if (!moved.result.ok) throw new Error('move failed')
|
||||
expect(moved.result.value.workspace.sessionIds).toEqual(['fx-alpha', 'fx-gamma', 'fx-beta'])
|
||||
const appended = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha') }))
|
||||
if (!appended.result.ok) throw new Error('append failed')
|
||||
expect(appended.result.value.workspace.sessionIds).toEqual(['fx-gamma', 'fx-beta', 'fx-alpha'])
|
||||
const before = appended.result.value.workspace.updatedAt
|
||||
const noop = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha') }))
|
||||
if (!noop.result.ok) throw new Error('no-op move failed')
|
||||
expect(noop.result.value.workspace.sessionIds).toEqual(['fx-gamma', 'fx-beta', 'fx-alpha'])
|
||||
expect(noop.result.value.workspace.updatedAt).toBe(before)
|
||||
})
|
||||
|
||||
it('session.create({workspaceId}) lands on the account and unknown ids error', async () => {
|
||||
const api = createFixtureApi()
|
||||
const abort = new AbortController()
|
||||
@@ -558,6 +612,15 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
const workspace = await client.workspace.create({ name: 'via-client' })
|
||||
if (!workspace.result.ok) throw new Error('workspace create failed')
|
||||
expect(workspace.result.value.workspace.title).toBe('via-client')
|
||||
const wsid = workspace.result.value.workspace.workspaceId
|
||||
const renamed = await client.workspace.rename({ workspaceId: wsid, title: 'via-client-2' })
|
||||
if (!renamed.result.ok) throw new Error('workspace rename failed')
|
||||
expect(renamed.result.value.workspace.title).toBe('via-client-2')
|
||||
const attached = await client.sessions.create({ workspaceId: wsid })
|
||||
if (!attached.result.ok) throw new Error('attached create failed')
|
||||
const moved = await client.workspace.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId })
|
||||
if (!moved.result.ok) throw new Error('workspace move failed')
|
||||
expect(moved.result.value.workspace.sessionIds).toEqual([attached.result.value.sessionId])
|
||||
})
|
||||
|
||||
it('maps empty, prompt-reject, and workspace-first query scenarios', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Workspace baseline, incremental-frame, and unary-action owner. */
|
||||
|
||||
import type {
|
||||
HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, WorkspaceView,
|
||||
HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
|
||||
@@ -143,6 +143,40 @@ export class WorkspaceManager {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a Workspace, then publish its returned snapshot without waiting
|
||||
* for the changed frame.
|
||||
* @param workspaceId - target workspace.
|
||||
* @param title - new display title.
|
||||
* @returns the wire result.
|
||||
*/
|
||||
async rename(workspaceId: WorkspaceId, title: string): Promise<RpcResult<{ workspace: WorkspaceView }>> {
|
||||
const { result } = await this.api.workspace.rename({ workspaceId, title })
|
||||
if (result.ok) this.upsert(result.value.workspace)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a session within its Workspace's manual order, then publish the
|
||||
* returned snapshot without waiting for the changed frame.
|
||||
* @param workspaceId - owning workspace.
|
||||
* @param sessionId - accounted session to move.
|
||||
* @param beforeSessionId - accounted anchor to insert before; omitted appends.
|
||||
* @returns the wire result.
|
||||
*/
|
||||
async insertSessionBefore(
|
||||
workspaceId: WorkspaceId,
|
||||
sessionId: SessionId,
|
||||
beforeSessionId?: SessionId,
|
||||
): Promise<RpcResult<{ workspace: WorkspaceView }>> {
|
||||
const { result } = await this.api.workspace.insertSessionBefore({
|
||||
workspaceId, sessionId,
|
||||
...beforeSessionId === undefined ? {} : { beforeSessionId },
|
||||
})
|
||||
if (result.ok) this.upsert(result.value.workspace)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-frame entry. Non-workspace frames are ignored so the runtime can
|
||||
* fan one host stream out to both object managers.
|
||||
@@ -189,6 +223,11 @@ export class WorkspaceManager {
|
||||
private upsert(view: WorkspaceView, identity?: Workspace): void {
|
||||
this.refreshFrames?.push(view)
|
||||
const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId)
|
||||
// Mutation responses and changed frames race (two carriers, no ordering):
|
||||
// reject a snapshot strictly older than the installed projection so a
|
||||
// late unary response cannot roll back a newer frame.
|
||||
const installed = index === -1 ? undefined : this.items[index]?.getSnapshot().view
|
||||
if (installed !== undefined && Date.parse(view.updatedAt) < Date.parse(installed.updatedAt)) return
|
||||
if (identity !== undefined) {
|
||||
this.items = index === -1
|
||||
? [identity, ...this.items]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
IApiClient, RpcError, WorkspaceId, WorkspaceView,
|
||||
IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
@@ -100,6 +100,35 @@ export class WorkspacesService {
|
||||
return result.value.workspace
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a Workspace.
|
||||
* @param workspaceId - target workspace.
|
||||
* @param title - new display title (trimmed non-empty by the Host).
|
||||
* @returns the renamed Workspace view.
|
||||
*/
|
||||
async rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView> {
|
||||
const result = await this.manager.rename(workspaceId, title)
|
||||
if (!result.ok) throw new Error(`workspace rename failed: ${result.error.code}: ${result.error.message}`)
|
||||
return result.value.workspace
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a session within its Workspace's manual order (DOM-insertBefore-like).
|
||||
* @param workspaceId - owning workspace.
|
||||
* @param sessionId - accounted session to move.
|
||||
* @param beforeSessionId - accounted anchor to insert before; omitted appends.
|
||||
* @returns the updated Workspace view.
|
||||
*/
|
||||
async insertSessionBefore(
|
||||
workspaceId: WorkspaceId,
|
||||
sessionId: SessionId,
|
||||
beforeSessionId?: SessionId,
|
||||
): Promise<WorkspaceView> {
|
||||
const result = await this.manager.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
|
||||
if (!result.ok) throw new Error(`workspace move failed: ${result.error.code}: ${result.error.message}`)
|
||||
return result.value.workspace
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the workspace baseline, reusing an in-flight pull.
|
||||
* @returns completion of the current or newly started workspace baseline pull.
|
||||
|
||||
@@ -92,9 +92,18 @@ export class FakeApiClient implements IApiClient {
|
||||
onWorkspaceCreate: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>> =
|
||||
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true }))
|
||||
|
||||
onWorkspaceRename: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
|
||||
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
|
||||
|
||||
onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
|
||||
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
|
||||
|
||||
readonly workspace: IApiClient['workspace'] = {
|
||||
list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)),
|
||||
create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
|
||||
rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),
|
||||
insertSessionBefore: (payload: unknown) =>
|
||||
this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)),
|
||||
}
|
||||
|
||||
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
|
||||
|
||||
22
packages/client/ui-primitives/src/HoverCard.module.css
Normal file
22
packages/client/ui-primitives/src/HoverCard.module.css
Normal file
@@ -0,0 +1,22 @@
|
||||
/* Block, not inline-flex: consumers wrap full-width list rows and an
|
||||
* inline wrapper would shrink them; the card still measures this rect. */
|
||||
.root {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Preview card (figma session hover card): 244 wide, r12, pad 12/16, the
|
||||
* menu card's elevation. Surface is #2C2C2E in both themes (figma value,
|
||||
* light/dark identical), so a component-level variable, not a theme token. */
|
||||
.card {
|
||||
--dsw-hovercard-bg: #2C2C2E;
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
box-sizing: border-box;
|
||||
width: 244px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-hovercard-bg);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
pointer-events: none;
|
||||
}
|
||||
112
packages/client/ui-primitives/src/HoverCard.tsx
Normal file
112
packages/client/ui-primitives/src/HoverCard.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
// HoverCard: delayed hover-preview card portaled to document.body.
|
||||
// Same portal mechanics as Menu: the wrapper span supplies the anchor rect,
|
||||
// the card is fixed-positioned at its right edge and repositions on
|
||||
// scroll/resize while open. Display-only — the card ignores pointer events
|
||||
// and closes the instant the pointer leaves the anchor (no close delay).
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import css from './HoverCard.module.css'
|
||||
|
||||
/**
|
||||
* Render an anchor with a hover-triggered preview card.
|
||||
* @param props.anchor - the hover target (rendered in place inside a wrapper span).
|
||||
* @param props.content - card content (display-only, no pointer interaction).
|
||||
* @param props.openDelayMs - hover dwell before the card shows (default 500).
|
||||
* @param props.disabled - suppress opening; turning true closes an open card.
|
||||
* @returns anchor wrapper with the conditional portaled card.
|
||||
*/
|
||||
export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false }: {
|
||||
anchor: ReactNode
|
||||
content: ReactNode
|
||||
openDelayMs?: number
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const rootRef = useRef<HTMLSpanElement>(null)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pos, setPos] = useState<{ left: number; top: number } | null>(null)
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
// Owner disabling mid-hover (menu opened, drag started) closes immediately.
|
||||
useEffect(() => {
|
||||
if (!disabled) return
|
||||
clearTimer()
|
||||
setOpen(false)
|
||||
}, [disabled])
|
||||
|
||||
useEffect(() => clearTimer, [])
|
||||
|
||||
// Fixed-position from the anchor rect before paint; track the anchor while
|
||||
// open (capture-phase scroll catches nested panes), as in Menu portal mode.
|
||||
useLayoutEffect(() => {
|
||||
if (!open) { setPos(null); return }
|
||||
const place = () => {
|
||||
const wrapper = rootRef.current
|
||||
/* v8 ignore next -- the ref is attached before the layout effect runs and the listeners die with it. */
|
||||
if (wrapper === null) return
|
||||
const r = wrapper.getBoundingClientRect()
|
||||
const h = cardRef.current?.offsetHeight ?? 0
|
||||
const top = r.top + h > window.innerHeight - 8 ? window.innerHeight - h - 8 : r.top
|
||||
setPos({ left: r.right + 8, top })
|
||||
}
|
||||
place()
|
||||
window.addEventListener('scroll', place, true)
|
||||
window.addEventListener('resize', place)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', place, true)
|
||||
window.removeEventListener('resize', place)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// The first placement ran before the card mounted (height read 0): once the
|
||||
// card's real height is measurable, correct the bottom-edge clamp. The
|
||||
// correction converges — a clamped top satisfies the guard, so it runs once.
|
||||
useLayoutEffect(() => {
|
||||
if (!open || pos === null) return
|
||||
/* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */
|
||||
const h = cardRef.current?.offsetHeight ?? 0
|
||||
if (pos.top + h > window.innerHeight - 8) {
|
||||
setPos({ left: pos.left, top: window.innerHeight - h - 8 })
|
||||
}
|
||||
}, [open, pos])
|
||||
|
||||
const card = open && pos !== null && (
|
||||
<div ref={cardRef} className={css.card} style={pos}>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={rootRef}
|
||||
className={css.root}
|
||||
onPointerEnter={() => {
|
||||
if (disabled) return
|
||||
clearTimer()
|
||||
timerRef.current = setTimeout(() => { setOpen(true) }, openDelayMs)
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
clearTimer()
|
||||
setOpen(false)
|
||||
}}
|
||||
// Any press inside the anchor (row click, menu trigger) dismisses the
|
||||
// card immediately, without waiting for the owner to flip `disabled`.
|
||||
onPointerDownCapture={() => {
|
||||
clearTimer()
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{anchor}
|
||||
{card !== false && createPortal(card, document.body)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -109,6 +109,27 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Destructive row: error text/icon, danger hover fill. */
|
||||
.danger {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.danger .itemIcon {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.danger:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
}
|
||||
|
||||
/* Heading row: non-interactive small grey text, padding aligned with items. */
|
||||
.label {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. */
|
||||
.separator {
|
||||
height: 1px;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// the anchor rect, for anchors inside overflow-clipping containers (sidebar).
|
||||
// The owner controls `open`; outside-click closing uses one document listener
|
||||
// active only while open. Submenus open on hover/focus inside the same root.
|
||||
// Entries also cover non-interactive `label` headings and `danger` rows.
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
@@ -19,6 +20,8 @@ export interface MenuItem {
|
||||
disabled?: boolean
|
||||
/** Leading icon (figma .Menu_cell gap 8). */
|
||||
icon?: ReactNode
|
||||
/** Destructive row: error-colored text/icon and danger hover fill. */
|
||||
danger?: boolean
|
||||
/** Nested card opened to the right on hover/focus. */
|
||||
submenu?: readonly MenuItem[]
|
||||
}
|
||||
@@ -29,13 +32,24 @@ export interface MenuSeparator {
|
||||
id: string
|
||||
}
|
||||
|
||||
/** One primary-menu entry: a row or a separator. */
|
||||
export type MenuEntry = MenuItem | MenuSeparator
|
||||
/** Non-interactive heading row above a group of items. */
|
||||
export interface MenuLabel {
|
||||
type: 'label'
|
||||
id: string
|
||||
text: string
|
||||
}
|
||||
|
||||
/** One primary-menu entry: a row, a separator, or a heading label. */
|
||||
export type MenuEntry = MenuItem | MenuSeparator | MenuLabel
|
||||
|
||||
function isSeparator(entry: MenuEntry): entry is MenuSeparator {
|
||||
return 'type' in entry && entry.type === 'separator'
|
||||
}
|
||||
|
||||
function isLabel(entry: MenuEntry): entry is MenuLabel {
|
||||
return 'type' in entry && entry.type === 'label'
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an anchored dropdown menu.
|
||||
* @param props.open - whether the list is showing (owner-controlled).
|
||||
@@ -50,6 +64,8 @@ function isSeparator(entry: MenuEntry): entry is MenuSeparator {
|
||||
* from the anchor rect (repositions on scroll/resize while open). Use when an
|
||||
* ancestor's overflow clipping would crop the in-place list; default false
|
||||
* keeps the pure-CSS in-place behavior.
|
||||
* @param props.closeOnPointerLeave - close the list when the pointer leaves
|
||||
* it (default false keeps it open until outside click/Escape/selection).
|
||||
* @param props.getAnchorRect - portal mode only: supply the anchor rect
|
||||
* directly (e.g. from a host-owned trigger button) instead of measuring the
|
||||
* Menu's own wrapper span. Required when the wrapper isn't itself laid out at
|
||||
@@ -58,7 +74,7 @@ function isSeparator(entry: MenuEntry): entry is MenuSeparator {
|
||||
* scroll/resize; return null to skip placement for that frame.
|
||||
* @returns anchor wrapper with the conditional list.
|
||||
*/
|
||||
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, getAnchorRect, className }: {
|
||||
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, className }: {
|
||||
open: boolean
|
||||
anchor: ReactNode
|
||||
items: readonly MenuEntry[]
|
||||
@@ -68,6 +84,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
align?: 'start' | 'end'
|
||||
side?: 'bottom' | 'top'
|
||||
portal?: boolean
|
||||
closeOnPointerLeave?: boolean
|
||||
getAnchorRect?: () => DOMRect | null
|
||||
className?: string
|
||||
}) {
|
||||
@@ -135,11 +152,19 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
className={clsx(css.list, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
|
||||
style={fixedPos ?? undefined}
|
||||
role="menu"
|
||||
onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined}
|
||||
// React portals bubble synthetic events through the REACT tree: without
|
||||
// this stop, an item click re-fires the anchor row's own onClick
|
||||
// (open/toggle) after onSelect.
|
||||
onClick={(e) => { e.stopPropagation() }}
|
||||
>
|
||||
{items.map(entry => {
|
||||
if (isSeparator(entry)) {
|
||||
return <div key={entry.id} className={css.separator} role="separator" />
|
||||
}
|
||||
if (isLabel(entry)) {
|
||||
return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div>
|
||||
}
|
||||
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
|
||||
const subOpen = hasSub && openSubmenuId === entry.id
|
||||
return (
|
||||
@@ -152,7 +177,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={clsx(css.item, entry.id === selectedId && css.selected)}
|
||||
className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)}
|
||||
disabled={entry.disabled}
|
||||
aria-haspopup={hasSub ? 'menu' : undefined}
|
||||
aria-expanded={hasSub ? subOpen : undefined}
|
||||
|
||||
@@ -9,7 +9,8 @@ export type { ButtonVariant } from './Button.tsx'
|
||||
export { Pill } from './Pill.tsx'
|
||||
export { Input } from './Input.tsx'
|
||||
export { Menu } from './Menu.tsx'
|
||||
export type { MenuEntry, MenuItem, MenuSeparator } from './Menu.tsx'
|
||||
export type { MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx'
|
||||
export { HoverCard } from './HoverCard.tsx'
|
||||
export { Modal } from './Modal.tsx'
|
||||
export { ConnectionBanner } from './ConnectionBanner.tsx'
|
||||
export { FishLogo } from './FishLogo.tsx'
|
||||
|
||||
@@ -136,6 +136,51 @@ describe('Menu', () => {
|
||||
expect(screen.getByRole('separator')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders a non-interactive heading label and a danger row', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(
|
||||
<Menu
|
||||
open
|
||||
anchor={<span>trigger</span>}
|
||||
items={[
|
||||
{ type: 'label', id: 'h', text: 'Group by' },
|
||||
{ id: 'del', label: 'Delete', danger: true },
|
||||
]}
|
||||
onSelect={onSelect}
|
||||
onClose={() => {}}
|
||||
/>)
|
||||
const heading = screen.getByText('Group by')
|
||||
expect(heading.getAttribute('role')).toBe('presentation')
|
||||
// The heading is not a menu item — only the danger row is interactive.
|
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(1)
|
||||
const danger = screen.getByRole('menuitem', { name: 'Delete' })
|
||||
expect(danger.className).toMatch(/danger/)
|
||||
fireEvent.click(danger)
|
||||
expect(onSelect).toHaveBeenCalledWith('del')
|
||||
})
|
||||
|
||||
it('closeOnPointerLeave closes when the pointer leaves the list; default stays open', () => {
|
||||
const onClose = vi.fn()
|
||||
const { rerender } = render(
|
||||
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
fireEvent.pointerLeave(screen.getByRole('menu'))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
rerender(
|
||||
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
fireEvent.pointerLeave(screen.getByRole('menu'))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a list click does not bubble to the anchor row (portal synthetic-event path)', () => {
|
||||
const rowClick = vi.fn()
|
||||
render(
|
||||
<div onClick={rowClick}>
|
||||
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={() => {}} />
|
||||
</div>)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' }))
|
||||
expect(rowClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens a submenu on hover and selects a nested item', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(
|
||||
|
||||
148
packages/client/ui-primitives/tests/hover-card.spec.tsx
Normal file
148
packages/client/ui-primitives/tests/hover-card.spec.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { HoverCard } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => { vi.useFakeTimers() })
|
||||
afterEach(() => { vi.useRealTimers() })
|
||||
|
||||
/** Anchor wrapper rect: the card positions from this (jsdom rects are all-zero by default). */
|
||||
function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number }): void {
|
||||
const wrapper = anchor.parentElement as HTMLElement
|
||||
wrapper.getBoundingClientRect = () => ({
|
||||
top: rect.top, right: rect.right, left: rect.right - 100, bottom: rect.top + 34,
|
||||
width: 100, height: 34, x: rect.right - 100, y: rect.top, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
}
|
||||
|
||||
function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) {
|
||||
const view = render(
|
||||
<HoverCard anchor={<span>row</span>} content={<div>card body</div>} {...props} />,
|
||||
)
|
||||
const anchor = screen.getByText('row')
|
||||
stubAnchorRect(anchor, { top: 40, right: 200 })
|
||||
return { view, anchor, wrapper: anchor.parentElement as HTMLElement }
|
||||
}
|
||||
|
||||
describe('HoverCard', () => {
|
||||
it('opens after the dwell delay, positioned right of the anchor', () => {
|
||||
const { wrapper } = mount()
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
expect(screen.queryByText('card body')).toBeNull()
|
||||
act(() => { vi.advanceTimersByTime(499) })
|
||||
expect(screen.queryByText('card body')).toBeNull()
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
const card = screen.getByText('card body').parentElement as HTMLElement
|
||||
expect(card.parentElement).toBe(document.body)
|
||||
expect(card.style.left).toBe('208px')
|
||||
expect(card.style.top).toBe('40px')
|
||||
})
|
||||
|
||||
it('honors a custom openDelayMs', () => {
|
||||
const { wrapper } = mount({ openDelayMs: 50 })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(50) })
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('pointerleave before the delay cancels the pending open', () => {
|
||||
const { wrapper } = mount()
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(1000) })
|
||||
expect(screen.queryByText('card body')).toBeNull()
|
||||
})
|
||||
|
||||
it('pointerleave closes an open card immediately; re-enter restarts the dwell', () => {
|
||||
const { wrapper } = mount()
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
expect(screen.queryByText('card body')).toBeNull()
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a press inside the anchor dismisses the card without waiting for disabled', () => {
|
||||
const { wrapper } = mount()
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
fireEvent.pointerDown(screen.getByText('row'))
|
||||
expect(screen.queryByText('card body')).toBeNull()
|
||||
// The pending timer is also cleared: no reopen after the dwell.
|
||||
act(() => { vi.advanceTimersByTime(1000) })
|
||||
expect(screen.queryByText('card body')).toBeNull()
|
||||
})
|
||||
|
||||
it('disabled suppresses opening entirely', () => {
|
||||
const { wrapper } = mount({ disabled: true })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(1000) })
|
||||
expect(screen.queryByText('card body')).toBeNull()
|
||||
})
|
||||
|
||||
it('flipping disabled true closes an open card', () => {
|
||||
const { view, wrapper } = mount()
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
view.rerender(<HoverCard anchor={<span>row</span>} content={<div>card body</div>} disabled />)
|
||||
expect(screen.queryByText('card body')).toBeNull()
|
||||
})
|
||||
|
||||
it('corrects the bottom-edge clamp once the mounted card height is measurable', () => {
|
||||
// First placement reads height 0 (card not yet mounted) and keeps the
|
||||
// anchor top; the post-mount correction re-clamps with the real height.
|
||||
window.innerHeight = 300
|
||||
const offsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')!
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 120 })
|
||||
try {
|
||||
const { wrapper } = mount()
|
||||
stubAnchorRect(screen.getByText('row'), { top: 280, right: 200 })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
const card = screen.getByText('card body').parentElement as HTMLElement
|
||||
// 300 - 120 - 8 = 172, instead of the anchor top 280.
|
||||
expect(card.style.top).toBe('172px')
|
||||
} finally {
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', offsetHeight)
|
||||
}
|
||||
})
|
||||
|
||||
it('clamps inside placement itself when the card is already measured (resize path)', () => {
|
||||
window.innerHeight = 300
|
||||
const { wrapper } = mount()
|
||||
stubAnchorRect(screen.getByText('row'), { top: 280, right: 200 })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
const card = screen.getByText('card body').parentElement as HTMLElement
|
||||
Object.defineProperty(card, 'offsetHeight', { value: 120 })
|
||||
act(() => { fireEvent.resize(window) })
|
||||
expect(card.style.top).toBe('172px')
|
||||
})
|
||||
|
||||
it('repositions on capture-phase scroll while open and stops listening after close', () => {
|
||||
const { wrapper } = mount()
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
stubAnchorRect(screen.getByText('row'), { top: 90, right: 300 })
|
||||
act(() => { fireEvent.scroll(document) })
|
||||
const card = screen.getByText('card body').parentElement as HTMLElement
|
||||
expect(card.style.left).toBe('308px')
|
||||
expect(card.style.top).toBe('90px')
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
expect(screen.queryByText('card body')).toBeNull()
|
||||
})
|
||||
|
||||
it('unmount clears a pending open timer', () => {
|
||||
const { view, wrapper } = mount()
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
view.unmount()
|
||||
act(() => { vi.advanceTimersByTime(1000) })
|
||||
expect(screen.queryByText('card body')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Sidebar 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.
|
||||
*/
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
|
||||
IconTriangleRightFill14, StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { GroupNode, SessionNode } from './tree.ts'
|
||||
import { formatRelativeTime } from './tree.ts'
|
||||
import css from './Rows.module.css'
|
||||
|
||||
/** Indent step per tree level: one 16px slot (figma session cell). */
|
||||
const INDENT_STEP = 16
|
||||
|
||||
/**
|
||||
* Project (workspace) header row: 54px, folder + title + session count;
|
||||
* hover reveals the chevron and create button. `containsCurrent` arrives on
|
||||
* the node (derivation fact, no renderer scan).
|
||||
* @param props.group - derived group node.
|
||||
* @param props.onToggle - expand/collapse the group.
|
||||
* @param props.onCreate - start a frontend Session inside this Workspace.
|
||||
* @returns the row element.
|
||||
*/
|
||||
export function ProjectRowItem({ group, onToggle, onCreate }: {
|
||||
group: GroupNode
|
||||
onToggle: () => void
|
||||
onCreate: () => void
|
||||
}) {
|
||||
const row = group
|
||||
const active = group.expanded && group.containsCurrent
|
||||
const count = `${row.sessionCount} ${row.sessionCount === 1 ? 'session' : 'sessions'}`
|
||||
return (
|
||||
<div className={css.projectRow} role="treeitem" aria-expanded={row.expanded} onClick={onToggle}>
|
||||
<span className={clsx(css.slot, css.folder, active && css.folderActive)}>
|
||||
{row.expanded ? <IconFolderOpen16 /> : <IconFolderClose16 />}
|
||||
</span>
|
||||
<span className={clsx(css.slot, css.chevron)}>
|
||||
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
|
||||
</span>
|
||||
<span className={css.projectText}>
|
||||
<span className={css.title}>{row.label}</span>
|
||||
<span className={css.meta}>{count}</span>
|
||||
</span>
|
||||
<span className={css.rowActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={`New session in ${row.label}`}
|
||||
onClick={(e) => { e.stopPropagation(); onCreate() }}
|
||||
>
|
||||
<IconPlusOutline16 />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected "New session" row for a frontend Session Intent targeted to a
|
||||
* real Workspace. The row disappears when the Intent is replaced or connects.
|
||||
* @returns the placeholder row element.
|
||||
*/
|
||||
export function IntentRowItem() {
|
||||
return (
|
||||
<div className={clsx(css.sessionRow, css.selected)} role="treeitem" aria-selected style={{ paddingLeft: 8 }}>
|
||||
<span className={css.slot} />
|
||||
<span className={css.slot} />
|
||||
<span className={css.title}>New session</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One session subtree: the node's own 34px row (indent by depth, expand
|
||||
* twist when it has children, running dot, relative time) plus its visible
|
||||
* children, recursively — the component tree mirrors the derived tree.
|
||||
* @param props.node - derived session node.
|
||||
* @param props.depth - 0 = directly under the group header.
|
||||
* @param props.currentId - selected session id (row highlight).
|
||||
* @param props.now - epoch ms for relative-time formatting.
|
||||
* @param props.onOpen - open a session by id.
|
||||
* @param props.onToggle - unfold/fold a subtree by id.
|
||||
* @returns the node's row followed by its children.
|
||||
*/
|
||||
export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle }: {
|
||||
node: SessionNode
|
||||
depth: number
|
||||
currentId: string | undefined
|
||||
now: number
|
||||
onOpen: (id: SessionNode['id']) => void
|
||||
onToggle: (id: SessionNode['id']) => void
|
||||
}) {
|
||||
const row = node
|
||||
const selected = node.id === currentId
|
||||
// Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to
|
||||
// the title): both slots are always reserved so titles align whether or not
|
||||
// the twist/dot is lit. Extra depth rides the left padding.
|
||||
const ownRow = (
|
||||
<div
|
||||
className={clsx(css.sessionRow, selected && css.selected)}
|
||||
role="treeitem"
|
||||
aria-selected={selected}
|
||||
{...(row.hasChildren ? { 'aria-expanded': row.expanded } : {})}
|
||||
style={{ paddingLeft: 8 + depth * INDENT_STEP }}
|
||||
onClick={() => { onOpen(node.id) }}
|
||||
>
|
||||
{row.hasChildren
|
||||
? (
|
||||
<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>
|
||||
)
|
||||
: <span className={css.slot} />}
|
||||
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
|
||||
<span className={css.title}>{row.title}</span>
|
||||
<span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
{ownRow}
|
||||
{node.children.map(child => (
|
||||
<SessionNodeItem
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
currentId={currentId}
|
||||
now={now}
|
||||
onOpen={onOpen}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -48,7 +48,6 @@
|
||||
refresh straight into the collapsed state renders statically. */
|
||||
.railIn .iconButton,
|
||||
.railIn .newSession,
|
||||
.railIn .searchButton,
|
||||
.railIn .foot {
|
||||
animation: rail-in 150ms var(--ds-ease-in-out) 100ms backwards;
|
||||
}
|
||||
@@ -184,133 +183,9 @@
|
||||
max-width: 0;
|
||||
}
|
||||
|
||||
/* Section header: 36px, "WorkSpace" label + group-by / new-workspace buttons;
|
||||
the right-anchored new-workspace button is the row's rail survivor. */
|
||||
.sectionHeader {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
height: 36px;
|
||||
padding-left: 12px;
|
||||
margin-bottom: 4px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.collapsed .sectionHeader {
|
||||
height: 36px;
|
||||
padding-left: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
/* Search input: 38px capsule (figma 133:7649); collapsed it renders as the
|
||||
rail's search control. Upstream binds a dedicated design-system variable (light
|
||||
#F1F3F5 / dark #1B1B1C) matching no shipped alias — a component token
|
||||
pinned to the static scale mirrors it (ruled compliant: indirect via
|
||||
custom property, upstream-variable equivalent). */
|
||||
.search {
|
||||
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-75);
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
margin: 0 2px 12px; /* bottom: former listArea gap 4 + own 8 (spec padB12 to the first cell) */
|
||||
padding: 0 14px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 24px;
|
||||
background: var(--dsh-search-input-fill);
|
||||
color: var(--dsw-alias-label-caption);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(body[data-ds-dark-theme]) .search {
|
||||
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-900);
|
||||
}
|
||||
|
||||
.collapsed .search {
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
margin: 0 0 12px;
|
||||
gap: 0;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* The capsule's leading icon, upgraded to the rail's search control. While
|
||||
expanded it is decorative: pointer-events off so clicks reach the label
|
||||
(native input focus); collapsed it becomes the hit target. */
|
||||
.searchButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.collapsed .searchButton {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.collapsed .searchButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Tree seat: always mounted so the foot never moves; the tree content inside
|
||||
is wide-only and clips while the column squeezes. */
|
||||
.listArea {
|
||||
/* Region seat: always mounted so the foot never moves; the browser inside
|
||||
handles its own wide/rail content. */
|
||||
.regionArea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
@@ -318,60 +193,6 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Relative for the bottom fade overlay. */
|
||||
.treeBody {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom,
|
||||
transparent -> sidebar fill so it tracks the theme. */
|
||||
.fade {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 72px;
|
||||
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Tree list: the only scrolling region. Block, not a flex column: as flex
|
||||
items the 54/34 rows would shrink under content overflow (scrollHeight
|
||||
collapses onto clientHeight and wheel scrolling dies); block children keep
|
||||
their design heights and the 4px rhythm rides margins instead of gap. */
|
||||
.list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
/* One workspace section: header row + expanded session run. Rows inside
|
||||
keep the former flat-list 4px gap as sibling margins; the inter-group
|
||||
breathing room (figma 133:7661 batch separator, 20px after an expanded
|
||||
run) rides the NEXT section's top margin so the last group adds none. */
|
||||
.groupSection > * + * {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.groupSection + .groupSection {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.groupSection:has([aria-expanded='true']) + .groupSection {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px 12px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Foot: settings entry (figma 133:7668, 49 hug): the former 18/10 vertical
|
||||
margins fold into the row so the hover pill spans the full 49px. */
|
||||
.foot {
|
||||
@@ -418,7 +239,6 @@
|
||||
.fading > *,
|
||||
.railIn .iconButton,
|
||||
.railIn .newSession,
|
||||
.railIn .searchButton,
|
||||
.railIn .foot {
|
||||
transition: none;
|
||||
animation: none;
|
||||
|
||||
@@ -1,170 +1,38 @@
|
||||
/**
|
||||
* Collapse is a slide plus crossfade: content freezes at its expanded
|
||||
* width (inline style) and fades out in place while the sliding column
|
||||
* (AppFrame grid tracks) clips it — nothing reflows mid-slide. At settle
|
||||
* the wide-only content (brand, labels, input, tree) unmounts, dropping
|
||||
* the sessions subscription, and the control rows snap to the 56px rail
|
||||
* (one icon each, same top-down order) fading in as the slide ends. Rail
|
||||
* search expands and focuses the search box.
|
||||
* Sidebar shell: column geometry only. Collapse is a slide plus crossfade:
|
||||
* content freezes at its expanded width (inline style) and fades out in place
|
||||
* while the sliding column (AppFrame grid tracks) clips it — nothing reflows
|
||||
* mid-slide. At settle the wide-only content unmounts and the control rows
|
||||
* snap to the 56px rail (one icon each, same top-down order) fading in as the
|
||||
* slide ends. The workspace/session browsing region between the New Session
|
||||
* button and the foot is the `sidebar.workspaces` registrant's; the shell
|
||||
* hands it the wide flag and an expand request callback.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
BrandWordmark, FishLogo,
|
||||
IconCloseFill14, IconNewChatOutline16, IconPanelLeftOutline16, IconPersonalizationOutline16,
|
||||
IconProjectAddOutline16, IconSearchOutline16, IconSettingsOutline14,
|
||||
Menu, Tooltip,
|
||||
IconNewChatOutline16, IconPanelLeftOutline16, IconSettingsOutline14,
|
||||
Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SidebarRootComponentProps } from './contract/slots.ts'
|
||||
import { deriveGroups, UNGROUPED_KEY } from './tree.ts'
|
||||
import { IntentRowItem, ProjectRowItem, SessionNodeItem } from './Rows.tsx'
|
||||
import css from './SidebarRoot.module.css'
|
||||
|
||||
/** Wide-content unmount delay; matches the 150ms wide-content fade-out. */
|
||||
const COLLAPSE_SETTLE_MS = 150
|
||||
|
||||
/** 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
|
||||
|
||||
const GROUP_BY_ITEMS = [
|
||||
{ id: 'workspace', label: 'Workspace' },
|
||||
// Only workspace grouping is implemented.
|
||||
{ id: 'update', label: 'Update', disabled: true },
|
||||
{ id: 'status', label: 'Status', disabled: true },
|
||||
]
|
||||
|
||||
/** 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]
|
||||
}
|
||||
|
||||
/** Group-by strategy menu; own open state so it resets with the wide chrome. */
|
||||
function GroupByMenu() {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={() => { setOpen(false) }}
|
||||
items={GROUP_BY_ITEMS}
|
||||
selectedId="workspace"
|
||||
onSelect={() => { setOpen(false) }}
|
||||
align="end"
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.iconButton, css.wide)}
|
||||
aria-label="Group by"
|
||||
onClick={() => { setOpen((v) => !v) }}
|
||||
>
|
||||
<IconPersonalizationOutline16 />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type SessionTreeProps = Pick<
|
||||
SidebarRootComponentProps,
|
||||
'useSessions' | 'startSession' | 'open'
|
||||
> & {
|
||||
workspaces: readonly WorkspaceView[]
|
||||
/** Live search filter owned by the root (the query outlives the tree). */
|
||||
query: string
|
||||
}
|
||||
|
||||
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
||||
function SessionTree({ useSessions, startSession, open, workspaces, query }: SessionTreeProps) {
|
||||
const list = useSessions((s) => s)
|
||||
const current = list.current
|
||||
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
|
||||
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
|
||||
// Re-expand when publication moves the selected intent into a real Workspace.
|
||||
const intent = list.intent
|
||||
const intentWorkspaceId = intent?.target.kind === 'workspace'
|
||||
? intent.target.workspaceId
|
||||
: undefined
|
||||
const currentGroup = current === undefined
|
||||
? undefined
|
||||
: intent?.sessionId === current
|
||||
? intentWorkspaceId
|
||||
: (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined)
|
||||
?? UNGROUPED_KEY
|
||||
useEffect(() => {
|
||||
if (current === undefined || currentGroup === undefined) return
|
||||
setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
||||
}, [current, currentGroup])
|
||||
const groups = useMemo(
|
||||
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
|
||||
[list, workspaces, expandedProjects, expandedSessions, query],
|
||||
)
|
||||
const now = Date.now()
|
||||
|
||||
return (
|
||||
<div className={clsx(css.treeBody, css.wide)}>
|
||||
<div className={css.list} role="tree" aria-label="Sessions">
|
||||
{groups.length === 0 && (
|
||||
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
|
||||
)}
|
||||
{groups.map(group => (
|
||||
// Group section: header row + expanded session subtree. The
|
||||
// inter-group breathing room (former flat-list batch separator)
|
||||
// is the section's own margin (SidebarRoot.module.css).
|
||||
<div key={group.key} className={css.groupSection}>
|
||||
<ProjectRowItem
|
||||
group={group}
|
||||
onToggle={() => { setExpandedProjects((l) => toggled(l, group.key)) }}
|
||||
onCreate={() => {
|
||||
if (group.workspaceId !== undefined) startSession(group.workspaceId)
|
||||
}}
|
||||
/>
|
||||
{group.intentHere && <IntentRowItem />}
|
||||
{group.sessions.map(node => (
|
||||
<SessionNodeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
currentId={current}
|
||||
now={now}
|
||||
onOpen={open}
|
||||
onToggle={(id) => { setExpandedSessions((l) => toggled(l, id)) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className={css.fade} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the sidebar column.
|
||||
* Render the sidebar column shell.
|
||||
* @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
|
||||
* @returns the sidebar element tree.
|
||||
*/
|
||||
export function SidebarRoot({
|
||||
collapsed,
|
||||
width,
|
||||
useSessions,
|
||||
useWorkspaces,
|
||||
startSession,
|
||||
open,
|
||||
toggleSidebar,
|
||||
renderSlot,
|
||||
}: SidebarRootComponentProps) {
|
||||
const workspaces = useWorkspaces(state => state.items)
|
||||
// The query outlives the tree and the input (both wide-only) so collapsing
|
||||
// does not silently drop an in-progress filter.
|
||||
const [query, setQuery] = useState('')
|
||||
const searchInput = useRef<HTMLInputElement | null>(null)
|
||||
// Section-header + opens the workspace picker (same popover in wide and
|
||||
// rail states; the hole sits beside the button and opens rightward).
|
||||
const [wsPickerOpen, setWsPickerOpen] = useState(false)
|
||||
// Placement anchor for the picker popover: the slot span renders elsewhere
|
||||
// in the DOM, so the picker positions off this button's rect.
|
||||
const wsPlusRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// Wide content stays mounted while the collapse animates (fading via
|
||||
// .collapsed .wide), unmounts at settle, and remounts right away on expand.
|
||||
const [settled, setSettled] = useState(collapsed)
|
||||
@@ -186,19 +54,6 @@ export function SidebarRoot({
|
||||
const everWide = useRef(!collapsed)
|
||||
if (!collapsed) everWide.current = true
|
||||
|
||||
// Rail search = expand + land in the search box: the flag arms before the
|
||||
// expand toggle; once expanded the input is mounted and takes focus.
|
||||
const [searchOnExpand, setSearchOnExpand] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!collapsed && searchOnExpand) {
|
||||
const timer = window.setTimeout(() => {
|
||||
searchInput.current?.focus({ preventScroll: true })
|
||||
setSearchOnExpand(false)
|
||||
}, EXPAND_SLIDE_MS)
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}
|
||||
}, [collapsed, searchOnExpand])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(css.root, !wide && css.collapsed, !wide && everWide.current && css.railIn, collapsed && wide && css.fading)}
|
||||
@@ -238,82 +93,15 @@ export function SidebarRoot({
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<div className={css.sectionHeader}>
|
||||
{wide && <span className={clsx(css.sectionLabel, css.wide)}>Workspaces</span>}
|
||||
{wide && <GroupByMenu />}
|
||||
<Tooltip label="New Workspace" disabled={wide}>
|
||||
<button
|
||||
ref={wsPlusRef}
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label="Create workspace"
|
||||
onClick={() => { setWsPickerOpen(v => !v) }}
|
||||
>
|
||||
<IconProjectAddOutline16 size={wide ? 16 : 18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/* Picker hole beside the + (same site in wide and rail states). */}
|
||||
{renderSlot('sidebar.workspace', {
|
||||
open: wsPickerOpen,
|
||||
anchorRef: wsPlusRef,
|
||||
onPick: (workspaceId) => {
|
||||
setWsPickerOpen(false)
|
||||
startSession(workspaceId)
|
||||
},
|
||||
onClose: () => { setWsPickerOpen(false) },
|
||||
{/* The browsing region fills the column between the controls and the
|
||||
foot in both states; its rail icon column rides the same slot. */}
|
||||
<div className={css.regionArea}>
|
||||
{renderSlot('sidebar.workspaces', {
|
||||
wide,
|
||||
expandSidebar: () => { if (collapsed) toggleSidebar() },
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Expanded: the row is a click-to-focus field (the leading icon is
|
||||
decorative). Collapsed: the icon is the rail's search control. */}
|
||||
<div className={css.search} onClick={() => { if (!collapsed) searchInput.current?.focus() }}>
|
||||
<Tooltip label="Search" disabled={wide}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.searchButton}
|
||||
aria-label="Search sessions"
|
||||
tabIndex={collapsed ? 0 : -1}
|
||||
onClick={() => { if (collapsed) { setSearchOnExpand(true); toggleSidebar() } }}
|
||||
>
|
||||
<IconSearchOutline16 size={wide ? 14 : 18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{wide && (
|
||||
<input
|
||||
ref={searchInput}
|
||||
className={clsx(css.searchInput, css.wide)}
|
||||
type="text"
|
||||
placeholder="Search name, keywords..."
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value) }}
|
||||
/>
|
||||
)}
|
||||
{wide && query !== '' && (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.clearButton, css.wide)}
|
||||
aria-label="Clear search"
|
||||
onClick={() => { setQuery('') }}
|
||||
>
|
||||
<IconCloseFill14 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Always-mounted seat: its flex slot pins the foot to the bottom in
|
||||
both states while the tree itself is wide-only. */}
|
||||
<div className={css.listArea}>
|
||||
{wide && (
|
||||
<SessionTree
|
||||
useSessions={useSessions}
|
||||
workspaces={workspaces}
|
||||
startSession={startSession}
|
||||
open={open}
|
||||
query={query}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
|
||||
<IconSettingsOutline14 size={wide ? 14 : 18} />
|
||||
{wide && <span className={clsx(css.footLabel, css.wide)}>Settings</span>}
|
||||
|
||||
@@ -1,69 +1,54 @@
|
||||
/**
|
||||
* Sidebar slot contract: the registrant-side props composition for the
|
||||
* layout-owned `sidebar` slot and the Workspace picker hole declared here.
|
||||
* The runtime share combines layout-owned page state and actions with the
|
||||
* global useSessions and useWorkspaces hooks; the injected share adds the
|
||||
* runtime navigation actions and sidebar toggle.
|
||||
* layout-owned `sidebar` slot, plus the workspace-browser hole this shell
|
||||
* declares. The shell owns column geometry (fold state machine, brand row,
|
||||
* New Session, Settings); everything between the section header and the list
|
||||
* bottom is the `sidebar.workspaces` registrant's (ui-workspace).
|
||||
*/
|
||||
import type { RefObject } from 'react'
|
||||
import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every
|
||||
// program that sees this contract, so PropsRuntime<'sidebar'> resolves.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/**
|
||||
* The workspace picker hole in the sidebar section header (anchored at
|
||||
* the + button). Declared by this package's 'sidebar' entry (declaring
|
||||
* is claiming); ui-workspace registers the picker.
|
||||
* The workspace/session browsing region: section header, search, the
|
||||
* grouped/flat session list, and every workspace dialog. Declared by this
|
||||
* package's 'sidebar' entry (declaring is claiming); ui-workspace
|
||||
* registers the browser.
|
||||
*/
|
||||
'sidebar.workspace': { kind: 'single'; scope: 'root'; owner: SidebarWorkspaceOwnerProps }
|
||||
'sidebar.workspaces': { kind: 'single'; scope: 'root'; owner: SidebarSectionOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner share of the sidebar workspace hole: popover geometry plus the
|
||||
* sidebar's pick semantics. The picked Host Workspace is already real; the
|
||||
* callback starts a frontend Session Intent targeted to it.
|
||||
* Owner share of the browser hole — the only facts crossing the shell/region
|
||||
* seam. Business data and actions arrive through the region's own inject.
|
||||
*/
|
||||
export interface SidebarWorkspaceOwnerProps {
|
||||
/** Popover visibility (+ button toggle state, host-local). */
|
||||
open: boolean
|
||||
/**
|
||||
* The + button element — the popover's placement anchor. The picker's
|
||||
* slot span renders elsewhere in the DOM, so without this the menu
|
||||
* positions off the zero-size placement span (order-dependent). Optional
|
||||
* only until the host passes it; absent falls back to in-place placement.
|
||||
*/
|
||||
anchorRef?: RefObject<HTMLElement>
|
||||
/** Start a frontend Session in a selected or newly created real Workspace. */
|
||||
onPick: (workspaceId: WorkspaceId) => void
|
||||
/** Close the popover (outside click / Escape / post-pick). */
|
||||
onClose: () => void
|
||||
export interface SidebarSectionOwnerProps {
|
||||
/** Shell fold-state output: wide renders the full browser, rail the icon column. */
|
||||
wide: boolean
|
||||
/** Rail icons request expansion; the browser rides the wide flip for focus. */
|
||||
expandSidebar: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrant-private injected share (arrives via the register inject
|
||||
* factory). Host Workspace and Session data use the global framework hooks;
|
||||
* navigation and panel actions are plain callbacks, and viewing state remains
|
||||
* component-local. A type alias supplies the implicit index signature required
|
||||
* by the registry.
|
||||
* factory). The shell keeps only its own controls: starting a Session from
|
||||
* the New Session button and toggling the column.
|
||||
*/
|
||||
export type SidebarRootInjected = {
|
||||
/** Start or replace the current frontend Session Intent. */
|
||||
startSession: (workspaceId?: WorkspaceId, prompt?: string) => void
|
||||
/** Open a real Session. */
|
||||
open: (sessionId: SessionId) => void
|
||||
/** Toggle the sidebar column through the layout service. */
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Full component props: layout owner state/actions plus global useSessions
|
||||
* and useWorkspaces, the declared Workspace picker render share, and this
|
||||
* package's injected callback. No store is registered.
|
||||
* Full component props: layout owner state/actions plus the browser hole's
|
||||
* render share and this package's injected callbacks. No store is registered.
|
||||
*/
|
||||
export type SidebarRootComponentProps =
|
||||
PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspace'> & SidebarRootInjected
|
||||
PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces'> & SidebarRootInjected
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
/** Registers the sidebar UI into the layout-owned slot. */
|
||||
/** Registers the sidebar shell into the layout-owned slot. */
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SidebarRootInjected } from './contract/slots.ts'
|
||||
import { SidebarRoot } from './SidebarRoot.tsx'
|
||||
|
||||
export type { SidebarRootComponentProps, SidebarRootInjected, SidebarWorkspaceOwnerProps } from './contract/slots.ts'
|
||||
export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps } from './contract/slots.ts'
|
||||
|
||||
/** Services required by the sidebar plugin. */
|
||||
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
|
||||
export const inject = ['slots', 'layout', 'workspaces']
|
||||
|
||||
/** Registers the sidebar component and its service callbacks.
|
||||
/** Registers the sidebar shell and its service callbacks.
|
||||
* @param ctx - Client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const injectProps = (): SidebarRootInjected => ({
|
||||
startSession: (workspaceId, prompt) => { ctx.workspaces.startSession(workspaceId, prompt) },
|
||||
open: (sessionId) => { ctx.sessions.open(sessionId) },
|
||||
toggleSidebar: () => { ctx.layout.toggleSidebar() },
|
||||
})
|
||||
ctx.effect(
|
||||
() => ctx.slots.register({
|
||||
name: 'sidebar',
|
||||
// SidebarRoot owns this picker site; ui-workspace registers the shared
|
||||
// picker that selects a Host Workspace for a frontend Session Intent.
|
||||
children: { 'sidebar.workspace': { kind: 'single', scope: 'root' } },
|
||||
// The shell owns geometry; ui-workspace registers the whole browsing
|
||||
// region (header, search, session list, workspace dialogs) here.
|
||||
children: { 'sidebar.workspaces': { kind: 'single', scope: 'root' } },
|
||||
inject: injectProps,
|
||||
}, SidebarRoot),
|
||||
'ui-sidebar: slot registration',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Sidebar slot registration and its plain runtime/layout callbacks. */
|
||||
/** Sidebar shell slot registration and its plain runtime/layout callbacks. */
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -9,10 +9,8 @@ async function bench(declare = true) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const layout = { toggleSidebar: vi.fn() }
|
||||
const sessions = { open: vi.fn() }
|
||||
const workspaces = { startSession: vi.fn() }
|
||||
ctx.provide('layout', layout)
|
||||
ctx.provide('sessions', sessions as never)
|
||||
ctx.provide('workspaces', workspaces as never)
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
if (declare) {
|
||||
@@ -21,25 +19,23 @@ async function bench(declare = true) {
|
||||
() => null,
|
||||
)
|
||||
}
|
||||
return { ctx, slots, layout, sessions, workspaces }
|
||||
return { ctx, slots, layout, workspaces }
|
||||
}
|
||||
|
||||
describe('ui-sidebar apply', () => {
|
||||
it('declares only the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces'])
|
||||
expect(inject).toEqual(['slots', 'layout', 'workspaces'])
|
||||
})
|
||||
|
||||
it('registers the sidebar and declares its Workspace picker hole', async () => {
|
||||
it('registers the shell and declares the browsing-region hole', async () => {
|
||||
const b = await bench()
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(b.slots.entries('sidebar')).toHaveLength(1)
|
||||
expect(b.slots.spec('sidebar.workspace')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' })
|
||||
const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
|
||||
expect(Object.keys(injected)).toEqual(['startSession', 'open', 'toggleSidebar'])
|
||||
expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar'])
|
||||
injected.startSession('workspace' as never, 'prompt')
|
||||
expect(b.workspaces.startSession).toHaveBeenCalledWith('workspace', 'prompt')
|
||||
injected.open('session' as never)
|
||||
expect(b.sessions.open).toHaveBeenCalledWith('session')
|
||||
injected.toggleSidebar()
|
||||
expect(b.layout.toggleSidebar).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -55,6 +51,6 @@ describe('ui-sidebar apply', () => {
|
||||
await fiber.await()
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('sidebar')).toHaveLength(0)
|
||||
expect(b.slots.spec('sidebar.workspace')).toBeUndefined()
|
||||
expect(b.slots.spec('sidebar.workspaces')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/Rows.tsx'
|
||||
import type { GroupNode, SessionNode } from '../src/client/tree.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
|
||||
describe('sidebar rows', () => {
|
||||
it('renders an active Workspace and keeps its create action separate from toggling', () => {
|
||||
const onToggle = vi.fn()
|
||||
const onCreate = vi.fn()
|
||||
const group: GroupNode = {
|
||||
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
|
||||
sessionCount: 1, expanded: true, containsCurrent: true, intentHere: false, sessions: [],
|
||||
}
|
||||
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} />)
|
||||
|
||||
expect(screen.getByText('1 session')).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session in Project' }))
|
||||
expect(onCreate).toHaveBeenCalledOnce()
|
||||
expect(onToggle).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByText('Project'))
|
||||
expect(onToggle).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders the frontend Intent placeholder as selected', () => {
|
||||
render(<IntentRowItem />)
|
||||
expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('renders and operates selected, running, recursive Session nodes', () => {
|
||||
const child: SessionNode = {
|
||||
id: sid('child'), title: 'Child', children: [], hasChildren: false,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
const parent: SessionNode = {
|
||||
id: sid('parent'), title: 'Parent', children: [child], hasChildren: true,
|
||||
expanded: true, running: true, updatedAt: 0,
|
||||
}
|
||||
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} />,
|
||||
)
|
||||
|
||||
const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
|
||||
const childRow = screen.getByText('Child').closest('[role="treeitem"]')!
|
||||
expect(parentRow.getAttribute('aria-selected')).toBe('true')
|
||||
expect(parentRow.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(childRow.getAttribute('aria-selected')).toBe('false')
|
||||
expect(childRow.hasAttribute('aria-expanded')).toBe(false)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
expect(onToggle).toHaveBeenCalledWith(parent.id)
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
fireEvent.click(parentRow)
|
||||
fireEvent.click(childRow)
|
||||
expect(onOpen.mock.calls).toEqual([[parent.id], [child.id]])
|
||||
|
||||
view.rerender(
|
||||
<SessionNodeItem
|
||||
node={{ ...parent, children: [], expanded: false, running: false }}
|
||||
depth={1} currentId={undefined} now={0} onOpen={onOpen} onToggle={onToggle}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('false')
|
||||
expect(screen.getByRole('treeitem').style.paddingLeft).toBe('24px')
|
||||
})
|
||||
})
|
||||
@@ -1,79 +1,42 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type {
|
||||
SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SidebarRootComponentProps } from '../src/client/contract/slots.ts'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SidebarRootComponentProps, SidebarSectionOwnerProps } from '../src/client/contract/slots.ts'
|
||||
import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
|
||||
const workspace: WorkspaceView = {
|
||||
workspaceId: wid('project'), path: '/projects/project', title: 'Project', sessionIds: [sid('s1')],
|
||||
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
const sessions: SessionListState = {
|
||||
ids: [sid('s1')],
|
||||
byId: { [sid('s1')]: { id: sid('s1'), displayTitle: 'First session', running: false, updatedAt: 1 } },
|
||||
current: undefined, phase: 'ready',
|
||||
intent: undefined,
|
||||
}
|
||||
const workspaces: WorkspaceListState = {
|
||||
items: [workspace], state: 'idle', phase: 'ready', error: null,
|
||||
intent: undefined, baselinesReady: true, recentWorkspaceId: workspace.workspaceId,
|
||||
}
|
||||
|
||||
function mount(sessionState: SessionListState = sessions) {
|
||||
const startSession = vi.fn()
|
||||
const open = vi.fn()
|
||||
let pickerOwner: unknown
|
||||
const view = render(
|
||||
<SidebarRoot
|
||||
collapsed={false} width={300}
|
||||
useSessions={hook(sessionState)} useWorkspaces={hook(workspaces)}
|
||||
startSession={startSession} open={open} toggleSidebar={vi.fn()}
|
||||
renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']}
|
||||
/>,
|
||||
)
|
||||
return { view, startSession, open, pickerOwner: () => pickerOwner }
|
||||
}
|
||||
// The shell never reads the global hooks itself, but they ride the standard
|
||||
// props share; stub them as never-called functions.
|
||||
const neverHook = (() => { throw new Error('shell must not read global hooks') }) as never
|
||||
|
||||
function mountSidebar({
|
||||
sessionState = sessions,
|
||||
workspaceState = workspaces,
|
||||
collapsed = false,
|
||||
width = 300,
|
||||
}: {
|
||||
sessionState?: SessionListState
|
||||
workspaceState?: WorkspaceListState
|
||||
collapsed?: boolean
|
||||
width?: number
|
||||
} = {}) {
|
||||
function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; width?: number } = {}) {
|
||||
const startSession = vi.fn()
|
||||
const open = vi.fn()
|
||||
const toggleSidebar = vi.fn()
|
||||
let pickerOwner: unknown
|
||||
let current = { sessionState, workspaceState, collapsed, width }
|
||||
let regionOwner: SidebarSectionOwnerProps | undefined
|
||||
let current = { collapsed, width }
|
||||
const root = () => (
|
||||
<SidebarRoot
|
||||
collapsed={current.collapsed} width={current.width}
|
||||
useSessions={hook(current.sessionState)} useWorkspaces={hook(current.workspaceState)}
|
||||
startSession={startSession} open={open} toggleSidebar={toggleSidebar}
|
||||
renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']}
|
||||
useSessions={neverHook} useWorkspaces={neverHook}
|
||||
startSession={startSession} toggleSidebar={toggleSidebar}
|
||||
renderSlot={((_key: string, owner: SidebarSectionOwnerProps) => {
|
||||
regionOwner = owner
|
||||
return <div data-testid="region" data-wide={owner.wide} />
|
||||
}) as SidebarRootComponentProps['renderSlot']}
|
||||
/>
|
||||
)
|
||||
const view = render(root())
|
||||
return {
|
||||
startSession,
|
||||
open,
|
||||
toggleSidebar,
|
||||
pickerOwner: () => pickerOwner,
|
||||
regionOwner: () => {
|
||||
if (regionOwner === undefined) throw new Error('region owner not rendered')
|
||||
return regionOwner
|
||||
},
|
||||
rerender(next: Partial<typeof current>) {
|
||||
current = { ...current, ...next }
|
||||
view.rerender(root())
|
||||
@@ -81,181 +44,40 @@ function mountSidebar({
|
||||
}
|
||||
}
|
||||
|
||||
describe('SidebarRoot', () => {
|
||||
it('renders real Workspaces from useWorkspaces and routes New Session', () => {
|
||||
const b = mount()
|
||||
expect(screen.getByText('Project')).toBeTruthy()
|
||||
describe('SidebarRoot shell', () => {
|
||||
it('routes New Session and the column toggle', () => {
|
||||
const b = mountShell()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session' }))
|
||||
expect(b.startSession).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('shows a frontend Session under its real Workspace and routes its row plus', () => {
|
||||
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: workspace.workspaceId }, prompt: '', phase: 'connecting' as const }
|
||||
const b = mount({
|
||||
...sessions,
|
||||
current: intent.sessionId,
|
||||
intent,
|
||||
})
|
||||
expect(screen.getByText('New session')).toBeTruthy()
|
||||
expect(screen.getByText('2 sessions')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session in Project' }))
|
||||
expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId)
|
||||
})
|
||||
|
||||
it('forwards Workspace picker selection and closes the picker', () => {
|
||||
const b = mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void }
|
||||
expect(owner.open).toBe(true)
|
||||
owner.onPick(workspace.workspaceId)
|
||||
expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId)
|
||||
})
|
||||
|
||||
it('opens a real Session through the owner action', () => {
|
||||
const b = mount({ ...sessions, current: sid('intent'), intent: {
|
||||
sessionId: sid('intent'), target: { kind: 'workspace', workspaceId: workspace.workspaceId }, prompt: '', phase: 'ready',
|
||||
} })
|
||||
fireEvent.click(screen.getByText('Project'))
|
||||
fireEvent.click(screen.getByText('First session'))
|
||||
expect(b.open).toHaveBeenCalledWith(sid('s1'))
|
||||
})
|
||||
|
||||
it('opens, selects, dismisses, and toggles the group-by menu', () => {
|
||||
mount()
|
||||
const button = screen.getByRole('button', { name: 'Group by' })
|
||||
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace' }))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
|
||||
fireEvent.click(button)
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('routes every Workspace picker close path', () => {
|
||||
const b = mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
const owner = b.pickerOwner() as { open: boolean; onClose(): void }
|
||||
expect(owner.open).toBe(true)
|
||||
act(() => { owner.onClose() })
|
||||
expect((b.pickerOwner() as { open: boolean }).open).toBe(false)
|
||||
})
|
||||
|
||||
it('focuses, filters, and clears search while distinguishing both empty states', () => {
|
||||
mount()
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
fireEvent.click(input.parentElement!)
|
||||
expect(document.activeElement).toBe(input)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
|
||||
fireEvent.change(input, { target: { value: 'missing' } })
|
||||
expect(screen.getByText('No matches')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
|
||||
expect(screen.queryByText('No matches')).toBeNull()
|
||||
|
||||
cleanup()
|
||||
const emptySessions = listState()
|
||||
const emptyWorkspaces: WorkspaceListState = { ...workspaces, items: [], recentWorkspaceId: undefined }
|
||||
mountSidebar({ sessionState: emptySessions, workspaceState: emptyWorkspaces })
|
||||
expect(screen.getByText('No sessions yet')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('toggles Workspace and nested Session expansion in both directions', () => {
|
||||
const parent = sid('parent')
|
||||
const child = sid('child')
|
||||
const nestedSessions: SessionListState = {
|
||||
...sessions,
|
||||
ids: [parent, child],
|
||||
byId: {
|
||||
[parent]: { id: parent, displayTitle: 'Parent', running: false, updatedAt: 2 },
|
||||
[child]: { id: child, displayTitle: 'Child', running: false, updatedAt: 1, parentId: parent },
|
||||
},
|
||||
}
|
||||
const nestedWorkspace: WorkspaceListState = {
|
||||
...workspaces,
|
||||
items: [{ ...workspace, sessionIds: [parent, child] }],
|
||||
}
|
||||
mountSidebar({ sessionState: nestedSessions, workspaceState: nestedWorkspace })
|
||||
|
||||
fireEvent.click(screen.getByText('Project'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
expect(screen.getByText('Child')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
expect(screen.queryByText('Child')).toBeNull()
|
||||
fireEvent.click(screen.getByText('Project'))
|
||||
expect(screen.queryByText('Parent')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not start a Session from an Ungrouped row create action', () => {
|
||||
const loose = sid('loose')
|
||||
const looseSessions: SessionListState = {
|
||||
...listState(),
|
||||
ids: [loose],
|
||||
byId: { [loose]: { id: loose, displayTitle: 'Loose', running: false, updatedAt: 1 } },
|
||||
current: loose,
|
||||
}
|
||||
const b = mountSidebar({
|
||||
sessionState: looseSessions,
|
||||
workspaceState: { ...workspaces, items: [], recentWorkspaceId: undefined },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' }))
|
||||
expect(b.startSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an already expanded selected Workspace open and resolves later Workspace matches', () => {
|
||||
const b = mountSidebar()
|
||||
fireEvent.click(screen.getByText('Project'))
|
||||
const other = { ...workspace, workspaceId: wid('other'), title: 'Other', sessionIds: [] }
|
||||
b.rerender({
|
||||
sessionState: { ...sessions, current: sid('s1') },
|
||||
workspaceState: { ...workspaces, items: [other, workspace] },
|
||||
})
|
||||
expect(screen.getByText('First session')).toBeTruthy()
|
||||
|
||||
b.rerender({
|
||||
sessionState: {
|
||||
...sessions,
|
||||
current: sid('draft'),
|
||||
intent: { sessionId: sid('draft'), target: { kind: 'workspace-intent' }, prompt: '', phase: 'ready' },
|
||||
},
|
||||
})
|
||||
expect(screen.getByText('Project')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the static collapsed rail and expands rail search into focused input', () => {
|
||||
vi.useFakeTimers()
|
||||
const b = mountSidebar({ collapsed: true })
|
||||
expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
|
||||
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open sidebar' }))
|
||||
expect(b.toggleSidebar).toHaveBeenCalledOnce()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
expect(b.toggleSidebar).toHaveBeenCalledTimes(2)
|
||||
b.rerender({ collapsed: false })
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
expect(document.activeElement).toBe(input)
|
||||
})
|
||||
|
||||
it('keeps wide content during live collapse, then settles to the rail', () => {
|
||||
vi.useFakeTimers()
|
||||
const b = mountSidebar({ width: 320 })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
|
||||
expect(b.toggleSidebar).toHaveBeenCalledOnce()
|
||||
b.rerender({ collapsed: true, width: 56 })
|
||||
expect(screen.getByPlaceholderText('Search name, keywords...')).toBeTruthy()
|
||||
act(() => { vi.advanceTimersByTime(150) })
|
||||
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
|
||||
})
|
||||
|
||||
it('hands the region its wide flag and clamps expandSidebar to the collapsed state', () => {
|
||||
const b = mountShell()
|
||||
expect(b.regionOwner().wide).toBe(true)
|
||||
// Expanded: the request is a no-op (no accidental collapse).
|
||||
b.regionOwner().expandSidebar()
|
||||
expect(b.toggleSidebar).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the region mounted through collapse and expands on its request', () => {
|
||||
vi.useFakeTimers()
|
||||
const b = mountShell()
|
||||
b.rerender({ collapsed: true })
|
||||
// Wide content survives the crossfade window, then settles into the rail.
|
||||
expect(b.regionOwner().wide).toBe(true)
|
||||
vi.advanceTimersByTime(200)
|
||||
b.rerender({})
|
||||
expect(b.regionOwner().wide).toBe(false)
|
||||
expect(screen.getByTestId('region')).toBeTruthy()
|
||||
b.regionOwner().expandSidebar()
|
||||
expect(b.toggleSidebar).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders statically collapsed on a cold start (no crossfade classes)', () => {
|
||||
const b = mountShell({ collapsed: true })
|
||||
expect(b.regionOwner().wide).toBe(false)
|
||||
expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
function listState(): SessionListState {
|
||||
return { ids: [], byId: {}, current: undefined, phase: 'ready', intent: undefined }
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/* Workspace browsing region (fills the sidebar shell's hole): section
|
||||
header, search capsule, and the scrolling session list. Wide/rail
|
||||
variants ride the shell's fold state through the `wide` owner prop —
|
||||
rail state renders only the two 36x36 icon controls. */
|
||||
|
||||
.root {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.iconButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Section header: 36px, "Workspaces/Sessions" label + group-by /
|
||||
new-workspace buttons; the right-anchored new-workspace button is the
|
||||
row's rail survivor. */
|
||||
.sectionHeader {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
height: 36px;
|
||||
padding-left: 12px;
|
||||
margin-bottom: 4px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
/* Search input: 38px capsule (figma 133:7649); rail state renders it as the
|
||||
region's search control. Upstream binds a dedicated design-system variable
|
||||
(light #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component
|
||||
token pinned to the static scale mirrors it. */
|
||||
.search {
|
||||
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-75);
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
margin: 0 2px 12px;
|
||||
padding: 0 14px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 24px;
|
||||
background: var(--dsh-search-input-fill);
|
||||
color: var(--dsw-alias-label-caption);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(body[data-ds-dark-theme]) .search {
|
||||
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-900);
|
||||
}
|
||||
|
||||
/* The capsule's leading icon: decorative while wide (pointer-events off so
|
||||
clicks reach the input), the hit target in rail state. */
|
||||
.searchButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Rail variant (own .rail class from the wide owner prop — the region never
|
||||
reads the shell's class names): the two icon controls stack as 36x36
|
||||
circles matching the shell's rail rhythm. */
|
||||
.rail .sectionHeader {
|
||||
padding-left: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rail .iconButton {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.rail .search {
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
margin: 0 0 12px;
|
||||
gap: 0;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.rail .searchButton {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.rail .searchButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* List seat: always mounted so the shell foot never moves. */
|
||||
.listArea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Relative for the bottom fade overlay. */
|
||||
.treeBody {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom,
|
||||
transparent -> sidebar fill so it tracks the theme. */
|
||||
.fade {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 72px;
|
||||
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Wide-only content fades back in on expand remount (mirrors the shell). */
|
||||
.wide {
|
||||
animation: wide-in 200ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
@keyframes wide-in {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
|
||||
/* List: the only scrolling region. Block, not a flex column: as flex items
|
||||
the 54/34 rows would shrink under content overflow; block children keep
|
||||
their design heights and the 4px rhythm rides margins instead of gap. */
|
||||
.list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
/* One workspace section: header row + expanded session run. Rows inside
|
||||
keep the former flat-list 4px gap as sibling margins; the inter-group
|
||||
breathing room (figma 133:7661 batch separator, 20px after an expanded
|
||||
run) rides the NEXT section's top margin so the last group adds none. */
|
||||
.groupSection > * + * {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.groupSection + .groupSection {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.groupSection:has([aria-expanded='true']) + .groupSection {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px 12px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Rename dialog form (same figma dialog family as the create modals). */
|
||||
.renameInput {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 22px;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.renameInput:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
.renameError {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wide {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
433
packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx
Normal file
433
packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx
Normal file
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* The workspace/session browsing region filling the sidebar shell's
|
||||
* `sidebar.workspaces` hole: section header (title + group-by + new
|
||||
* workspace), search, the grouped tree or flat list, and the workspace
|
||||
* dialogs. Wide state renders the full browser; rail state renders the two
|
||||
* region icons (search / new workspace), each requesting shell expansion
|
||||
* through the owner share. The picker menu and create dialogs live in
|
||||
* WorkspacePicker (same package — direct composition, no slot between them).
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
Button, IconCloseFill14, IconPersonalizationOutline16,
|
||||
IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WorkspaceBrowserProps } from './contract/slots.ts'
|
||||
import type { SessionNode } from './tree.ts'
|
||||
import { deriveFlat, deriveGroups, UNGROUPED_KEY } from './tree.ts'
|
||||
import { IntentRowItem, ProjectRowItem, SessionNodeItem } from './rows/Rows.tsx'
|
||||
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. */
|
||||
const EXPAND_SLIDE_MS = 300
|
||||
|
||||
const GROUP_BY_ITEMS = [
|
||||
{ type: 'label' as const, id: 'group-by', text: 'Group by' },
|
||||
{ id: 'workspace', label: 'WorkSpace' },
|
||||
{ id: 'flat', label: 'In one list' },
|
||||
]
|
||||
|
||||
/** 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]
|
||||
}
|
||||
|
||||
/** Group-by strategy menu; own open state so it resets with the wide chrome. */
|
||||
function GroupByMenu({ groupBy, onPick }: {
|
||||
groupBy: 'workspace' | 'flat'
|
||||
onPick: (mode: 'workspace' | 'flat') => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={() => { setOpen(false) }}
|
||||
items={GROUP_BY_ITEMS}
|
||||
selectedId={groupBy}
|
||||
onSelect={(id) => {
|
||||
/* v8 ignore next -- narrowing guard: the heading label is not selectable, so the only arriving ids are the two modes. */
|
||||
if (id === 'workspace' || id === 'flat') onPick(id)
|
||||
setOpen(false)
|
||||
}}
|
||||
align="end"
|
||||
// Portal: the section header clips overflow, so an in-place list would
|
||||
// be cut off at the header's bounds.
|
||||
portal
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.iconButton, css.wide)}
|
||||
aria-label="Group by"
|
||||
onClick={() => { setOpen((v) => !v) }}
|
||||
>
|
||||
<IconPersonalizationOutline16 />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** In-flight root-row drag: source identity plus the current insert marker. */
|
||||
interface DragState {
|
||||
workspaceId: WorkspaceId
|
||||
sessionId: SessionNode['id']
|
||||
/** Row the marker sits on and which half (insert above/below it). */
|
||||
over: { id: SessionNode['id']; half: 'before' | 'after' } | null
|
||||
}
|
||||
|
||||
type SessionTreeProps = Pick<
|
||||
WorkspaceBrowserProps,
|
||||
'useSessions' | 'startSession' | 'open' | 'insertSessionBefore'
|
||||
> & {
|
||||
workspaces: readonly WorkspaceView[]
|
||||
/** Live search filter owned by the browser root (the query outlives the tree). */
|
||||
query: string
|
||||
/** Open the browser-owned rename dialog for a real Workspace group. */
|
||||
onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
|
||||
}
|
||||
|
||||
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
||||
function SessionTree({ useSessions, startSession, open, workspaces, query, onRenameRequest, insertSessionBefore }: SessionTreeProps) {
|
||||
const list = useSessions((s) => s)
|
||||
const current = list.current
|
||||
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
|
||||
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
|
||||
// Transient drag viewing state (never store-bound; order truth stays Host-side).
|
||||
const [drag, setDrag] = useState<DragState | null>(null)
|
||||
// Re-expand when publication moves the selected intent into a real Workspace.
|
||||
const intent = list.intent
|
||||
const intentWorkspaceId = intent?.target.kind === 'workspace'
|
||||
? intent.target.workspaceId
|
||||
: undefined
|
||||
const currentGroup = current === undefined
|
||||
? undefined
|
||||
: intent?.sessionId === current
|
||||
? intentWorkspaceId
|
||||
: (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined)
|
||||
?? UNGROUPED_KEY
|
||||
useEffect(() => {
|
||||
if (current === undefined || currentGroup === undefined) return
|
||||
setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
||||
}, [current, currentGroup])
|
||||
const groups = useMemo(
|
||||
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
|
||||
[list, workspaces, expandedProjects, expandedSessions, query],
|
||||
)
|
||||
const now = Date.now()
|
||||
|
||||
return (
|
||||
<div className={clsx(css.treeBody, css.wide)}>
|
||||
<div className={css.list} role="tree" aria-label="Sessions">
|
||||
{groups.length === 0 && (
|
||||
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
|
||||
)}
|
||||
{groups.map(group => (
|
||||
// Group section: header row + expanded session subtree. The
|
||||
// inter-group breathing room (former flat-list batch separator)
|
||||
// is the section's own margin (WorkspaceBrowser.module.css).
|
||||
<div key={group.key} className={css.groupSection}>
|
||||
<ProjectRowItem
|
||||
group={group}
|
||||
onToggle={() => { setExpandedProjects((l) => toggled(l, group.key)) }}
|
||||
onCreate={() => {
|
||||
if (group.workspaceId !== undefined) startSession(group.workspaceId)
|
||||
}}
|
||||
onRename={group.workspaceId === undefined
|
||||
? undefined
|
||||
: () => {
|
||||
/* v8 ignore next -- narrowing guard: the closure is only created for real-workspace groups. */
|
||||
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
|
||||
}}
|
||||
/>
|
||||
{group.expanded && group.intentHere && <IntentRowItem />}
|
||||
{group.sessions.map((node, index) => {
|
||||
// Draggable: real-workspace group roots outside search. The drag
|
||||
// never leaves its group — rows of other groups show no markers
|
||||
// and reject drops (visual movement confined to this section).
|
||||
const draggable = group.workspaceId !== undefined && query === ''
|
||||
const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId
|
||||
const dragProps = !draggable || group.workspaceId === undefined ? undefined : {
|
||||
start: () => {
|
||||
setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null })
|
||||
},
|
||||
active: sameGroupDrag,
|
||||
marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null,
|
||||
hover: (half: 'before' | 'after') => {
|
||||
/* v8 ignore next -- narrowing guard: Rows gates hover on `active`, which is false while the drag state is null. */
|
||||
setDrag(d => (d === null ? d : { ...d, over: { id: node.id, half } }))
|
||||
},
|
||||
drop: (half: 'before' | 'after') => {
|
||||
/* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */
|
||||
if (drag === null) return
|
||||
const roots = group.sessions
|
||||
// Anchor = the row the insert line points at ('after' means
|
||||
// the next root; end-of-list omits the anchor → append).
|
||||
const anchor = half === 'before' ? node.id : roots[index + 1]?.id
|
||||
setDrag(null)
|
||||
if (anchor === drag.sessionId) return
|
||||
// No-op when the drop lands back on the source position.
|
||||
const sourceIndex = roots.findIndex(r => r.id === drag.sessionId)
|
||||
const anchorIndex = anchor === undefined ? roots.length : roots.findIndex(r => r.id === anchor)
|
||||
if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
|
||||
insertSessionBefore(drag.workspaceId, drag.sessionId, anchor).catch((reason: unknown) => {
|
||||
console.warn('session reorder rejected:', reason)
|
||||
})
|
||||
},
|
||||
end: () => { setDrag(null) },
|
||||
}
|
||||
return (
|
||||
<SessionNodeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
currentId={current}
|
||||
now={now}
|
||||
onOpen={open}
|
||||
onToggle={(id) => { setExpandedSessions((l) => toggled(l, id)) }}
|
||||
drag={dragProps}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className={css.fade} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The flat "In one list" body: every session a top-level row, newest-first. */
|
||||
function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'query'>) {
|
||||
const list = useSessions((s) => s)
|
||||
const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
|
||||
const now = Date.now()
|
||||
// The intent placeholder renders outside search only; it suppresses the
|
||||
// empty state only while actually rendered (a query hides both).
|
||||
const intentRow = query === '' && list.intent !== undefined
|
||||
return (
|
||||
<div className={clsx(css.treeBody, css.wide)}>
|
||||
<div className={css.list} role="tree" aria-label="Sessions">
|
||||
{rows.length === 0 && !intentRow && (
|
||||
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
|
||||
)}
|
||||
{intentRow && <IntentRowItem />}
|
||||
{rows.map(node => (
|
||||
<SessionNodeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
currentId={list.current}
|
||||
now={now}
|
||||
onOpen={open}
|
||||
/* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */
|
||||
onToggle={() => {}}
|
||||
flat
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className={css.fade} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the browsing region.
|
||||
* @param props - composed slot props (shell owner share + store + injected actions).
|
||||
* @returns the region element tree.
|
||||
*/
|
||||
export function WorkspaceBrowser({
|
||||
wide,
|
||||
expandSidebar,
|
||||
useSessions,
|
||||
useWorkspaces,
|
||||
useStore,
|
||||
actions,
|
||||
startSession,
|
||||
open,
|
||||
renameWorkspace,
|
||||
insertSessionBefore,
|
||||
createWorkspace,
|
||||
}: WorkspaceBrowserProps) {
|
||||
const workspaces = useWorkspaces(state => state.items)
|
||||
const groupBy = useStore(s => s.groupBy)
|
||||
// The query outlives the tree and the input (both wide-only) so collapsing
|
||||
// does not silently drop an in-progress filter.
|
||||
const [query, setQuery] = useState('')
|
||||
const searchInput = useRef<HTMLInputElement | null>(null)
|
||||
// Section-header + opens the picker menu (same popover in wide and rail
|
||||
// states; the menu anchors on this button).
|
||||
const [wsPickerOpen, setWsPickerOpen] = useState(false)
|
||||
const wsPlusRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// 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.
|
||||
const [searchOnExpand, setSearchOnExpand] = useState(false)
|
||||
useEffect(() => {
|
||||
if (wide && searchOnExpand) {
|
||||
const timer = window.setTimeout(() => {
|
||||
searchInput.current?.focus({ preventScroll: true })
|
||||
setSearchOnExpand(false)
|
||||
}, EXPAND_SLIDE_MS)
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}
|
||||
}, [wide, searchOnExpand])
|
||||
|
||||
// Rename dialog (browser-owned so it outlives row unmounts during collapse).
|
||||
const [renameTarget, setRenameTarget] = useState<{ workspaceId: WorkspaceId; currentTitle: string } | null>(null)
|
||||
const [renameDraft, setRenameDraft] = useState('')
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
const [renameError, setRenameError] = useState<string | null>(null)
|
||||
const renameTrimmed = renameDraft.trim()
|
||||
const renameDuplicate = renameTarget !== null && renameTrimmed !== '' && renameTrimmed !== renameTarget.currentTitle
|
||||
&& workspaces.some(w => w.title === renameTrimmed)
|
||||
const renameBlocked = renaming || renameTrimmed === ''
|
||||
|| renameTarget === null || renameTrimmed === renameTarget.currentTitle || renameDuplicate
|
||||
const closeRename = () => {
|
||||
if (renaming) return
|
||||
setRenameTarget(null)
|
||||
setRenameError(null)
|
||||
}
|
||||
const confirmRename = () => {
|
||||
if (renameBlocked || renameTarget === null) return
|
||||
setRenaming(true)
|
||||
setRenameError(null)
|
||||
renameWorkspace(renameTarget.workspaceId, renameTrimmed).then(() => {
|
||||
setRenaming(false)
|
||||
setRenameTarget(null)
|
||||
}).catch((reason: unknown) => {
|
||||
setRenaming(false)
|
||||
setRenameError(reason instanceof Error ? reason.message : String(reason))
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx(css.root, !wide && css.rail)}>
|
||||
<div className={css.sectionHeader}>
|
||||
{wide && (
|
||||
<span className={clsx(css.sectionLabel, css.wide)}>
|
||||
{groupBy === 'flat' ? 'Sessions' : 'Workspaces'}
|
||||
</span>
|
||||
)}
|
||||
{wide && <GroupByMenu groupBy={groupBy} onPick={(mode) => { actions.setGroupBy(mode) }} />}
|
||||
<Tooltip label="New Workspace" disabled={wide}>
|
||||
<button
|
||||
ref={wsPlusRef}
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label="Create workspace"
|
||||
onClick={() => {
|
||||
if (!wide) expandSidebar()
|
||||
setWsPickerOpen(v => !v)
|
||||
}}
|
||||
>
|
||||
<IconProjectAddOutline16 size={wide ? 16 : 18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/* Picker menu + create dialogs (same package — direct composition). */}
|
||||
<WorkspaceCreateFlow
|
||||
open={wsPickerOpen}
|
||||
anchorRef={wsPlusRef}
|
||||
useWorkspaces={useWorkspaces}
|
||||
createWorkspace={createWorkspace}
|
||||
onPick={(workspaceId) => {
|
||||
setWsPickerOpen(false)
|
||||
startSession(workspaceId)
|
||||
}}
|
||||
onClose={() => { setWsPickerOpen(false) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expanded: the row is a click-to-focus field (the leading icon is
|
||||
decorative). Rail: the icon is the region's search control. */}
|
||||
<div className={css.search} onClick={() => { if (wide) searchInput.current?.focus() }}>
|
||||
<Tooltip label="Search" disabled={wide}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.searchButton}
|
||||
aria-label="Search sessions"
|
||||
tabIndex={wide ? -1 : 0}
|
||||
onClick={() => { if (!wide) { setSearchOnExpand(true); expandSidebar() } }}
|
||||
>
|
||||
<IconSearchOutline16 size={wide ? 14 : 18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{wide && (
|
||||
<input
|
||||
ref={searchInput}
|
||||
className={clsx(css.searchInput, css.wide)}
|
||||
type="text"
|
||||
placeholder="Search name, keywords..."
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value) }}
|
||||
/>
|
||||
)}
|
||||
{wide && query !== '' && (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.clearButton, css.wide)}
|
||||
aria-label="Clear search"
|
||||
onClick={() => { setQuery('') }}
|
||||
>
|
||||
<IconCloseFill14 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Always-mounted seat keeps the region's flex slot while the list
|
||||
itself is wide-only. */}
|
||||
<div className={css.listArea}>
|
||||
{wide && (groupBy === 'flat'
|
||||
? <FlatList useSessions={useSessions} open={open} query={query} />
|
||||
: (
|
||||
<SessionTree
|
||||
useSessions={useSessions}
|
||||
workspaces={workspaces}
|
||||
startSession={startSession}
|
||||
open={open}
|
||||
query={query}
|
||||
insertSessionBefore={insertSessionBefore}
|
||||
onRenameRequest={(workspaceId, currentTitle) => {
|
||||
setRenameTarget({ workspaceId, currentTitle })
|
||||
setRenameDraft(currentTitle)
|
||||
setRenameError(null)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={renameTarget !== null}
|
||||
onClose={closeRename}
|
||||
title="Rename workspace"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" disabled={renaming} onClick={closeRename}>Cancel</Button>
|
||||
<Button variant="primary" disabled={renameBlocked} onClick={confirmRename}>Rename</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className={css.renameInput}
|
||||
value={renameDraft}
|
||||
aria-label="Workspace name"
|
||||
autoFocus
|
||||
disabled={renaming}
|
||||
onChange={(e) => { setRenameDraft(e.target.value); setRenameError(null) }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
confirmRename()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{renameDuplicate && (
|
||||
<div className={css.renameError} role="alert">A workspace named “{renameTrimmed}” already exists.</div>
|
||||
)}
|
||||
{renameError !== null && <div className={css.renameError} role="alert">{renameError}</div>}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
/** Shared Workspace picker for the sidebar and New Session hero. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import type { RefObject } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import {
|
||||
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WorkspaceId, WorkspaceListState, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WorkspacePickerProps } from './contract/slots.ts'
|
||||
import css from './WorkspacePicker.module.css'
|
||||
|
||||
@@ -13,14 +19,35 @@ const CREATE_NEW = '::create-new'
|
||||
|
||||
type ModalKind = 'path' | 'create' | null
|
||||
|
||||
export function WorkspacePicker({
|
||||
/** Core flow props: the owner supplies popover control and pick semantics. */
|
||||
export interface WorkspaceCreateFlowProps {
|
||||
/** Popover visibility (anchor button toggle state, owner-local). */
|
||||
open: boolean
|
||||
/** The anchor button element — the popover's placement anchor. */
|
||||
anchorRef?: RefObject<HTMLElement | null> | undefined
|
||||
/** Selector hook over the workspace list (framework standard hook). */
|
||||
useWorkspaces: <S>(selector: (state: WorkspaceListState) => S) => S
|
||||
/** Create or adopt a real Host Workspace. */
|
||||
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
|
||||
/** A real Workspace was picked or created. */
|
||||
onPick: (workspaceId: WorkspaceId) => void
|
||||
/** Close the popover (outside click / Escape / post-pick). */
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the pick menu plus the two create dialogs.
|
||||
* @param props - owner-controlled flow props.
|
||||
* @returns menu + dialog elements.
|
||||
*/
|
||||
export function WorkspaceCreateFlow({
|
||||
open,
|
||||
anchorRef,
|
||||
useWorkspaces,
|
||||
createWorkspace,
|
||||
onPick,
|
||||
onClose,
|
||||
createWorkspace,
|
||||
}: WorkspacePickerProps) {
|
||||
}: WorkspaceCreateFlowProps) {
|
||||
const workspaceSnapshot = useWorkspaces(state => state)
|
||||
const workspaces = workspaceSnapshot.items
|
||||
const getAnchorRect = useCallback(
|
||||
@@ -194,3 +221,29 @@ export function WorkspacePicker({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The conversation empty-state registration: adapts the owner share to the
|
||||
* core flow (all state and semantics live in the flow / the owner).
|
||||
* @param props - empty-state slot props (owner share + injected creation callback).
|
||||
* @returns the flow element.
|
||||
*/
|
||||
export function WorkspacePicker({
|
||||
open,
|
||||
anchorRef,
|
||||
useWorkspaces,
|
||||
onPick,
|
||||
onClose,
|
||||
createWorkspace,
|
||||
}: WorkspacePickerProps) {
|
||||
return (
|
||||
<WorkspaceCreateFlow
|
||||
open={open}
|
||||
anchorRef={anchorRef}
|
||||
useWorkspaces={useWorkspaces}
|
||||
createWorkspace={createWorkspace}
|
||||
onPick={onPick}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,30 +1,59 @@
|
||||
/**
|
||||
* Shared Workspace picker contract for the sidebar and page-local Session Intent hero
|
||||
* slots. Each runtime share provides its owner's popover controls plus the
|
||||
* global useWorkspaces hook; this package adds the injected Host Workspace
|
||||
* creation callback.
|
||||
* ui-workspace contracts. Two registrations share this package:
|
||||
*
|
||||
* - WorkspaceBrowser fills the sidebar shell's `sidebar.workspaces` hole —
|
||||
* the whole browsing region (section header, search, grouped/flat session
|
||||
* list, workspace dialogs). It registers this package's viewing store and
|
||||
* consumes the shell's two-fact owner share (wide / expandSidebar).
|
||||
* - WorkspacePicker fills the conversation empty-state hole (menu +
|
||||
* create dialogs shared with the browser).
|
||||
*/
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: pull both owner SlotMap merges into programs that resolve the
|
||||
// picker runtime union below.
|
||||
import type { PropsRuntime, PropsStore } 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'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { createWorkspaceViewStore } from '../stores.ts'
|
||||
|
||||
/**
|
||||
* Registrant-private injected share. Pick semantics remain in each owner's
|
||||
* onPick callback; this callback creates only the real Host Workspace. A type
|
||||
* alias supplies the implicit index signature required by the registry.
|
||||
* 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 = {
|
||||
/** Start or replace the current frontend Session Intent. */
|
||||
startSession: (workspaceId?: WorkspaceId, prompt?: string) => void
|
||||
/** Open a real Session. */
|
||||
open: (sessionId: SessionId) => void
|
||||
/** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
|
||||
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
|
||||
/**
|
||||
* Reorder a session inside its Workspace account (DOM-insertBefore
|
||||
* semantics: omitted anchor appends to the end). The view refreshes from
|
||||
* the Host response/changed frame; failures leave the order unchanged.
|
||||
*/
|
||||
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>
|
||||
}
|
||||
|
||||
/** Full browser props: shell owner share + viewing store + injected actions. */
|
||||
export type WorkspaceBrowserProps =
|
||||
PropsRuntime<'sidebar.workspaces'>
|
||||
& PropsStore<ReturnType<typeof createWorkspaceViewStore>>
|
||||
& WorkspaceBrowserInjected
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
/** Explicitly create or adopt a real Workspace before targeting a Session. */
|
||||
createWorkspace(input: { name: string } | { path: string }): Promise<WorkspaceView>
|
||||
}
|
||||
|
||||
/**
|
||||
* Full picker props: either owner's runtime share, including useWorkspaces,
|
||||
* plus this package's injected creation callback.
|
||||
*/
|
||||
/** Full picker props: the empty-state owner share plus the creation callback. */
|
||||
export type WorkspacePickerProps =
|
||||
(PropsRuntime<'sidebar.workspace'> | PropsRuntime<'conversation.empty.workspace'>)
|
||||
& WorkspacePickerInjected
|
||||
PropsRuntime<'conversation.empty.workspace'> & WorkspacePickerInjected
|
||||
|
||||
@@ -1,54 +1,85 @@
|
||||
/**
|
||||
* Shared Workspace picker plugin, browser half. WorkspacePicker registers in
|
||||
* the sidebar and page-local Session Intent hero slots, reads real Host Workspaces
|
||||
* through the global useWorkspaces hook, and delegates selection semantics to
|
||||
* each owner. Its injected share creates a Workspace without creating a
|
||||
* Session. Export discipline: packages/client/AGENTS.md.
|
||||
* Workspace plugin, browser half. Two registrations: WorkspaceBrowser fills
|
||||
* the sidebar shell's `sidebar.workspaces` hole (the whole browsing region),
|
||||
* and WorkspacePicker fills the conversation empty-state hole. Both read real
|
||||
* Host Workspaces through the global useWorkspaces hook. Export discipline:
|
||||
* packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WorkspacePickerInjected } from './contract/slots.ts'
|
||||
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
|
||||
import { createWorkspaceViewStore } from './stores.ts'
|
||||
import { WorkspaceBrowser } from './WorkspaceBrowser.tsx'
|
||||
import { WorkspacePicker } from './WorkspacePicker.tsx'
|
||||
|
||||
export type { WorkspacePickerInjected, WorkspacePickerProps } from './contract/slots.ts'
|
||||
export type {
|
||||
WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps,
|
||||
} from './contract/slots.ts'
|
||||
|
||||
/**
|
||||
* Required services (cordis fiber inject). The target slot is declared by
|
||||
* the ui-sidebar apply, whose activation order relative to this one is NOT
|
||||
* constrained: dshClient.inject edges are informational (loading/prefetch
|
||||
* metadata, never apply sequencing) and the sidebar provides no waitable
|
||||
* service. apply therefore registers via declaration-aware deferral instead
|
||||
* of assuming order.
|
||||
* Required services (cordis fiber inject). The target slots are declared by
|
||||
* the ui-sidebar / ui-conversation applies, whose activation order relative
|
||||
* to this one is NOT constrained: dshClient.inject edges are informational
|
||||
* (loading/prefetch metadata, never apply sequencing) and neither owner
|
||||
* provides a waitable service. apply therefore registers via
|
||||
* declaration-aware deferral instead of assuming order.
|
||||
*/
|
||||
export const inject = ['slots', 'workspaces']
|
||||
export const inject = ['slots', 'sessions', 'workspaces']
|
||||
|
||||
/**
|
||||
* Register WorkspacePicker in both owner slots once their declarations are on
|
||||
* the ledger. The inject factory returns a plain Workspace creation callback;
|
||||
* data reads use the framework's global useWorkspaces hook.
|
||||
* Register the browser and picker once their slot declarations are on the
|
||||
* ledger. Inject factories return plain callbacks; data reads use the
|
||||
* framework's global hooks.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const injected = (): WorkspacePickerInjected => ({
|
||||
const browserInjected = (): WorkspaceBrowserInjected => ({
|
||||
startSession: (workspaceId, prompt) => { ctx.workspaces.startSession(workspaceId, prompt) },
|
||||
open: (sessionId) => { ctx.sessions.open(sessionId) },
|
||||
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
|
||||
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
|
||||
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
|
||||
},
|
||||
createWorkspace: input => ctx.workspaces.create(input),
|
||||
})
|
||||
// Declaration-aware registration: the sidebar'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).
|
||||
const pickerInjected = (): WorkspacePickerInjected => ({
|
||||
createWorkspace: input => ctx.workspaces.create(input),
|
||||
})
|
||||
// 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).
|
||||
ctx.effect(() => {
|
||||
const slotNames = ['sidebar.workspace', 'conversation.empty.workspace'] as const
|
||||
const disposers = new Map<(typeof slotNames)[number], () => void>()
|
||||
const tryRegister = (name: (typeof slotNames)[number]): void => {
|
||||
if (ctx.slots.spec(name) === undefined) return
|
||||
if (ctx.slots.entries(name).some(e => e.component === WorkspacePicker)) return
|
||||
disposers.set(name, ctx.slots.register({ name, inject: injected }, WorkspacePicker))
|
||||
const registrations = [
|
||||
{
|
||||
name: 'sidebar.workspaces' as const,
|
||||
component: WorkspaceBrowser,
|
||||
register: () => ctx.slots.register(
|
||||
{ name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected },
|
||||
WorkspaceBrowser,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'conversation.empty.workspace' as const,
|
||||
component: WorkspacePicker,
|
||||
register: () => ctx.slots.register(
|
||||
{ name: 'conversation.empty.workspace', 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 = slotNames.map(name => ctx.slots.subscribe(name, () => { tryRegister(name) }))
|
||||
for (const name of slotNames) tryRegister(name)
|
||||
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()
|
||||
}
|
||||
}, 'ui-workspace: picker registrations')
|
||||
}, 'ui-workspace: browser + picker registrations')
|
||||
}
|
||||
|
||||
@@ -150,14 +150,63 @@
|
||||
}
|
||||
|
||||
.projectRow:hover .rowActions,
|
||||
.sessionRow:hover .rowActions {
|
||||
.sessionRow:hover .rowActions,
|
||||
.projectRow.menuOpen .rowActions,
|
||||
.sessionRow.menuOpen .rowActions {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.sessionRow:hover .time {
|
||||
.sessionRow:hover .time,
|
||||
.sessionRow.menuOpen .time {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* An open row menu pins the hover affordances (figma: the row keeps its
|
||||
hover fill while its dropdown is up). */
|
||||
.projectRow.menuOpen,
|
||||
.sessionRow.menuOpen {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Drag reorder insert line (workspace-group roots): 2px accent above or
|
||||
below the hovered row, drawn with box-shadow so no layout shift. */
|
||||
.sessionRow.dropBefore {
|
||||
box-shadow: 0 -2px 0 0 var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.sessionRow.dropAfter {
|
||||
box-shadow: 0 2px 0 0 var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
/* Hover-card body (figma 169:16903): dark surface, fixed colors both themes. */
|
||||
.hoverContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hoverTitle {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #FFFFFF;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.hoverTime {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: #CFD3D6;
|
||||
}
|
||||
|
||||
.hoverStatus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: #ADB2B8;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
284
packages/client/ui-workspace/src/client/rows/Rows.tsx
Normal file
284
packages/client/ui-workspace/src/client/rows/Rows.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
HoverCard, IconBranchOutline16, IconEditOutline16, IconEllipsisOutline16,
|
||||
IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
|
||||
IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { GroupNode, SessionNode } from '../tree.ts'
|
||||
import { formatRelativeTime } from '../tree.ts'
|
||||
import css from './Rows.module.css'
|
||||
|
||||
/** Indent step per tree level: one 16px slot (figma session cell). */
|
||||
const INDENT_STEP = 16
|
||||
|
||||
const SESSION_MENU_ITEMS = [
|
||||
{ id: 'rename', label: 'Rename', icon: <IconEditOutline16 /> },
|
||||
{ id: 'fork', label: 'Fork session', icon: <IconBranchOutline16 /> },
|
||||
{ id: 'delete', label: 'Delete session', icon: <IconTrashOutline16 />, danger: true },
|
||||
]
|
||||
|
||||
const WORKSPACE_MENU_ITEMS = [
|
||||
{ id: 'rename', label: 'Rename', icon: <IconEditOutline16 /> },
|
||||
{ id: 'delete', label: 'Delete workspace', icon: <IconTrashOutline16 />, danger: true },
|
||||
]
|
||||
|
||||
/**
|
||||
* Project (workspace) header row: 54px, folder + title + session count;
|
||||
* hover reveals the chevron and create button. `containsCurrent` arrives on
|
||||
* the node (derivation fact, no renderer scan).
|
||||
* @param props.group - derived group node.
|
||||
* @param props.onToggle - expand/collapse the group.
|
||||
* @param props.onCreate - start a frontend Session inside this Workspace.
|
||||
* @returns the row element.
|
||||
*/
|
||||
export function ProjectRowItem({ group, onToggle, onCreate, onRename }: {
|
||||
group: GroupNode
|
||||
onToggle: () => void
|
||||
onCreate: () => void
|
||||
/** Open the rename dialog; absent for the ungrouped bucket (no menu shown). */
|
||||
onRename?: (() => void) | undefined
|
||||
}) {
|
||||
const row = group
|
||||
const active = group.expanded && group.containsCurrent
|
||||
const count = `${row.sessionCount} ${row.sessionCount === 1 ? 'session' : 'sessions'}`
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
return (
|
||||
<div
|
||||
className={clsx(css.projectRow, menuOpen && css.menuOpen)}
|
||||
role="treeitem"
|
||||
aria-expanded={row.expanded}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className={clsx(css.slot, css.folder, active && css.folderActive)}>
|
||||
{row.expanded ? <IconFolderOpen16 /> : <IconFolderClose16 />}
|
||||
</span>
|
||||
<span className={clsx(css.slot, css.chevron)}>
|
||||
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
|
||||
</span>
|
||||
<span className={css.projectText}>
|
||||
<span className={css.title}>{row.label}</span>
|
||||
<span className={css.meta}>{count}</span>
|
||||
</span>
|
||||
<span className={css.rowActions}>
|
||||
{onRename !== undefined && (
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
items={WORKSPACE_MENU_ITEMS}
|
||||
onSelect={(id) => {
|
||||
setMenuOpen(false)
|
||||
if (id === 'rename') onRename()
|
||||
// Delete is visual-only for now.
|
||||
}}
|
||||
portal
|
||||
closeOnPointerLeave
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={`Workspace actions for ${row.label}`}
|
||||
onClick={(e) => { e.stopPropagation(); setMenuOpen(v => !v) }}
|
||||
>
|
||||
<IconEllipsisOutline16 />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={`New session in ${row.label}`}
|
||||
onClick={(e) => { e.stopPropagation(); onCreate() }}
|
||||
>
|
||||
<IconPlusOutline16 />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected "New session" row for a frontend Session Intent targeted to a
|
||||
* real Workspace. The row disappears when the Intent is replaced or connects.
|
||||
* One status-slot indent in both grouped and flat lists (session rows carry
|
||||
* no twist slot either, so titles align).
|
||||
* @returns the placeholder row element.
|
||||
*/
|
||||
export function IntentRowItem() {
|
||||
return (
|
||||
<div className={clsx(css.sessionRow, css.selected)} role="treeitem" aria-selected style={{ paddingLeft: 8 }}>
|
||||
<span className={css.slot} />
|
||||
<span className={css.title}>New session</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One session subtree: the node's own 34px row (indent by depth, expand
|
||||
* twist when it has children, running dot, relative time) plus its visible
|
||||
* children, recursively — the component tree mirrors the derived tree.
|
||||
* @param props.node - derived session node.
|
||||
* @param props.depth - 0 = directly under the group header.
|
||||
* @param props.currentId - selected session id (row highlight).
|
||||
* @param props.now - epoch ms for relative-time formatting.
|
||||
* @param props.onOpen - open a session by id.
|
||||
* @param props.onToggle - unfold/fold a subtree by id.
|
||||
* @returns the node's row followed by its children.
|
||||
*/
|
||||
/** Hover-card body: full title, relative time, and the status line (running/idle until wire status lands). */
|
||||
function SessionHoverContent({ node, now }: { node: SessionNode; now: number }) {
|
||||
return (
|
||||
<div className={css.hoverContent}>
|
||||
<div className={css.hoverTitle}>{node.title}</div>
|
||||
<div className={css.hoverTime}>{`${formatRelativeTime(node.updatedAt, now)} ago`}</div>
|
||||
<div className={css.hoverStatus}>
|
||||
<StateDot state={node.running ? 'ongoing' : 'done'} />
|
||||
<span>{node.running ? 'Running' : 'Idle'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Root-row drag wiring supplied by the group owner (workspace groups only).
|
||||
* `drop` reports the half of the row the pointer released on: 'before'
|
||||
* inserts above this row, 'after' below it (the owner resolves the anchor).
|
||||
*/
|
||||
export interface RowDragProps {
|
||||
/** Start dragging this row. */
|
||||
start: () => void
|
||||
/** A drag from the same group is in flight (rows show insert markers). */
|
||||
active: boolean
|
||||
/** Current marker on this row: insert line above, below, or none. */
|
||||
marker: 'before' | 'after' | null
|
||||
/** Report the hovered half while a same-group drag passes over this row. */
|
||||
hover: (half: 'before' | 'after') => void
|
||||
drop: (half: 'before' | 'after') => void
|
||||
end: () => void
|
||||
}
|
||||
|
||||
/** Pointer-position half of a row (insert line above or below). */
|
||||
function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
|
||||
}
|
||||
|
||||
export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle, drag, flat = false }: {
|
||||
node: SessionNode
|
||||
depth: number
|
||||
currentId: string | undefined
|
||||
now: number
|
||||
onOpen: (id: SessionNode['id']) => void
|
||||
onToggle: (id: SessionNode['id']) => void
|
||||
/** Present only on draggable rows (workspace-group roots outside search). */
|
||||
drag?: RowDragProps | undefined
|
||||
/** Flat-list variant: no twist slot (figma flat cell) — titles align on the status slot. */
|
||||
flat?: boolean
|
||||
}) {
|
||||
const row = node
|
||||
const selected = node.id === currentId
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
// Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to
|
||||
// the title): both slots are always reserved so titles align whether or not
|
||||
// the twist/dot is lit. Extra depth rides the left padding.
|
||||
const ownRow = (
|
||||
<div
|
||||
className={clsx(
|
||||
css.sessionRow, selected && css.selected, menuOpen && css.menuOpen,
|
||||
drag?.marker === 'before' && css.dropBefore, drag?.marker === 'after' && css.dropAfter,
|
||||
)}
|
||||
role="treeitem"
|
||||
aria-selected={selected}
|
||||
{...(row.hasChildren ? { 'aria-expanded': row.expanded } : {})}
|
||||
style={{ paddingLeft: 8 + depth * INDENT_STEP }}
|
||||
onClick={() => { onOpen(node.id) }}
|
||||
draggable={drag !== undefined}
|
||||
onDragStart={drag === undefined
|
||||
? undefined
|
||||
: (e) => {
|
||||
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))
|
||||
}}
|
||||
onDrop={drag === undefined
|
||||
? undefined
|
||||
: (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>
|
||||
)
|
||||
: null}
|
||||
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
|
||||
<span className={css.title}>{row.title}</span>
|
||||
<span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span>
|
||||
<span className={css.rowActions}>
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
items={SESSION_MENU_ITEMS}
|
||||
onSelect={() => { setMenuOpen(false) }} // Visual-only for now.
|
||||
portal
|
||||
closeOnPointerLeave
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={`Session actions for ${row.title}`}
|
||||
onClick={(e) => { e.stopPropagation(); setMenuOpen(v => !v) }}
|
||||
>
|
||||
<IconEllipsisOutline16 />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<HoverCard
|
||||
anchor={ownRow}
|
||||
content={<SessionHoverContent node={node} now={now} />}
|
||||
disabled={menuOpen || drag?.active === true}
|
||||
/>
|
||||
{node.children.map(child => (
|
||||
<SessionNodeItem
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
currentId={currentId}
|
||||
now={now}
|
||||
onOpen={onOpen}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
36
packages/client/ui-workspace/src/client/stores.ts
Normal file
36
packages/client/ui-workspace/src/client/stores.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* The workspace browser's viewing store: the session-list grouping mode,
|
||||
* persisted across reloads. Module level exports the factory only (a
|
||||
* module-level handle would pin the store identity across plugin reloads);
|
||||
* register() receives the factory and the browser derives its PropsStore
|
||||
* share from the return type.
|
||||
*/
|
||||
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Session-list grouping mode: workspace sections or one flat recency list. */
|
||||
export type WorkspaceGroupBy = 'workspace' | 'flat'
|
||||
|
||||
/** Workspace browser viewing state (grouping mode only; transient UI facts stay component-local). */
|
||||
type WorkspaceViewState = { groupBy: WorkspaceGroupBy }
|
||||
|
||||
/**
|
||||
* Annotation twin of the actions literal below (the export needs a declared
|
||||
* return type); drift fails assignability at the defineStore call.
|
||||
*/
|
||||
type WorkspaceViewActions = {
|
||||
setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the workspace browser viewing store handle.
|
||||
* @returns the store handle (spec + type + identity + factory in one).
|
||||
*/
|
||||
export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState, WorkspaceViewActions> {
|
||||
return defineStore({
|
||||
init: (): WorkspaceViewState => ({ groupBy: 'workspace' }),
|
||||
persist: 'dsh.workspace.view',
|
||||
actions: {
|
||||
setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode },
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Derives the sidebar tree from Host Workspace order and membership.
|
||||
* Derives the workspace browser tree from Host Workspace order and membership.
|
||||
* Unassigned Sessions trail under Ungrouped; only Intents targeting real Workspaces render.
|
||||
*/
|
||||
import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -223,11 +223,12 @@ function buildSearch(g: Group, visible: ReadonlySet<SessionId>): SessionNode[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the nested sidebar group structure.
|
||||
* Derive the nested workspace browser group structure.
|
||||
*
|
||||
* Normal mode: every group shows; sessions populate under expanded groups,
|
||||
* descending only into expanded sessions. A frontend Session Intent targeting
|
||||
* a real Workspace marks that group `intentHere` and forces it expanded. Search mode (non-blank query,
|
||||
* a real Workspace marks that group `intentHere` (rendered only while the
|
||||
* group is expanded; expansion stays viewer-owned). Search mode (non-blank query,
|
||||
* case-insensitive display-title substring): expansion state is ignored —
|
||||
* matched sessions and their ancestor chains are forced visible, groups
|
||||
* without a display-title or label hit are dropped, a label-only hit keeps
|
||||
@@ -263,7 +264,9 @@ export function deriveGroups(
|
||||
&& g.workspaceId !== undefined && intentWorkspaceId === g.workspaceId
|
||||
const intentHere = q === '' && hasIntent
|
||||
if (q === '') {
|
||||
const expanded = intentHere || expandedProjects.has(g.key)
|
||||
// The intent never forces expansion — the viewer auto-expands the
|
||||
// target group once (current-group effect); the toggle stays live.
|
||||
const expanded = expandedProjects.has(g.key)
|
||||
groups.push({
|
||||
key: g.key,
|
||||
workspaceId: g.workspaceId,
|
||||
@@ -294,6 +297,29 @@ export function deriveGroups(
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the flat session list ("In one list" mode): every session — fork
|
||||
* children included — as a top-level row, strictly newest-first. No grouping,
|
||||
* no parent/child adjacency; rows reuse SessionNode with children always
|
||||
* empty so the renderer stays branch-free. Search mode filters by
|
||||
* case-insensitive display-title substring.
|
||||
* @param list - sessions list snapshot.
|
||||
* @param view - the search query (expansion state does not apply).
|
||||
* @returns flat rows in render order.
|
||||
*/
|
||||
export function deriveFlat(list: SessionListState, view: Pick<TreeView, 'query'>): SessionNode[] {
|
||||
const q = view.query.trim().toLowerCase()
|
||||
const rows: SessionSummary[] = []
|
||||
for (const id of list.ids) {
|
||||
const s = list.byId[id]
|
||||
if (s === undefined) continue
|
||||
if (q !== '' && !s.displayTitle.toLowerCase().includes(q)) continue
|
||||
rows.push(s)
|
||||
}
|
||||
rows.sort(byRecency)
|
||||
return rows.map(s => sessionNode(s, [], false, false))
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y").
|
||||
* @param updatedAt - epoch ms of the session's last activity.
|
||||
@@ -2,7 +2,8 @@ import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import type { WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
|
||||
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
|
||||
|
||||
async function bench() {
|
||||
@@ -13,32 +14,33 @@ async function bench() {
|
||||
path: 'name' in input ? `/projects/${input.name}` : input.path,
|
||||
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
|
||||
}))
|
||||
ctx.provide('workspaces', { create })
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, create }
|
||||
const startSession = vi.fn()
|
||||
const rename = vi.fn(async () => ({}))
|
||||
const insertSessionBefore = vi.fn(async () => ({}))
|
||||
const open = vi.fn()
|
||||
ctx.provide('workspaces', { create, startSession, rename, insertSessionBefore } as never)
|
||||
ctx.provide('sessions', { open } as never)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open }
|
||||
}
|
||||
|
||||
function declare(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): () => void {
|
||||
return slots.register(
|
||||
{ name: 'root', children: { [name]: { kind: 'single', scope: 'root' } } } as never,
|
||||
() => null,
|
||||
)
|
||||
}
|
||||
type HoleName = 'sidebar.workspaces' | 'conversation.empty.workspace'
|
||||
|
||||
function injectedOf(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): WorkspacePickerInjected {
|
||||
const entry = slots.entries(name)[0]!
|
||||
return (entry.inject as () => WorkspacePickerInjected)()
|
||||
/** Declare one or both holes with a single root registration ('root' is a single slot). */
|
||||
function declare(slots: SlotsService, ...names: HoleName[]): () => void {
|
||||
const children = Object.fromEntries(names.map(name => [name, { kind: 'single', scope: 'root' }]))
|
||||
return slots.register({ name: 'root', children } as never, () => null)
|
||||
}
|
||||
|
||||
describe('ui-workspace apply', () => {
|
||||
it('declares the independent Workspace service', () => {
|
||||
expect(inject).toEqual(['slots', 'workspaces'])
|
||||
it('declares the services it drives', () => {
|
||||
expect(inject).toEqual(['slots', 'sessions', 'workspaces'])
|
||||
})
|
||||
|
||||
it('registers the shared picker for declarations that arrive before or after apply', async () => {
|
||||
it('registers browser and picker for declarations arriving before or after apply', async () => {
|
||||
const before = await bench()
|
||||
declare(before.slots, 'sidebar.workspace')
|
||||
declare(before.slots, 'sidebar.workspaces')
|
||||
await before.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(before.slots.entries('sidebar.workspace')[0]!.component).toBe(WorkspacePicker)
|
||||
expect(before.slots.entries('sidebar.workspaces')[0]!.component).toBe(WorkspaceBrowser)
|
||||
|
||||
const after = await bench()
|
||||
await after.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
@@ -47,23 +49,35 @@ describe('ui-workspace apply', () => {
|
||||
expect(after.slots.entries('conversation.empty.workspace')[0]!.component).toBe(WorkspacePicker)
|
||||
})
|
||||
|
||||
it('routes name and path creation to WorkspacesService', async () => {
|
||||
it('routes browser actions and picker creation to the services', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots, 'sidebar.workspace')
|
||||
declare(b.slots, 'sidebar.workspaces', 'conversation.empty.workspace')
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const injected = injectedOf(b.slots, 'sidebar.workspace')
|
||||
await injected.createWorkspace({ name: 'project' })
|
||||
await injected.createWorkspace({ path: '/tmp/project' })
|
||||
expect(b.create).toHaveBeenNthCalledWith(1, { name: 'project' })
|
||||
expect(b.create).toHaveBeenNthCalledWith(2, { path: '/tmp/project' })
|
||||
|
||||
const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)()
|
||||
browser.startSession('ws' as never, 'prompt')
|
||||
expect(b.startSession).toHaveBeenCalledWith('ws', 'prompt')
|
||||
browser.open('session' as never)
|
||||
expect(b.open).toHaveBeenCalledWith('session')
|
||||
await browser.renameWorkspace('ws' as never, 'renamed')
|
||||
expect(b.rename).toHaveBeenCalledWith('ws', 'renamed')
|
||||
await browser.insertSessionBefore('ws' as never, 's1' as never, 's2' as never)
|
||||
expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2')
|
||||
await browser.createWorkspace({ name: 'project' })
|
||||
expect(b.create).toHaveBeenCalledWith({ name: 'project' })
|
||||
|
||||
const picker = (b.slots.entries('conversation.empty.workspace')[0]!.inject as () => WorkspacePickerInjected)()
|
||||
await picker.createWorkspace({ path: '/tmp/project' })
|
||||
expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' })
|
||||
})
|
||||
|
||||
it('unregisters picker entries on teardown', async () => {
|
||||
it('unregisters both entries on teardown', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots, 'sidebar.workspace')
|
||||
declare(b.slots, 'sidebar.workspaces', 'conversation.empty.workspace')
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('sidebar.workspace')).toHaveLength(0)
|
||||
expect(b.slots.entries('sidebar.workspaces')).toHaveLength(0)
|
||||
expect(b.slots.entries('conversation.empty.workspace')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
251
packages/client/ui-workspace/tests/rows.spec.tsx
Normal file
251
packages/client/ui-workspace/tests/rows.spec.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { RowDragProps } from '../src/client/rows/Rows.tsx'
|
||||
import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/rows/Rows.tsx'
|
||||
import type { GroupNode, SessionNode } from '../src/client/tree.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
|
||||
/** Half detection reads the row rect; jsdom rects are all-zero by default. */
|
||||
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 {
|
||||
return {
|
||||
start: vi.fn(), active: false, marker: null,
|
||||
hover: vi.fn(), drop: vi.fn(), end: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
|
||||
/** 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 {
|
||||
const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row)
|
||||
Object.defineProperty(event, 'clientY', { value: clientY })
|
||||
Object.defineProperty(event, 'dataTransfer', { value: { ...dataTransfer } })
|
||||
fireEvent(row, event)
|
||||
}
|
||||
|
||||
describe('workspace browser rows', () => {
|
||||
it('renders an active Workspace and keeps its create action separate from toggling', () => {
|
||||
const onToggle = vi.fn()
|
||||
const onCreate = vi.fn()
|
||||
const group: GroupNode = {
|
||||
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
|
||||
sessionCount: 1, expanded: true, containsCurrent: true, intentHere: false, sessions: [],
|
||||
}
|
||||
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} />)
|
||||
|
||||
expect(screen.getByText('1 session')).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session in Project' }))
|
||||
expect(onCreate).toHaveBeenCalledOnce()
|
||||
expect(onToggle).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByText('Project'))
|
||||
expect(onToggle).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders the frontend Intent placeholder as selected', () => {
|
||||
render(<IntentRowItem />)
|
||||
expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('renders and operates selected, running, recursive Session nodes', () => {
|
||||
const child: SessionNode = {
|
||||
id: sid('child'), title: 'Child', children: [], hasChildren: false,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
const parent: SessionNode = {
|
||||
id: sid('parent'), title: 'Parent', children: [child], hasChildren: true,
|
||||
expanded: true, running: true, updatedAt: 0,
|
||||
}
|
||||
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} />,
|
||||
)
|
||||
|
||||
const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
|
||||
const childRow = screen.getByText('Child').closest('[role="treeitem"]')!
|
||||
expect(parentRow.getAttribute('aria-selected')).toBe('true')
|
||||
expect(parentRow.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(childRow.getAttribute('aria-selected')).toBe('false')
|
||||
expect(childRow.hasAttribute('aria-expanded')).toBe(false)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
expect(onToggle).toHaveBeenCalledWith(parent.id)
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
fireEvent.click(parentRow)
|
||||
fireEvent.click(childRow)
|
||||
expect(onOpen.mock.calls).toEqual([[parent.id], [child.id]])
|
||||
|
||||
view.rerender(
|
||||
<SessionNodeItem
|
||||
node={{ ...parent, children: [], expanded: false, running: false }}
|
||||
depth={1} currentId={undefined} now={0} onOpen={onOpen} onToggle={onToggle}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('false')
|
||||
expect(screen.getByRole('treeitem').style.paddingLeft).toBe('24px')
|
||||
})
|
||||
|
||||
it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => {
|
||||
const onRename = vi.fn()
|
||||
const onToggle = vi.fn()
|
||||
const group: GroupNode = {
|
||||
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
|
||||
sessionCount: 0, expanded: false, containsCurrent: false, intentHere: false, sessions: [],
|
||||
}
|
||||
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={vi.fn()} onRename={onRename} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
|
||||
// Opening the menu neither toggles the group nor renames yet.
|
||||
expect(onToggle).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('menuitem', { name: 'Delete workspace' }).className).toMatch(/danger/)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
|
||||
expect(onRename).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
// Delete stays visual-only: selecting it just closes the menu.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(onRename).toHaveBeenCalledOnce()
|
||||
// Escape closes without selecting (Menu onClose path).
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('ungrouped bucket renders no workspace menu', () => {
|
||||
const group: GroupNode = {
|
||||
key: '', workspaceId: undefined, cwd: undefined, label: 'Ungrouped',
|
||||
sessionCount: 0, expanded: false, containsCurrent: false, intentHere: false, sessions: [],
|
||||
}
|
||||
render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} />)
|
||||
expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('session row menu opens without opening the session and closes on selection', () => {
|
||||
const onOpen = 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()} />)
|
||||
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' }))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
// Escape closes without selecting (Menu onClose path).
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('flat variant renders no twist even for a parent and ignores toggling', () => {
|
||||
const node: SessionNode = {
|
||||
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 />)
|
||||
expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the hover card after the dwell and suppresses it while the row menu is open', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const node: SessionNode = {
|
||||
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()} />)
|
||||
const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
// Card body: full title + relative time + running status.
|
||||
expect(screen.getAllByText('Hovered')).toHaveLength(2)
|
||||
expect(screen.getByText('1min ago')).toBeTruthy()
|
||||
expect(screen.getByText('Running')).toBeTruthy()
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
// Menu open (disabled=true) suppresses the card for the same hover.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for Hovered' }))
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(1000) })
|
||||
expect(screen.queryByText('1min ago')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('idle hover card shows the Idle status line', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const node: SessionNode = {
|
||||
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()} />)
|
||||
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByText('Idle')).toBeTruthy()
|
||||
expect(screen.getByText('now ago')).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'Drag me', children: [], hasChildren: false,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
const inactive = dragProps()
|
||||
const { rerender } = render(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
|
||||
)
|
||||
const row = screen.getByRole('treeitem')
|
||||
stubRect(row)
|
||||
expect(row.getAttribute('draggable')).toBe('true')
|
||||
fireEvent.dragStart(row, { dataTransfer })
|
||||
expect(inactive.start).toHaveBeenCalledOnce()
|
||||
// Inactive drag: hover and drop are rejected.
|
||||
fireEvent.dragOver(row, { dataTransfer })
|
||||
fireEvent.drop(row, { dataTransfer })
|
||||
expect(inactive.hover).not.toHaveBeenCalled()
|
||||
expect(inactive.drop).not.toHaveBeenCalled()
|
||||
fireEvent.dragEnd(row)
|
||||
expect(inactive.end).toHaveBeenCalledOnce()
|
||||
|
||||
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} />,
|
||||
)
|
||||
stubRect(screen.getByRole('treeitem'))
|
||||
// Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
|
||||
fireDrag(screen.getByRole('treeitem'), 'dragOver', 105)
|
||||
expect(active.hover).toHaveBeenCalledWith('before')
|
||||
fireDrag(screen.getByRole('treeitem'), 'dragOver', 130)
|
||||
expect(active.hover).toHaveBeenCalledWith('after')
|
||||
fireDrag(screen.getByRole('treeitem'), 'drop', 130)
|
||||
expect(active.drop).toHaveBeenCalledWith('after')
|
||||
|
||||
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} />,
|
||||
)
|
||||
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts'
|
||||
import { deriveFlat, deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts'
|
||||
import { createWorkspaceViewStore } from '../src/client/stores.ts'
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
@@ -52,6 +53,12 @@ describe('deriveGroups', () => {
|
||||
expect(deriveGroups({ ...list(), intent: hiddenIntent }, [target], view())[0]!.intentHere).toBe(false)
|
||||
})
|
||||
|
||||
it('an Intent no longer forces its target group expanded (viewer owns expansion)', () => {
|
||||
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'connecting' as const }
|
||||
const groups = deriveGroups({ ...list(), intent }, [workspace('first', [])], view())
|
||||
expect(groups[0]).toEqual(expect.objectContaining({ intentHere: true, expanded: false }))
|
||||
})
|
||||
|
||||
it('search filters real Sessions and omits the Intent placeholder', () => {
|
||||
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'ready' as const }
|
||||
const groups = deriveGroups({ ...list(summary('match', 1)), intent }, [workspace('first', ['match'])], view([], 'match'))
|
||||
@@ -135,6 +142,39 @@ describe('deriveGroups', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveFlat', () => {
|
||||
it('flattens every session — fork children included — newest-first with id tiebreak', () => {
|
||||
const parent = summary('parent', 10)
|
||||
const child = { ...summary('child', 30), parentId: parent.id }
|
||||
const tieB = summary('tie-b', 20)
|
||||
const tieA = summary('tie-a', 20)
|
||||
const rows = deriveFlat(list(parent, child, tieB, tieA), { query: '' })
|
||||
expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
|
||||
// Rows are branch-free: no children, no expansion.
|
||||
expect(rows.every(row => row.children.length === 0 && !row.hasChildren && !row.expanded)).toBe(true)
|
||||
})
|
||||
|
||||
it('search filters by case-insensitive display-title substring', () => {
|
||||
const hit = { ...summary('hit', 2), displayTitle: 'Needle row' }
|
||||
const miss = { ...summary('miss', 1), displayTitle: 'Other' }
|
||||
expect(deriveFlat(list(hit, miss), { query: ' NEEDLE ' }).map(row => row.id)).toEqual([sid('hit')])
|
||||
})
|
||||
|
||||
it('tolerates ids whose summary has not landed yet', () => {
|
||||
const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
|
||||
expect(deriveFlat(partial, { query: '' }).map(row => row.id)).toEqual([sid('present')])
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWorkspaceViewStore', () => {
|
||||
it('defaults to workspace grouping; setGroupBy is the sole mutation', () => {
|
||||
const store = createWorkspaceViewStore().create()
|
||||
expect(store.getSnapshot().groupBy).toBe('workspace')
|
||||
store.actions.setGroupBy('flat')
|
||||
expect(store.getSnapshot().groupBy).toBe('flat')
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectLabel', () => {
|
||||
it('uses the Ungrouped fallback and extracts POSIX and Windows basenames', () => {
|
||||
expect(projectLabel(undefined)).toBe(UNGROUPED_LABEL)
|
||||
458
packages/client/ui-workspace/tests/workspace-browser.spec.tsx
Normal file
458
packages/client/ui-workspace/tests/workspace-browser.spec.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type {
|
||||
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts'
|
||||
import { createWorkspaceViewStore } from '../src/client/stores.ts'
|
||||
import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
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, updatedAt, ...overrides,
|
||||
})
|
||||
const sessionState = (items: readonly SessionSummary[], overrides: Partial<SessionListState> = {}): SessionListState => ({
|
||||
ids: items.map(item => item.id),
|
||||
byId: Object.fromEntries(items.map(item => [item.id, item])),
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
intent: undefined,
|
||||
...overrides,
|
||||
})
|
||||
const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
|
||||
workspaceId: wid(id), path: `/projects/${id}`, title,
|
||||
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
|
||||
items, intent: undefined, 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)
|
||||
|
||||
/** 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 {
|
||||
const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row)
|
||||
Object.defineProperty(event, 'clientY', { value: clientY })
|
||||
Object.defineProperty(event, 'dataTransfer', { value: { effectAllowed: '', dropEffect: '' } })
|
||||
fireEvent(row, event)
|
||||
}
|
||||
|
||||
function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
const store = createWorkspaceViewStore().create()
|
||||
const props: WorkspaceBrowserProps = {
|
||||
wide: true,
|
||||
expandSidebar: vi.fn(),
|
||||
useSessions: hook(sessionState([])),
|
||||
useWorkspaces: hook(workspaceState([])),
|
||||
useStore: bindSnapshotSelector(store),
|
||||
actions: store.actions,
|
||||
startSession: vi.fn(),
|
||||
open: vi.fn(),
|
||||
renameWorkspace: vi.fn(async () => {}),
|
||||
insertSessionBefore: vi.fn(async () => {}),
|
||||
createWorkspace: vi.fn(async () => workspace('created', [])),
|
||||
...overrides,
|
||||
}
|
||||
const view = render(<WorkspaceBrowser {...props} />)
|
||||
return { view, props, store }
|
||||
}
|
||||
|
||||
/** Re-render with (possibly) changed props — WorkspaceBrowser has no side channel. */
|
||||
function rerender(b: ReturnType<typeof mount>, overrides: Partial<WorkspaceBrowserProps>) {
|
||||
Object.assign(b.props, overrides)
|
||||
b.view.rerender(<WorkspaceBrowser {...b.props} />)
|
||||
}
|
||||
|
||||
describe('WorkspaceBrowser', () => {
|
||||
it('renders the grouped tree by default and switches to the flat list via Group by', () => {
|
||||
const sessions = sessionState([summary('alpha-s', 2), summary('beta-s', 1)])
|
||||
const b = mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s']), workspace('beta', ['beta-s'])])),
|
||||
})
|
||||
expect(screen.getByText('Workspaces')).toBeTruthy()
|
||||
expect(screen.getByText('alpha')).toBeTruthy()
|
||||
// Sessions hidden while their group is folded.
|
||||
expect(screen.queryByText('alpha-s')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Group by' }))
|
||||
expect(screen.getByText('Group by')).toBeTruthy() // the menu heading label
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'In one list' }))
|
||||
// Store-driven flip: title changes, rows flatten newest-first, headers gone.
|
||||
expect(b.store.getSnapshot().groupBy).toBe('flat')
|
||||
expect(screen.getByText('Sessions')).toBeTruthy()
|
||||
expect(screen.queryByText('alpha')).toBeNull()
|
||||
expect(screen.getByText('alpha-s')).toBeTruthy()
|
||||
expect(screen.getByText('beta-s')).toBeTruthy()
|
||||
|
||||
// Back to workspace grouping through the same menu.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Group by' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'WorkSpace' }))
|
||||
expect(b.store.getSnapshot().groupBy).toBe('workspace')
|
||||
expect(screen.getByText('Workspaces')).toBeTruthy()
|
||||
|
||||
// Escape closes the menu without picking.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Group by' }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(b.store.getSnapshot().groupBy).toBe('workspace')
|
||||
})
|
||||
|
||||
it('expands a group on click and opens a session row', () => {
|
||||
const open = vi.fn()
|
||||
mount({
|
||||
useSessions: hook(sessionState([summary('alpha-s', 1)])),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])),
|
||||
open,
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
fireEvent.click(screen.getByText('alpha-s'))
|
||||
expect(open).toHaveBeenCalledWith(sid('alpha-s'))
|
||||
// Collapse hides the row again.
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
expect(screen.queryByText('alpha-s')).toBeNull()
|
||||
})
|
||||
|
||||
it('unfolds a session subtree through the row twist', () => {
|
||||
const parent = summary('parent-s', 2)
|
||||
const child = { ...summary('child-s', 1), parentId: parent.id }
|
||||
mount({
|
||||
useSessions: hook(sessionState([parent, child])),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['parent-s', 'child-s'])])),
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
expect(screen.queryByText('child-s')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
expect(screen.getByText('child-s')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
expect(screen.queryByText('child-s')).toBeNull()
|
||||
})
|
||||
|
||||
it('auto-expands the selected session group and starts a session from the group +', () => {
|
||||
const startSession = vi.fn()
|
||||
mount({
|
||||
useSessions: hook(sessionState([summary('alpha-s', 1)], { current: sid('alpha-s') })),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])),
|
||||
startSession,
|
||||
})
|
||||
// The current-group effect expanded the owning group without a click.
|
||||
expect(screen.getByText('alpha-s')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session in alpha' }))
|
||||
expect(startSession).toHaveBeenCalledWith(wid('alpha'))
|
||||
})
|
||||
|
||||
it('auto-expands the Ungrouped bucket for a loose current session; its header has no menu and its + is inert', () => {
|
||||
const startSession = vi.fn()
|
||||
mount({
|
||||
useSessions: hook(sessionState([summary('loose', 1)], { current: sid('loose') })),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', [])])),
|
||||
startSession,
|
||||
})
|
||||
// The loose session's group is UNGROUPED_KEY: expanded by the effect.
|
||||
expect(screen.getByText('loose')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'Workspace actions for Ungrouped' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' }))
|
||||
expect(startSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an already-expanded group when the selection moves within it', () => {
|
||||
const first = sessionState([summary('a', 2), summary('b', 1)], { current: sid('a') })
|
||||
const b = mount({
|
||||
useSessions: hook(first),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['a', 'b'])])),
|
||||
})
|
||||
expect(screen.getByText('a')).toBeTruthy()
|
||||
// Selection hop inside the same group: the effect re-runs and leaves the
|
||||
// expansion list unchanged (no duplicate key, group still open).
|
||||
rerender(b, { useSessions: hook({ ...first, current: sid('b') }) })
|
||||
expect(screen.getByText('b')).toBeTruthy()
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
expect(screen.queryByText('b')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the intent placeholder in both modes', () => {
|
||||
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('alpha') }, prompt: '', phase: 'connecting' as const }
|
||||
const sessions = sessionState([], { intent, current: sid('intent') })
|
||||
const b = mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', [])])),
|
||||
})
|
||||
// Grouped: the current-group effect expands the target group.
|
||||
expect(screen.getByText('New session')).toBeTruthy()
|
||||
b.store.actions.setGroupBy('flat')
|
||||
rerender(b, {})
|
||||
expect(screen.getByText('New session')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('searches across groups, clears via the clear button, and shows the empty states', () => {
|
||||
const sessions = sessionState([
|
||||
summary('needle-row', 2, { displayTitle: 'Needle row' }),
|
||||
summary('other-row', 1, { displayTitle: 'Other row' }),
|
||||
])
|
||||
mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])),
|
||||
})
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('Search name, keywords...')
|
||||
fireEvent.change(input, { target: { value: 'needle' } })
|
||||
// Search forces matches visible without expansion state.
|
||||
expect(screen.getByText('Needle row')).toBeTruthy()
|
||||
expect(screen.queryByText('Other row')).toBeNull()
|
||||
fireEvent.change(input, { target: { value: 'zzz' } })
|
||||
expect(screen.getByText('No matches')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
|
||||
expect(input.value).toBe('')
|
||||
// Clicking the field row focuses the input (wide mode).
|
||||
fireEvent.click(input.parentElement as HTMLElement)
|
||||
expect(document.activeElement).toBe(input)
|
||||
})
|
||||
|
||||
it('shows the no-sessions empty state in both modes', () => {
|
||||
const b = mount()
|
||||
expect(screen.getByText('No sessions yet')).toBeTruthy()
|
||||
b.store.actions.setGroupBy('flat')
|
||||
rerender(b, {})
|
||||
expect(screen.getByText('No sessions yet')).toBeTruthy()
|
||||
// Flat search misses show No matches.
|
||||
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'x' } })
|
||||
expect(screen.getByText('No matches')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rail state renders icon controls that request expansion', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const expandSidebar = vi.fn()
|
||||
const b = mount({ wide: false, expandSidebar })
|
||||
// No wide chrome in rail state.
|
||||
expect(screen.queryByText('Workspaces')).toBeNull()
|
||||
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
expect(expandSidebar).toHaveBeenCalledTimes(1)
|
||||
// The wide flip mounts the input and focuses it after the slide.
|
||||
rerender(b, { wide: true })
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
expect(document.activeElement).toBe(input)
|
||||
// Wide search button is decorative (tabIndex -1, no expand call).
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
expect(expandSidebar).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('rail create-workspace expands the shell and opens the picker; wide toggles in place', () => {
|
||||
const expandSidebar = vi.fn()
|
||||
const b = 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()
|
||||
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' }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => {
|
||||
const insertSessionBefore = vi.fn(async () => {})
|
||||
const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])
|
||||
mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])])),
|
||||
insertSessionBefore,
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
const rows = screen.getAllByRole('treeitem').slice(1) // drop the group header
|
||||
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.
|
||||
fireDrag(three, 'dragOver', 205)
|
||||
fireDrag(three, 'drop', 205)
|
||||
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('three'))
|
||||
|
||||
// Dropping right back onto its own position is a no-op — top half
|
||||
// (anchor = itself) and bottom half (anchor = the next root) alike.
|
||||
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)
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
fireDrag(one, 'drop', 130)
|
||||
expect(insertSessionBefore).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('still sends the reorder when the dragged row left the group mid-drag', () => {
|
||||
const insertSessionBefore = vi.fn(async () => {})
|
||||
const sessions = sessionState([summary('one', 2), summary('two', 1)])
|
||||
const b = mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
|
||||
insertSessionBefore,
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement
|
||||
fireEvent.dragStart(one, { dataTransfer: { effectAllowed: '', dropEffect: '' } })
|
||||
// The host dropped "one" from the workspace account while the drag is in
|
||||
// flight: the source index is gone but the drop still resolves its anchor.
|
||||
rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['two'])])) })
|
||||
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'))
|
||||
})
|
||||
|
||||
it('drag end without a drop clears markers; bottom-half drop appends past the last row', () => {
|
||||
const insertSessionBefore = vi.fn(async () => {})
|
||||
const sessions = sessionState([summary('one', 2), summary('two', 1)])
|
||||
mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
|
||||
insertSessionBefore,
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
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)
|
||||
// The drag ended: rows no longer accept drops.
|
||||
fireDrag(two, 'drop', 180)
|
||||
expect(insertSessionBefore).not.toHaveBeenCalled()
|
||||
|
||||
// Bottom half of the last row: append (anchor omitted).
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
fireDrag(two, 'dragOver', 180)
|
||||
fireDrag(two, 'drop', 180)
|
||||
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined)
|
||||
})
|
||||
|
||||
it('logs and keeps the order when the reorder call rejects', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
const insertSessionBefore = vi.fn(async () => { throw new Error('stale anchor') })
|
||||
const sessions = sessionState([summary('one', 2), summary('two', 1)])
|
||||
mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
|
||||
insertSessionBefore,
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
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)
|
||||
await waitFor(() => { expect(warn).toHaveBeenCalledWith('session reorder rejected:', expect.any(Error)) })
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('renames a workspace through the row menu dialog', async () => {
|
||||
let resolveRename!: () => void
|
||||
const renameWorkspace = vi.fn(() => new Promise<void>((resolve) => { resolveRename = resolve }))
|
||||
mount({
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha'), workspace('beta', [], 'Beta')])),
|
||||
renameWorkspace,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
|
||||
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)
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).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)
|
||||
fireEvent.change(input, { target: { value: 'Gamma' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
|
||||
expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Gamma')
|
||||
// While renaming: input disabled, close blocked, Enter ignored.
|
||||
expect(input.disabled).toBe(true)
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
await act(async () => { resolveRename() })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('rename via Enter, failure surfaces the error, Cancel closes', async () => {
|
||||
const renameWorkspace = vi.fn(async () => { throw new Error('rename conflict') })
|
||||
mount({
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
|
||||
renameWorkspace,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('Workspace name')
|
||||
// Enter with a blocked draft (unchanged) does nothing.
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(renameWorkspace).not.toHaveBeenCalled()
|
||||
fireEvent.change(input, { target: { value: 'Renamed' } })
|
||||
fireEvent.keyDown(input, { key: 'a' })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Renamed')
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('rename conflict') })
|
||||
// The dialog stays for retry; typing clears the error; Cancel closes.
|
||||
fireEvent.change(input, { target: { value: 'Renamed2' } })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('reports non-Error rename failures as text', async () => {
|
||||
const renameWorkspace = vi.fn(async () => { throw 'denied' })
|
||||
mount({
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
|
||||
renameWorkspace,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
|
||||
fireEvent.change(screen.getByLabelText('Workspace name'), { target: { value: 'Other' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
|
||||
})
|
||||
|
||||
it('search hides drag affordances (rows are not draggable during search)', () => {
|
||||
const sessions = sessionState([summary('needle-a', 2, { displayTitle: 'Needle A' })])
|
||||
mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])),
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'needle' } })
|
||||
const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement
|
||||
expect(row.getAttribute('draggable')).toBe('false')
|
||||
})
|
||||
})
|
||||
@@ -942,10 +942,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'list(): Workspace[]',
|
||||
jsDoc: '/**\n * Synchronous workspace projection in durable registry order. Every\n * entity\'s `sessionIds` getter is already filtered by the startup/live\n * canonical-cwd header index; this method performs no persistence reads.\n * @returns a fresh ordered array of workspace entities.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async touchSession(sessionId: SessionId): Promise<void>',
|
||||
jsDoc: '/**\n * Move one accounted, cwd-validated session to the front of its workspace.\n * Ungrouped sessions and candidates filtered by the header check are\n * no-ops. The owning workspace\'s relative position never changes.\n * @param sessionId - Session whose activity was observed.\n * @returns resolution after the possible record write.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async resolveByPath(path: string): Promise<Workspace | undefined>',
|
||||
jsDoc: '/**\n * Resolve by canonical directory path without creating or mutating a\n * workspace. A missing path rejects during `realpath`; an existing unowned\n * directory returns `undefined`.\n * @param path - Existing directory path in any spelling.\n * @returns the workspace owning the canonical path, when one exists.\n */',
|
||||
@@ -2522,7 +2518,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'Workspace',
|
||||
declaration: 'export interface Workspace {\n readonly id: WorkspaceId;\n readonly path: string;\n readonly title: string;\n readonly createdAt: string;\n readonly updatedAt: string;\n readonly sessionIds: readonly SessionId[];\n setTitle(title: string): Promise<void>;\n attachSession(sessionId: SessionId): Promise<void>;\n detachSession(sessionId: SessionId): Promise<void>;\n status(): Promise<\'ok\' | \'missing-dir\'>;\n}',
|
||||
declaration: 'export interface Workspace {\n readonly id: WorkspaceId;\n readonly path: string;\n readonly title: string;\n readonly createdAt: string;\n readonly updatedAt: string;\n readonly sessionIds: readonly SessionId[];\n setTitle(title: string): Promise<void>;\n attachSession(sessionId: SessionId): Promise<void>;\n insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise<void>;\n detachSession(sessionId: SessionId): Promise<void>;\n status(): Promise<\'ok\' | \'missing-dir\'>;\n}',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
|
||||
import {
|
||||
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, WorkspaceNameConflictError,
|
||||
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
|
||||
WorkspaceMoveInvalidError, WorkspaceNameConflictError,
|
||||
} from '@deepseek-ai/dsh-workspace'
|
||||
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
@@ -298,6 +299,15 @@ class SessionCwdConflict extends Error {
|
||||
/** Host failed before the registry could adopt a name-created directory. */
|
||||
class WorkspaceDirectoryCreationError extends Error {}
|
||||
|
||||
/** Shared workspace-not-found error response of the workspace.* mutation rows. */
|
||||
function workspaceNotFound<T>(request: RpcRequest<unknown>, workspaceId: string): RpcResponse<T> {
|
||||
return err(request, {
|
||||
code: 'workspace-not-found',
|
||||
message: `workspace "${workspaceId}" not found`,
|
||||
details: { workspaceId },
|
||||
})
|
||||
}
|
||||
|
||||
/** Wire projection of one workspace entity (the workspace.* value row). */
|
||||
function workspaceView(workspace: Workspace): WorkspaceView {
|
||||
return {
|
||||
@@ -680,6 +690,60 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
},
|
||||
|
||||
async rename(request) {
|
||||
const { payload } = request
|
||||
const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
|
||||
if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId)
|
||||
const title = payload.title.trim()
|
||||
// Uniqueness AND the same-title no-op both ride the create chain so
|
||||
// they observe the state left by earlier queued renames — checked
|
||||
// up front, a queued A→A could report success while an earlier A→B
|
||||
// still lands afterwards.
|
||||
const operation = workspaceCreationChain.then(async () => {
|
||||
if (title === workspace.title) return
|
||||
if (ctx.workspace.list().some(other => other.id !== workspace.id && other.title === title)) {
|
||||
throw new WorkspaceNameConflictError(title)
|
||||
}
|
||||
await workspace.setTitle(title)
|
||||
})
|
||||
workspaceCreationChain = operation.then(() => undefined, () => undefined)
|
||||
try {
|
||||
await operation
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof WorkspaceNameConflictError) {
|
||||
return err(request, {
|
||||
code: 'workspace-name-conflict',
|
||||
message: error.message,
|
||||
details: { name: error.workspaceName },
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return ok(request, { workspace: workspaceView(workspace) })
|
||||
},
|
||||
|
||||
async insertSessionBefore(request) {
|
||||
const { payload } = request
|
||||
const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
|
||||
if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId)
|
||||
try {
|
||||
await workspace.insertSessionBefore(payload.sessionId, payload.beforeSessionId)
|
||||
} catch (error: unknown) {
|
||||
// Only the entity's unaccounted-id rejection is the business code;
|
||||
// storage/durability failures propagate as internal errors.
|
||||
if (!(error instanceof WorkspaceMoveInvalidError)) throw error
|
||||
return err(request, {
|
||||
code: 'workspace-move-invalid',
|
||||
message: error.message,
|
||||
details: {
|
||||
workspaceId: payload.workspaceId,
|
||||
sessionId: payload.sessionId,
|
||||
...payload.beforeSessionId === undefined ? {} : { beforeSessionId: payload.beforeSessionId },
|
||||
},
|
||||
})
|
||||
}
|
||||
return ok(request, { workspace: workspaceView(workspace) })
|
||||
},
|
||||
},
|
||||
|
||||
host: {
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface RpcMethodMap {
|
||||
'host.describe': HostApi['describe']
|
||||
'workspace.list': WorkspaceApi['list']
|
||||
'workspace.create': WorkspaceApi['create']
|
||||
'workspace.rename': WorkspaceApi['rename']
|
||||
'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore']
|
||||
}
|
||||
|
||||
/** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */
|
||||
|
||||
@@ -40,6 +40,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
z.object({ code: z.literal('workspace-not-found'), message: z.string(), details: z.object({ workspaceId: z.string() }) }),
|
||||
z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }),
|
||||
z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }),
|
||||
z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }),
|
||||
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
|
||||
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
|
||||
]) as unknown as z.ZodType<RpcError>
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface RpcErrorDetailsMap {
|
||||
'workspace-not-found': { workspaceId: string }
|
||||
'workspace-invalid-path': { path: string }
|
||||
'workspace-name-conflict': { name: string }
|
||||
'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId }
|
||||
'agent-busy': { reason: string }
|
||||
'internal': {}
|
||||
}
|
||||
|
||||
@@ -44,3 +44,29 @@ export const workspaceCreateValueSchema = z.object({
|
||||
workspace: workspaceViewSchema,
|
||||
created: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.create'>>>
|
||||
|
||||
/** workspace.rename request payload: the new title must be non-blank. */
|
||||
export const workspaceRenameRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
title: z.string(),
|
||||
}).refine(
|
||||
payload => payload.title.trim() !== '',
|
||||
{ message: 'workspace.rename requires a non-blank title' },
|
||||
) satisfies z.ZodType<Wire<RequestPayload<'workspace.rename'>>>
|
||||
|
||||
/** workspace.rename response value. */
|
||||
export const workspaceRenameValueSchema = z.object({
|
||||
workspace: workspaceViewSchema,
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.rename'>>>
|
||||
|
||||
/** workspace.insertSessionBefore request payload (anchor omitted = append to end). */
|
||||
export const workspaceInsertSessionBeforeRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
sessionId: sessionIdSchema,
|
||||
beforeSessionId: sessionIdSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'workspace.insertSessionBefore'>>>
|
||||
|
||||
/** workspace.insertSessionBefore response value. */
|
||||
export const workspaceInsertSessionBeforeValueSchema = z.object({
|
||||
workspace: workspaceViewSchema,
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.insertSessionBefore'>>>
|
||||
|
||||
@@ -24,7 +24,10 @@ export interface WorkspaceView {
|
||||
path: string
|
||||
/** Unique display title (defaults to the path basename at create). */
|
||||
title: string
|
||||
/** Sessions accounted under this workspace, newest-first for display. */
|
||||
/**
|
||||
* Sessions accounted under this workspace, in manually owned order
|
||||
* (attach prepends, insertSessionBefore reorders; activity never does).
|
||||
*/
|
||||
sessionIds: SessionId[]
|
||||
/** ISO-8601 creation instant. */
|
||||
createdAt: string
|
||||
@@ -52,4 +55,27 @@ export interface WorkspaceApi {
|
||||
*/
|
||||
create(request: RpcRequest<{ path?: string; name?: string }>):
|
||||
Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>>
|
||||
|
||||
/**
|
||||
* Renames a workspace. `title` is trimmed and must be non-empty
|
||||
* (schema-enforced). An unknown id fails with `workspace-not-found`; a
|
||||
* title equal to another workspace's fails with `workspace-name-conflict`.
|
||||
* Renaming to the current title is a no-op success (no durable write).
|
||||
*/
|
||||
rename(request: RpcRequest<{ workspaceId: WorkspaceId; title: string }>):
|
||||
Promise<RpcResponse<{ workspace: WorkspaceView }>>
|
||||
|
||||
/**
|
||||
* Moves an accounted session within its workspace's manual order,
|
||||
* DOM-insertBefore-like: with `beforeSessionId` the session is inserted
|
||||
* before that anchor; omitted appends to the end. An unknown workspace
|
||||
* fails with `workspace-not-found`; a session or anchor not accounted by
|
||||
* the workspace fails with `workspace-move-invalid`. A move to the current
|
||||
* position is a no-op success.
|
||||
*/
|
||||
insertSessionBefore(request: RpcRequest<{
|
||||
workspaceId: WorkspaceId
|
||||
sessionId: SessionId
|
||||
beforeSessionId?: SessionId
|
||||
}>): Promise<RpcResponse<{ workspace: WorkspaceView }>>
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@ import {
|
||||
} from '../api/sessions.schema.ts'
|
||||
import {
|
||||
workspaceCreateValueSchema,
|
||||
workspaceInsertSessionBeforeValueSchema,
|
||||
workspaceListValueSchema,
|
||||
workspaceRenameValueSchema,
|
||||
} from '../api/workspace.schema.ts'
|
||||
|
||||
/**
|
||||
@@ -55,6 +57,8 @@ export interface IApiClient {
|
||||
workspace: {
|
||||
list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>>
|
||||
create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.create'>>>
|
||||
rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.rename'>>>
|
||||
insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>>
|
||||
}
|
||||
events: {
|
||||
mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
|
||||
@@ -77,6 +81,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'host.describe': hostDescribeValueSchema,
|
||||
'workspace.list': workspaceListValueSchema,
|
||||
'workspace.create': workspaceCreateValueSchema,
|
||||
'workspace.rename': workspaceRenameValueSchema,
|
||||
'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema,
|
||||
}
|
||||
|
||||
/** Default unary timeout (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
|
||||
@@ -266,6 +272,8 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
readonly workspace: IApiClient['workspace'] = {
|
||||
list: (payload, signal) => this.callUnary('workspace.list', payload, signal),
|
||||
create: (payload, signal) => this.callUnary('workspace.create', payload, signal),
|
||||
rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal),
|
||||
insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal),
|
||||
}
|
||||
|
||||
readonly events: IApiClient['events'] = {
|
||||
|
||||
@@ -24,7 +24,9 @@ import {
|
||||
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
|
||||
import {
|
||||
workspaceCreateRequestSchema,
|
||||
workspaceInsertSessionBeforeRequestSchema,
|
||||
workspaceListRequestSchema,
|
||||
workspaceRenameRequestSchema,
|
||||
} from '../api/workspace.schema.ts'
|
||||
|
||||
/**
|
||||
@@ -50,6 +52,8 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
|
||||
'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) },
|
||||
'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) },
|
||||
'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) },
|
||||
'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) },
|
||||
}
|
||||
|
||||
/** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */
|
||||
|
||||
@@ -37,6 +37,8 @@ function scriptedApi(overrides: {
|
||||
workspace: {
|
||||
list: r => ok(r, { items: [] }),
|
||||
create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }),
|
||||
rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
|
||||
insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
|
||||
},
|
||||
events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
|
||||
respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })),
|
||||
@@ -66,6 +68,19 @@ describe('unary round trip', () => {
|
||||
expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false }] } })
|
||||
})
|
||||
|
||||
it('routes workspace rename and insertSessionBefore through the wire', async () => {
|
||||
const api = scriptedApi()
|
||||
const c = client(api)
|
||||
const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' })
|
||||
expect(renamed.result.ok).toBe(true)
|
||||
const blankTitle = await c.workspace.rename({ workspaceId: 'w1' as never, title: ' ' })
|
||||
expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } })
|
||||
const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') })
|
||||
expect(anchored.result.ok).toBe(true)
|
||||
const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') })
|
||||
expect(appended.result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('passes business errors through as 200 + err result, not a throw', async () => {
|
||||
const api = scriptedApi({
|
||||
sessions: {
|
||||
|
||||
@@ -52,6 +52,18 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' }, created: true } },
|
||||
}
|
||||
},
|
||||
async rename(request) {
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } },
|
||||
}
|
||||
},
|
||||
async insertSessionBefore(request) {
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } },
|
||||
}
|
||||
},
|
||||
},
|
||||
events: {
|
||||
mux: (_request, signal) => stream(muxFrames, signal),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RpcId } from '../src/api/rpc.ts'
|
||||
import { RpcId, transportError } from '../src/api/rpc.ts'
|
||||
import {
|
||||
clientRequestSchema, clientResponseSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema,
|
||||
rpcReceiptSchema, rpcResultSchema, serverRequestSchema, serverResponseSchema,
|
||||
@@ -13,8 +13,10 @@ import {
|
||||
} from '../src/api/sessions.schema.ts'
|
||||
import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts'
|
||||
import {
|
||||
workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, workspaceListRequestSchema,
|
||||
workspaceListValueSchema, workspaceViewSchema,
|
||||
workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema,
|
||||
workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema,
|
||||
workspaceListRequestSchema, workspaceListValueSchema,
|
||||
workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema,
|
||||
} from '../src/api/workspace.schema.ts'
|
||||
import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts'
|
||||
import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts'
|
||||
@@ -30,6 +32,13 @@ describe('RpcId', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('transportError', () => {
|
||||
it('folds Error and non-Error throws into the internal error branch', () => {
|
||||
expect(transportError(new Error('wire down'))).toEqual({ ok: false, error: { code: 'internal', message: 'wire down', details: {} } })
|
||||
expect(transportError('raw')).toMatchObject({ ok: false, error: { code: 'internal', message: 'raw' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('rpcErrorSchema', () => {
|
||||
it('accepts every code branch with its required details', () => {
|
||||
expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
|
||||
@@ -40,6 +49,7 @@ describe('rpcErrorSchema', () => {
|
||||
expect(rpcErrorSchema.parse({ code: 'workspace-not-found', message: 'm', details: { workspaceId: 'w' } }).code).toBe('workspace-not-found')
|
||||
expect(rpcErrorSchema.parse({ code: 'workspace-invalid-path', message: 'm', details: { path: '/x' } }).code).toBe('workspace-invalid-path')
|
||||
expect(rpcErrorSchema.parse({ code: 'workspace-name-conflict', message: 'm', details: { name: 'x' } }).code).toBe('workspace-name-conflict')
|
||||
expect(rpcErrorSchema.parse({ code: 'workspace-move-invalid', message: 'm', details: { workspaceId: 'w', sessionId: 's' } }).code).toBe('workspace-move-invalid')
|
||||
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
|
||||
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
|
||||
})
|
||||
@@ -154,6 +164,19 @@ describe('workspace domain schemas', () => {
|
||||
expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false)
|
||||
})
|
||||
|
||||
it('rename requires a non-blank title (both refine arms)', () => {
|
||||
expect(workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: 'new' }).title).toBe('new')
|
||||
expect(() => workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: ' ' })).toThrow(/non-blank/)
|
||||
expect(workspaceRenameValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1')
|
||||
})
|
||||
|
||||
it('insertSessionBefore accepts an anchored and an anchorless move', () => {
|
||||
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2')
|
||||
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined()
|
||||
expect(() => workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1' })).toThrow()
|
||||
expect(workspaceInsertSessionBeforeValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('events frame schemas', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { request } from 'node:http'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { MockLlmBehavior, MockLlmServer, MockLlmServerEvent } from '../src/index.ts'
|
||||
import { startMockLlmServer } from '../src/index.ts'
|
||||
|
||||
@@ -179,9 +179,11 @@ describe('mock LLM server wire behaviors', () => {
|
||||
const response = await chat(server, { signal: controller.signal })
|
||||
controller.abort()
|
||||
await expect(response.text()).rejects.toThrow()
|
||||
await new Promise((resolve) => { setTimeout(resolve, 5) })
|
||||
|
||||
expect(server.requests[0]).toMatchObject({ behavior, outcome: 'client_closed' })
|
||||
// The server observes the socket close asynchronously; a fixed sleep
|
||||
// raced slow runners, so poll until the outcome lands.
|
||||
await vi.waitFor(() => {
|
||||
expect(server.requests[0]).toMatchObject({ behavior, outcome: 'client_closed' })
|
||||
})
|
||||
expect(events.filter(event => event.type === 'result')).toEqual([
|
||||
expect.objectContaining({ behavior, outcome: 'client_closed' }),
|
||||
])
|
||||
|
||||
@@ -15,6 +15,17 @@ import type { WorkspaceRecord } from './spec.ts'
|
||||
import type { Workspace, WorkspaceId } from './types.ts'
|
||||
import { realpathNormalize } from './paths.ts'
|
||||
|
||||
/** An insertSessionBefore request named a session or anchor not on the account (storage failures stay plain errors). */
|
||||
export class WorkspaceMoveInvalidError extends Error {
|
||||
/**
|
||||
* @param message - Which id was unaccounted and where.
|
||||
*/
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'WorkspaceMoveInvalidError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry-owned machinery an entity mutates through. Entities never see
|
||||
* the registry itself — only the open table, the canonical session-path
|
||||
@@ -137,30 +148,27 @@ export class WorkspaceEntity implements Workspace {
|
||||
: { ...record, sessionIds: [sessionId, ...record.sessionIds] })
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the durable candidate account without applying header projection.
|
||||
* @param sessionId - Candidate session id.
|
||||
* @returns whether this workspace's stored account contains the id.
|
||||
*/
|
||||
hasSession(sessionId: SessionId): boolean {
|
||||
return this.record.sessionIds.includes(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move one validated accounted session to the front without touching peers.
|
||||
* @param sessionId - Accounted session whose activity was observed.
|
||||
*/
|
||||
async touchSession(sessionId: SessionId): Promise<void> {
|
||||
if (
|
||||
this.host.sessionPath(sessionId) !== this.record.path
|
||||
|| this.record.sessionIds[0] === sessionId
|
||||
) return
|
||||
await this.mutate(record => !record.sessionIds.includes(sessionId) || record.sessionIds[0] === sessionId
|
||||
? record
|
||||
: {
|
||||
...record,
|
||||
sessionIds: [sessionId, ...record.sessionIds.filter(id => id !== sessionId)],
|
||||
})
|
||||
async insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise<void> {
|
||||
await this.mutate((record) => {
|
||||
if (!record.sessionIds.includes(sessionId)) {
|
||||
throw new WorkspaceMoveInvalidError(
|
||||
`cannot move session '${sessionId}' in workspace '${record.path}': the session is not accounted`,
|
||||
)
|
||||
}
|
||||
if (beforeSessionId !== undefined && !record.sessionIds.includes(beforeSessionId)) {
|
||||
throw new WorkspaceMoveInvalidError(
|
||||
`cannot move session '${sessionId}' before '${beforeSessionId}' in workspace '${record.path}': `
|
||||
+ 'the anchor session is not accounted',
|
||||
)
|
||||
}
|
||||
if (beforeSessionId === sessionId) return record
|
||||
const without = record.sessionIds.filter(id => id !== sessionId)
|
||||
const at = beforeSessionId === undefined ? without.length : without.indexOf(beforeSessionId)
|
||||
const sessionIds = [...without.slice(0, at), sessionId, ...without.slice(at)]
|
||||
return sessionIds.every((id, index) => id === record.sessionIds[index])
|
||||
? record
|
||||
: { ...record, sessionIds }
|
||||
})
|
||||
}
|
||||
|
||||
async detachSession(sessionId: SessionId): Promise<void> {
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { DomainGlobal, KvTable } from '@deepseek-ai/dsh-storage-domain'
|
||||
import { WorkspaceEntity } from './entity.ts'
|
||||
import type { WorkspaceEntityHost } from './entity.ts'
|
||||
|
||||
export { WorkspaceMoveInvalidError } from './entity.ts'
|
||||
import { realpathNormalize } from './paths.ts'
|
||||
import { workspaceDomainSpec } from './spec.ts'
|
||||
import type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts'
|
||||
@@ -47,6 +49,7 @@ export class WorkspaceNameConflictError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
workspace: WorkspaceRegistry
|
||||
@@ -82,7 +85,6 @@ export class WorkspaceRegistry extends Service {
|
||||
private readonly headers = new Map<SessionId, SessionHeader>()
|
||||
private readonly sessionPaths = new Map<SessionId, string>()
|
||||
private readonly invalidSessionPaths = new Map<SessionId, string>()
|
||||
private readonly pendingTouches = new Map<SessionId, Promise<void>>()
|
||||
private operationTail: Promise<void> = Promise.resolve()
|
||||
|
||||
private readonly host: WorkspaceEntityHost = {
|
||||
@@ -120,13 +122,6 @@ export class WorkspaceRegistry extends Service {
|
||||
this.validateStoredState(this.requireState())
|
||||
this.rebuildEntities()
|
||||
this.reportFilteredCandidates()
|
||||
// Session activity is authoritative even when no RPC/SSE consumer is
|
||||
// connected. This service-owned listener is disposed with the registry.
|
||||
this.ctx.on('session/event', (session) => {
|
||||
void this.touchSession(session.id).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`workspace activity touch failed for session '${session.id}': ${String(error)}`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,32 +168,6 @@ export class WorkspaceRegistry extends Service {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Move one accounted, cwd-validated session to the front of its workspace.
|
||||
* Ungrouped sessions and candidates filtered by the header check are
|
||||
* no-ops. The owning workspace's relative position never changes.
|
||||
* @param sessionId - Session whose activity was observed.
|
||||
* @returns resolution after the possible record write.
|
||||
*/
|
||||
async touchSession(sessionId: SessionId): Promise<void> {
|
||||
const pending = this.pendingTouches.get(sessionId)
|
||||
if (pending !== undefined) {
|
||||
await pending
|
||||
return
|
||||
}
|
||||
for (const entity of this.entities.values()) {
|
||||
if (!entity.hasSession(sessionId)) continue
|
||||
const touch = entity.touchSession(sessionId)
|
||||
this.pendingTouches.set(sessionId, touch)
|
||||
try {
|
||||
await touch
|
||||
} finally {
|
||||
this.pendingTouches.delete(sessionId)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve by canonical directory path without creating or mutating a
|
||||
* workspace. A missing path rejects during `realpath`; an existing unowned
|
||||
|
||||
@@ -41,10 +41,12 @@ export interface Workspace {
|
||||
readonly updatedAt: string
|
||||
|
||||
/**
|
||||
* Header-validated sessions in newest-first display order. The durable
|
||||
* candidate account is filtered synchronously: missing headers, invalid
|
||||
* cwd values, and canonical cwd mismatches are never returned. A subsequent
|
||||
* workspace mutation prunes those filtered candidates durably.
|
||||
* Header-validated sessions in manually owned order: a new session is
|
||||
* prepended at attach, explicit reordering goes through
|
||||
* `insertSessionBefore`, and activity never reorders. The durable candidate
|
||||
* account is filtered synchronously: missing headers, invalid cwd values,
|
||||
* and canonical cwd mismatches are never returned. A subsequent workspace
|
||||
* mutation prunes those filtered candidates durably.
|
||||
*/
|
||||
readonly sessionIds: readonly SessionId[]
|
||||
|
||||
@@ -57,8 +59,7 @@ export interface Workspace {
|
||||
|
||||
/**
|
||||
* Prepend a session to this workspace's candidate account. An already
|
||||
* accounted id resolves without writing; activity-driven reordering uses
|
||||
* `WorkspaceRegistry.touchSession` instead. A new id's live or persisted
|
||||
* accounted id resolves without writing. A new id's live or persisted
|
||||
* header cwd must resolve to an existing directory equal to {@link path};
|
||||
* unknown ids, missing or invalid cwd values, and mismatches reject without
|
||||
* writing.
|
||||
@@ -67,6 +68,18 @@ export interface Workspace {
|
||||
*/
|
||||
attachSession(sessionId: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Move an accounted session within the manual order, DOM-insertBefore-like:
|
||||
* with an anchor the session lands before it, without one it appends to the
|
||||
* end. Only the moved id changes position. A session or anchor absent from
|
||||
* the account rejects without writing; a move to the current position
|
||||
* resolves without writing (decided on the domain write chain).
|
||||
* @param sessionId - The accounted session to move.
|
||||
* @param beforeSessionId - Accounted anchor to insert before; omitted appends.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Remove a session from this workspace's account. Idempotent: an id not on
|
||||
* the account resolves without writing (decided on the domain write chain,
|
||||
|
||||
@@ -10,8 +10,7 @@ import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
|
||||
import { WorkspaceEntity } from '../src/entity.ts'
|
||||
import WorkspaceRegistry, { WorkspaceId, WorkspaceNameConflictError } from '../src/index.ts'
|
||||
import WorkspaceRegistry, { WorkspaceId, WorkspaceMoveInvalidError, WorkspaceNameConflictError } from '../src/index.ts'
|
||||
import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts'
|
||||
|
||||
const DOMAIN_VERSION = 2
|
||||
@@ -434,13 +433,12 @@ describe('WorkspaceRegistry create and lookup', () => {
|
||||
})
|
||||
|
||||
describe('Workspace session ordering', () => {
|
||||
it('prepends new attaches, keeps repeat attach idempotent, and touches one id only', async () => {
|
||||
it('prepends new attaches and keeps repeat attach idempotent', async () => {
|
||||
const dir = await makeDir('attach-order')
|
||||
const result = await harness()
|
||||
result.setSessions([
|
||||
header('s1', dir, 1),
|
||||
header('s2', dir, 2),
|
||||
header('ungrouped', dir, 3),
|
||||
])
|
||||
const workspace = await result.registry.create(dir)
|
||||
await workspace.attachSession(SessionId('s1'))
|
||||
@@ -448,59 +446,57 @@ describe('Workspace session ordering', () => {
|
||||
expect(workspace.sessionIds).toEqual(['s2', 's1'])
|
||||
await workspace.attachSession(SessionId('s1'))
|
||||
expect(workspace.sessionIds).toEqual(['s2', 's1'])
|
||||
|
||||
const beforeTouch = result.changes.length
|
||||
await Promise.all([
|
||||
result.registry.touchSession(SessionId('s1')),
|
||||
result.registry.touchSession(SessionId('s1')),
|
||||
])
|
||||
expect(workspace.sessionIds).toEqual(['s1', 's2'])
|
||||
expect(result.changes).toHaveLength(beforeTouch + 1)
|
||||
await result.registry.touchSession(SessionId('s1'))
|
||||
expect(result.changes).toHaveLength(beforeTouch + 1)
|
||||
await result.registry.touchSession(SessionId('ungrouped'))
|
||||
expect(result.changes).toHaveLength(beforeTouch + 1)
|
||||
expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s1', 's2'])
|
||||
expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s2', 's1'])
|
||||
})
|
||||
|
||||
it('does not resurrect a session detached before its queued touch', async () => {
|
||||
const dir = await makeDir('detach-touch-race')
|
||||
const result = await harness({ sessions: [header('s1', dir), header('s2', dir)] })
|
||||
it('moves one id before an anchor or to the end, durably', async () => {
|
||||
const dir = await makeDir('insert-before')
|
||||
const result = await harness()
|
||||
result.setSessions([header('s1', dir, 1), header('s2', dir, 2), header('s3', dir, 3)])
|
||||
const workspace = await result.registry.create(dir)
|
||||
await workspace.attachSession(SessionId('s1'))
|
||||
await workspace.attachSession(SessionId('s2'))
|
||||
await workspace.attachSession(SessionId('s3'))
|
||||
expect(workspace.sessionIds).toEqual(['s3', 's2', 's1'])
|
||||
|
||||
await workspace.insertSessionBefore(SessionId('s1'), SessionId('s2'))
|
||||
expect(workspace.sessionIds).toEqual(['s3', 's1', 's2'])
|
||||
await workspace.insertSessionBefore(SessionId('s3'))
|
||||
expect(workspace.sessionIds).toEqual(['s1', 's2', 's3'])
|
||||
expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s1', 's2', 's3'])
|
||||
})
|
||||
|
||||
it('treats self-anchored and already-in-place moves as no-ops without writing', async () => {
|
||||
const dir = await makeDir('insert-noop')
|
||||
const result = await harness()
|
||||
result.setSessions([header('s1', dir, 1), header('s2', dir, 2)])
|
||||
const workspace = await result.registry.create(dir)
|
||||
await workspace.attachSession(SessionId('s1'))
|
||||
await workspace.attachSession(SessionId('s2'))
|
||||
await Promise.all([
|
||||
workspace.detachSession(SessionId('s1')),
|
||||
result.registry.touchSession(SessionId('s1')),
|
||||
])
|
||||
const written = result.changes.length
|
||||
|
||||
await workspace.insertSessionBefore(SessionId('s1'), SessionId('s1'))
|
||||
await workspace.insertSessionBefore(SessionId('s2'), SessionId('s1'))
|
||||
await workspace.insertSessionBefore(SessionId('s1'))
|
||||
await workspace.detachSession(SessionId('absent'))
|
||||
expect(result.changes).toHaveLength(written)
|
||||
expect(workspace.sessionIds).toEqual(['s2'])
|
||||
expect(workspace.sessionIds).toEqual(['s2', 's1'])
|
||||
})
|
||||
|
||||
it('does not reinsert a candidate absent at the durable touch slot', async () => {
|
||||
const dir = await makeDir('stale-touch')
|
||||
const id = WorkspaceId('00000000-0000-4000-8000-000000000030')
|
||||
let durable = record(dir, ['s2', 's1'])
|
||||
const table = {
|
||||
update: async (
|
||||
_id: WorkspaceId,
|
||||
update: (current: WorkspaceRecord) => WorkspaceRecord,
|
||||
): Promise<WorkspaceRecord> => {
|
||||
durable = { ...durable, sessionIds: [SessionId('s2')] }
|
||||
durable = update(durable)
|
||||
return durable
|
||||
},
|
||||
}
|
||||
const entity = new WorkspaceEntity({
|
||||
table: () => table as never,
|
||||
sessionPath: () => dir,
|
||||
readSessionHeader: async () => header('s1', dir),
|
||||
rememberSessionPath: () => {},
|
||||
}, id, record(dir, ['s2', 's1']))
|
||||
await entity.touchSession(SessionId('s1'))
|
||||
expect(durable.sessionIds).toEqual(['s2'])
|
||||
it('rejects moves naming an unaccounted session or anchor', async () => {
|
||||
const dir = await makeDir('insert-invalid')
|
||||
const result = await harness()
|
||||
result.setSessions([header('s1', dir, 1)])
|
||||
const workspace = await result.registry.create(dir)
|
||||
await workspace.attachSession(SessionId('s1'))
|
||||
const written = result.changes.length
|
||||
|
||||
await expect(workspace.insertSessionBefore(SessionId('ghost')))
|
||||
.rejects.toBeInstanceOf(WorkspaceMoveInvalidError)
|
||||
await expect(workspace.insertSessionBefore(SessionId('s1'), SessionId('ghost')))
|
||||
.rejects.toThrow(/anchor session is not accounted/)
|
||||
expect(result.changes).toHaveLength(written)
|
||||
expect(workspace.sessionIds).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('validates a lazy live session without requiring it in persistence.list()', async () => {
|
||||
@@ -546,66 +542,6 @@ describe('Workspace session ordering', () => {
|
||||
expect(workspace.sessionIds).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('keeps workspace order stable while touch order survives reload', async () => {
|
||||
const older = await makeDir('stable-older')
|
||||
const newer = await makeDir('stable-newer')
|
||||
const sessions = [
|
||||
header('old-1', older, 100),
|
||||
header('old-2', older, 200),
|
||||
header('new-1', newer, 300),
|
||||
]
|
||||
const pool = new MemoryMediaPool()
|
||||
const first = await harness({ pool, sessions })
|
||||
const originalWorkspaceIds = first.registry.list().map(workspace => workspace.id)
|
||||
const oldWorkspace = first.registry.list().find(workspace => workspace.path === older)!
|
||||
expect(oldWorkspace.sessionIds).toEqual(['old-2', 'old-1'])
|
||||
await first.registry.touchSession(SessionId('old-1'))
|
||||
expect(oldWorkspace.sessionIds).toEqual(['old-1', 'old-2'])
|
||||
expect(first.registry.list().map(workspace => workspace.id)).toEqual(originalWorkspaceIds)
|
||||
await first.fiber.dispose()
|
||||
|
||||
const reloaded = await harness({ pool, sessions })
|
||||
expect(reloaded.registry.list().map(workspace => workspace.id)).toEqual(originalWorkspaceIds)
|
||||
expect(reloaded.registry.list().find(workspace => workspace.path === older)!.sessionIds)
|
||||
.toEqual(['old-1', 'old-2'])
|
||||
})
|
||||
|
||||
it('persists activity order from session/event without any stream consumer', async () => {
|
||||
const dir = await makeDir('event-touch')
|
||||
const result = await harness({ sessionStore: true })
|
||||
const workspace = await result.registry.create(dir)
|
||||
const first = result.ctx.sessions.create(SessionId('event-first'), { meta: { cwd: dir } })
|
||||
result.ctx.sessions.create(SessionId('event-second'), { meta: { cwd: dir } })
|
||||
await workspace.attachSession(SessionId('event-first'))
|
||||
await workspace.attachSession(SessionId('event-second'))
|
||||
expect(workspace.sessionIds).toEqual(['event-second', 'event-first'])
|
||||
|
||||
first.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
await vi.waitFor(() => { expect(workspace.sessionIds).toEqual(['event-first', 'event-second']) })
|
||||
expect(storedRecord(result.pool, workspace.id).sessionIds)
|
||||
.toEqual(['event-first', 'event-second'])
|
||||
})
|
||||
|
||||
it('contains a background activity write failure at the service listener', async () => {
|
||||
const dir = await makeDir('event-touch-failure')
|
||||
const result = await harness({ sessionStore: true })
|
||||
const workspace = await result.registry.create(dir)
|
||||
const first = result.ctx.sessions.create(SessionId('failed-first'), { meta: { cwd: dir } })
|
||||
result.ctx.sessions.create(SessionId('failed-second'), { meta: { cwd: dir } })
|
||||
await workspace.attachSession(SessionId('failed-first'))
|
||||
await workspace.attachSession(SessionId('failed-second'))
|
||||
const warn = vi.spyOn(result.ctx.logger, 'warn')
|
||||
result.pool.failNextWrites = 1
|
||||
first.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('touch failed')) })
|
||||
expect(workspace.sessionIds).toEqual(['failed-second', 'failed-first'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('header-validated membership projection', () => {
|
||||
|
||||
4
pnpm-lock.yaml
generated
4
pnpm-lock.yaml
generated
@@ -1023,6 +1023,10 @@ importers:
|
||||
version: 18.3.1
|
||||
|
||||
packages/client/ui-workspace:
|
||||
dependencies:
|
||||
clsx:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.1
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
|
||||
Reference in New Issue
Block a user