diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml index 590d9227b8..ebc2df795a 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml @@ -1,6 +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 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md +2026-07-25-session-list-browsing-and-manual-order.md: 831aa53e532a75392690c330837482bb0f9c32b1 +2026-07-25-session-list-browsing-and-manual-order.zh.md: 9ad074d59c13585aa4fca46ae4d40e2deb15cde6 diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md index 586995bf45..831aa53e53 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md @@ -12,14 +12,14 @@ Two existing mechanisms stood in the way. First, the host durably promoted the a ## Decision -### Flat view and viewing state +### Flat rows 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. +The group-by menu offers two modes, WorkSpace / In one list. WorkSpace mode renders peer session rows within each group in the manual order from `WorkspaceView.sessionIds`; In one list combines every session and sorts them strictly newest-first by `updatedAt`. Neither mode projects `parentId` into a list hierarchy; fork lineage remains session data only. [Web session fork actions](2026-07-27-web-session-fork-actions.md) define the complete fork behavior. 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. +- Session-row … menu: Rename / Fork session / Delete session; Rename and Fork are wired, while Delete remains visual-only. The workspace-header … menu's Rename / Delete workspace actions are both wired. 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 @@ -30,7 +30,7 @@ The group-by menu offers two modes, WorkSpace / In one list. Flat mode renders e 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. +The UI is HTML5 drag on session rows inside a group (workspace grouping only, outside search; fork children and their source sessions are ordered independently). 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 @@ -46,15 +46,15 @@ ui-sidebar shrinks to the column-geometry shell: brand row, fold state machine, **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. +**Nest sessions by fork lineage in WorkSpace mode** — nesting makes the current child visible only while its ancestors are expanded and limits in-group manual ordering to root nodes; `parentId` is lineage data, not a list-navigation structure. Flattening all sessions into peer rows lets each row be opened, searched, and ordered independently; In one list still disables drag because it has no workspace persistence carrier. ## 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. +- Wiring session 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`. +Package-level suites cover the derivations (deriveGroups/deriveFlat), peer session rows, 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 and pin that a fork does not introduce session expansion controls. diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md index 432d5167a5..9ad074d59c 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md @@ -12,14 +12,14 @@ Status: implemented ## Decision -### 平铺视图与浏览态 +### 平铺行与浏览态 -group-by 菜单提供 WorkSpace / In one list 两种模式。平铺模式把所有 session(含 fork 子)一律作为顶层行,严格按 `updatedAt` 新→旧排序,不保持父子相邻;Intent 占位行渲染在列表首行。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。 +group-by 菜单提供 WorkSpace / In one list 两种模式。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序在各组内展示同级 session 行;In one list 把所有 session 合并后严格按 `updatedAt` 新→旧排序。两种模式都不把 `parentId` 投影成列表层级,fork 谱系只保留为 session 数据;完整 fork 行为由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。 ### 行交互 - session 行悬停 500ms 出详情卡(全名/相对时间/状态行;状态本期只有 running/idle 两态,枚举扩展待 wire 增补 status 字段)。卡片与行菜单互斥:菜单开启或拖拽进行中不出卡。 -- session 行 … 菜单:Rename / Fork session / Delete session,本期纯视觉;workspace 组头 … 菜单:Rename(已接线)/ Delete workspace(纯视觉)。菜单鼠标移出即关。 +- session 行 … 菜单:Rename / Fork session / Delete session,其中 Rename 与 Fork 已接线,Delete 仍为纯视觉;workspace 组头 … 菜单的 Rename / Delete workspace 均已接线。菜单鼠标移出即关。 - 支撑件:`Menu` 新增 label 条目、danger 行、`closeOnPointerLeave`;新增 `HoverCard`(portal 定位、开启延时、disabled 守卫)。 ### workspace.rename @@ -30,7 +30,7 @@ group-by 菜单提供 WorkSpace / In one list 两种模式。平铺模式把所 `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 为组内 session 行的 HTML5 拖拽(仅 workspace 分组、非搜索态;fork 子与源会话一样独立排序)。顺序权威完全在 host:drop 只发 RPC,client 零本地重排,视图靠响应体 upsert 与 changed 帧刷新;失败即无事发生。client 的 upsert 拒绝比已装载投影更旧(`updatedAt`)的快照,防迟到的一元响应回滚更新的帧。 ### 壳/区域切分 @@ -46,15 +46,15 @@ ui-sidebar 缩为列几何壳:品牌行、折叠状态机、New Session、Settin **rename 对话框留在 ui-sidebar(最小改动)** —— 正是问题本身:workspace 域的对话框散落在借来的坑里,每加一个(Delete 确认框将至)都重演跨包接线。评审中先议了「只挪 rename Modal」的中间态,最终裁定整个浏览区域归 ui-workspace,壳只留几何。 -**平铺模式保持父子相邻成组** —— 与「严格按时间」矛盾(子新于兄则插不进相邻位),且平铺本意就是取消层级;拉平并禁用平铺下的拖拽(无持久化载体)更一致。 +**WorkSpace 模式按 fork 谱系嵌套 session** —— 嵌套会让当前子会话依赖祖先展开态才能可见,也让组内手动序只能移动根节点;`parentId` 是 lineage 数据,不是列表导航结构。所有 session 拍平成同级行后,每行都可独立打开、搜索与排序;In one list 仍因没有 workspace 持久化载体而禁用拖拽。 ## Consequences - 手动序是唯一的 workspace 账本序权威:用户排好的顺序不再被活动打乱;代价是「最近活跃浮到最上」的行为消失,活跃感知转由行内状态点与时间标签承担。`WorkspaceView.sessionIds` 的 wire 契约随之改为手动序措辞。 - 壳/区域两事实契约把 workspace 域的后续功能(Delete 确认、跨组移动、Ungrouped 收编)全部收进 ui-workspace 单包;ui-sidebar 不再随 session 列表功能演进。 - 平铺模式不支持排序与分组入口(建到指定 workspace 需切回分组视图),是拍板接受的范围收窄。 -- session 菜单三项与 workspace Delete 的功能接线、状态枚举扩 wire,留待后续迭代。 +- session 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` 三径。 +包级用例覆盖派生(deriveGroups/deriveFlat)、同级 session 行、两处 apply 注册与透传、host 实体移位语义、rename/insertSessionBefore 的 RPC 实现与 fixture 桩;`apps/web` keyless snapshot 回归覆盖装配后的应用,并钉住 fork 后没有 session 展开控件。 diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index a8f5d98981..1223fcc0f9 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: f0dbd3de4ac5b7934b4077b2986f5def5be329c8 -README.zh.md: b9f068c6d1709f0a01d22c3fb1cdc391764d666b +README.md: 2a4e544250756db50b19bc101c19ac79505a3d6c +README.zh.md: 7ae2b0e196a98d1375e9073146bb8119ea8cbe90 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index f0dbd3de4a..2a4e544250 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -6,7 +6,7 @@ Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sideba The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. -The Session row's Fork action forks at the source's last completed turn and opens the child; the lineage-aware tree nests it beneath the source. A failure leaves the current selection unchanged. +The Session row's Fork action forks at the source's last completed turn and opens the child; the source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A failure leaves the current selection unchanged. Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index b9f068c6d1..7ae2b0e196 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -6,7 +6,7 @@ 该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。 -Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork 并打开子会话;感知谱系的树会将子会话嵌套在源会话下。失败不会改变当前选中项。 +Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork 并打开子会话;源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。失败不会改变当前选中项。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 933b49bdf0..60b57e0b06 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -104,7 +104,6 @@ function SessionTree({ const list = useSessions(s => s) const current = list.current const [expandedProjects, setExpandedProjects] = useState([]) - const [expandedSessions, setExpandedSessions] = useState([]) // Transient drag viewing state (never store-bound; order truth stays Host-side). const [drag, setDrag] = useState(null) const currentGroup = current === undefined @@ -115,27 +114,9 @@ function SessionTree({ if (current === undefined || currentGroup === undefined) return setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup])) }, [current, currentGroup]) - // The selected session must be visible: unfold its ancestor chain (fork - // lands the child under a possibly folded parent row). - const currentAncestors = useMemo(() => { - const chain: string[] = [] - let cursor = current === undefined ? undefined : list.byId[current]?.parentId - while (cursor !== undefined && !chain.includes(cursor)) { - chain.push(cursor) - cursor = list.byId[cursor]?.parentId - } - return chain - }, [current, list]) - useEffect(() => { - if (currentAncestors.length === 0) return - setExpandedSessions((l) => { - const missing = currentAncestors.filter(id => !l.includes(id)) - return missing.length === 0 ? l : [...l, ...missing] - }) - }, [currentAncestors]) const groups = useMemo( - () => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }), - [list, workspaces, expandedProjects, expandedSessions, query], + () => deriveGroups(list, workspaces, { expandedProjects, query }), + [list, workspaces, expandedProjects, query], ) const now = Date.now() @@ -146,7 +127,7 @@ function SessionTree({
{query === '' ? 'No sessions yet' : 'No matches'}
)} {groups.map(group => ( - // Group section: header row + expanded session subtree. The + // Group section: header row + expanded top-level session rows. The // inter-group breathing room (former flat-list batch separator) // is the section's own margin (WorkspaceBrowser.module.css).
@@ -170,7 +151,7 @@ function SessionTree({ }} /> {group.sessions.map((node, index) => { - // Draggable: real-workspace group roots outside search. The drag + // Draggable: real-workspace session rows 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 === '' @@ -188,15 +169,15 @@ function SessionTree({ 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 + const sessions = 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 + const anchor = half === 'before' ? node.id : sessions[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) + const sourceIndex = sessions.findIndex(r => r.id === drag.sessionId) + const anchorIndex = anchor === undefined ? sessions.length : sessions.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) @@ -208,13 +189,11 @@ function SessionTree({ { setExpandedSessions(l => toggled(l, id)) }} drag={dragProps} /> ) @@ -242,15 +221,11 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, query }: Pi {}} - flat /> ))}
diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 5a5cac914e..f64849e1c5 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -39,9 +39,7 @@ height: 20px; } -/* Session cell (figma): pad 8, adjacent 16px twist + status slots, then a 4px - gap to the title — the slots butt together, so the row gap is zeroed and - the title carries its own margins. */ +/* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. */ .sessionRow { height: 34px; gap: 0; @@ -168,7 +166,7 @@ background: var(--dsw-alias-interactive-bg-hover); } -/* Drag reorder insert line (workspace-group roots): 2px accent above or +/* Drag reorder insert line (workspace-group session rows): 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); @@ -233,33 +231,9 @@ color: var(--dsw-alias-label-primary); } -/* Session expand twist occupies the leading 16px slot; keep a spacer when absent - so titles align across sibling rows. Duplicates the .iconButton reset instead - of `composes:` — the tsdown CSS-modules pipeline drops composes mappings, which - left the raw UA button box showing. */ -.twist { - flex: none; - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - height: 20px; - border: none; - border-radius: 4px; - padding: 0; - background: transparent; - cursor: pointer; -} - -.twist:hover { - color: var(--dsw-alias-label-primary); -} - -/* Chevrons and tree twists ride the caption grey (#ADB2B8); the folder glyph - stays one step darker (tertiary, #81858C) per the cell spec. Declared last - to win over the composed .iconButton color. */ -.chevron, -.twist { +/* Chevrons ride the caption grey (#ADB2B8); the folder glyph stays one step + darker (tertiary, #81858C) per the cell spec. */ +.chevron { color: var(--dsw-alias-label-caption); } diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 657585f075..6dceec4ef6 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -16,9 +16,6 @@ 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: }, { id: 'fork', label: 'Fork session', icon: }, @@ -135,16 +132,12 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: { } /** - * 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. + * One top-level 34px session row with running dot and relative time. * @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. + * @returns the session row. */ /** 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 }) { @@ -161,7 +154,7 @@ function SessionHoverContent({ node, now }: { node: SessionNode; now: number }) } /** - * Root-row drag wiring supplied by the group owner (workspace groups only). + * Session-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). */ @@ -184,9 +177,8 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' } -export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, onFork, onToggle, drag, flat = false }: { +export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, drag }: { node: SessionNode - depth: number currentId: string | undefined now: number onOpen: (id: SessionNode['id']) => void @@ -194,18 +186,13 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, onRename: (id: SessionNode['id'], currentTitle: string) => void /** Fork a session at its last completed turn (row menu action). */ onFork: (id: SessionNode['id']) => void - onToggle: (id: SessionNode['id']) => void - /** Present only on draggable rows (workspace-group roots outside search). */ + /** Present only on draggable rows (workspace-group sessions 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. + // Figma session cell: pad 8, status slot 16, then a 4px title gap. const ownRow = (
{ onOpen(node.id) }} draggable={drag !== undefined} onDragStart={drag === undefined @@ -241,18 +226,6 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, drag.drop(rowHalf(e)) }} > - {row.hasChildren && !flat - ? ( - - ) - : null} {row.running && } {row.title} {formatRelativeTime(row.updatedAt, now)} @@ -283,25 +256,10 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
) return ( - <> - } - disabled={menuOpen || drag?.active === true} - /> - {node.children.map(child => ( - - ))} - + } + disabled={menuOpen || drag?.active === true} + /> ) } diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index de47454d84..3f30064269 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -11,20 +11,15 @@ export const UNGROUPED_KEY = '' /** Display label for the ungrouped bucket row. */ export const UNGROUPED_LABEL = 'Ungrouped' -/** One session node of a group's visible tree (34px row; children render indented one step). */ +/** One top-level session row in a group or the flat list. */ export interface SessionNode { id: SessionId title: string - /** Visible children, already expansion/search-filtered (empty when folded). */ - children: readonly SessionNode[] - /** The session HAS children in the data (the twist renders even while folded). */ - hasChildren: boolean - expanded: boolean running: boolean updatedAt: number } -/** One workspace group section: header row facts + the visible session tree. */ +/** One workspace group section: header row facts + visible top-level session rows. */ export interface GroupNode { /** Group key: the workspace id or {@link UNGROUPED_KEY}. */ key: string @@ -39,14 +34,13 @@ export interface GroupNode { expanded: boolean /** The group contains the selected session (active folder tint; supplied here so the renderer never scans). */ containsCurrent: boolean - /** Visible roots (empty while the group is folded). */ + /** Visible session rows (empty while the group is folded). */ sessions: readonly SessionNode[] } -/** Viewing state consumed by the derivation — the component's local useState arrays, taken as-is. */ +/** Viewing state consumed by the derivation. */ export interface TreeView { expandedProjects: readonly string[] - expandedSessions: readonly string[] query: string } @@ -56,9 +50,7 @@ interface Group { cwd: string | undefined createdAt: number | undefined label: string - summaries: Map - roots: SessionId[] - children: Map + sessions: SessionSummary[] } /** @@ -89,7 +81,7 @@ function sessionTitle(session: SessionSummary): string { return session.blank ? 'New Session' : session.displayTitle } -/** Build one group's parent/child tree from an ordered member list. */ +/** Build one group without projecting session lineage into presentation. */ function buildGroup( key: string, workspaceId: WorkspaceId | undefined, @@ -99,54 +91,11 @@ function buildGroup( members: readonly SessionSummary[], order: 'account' | 'recency', ): Group { - const summaries = new Map(members.map(m => [m.id, m])) - const children = new Map() - const roots: SessionSummary[] = [] - for (const m of members) { - // A session is a tree child only when its parent lives in the same - // group; cross-group or unknown parents degrade to group roots. - if (m.parentId !== undefined && m.parentId !== m.id && summaries.has(m.parentId)) { - const kids = children.get(m.parentId) - if (kids === undefined) children.set(m.parentId, [m.id]) - else kids.push(m.id) - } else { - roots.push(m) - } - } - // Workspace order is the member iteration order (workspace.sessionIds), so - // attached groups keep insertion order; Ungrouped sorts by recency. - if (order === 'recency') { - roots.sort(byRecency) - for (const kids of children.values()) { - kids.sort((a, b) => { - const sa = summaries.get(a) - const sb = summaries.get(b) - /* v8 ignore next -- unreachable: kid ids are inserted alongside their summaries. */ - if (sa === undefined || sb === undefined) return 0 - return byRecency(sa, sb) - }) - } - } - const rootIds = roots.map(r => r.id) - // parentId cycles (host bug) leave members unreachable from any root; - // surface them as extra roots — the flatten walk's visited set stops - // loops. Each node sits in at most one kids list and roots have no - // in-group parent, so the scan pushes every reachable node exactly once. - const reachable = new Set(rootIds) - const stack = [...rootIds] - while (stack.length > 0) { - const top = stack.pop() - /* v8 ignore next -- unreachable: the loop condition guarantees a non-empty stack. */ - if (top === undefined) break - for (const kid of children.get(top) ?? []) { - reachable.add(kid) - stack.push(kid) - } - } - for (const m of members) { - if (!reachable.has(m.id)) rootIds.push(m.id) - } - return { key, workspaceId, cwd, createdAt, label, summaries, roots: rootIds, children } + const sessions = [...members] + // Workspace order is workspace.sessionIds; only Ungrouped lacks an account + // order and therefore falls back to recency. + if (order === 'recency') sessions.sort(byRecency) + return { key, workspaceId, cwd, createdAt, label, sessions } } /** @@ -181,72 +130,24 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace return groups } -function sessionNode(s: SessionSummary, children: readonly SessionNode[], hasChildren: boolean, expanded: boolean): SessionNode { +function sessionNode(s: SessionSummary): SessionNode { return { id: s.id, title: sessionTitle(s), - children, - hasChildren, - expanded, running: s.running, updatedAt: s.updatedAt, } } -function buildVisible(g: Group, expandedSessions: ReadonlySet): SessionNode[] { - const visited = new Set() - const walk = (id: SessionId): SessionNode | null => { - if (visited.has(id)) return null - visited.add(id) - const s = g.summaries.get(id) - /* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */ - if (s === undefined) return null - const kids = g.children.get(id) ?? [] - const expanded = expandedSessions.has(id) - const children = expanded ? kids.map(walk).filter((n): n is SessionNode => n !== null) : [] - return sessionNode(s, children, kids.length > 0, expanded) - } - return g.roots.map(walk).filter((n): n is SessionNode => n !== null) -} - -/** Matched sessions plus their ancestor chains (forced visible under search). */ -function searchVisible(g: Group, q: string): Set { - const visible = new Set() - for (const m of g.summaries.values()) { - if (!sessionTitle(m).toLowerCase().includes(q)) continue - let cur: SessionSummary | undefined = m - while (cur !== undefined && !visible.has(cur.id)) { - visible.add(cur.id) - cur = cur.parentId !== undefined && cur.parentId !== cur.id ? g.summaries.get(cur.parentId) : undefined - } - } - return visible -} - -function buildSearch(g: Group, visible: ReadonlySet): SessionNode[] { - const visited = new Set() - const walk = (id: SessionId): SessionNode | null => { - if (visited.has(id) || !visible.has(id)) return null - visited.add(id) - const s = g.summaries.get(id) - /* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */ - if (s === undefined) return null - const kids = (g.children.get(id) ?? []).filter(kid => visible.has(kid)) - const children = kids.map(walk).filter((n): n is SessionNode => n !== null) - return sessionNode(s, children, kids.length > 0, kids.length > 0) - } - return g.roots.map(walk).filter((n): n is SessionNode => n !== null) -} - /** - * Derive the nested workspace browser group structure. + * Derive the workspace browser groups with every session as a top-level row. * * Normal mode: every group shows; sessions populate under expanded groups, - * descending only into expanded sessions. Search mode (non-blank query, + * preserving Host account order. 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, and a label-only hit - * keeps the bare group header. Blank sessions are excluded everywhere. + * matching sessions are forced visible, groups without a display-title or + * label hit are dropped, and a label-only hit + * keeps the bare group header. Non-current blank sessions are excluded. * @param list - sessions list snapshot (`current` feeds containsCurrent). * @param workspaces - real workspaces in stable Host order. * @param view - local expansion arrays and search query. @@ -259,7 +160,6 @@ export function deriveGroups( ): GroupNode[] { const q = view.query.trim().toLowerCase() const expandedProjects = new Set(view.expandedProjects) - const expandedSessions = new Set(view.expandedSessions) const currentGroup = list.current === undefined ? undefined : (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined) @@ -274,24 +174,24 @@ export function deriveGroups( cwd: g.cwd, createdAt: g.createdAt, label: g.label, - sessionCount: g.summaries.size, + sessionCount: g.sessions.length, expanded, containsCurrent: g.key === currentGroup, - sessions: expanded ? buildVisible(g, expandedSessions) : [], + sessions: expanded ? g.sessions.map(sessionNode) : [], }) } else { - const visible = searchVisible(g, q) - if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue + const matches = g.sessions.filter(session => sessionTitle(session).toLowerCase().includes(q)) + if (matches.length === 0 && !g.label.toLowerCase().includes(q)) continue groups.push({ key: g.key, workspaceId: g.workspaceId, cwd: g.cwd, createdAt: g.createdAt, label: g.label, - sessionCount: g.summaries.size, - expanded: visible.size > 0, + sessionCount: g.sessions.length, + expanded: matches.length > 0, containsCurrent: g.key === currentGroup, - sessions: buildSearch(g, visible), + sessions: matches.map(sessionNode), }) } } @@ -301,9 +201,8 @@ export function deriveGroups( /** * 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. + * no parent/child adjacency. 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. @@ -318,7 +217,7 @@ export function deriveFlat(list: SessionListState, view: Pick rows.push(s) } rows.sort(byRecency) - return rows.map(s => sessionNode(s, [], false, false)) + return rows.map(sessionNode) } /** diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index 44a311d6d3..3be85a7646 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -56,46 +56,22 @@ describe('workspace browser rows', () => { expect(onToggle).toHaveBeenCalledOnce() }) - 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, + it('renders and opens a selected running Session row', () => { + const node: SessionNode = { + id: sid('session'), title: 'Session', running: true, updatedAt: 0, } const onOpen = vi.fn() - const onToggle = vi.fn() - const view = render( - , + render( + , ) - 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( - , - ) - expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy() - expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('false') - expect(screen.getByRole('treeitem').style.paddingLeft).toBe('24px') + const row = screen.getByRole('treeitem') + expect(row.getAttribute('aria-selected')).toBe('true') + expect(row.hasAttribute('aria-expanded')).toBe(false) + expect(screen.queryByRole('button', { name: /Expand|Collapse/ })).toBeNull() + fireEvent.click(row) + expect(onOpen).toHaveBeenCalledWith(node.id) }) it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => { @@ -161,11 +137,10 @@ describe('workspace browser rows', () => { const onRename = vi.fn() const onFork = vi.fn() const node: SessionNode = { - id: sid('s1'), title: 'One', children: [], hasChildren: false, - expanded: false, running: false, updatedAt: 0, + id: sid('s1'), title: 'One', running: false, updatedAt: 0, } - render() + render() fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' })) expect(onOpen).not.toHaveBeenCalled() expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/) @@ -187,25 +162,14 @@ describe('workspace browser rows', () => { 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() - 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, + id: sid('s1'), title: 'Hovered', running: true, updatedAt: 0, } - render() + render() const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement fireEvent.pointerEnter(wrapper) act(() => { vi.advanceTimersByTime(500) }) @@ -228,11 +192,10 @@ describe('workspace browser rows', () => { vi.useFakeTimers() try { const node: SessionNode = { - id: sid('s1'), title: 'Quiet', children: [], hasChildren: false, - expanded: false, running: false, updatedAt: 0, + id: sid('s1'), title: 'Quiet', running: false, updatedAt: 0, } - render() + render() fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) }) expect(screen.getByText('Idle')).toBeTruthy() @@ -244,13 +207,12 @@ describe('workspace browser rows', () => { 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, + id: sid('s1'), title: 'Drag me', running: false, updatedAt: 0, } const inactive = dragProps() const { rerender } = render( - , + , ) const row = screen.getByRole('treeitem') stubRect(row) @@ -267,8 +229,8 @@ describe('workspace browser rows', () => { const active = dragProps({ active: true, marker: 'before' }) rerender( - , + , ) stubRect(screen.getByRole('treeitem')) // Top half hovers/drops 'before'; bottom half 'after' (row mid = 117). @@ -281,8 +243,8 @@ describe('workspace browser rows', () => { const after = dragProps({ active: true, marker: 'after' }) rerender( - , + , ) expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/) }) diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index eb34f633d8..aa5828e8ac 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -21,7 +21,7 @@ const workspace = (id: string, sessionIds: string[]): WorkspaceView => ({ sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }) const view = (expandedProjects: readonly string[] = [], query = '') => ({ - expandedProjects, expandedSessions: [] as string[], query, + expandedProjects, query, }) describe('deriveGroups', () => { @@ -74,7 +74,7 @@ describe('deriveGroups', () => { expect(groups[0]!.sessionCount).toBe(1) }) - it('builds, sorts, expands, and cycle-guards an ungrouped session tree', () => { + it('ignores fork lineage and sorts every ungrouped session as a top-level row', () => { const parent = summary('parent', 1) const oldChild = { ...summary('old-child', 10), parentId: parent.id } const newChild = { ...summary('new-child', 20), parentId: parent.id } @@ -87,15 +87,13 @@ describe('deriveGroups', () => { const groups = deriveGroups( list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB), [], - { expandedProjects: [UNGROUPED_KEY], expandedSessions: [parent.id, cycleA.id, cycleB.id], query: '' }, + { expandedProjects: [UNGROUPED_KEY], query: '' }, ) expect(groups).toHaveLength(1) expect(groups[0]!.sessions.map(node => node.id)).toEqual([ - sid('orphan'), sid('self'), parent.id, sid('cycle-a'), - ]) - expect(groups[0]!.sessions[2]!.children.map(node => node.id)).toEqual([ newChild.id, tieA.id, tieB.id, oldChild.id, + cycleB.id, cycleA.id, orphan.id, self.id, parent.id, ]) // Equal timestamps use ids as a deterministic tiebreak in either input order. @@ -113,7 +111,7 @@ describe('deriveGroups', () => { expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')]) }) - it('searches descendants with ancestors and handles cycles, self parents, and label-only hits', () => { + it('searches rows independently of lineage and keeps label-only hits', () => { const root = { ...summary('root', 1), displayTitle: 'Ancestor' } const match = { ...summary('match', 2), displayTitle: 'Needle child', parentId: root.id } const sibling = { ...summary('sibling', 3), displayTitle: 'Other child', parentId: root.id } @@ -124,8 +122,8 @@ describe('deriveGroups', () => { const sessions = list(root, match, sibling, self, orphan, cycleA, cycleB) const groups = deriveGroups(sessions, [workspace('project', sessions.ids)], view([], 'needle')) - expect(groups[0]!.sessions.flatMap(node => [node.id, ...node.children.map(child => child.id)])).toEqual([ - root.id, match.id, self.id, orphan.id, cycleA.id, cycleB.id, + expect(groups[0]!.sessions.map(node => node.id)).toEqual([ + match.id, self.id, orphan.id, cycleA.id, cycleB.id, ]) const labelOnly = deriveGroups( @@ -157,8 +155,6 @@ describe('deriveFlat', () => { 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', () => { diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index d0535ecdfa..a6603593cc 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -125,7 +125,7 @@ describe('WorkspaceBrowser', () => { expect(screen.queryByText('alpha-s')).toBeNull() }) - it('unfolds a session subtree through the row twist', () => { + it('renders a fork child as a top-level row without a session twist', () => { const parent = summary('parent-s', 2) const child = { ...summary('child-s', 1), parentId: parent.id } mount({ @@ -133,11 +133,9 @@ describe('WorkspaceBrowser', () => { 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() + expect(screen.queryByRole('button', { name: /Expand|Collapse/ })).toBeNull() + expect(screen.getByText('child-s').closest('[role="treeitem"]')?.getAttribute('draggable')).toBe('true') }) it('auto-expands the selected session group and starts a session from the group +', () => {