feat(ui-workspace): flatten session fork rows
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 展开控件。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 的插件恢复后重新注册。
|
||||
|
||||
|
||||
@@ -104,7 +104,6 @@ function SessionTree({
|
||||
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)
|
||||
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({
|
||||
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
|
||||
)}
|
||||
{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).
|
||||
<div key={group.key} className={css.groupSection}>
|
||||
@@ -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({
|
||||
<SessionNodeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
currentId={current}
|
||||
now={now}
|
||||
onOpen={open}
|
||||
onRename={onSessionRename}
|
||||
onFork={forkSession}
|
||||
onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
|
||||
drag={dragProps}
|
||||
/>
|
||||
)
|
||||
@@ -242,15 +221,11 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, query }: Pi
|
||||
<SessionNodeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
currentId={list.current}
|
||||
now={now}
|
||||
onOpen={open}
|
||||
onRename={onSessionRename}
|
||||
onFork={forkSession}
|
||||
/* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */
|
||||
onToggle={() => {}}
|
||||
flat
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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: <IconEditOutline16 /> },
|
||||
{ id: 'fork', label: 'Fork session', icon: <IconBranchOutline16 /> },
|
||||
@@ -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 = (
|
||||
<div
|
||||
className={clsx(
|
||||
@@ -214,8 +201,6 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
|
||||
)}
|
||||
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
|
||||
@@ -241,18 +226,6 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
|
||||
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>
|
||||
@@ -283,25 +256,10 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
|
||||
</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}
|
||||
onRename={onRename}
|
||||
onFork={onFork}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
<HoverCard
|
||||
anchor={ownRow}
|
||||
content={<SessionHoverContent node={node} now={now} />}
|
||||
disabled={menuOpen || drag?.active === true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<SessionId, SessionSummary>
|
||||
roots: SessionId[]
|
||||
children: Map<SessionId, SessionId[]>
|
||||
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<SessionId, SessionId[]>()
|
||||
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<SessionId>(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<string>): SessionNode[] {
|
||||
const visited = new Set<SessionId>()
|
||||
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<SessionId> {
|
||||
const visible = new Set<SessionId>()
|
||||
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<SessionId>): SessionNode[] {
|
||||
const visited = new Set<SessionId>()
|
||||
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<TreeView, 'query'>
|
||||
rows.push(s)
|
||||
}
|
||||
rows.sort(byRecency)
|
||||
return rows.map(s => sessionNode(s, [], false, false))
|
||||
return rows.map(sessionNode)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={onToggle} />,
|
||||
render(
|
||||
<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={onOpen}
|
||||
onRename={vi.fn()} onFork={vi.fn()} />,
|
||||
)
|
||||
|
||||
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}
|
||||
onRename={vi.fn()} onFork={vi.fn()} 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')
|
||||
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(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={onRename} onFork={onFork} onToggle={vi.fn()} />)
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={onRename} onFork={onFork} />)
|
||||
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(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={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,
|
||||
id: sid('s1'), title: 'Hovered', running: true, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} />)
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} />)
|
||||
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(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} />)
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} />)
|
||||
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(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
|
||||
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} drag={inactive} />,
|
||||
)
|
||||
const row = screen.getByRole('treeitem')
|
||||
stubRect(row)
|
||||
@@ -267,8 +229,8 @@ describe('workspace browser rows', () => {
|
||||
|
||||
const active = dragProps({ active: true, marker: 'before' })
|
||||
rerender(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} drag={active} />,
|
||||
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} drag={active} />,
|
||||
)
|
||||
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(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} drag={after} />,
|
||||
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} drag={after} />,
|
||||
)
|
||||
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 +', () => {
|
||||
|
||||
Reference in New Issue
Block a user