feat(ui-workspace): flatten session fork rows
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user