feat(ui-workspace): flatten session fork rows

This commit is contained in:
imccyu
2026-07-30 20:29:29 +08:00
parent 7d27c92851
commit 2f8f47e500
13 changed files with 110 additions and 348 deletions
@@ -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}
/>
)
}
+27 -128
View File
@@ -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)
}
/**