fix(web): batch session expansion and keep running sessions visible (#4715)

* fix(web): expand workspace sessions in bounded batches

* fix(web): keep running sessions outside overflow quota

---------

Co-authored-by: Turtle <[email protected]>
This commit is contained in:
Turtle 2099andTurtle authored and GitHub committed 2026-09-20 10:38:56 +00:00
1 parent 068cf03441
commit e8f3d02da8
8 files changed
+267 -33

No files matched your search

@@ -50,19 +50,19 @@ const EXPAND_SLIDE_MS = 300
const SEARCH_DEBOUNCE_MS = 250
/** `session.search` wire bound, measured in JavaScript UTF-16 code units. */
const SEARCH_QUERY_MAX_CODE_UNITS = 500
/** Session rows visible per Workspace before the local overflow control. */
/** Idle Session rows visible per Workspace before the local overflow control. */
const COLLAPSED_SESSION_LIMIT = 5
/** Fold one Workspace without charging its provisional New Session against the ordinary-row limit. */
function collapsedSessionRows(sessions: readonly SessionNode[]): {
/** Keep provisional and running rows outside the idle-session quota, including parents with running children. */
function collapsedSessionRows(sessions: readonly SessionNode[], limit = COLLAPSED_SESSION_LIMIT): {
rows: readonly SessionNode[]
hiddenCount: number
} {
let ordinaryCount = 0
let idleCount = 0
const rows = sessions.filter((session) => {
if (session.blank) return true
if (ordinaryCount >= COLLAPSED_SESSION_LIMIT) return false
ordinaryCount += 1
if (session.blank || session.running || session.runningSubagentCount > 0) return true
if (idleCount >= limit) return false
idleCount += 1
return true
})
return { rows, hiddenCount: sessions.length - rows.length }
@@ -79,11 +79,6 @@ function sanitizeSearchQuery(value: string): string {
return withoutNul.slice(0, end)
}
/** Immutable membership toggle for the local expand-all array. */
function toggled(list: readonly string[], key: string): string[] {
return list.includes(key) ? list.filter(k => k !== key) : [...list, key]
}
/**
* Accept the native drag at document level while a row drag is active: row
* hover still owns the insertion marker, and releasing outside the list must
@@ -258,7 +253,7 @@ type SessionTreeProps = Pick<
onSessionRevealed: (sessionId: SessionId) => void
}
/** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */
/** The scrolling session tree; unmounting drops the sessions subscription and local row limits. */
function SessionTree({
list, useSessionStatus, startSession, open, workspaces, ungroupedSessionIds,
rowState,
@@ -278,7 +273,7 @@ function SessionTree({
const revealGroup = revealSessionId === undefined || !workspaceReady
? undefined
: owningGroupKey(workspaces, revealSessionId)
const [expandedSessionGroups, setExpandedSessionGroups] = useState<string[]>([])
const [sessionLimits, setSessionLimits] = useState<Readonly<Record<string, number>>>({})
// Transient drag marker state; the selected mode owns the resulting order.
const [drag, setDrag] = useState<DragState | null>(null)
const sessionDropCommitted = useRef(false)
@@ -333,7 +328,7 @@ function SessionTree({
const group = groups.find(candidate => candidate.key === revealGroup)
if (group === undefined || !group.expanded || !group.sessions.some(row => row.id === revealSessionId)) return
if (collapsedSessionRows(group.sessions).rows.some(row => row.id === revealSessionId)) return
setExpandedSessionGroups(keys => keys.includes(revealGroup) ? keys : [...keys, revealGroup])
setSessionLimits(limits => limits[revealGroup] === Infinity ? limits : { ...limits, [revealGroup]: Infinity })
}, [groups, revealGroup, revealSessionId])
const now = Date.now()
const commitSessionDrag = (activeDrag: DragState, over: NonNullable<DragState['over']>): void => {
@@ -347,8 +342,7 @@ function SessionTree({
? ungroupedSessionIds
: workspaces.find(workspace => workspace.workspaceId === activeDrag.accountKey)?.sessionIds
if (accountSessionIds === undefined) return
const sessionsExpanded = expandedSessionGroups.includes(group.key)
const renderedSessions = sessionsExpanded ? group.sessions : collapsedSessionRows(group.sessions).rows
const renderedSessions = collapsedSessionRows(group.sessions, sessionLimits[group.key]).rows
const nextOrder = sessionDragOrder(accountSessionIds, renderedSessions, activeDrag, over)
if (nextOrder !== undefined) setSessionOrder(activeDrag.accountKey, nextOrder)
}
@@ -395,10 +389,11 @@ function SessionTree({
const children = childrenByParent.get(group.key) ?? []
const compatibleDrag = workspaceDrag !== null && parents.get(workspaceDrag.workspaceId) === parents.get(group.key)
const collapsed = collapsedSessionRows(group.sessions)
const sessionsExpanded = expandedSessionGroups.includes(group.key)
const visible = collapsedSessionRows(group.sessions, sessionLimits[group.key])
const sessionsExpanded = visible.hiddenCount === 0
rowKeys.push(`workspace:${group.key}`)
const childRows = group.expanded ? children.map(child => renderGroup(child, depth + 1)) : []
const sessions = sessionsExpanded ? group.sessions : collapsed.rows
const sessions = visible.rows
for (const node of sessions) rowKeys.push(`session:${node.id}`)
if (collapsed.hiddenCount > 0) rowKeys.push(`overflow:${group.key}`)
const workspaceMarker = workspaceId !== undefined && workspaceDrag?.over?.id === workspaceId
@@ -477,7 +472,7 @@ function SessionTree({
t={t}
onToggle={() => {
if (group.expanded) {
setExpandedSessionGroups(keys => keys.filter(key => key !== group.key))
setSessionLimits(limits => ({ ...limits, [group.key]: COLLAPSED_SESSION_LIMIT }))
}
setGroupExpanded(group.key, !group.expanded)
}}
@@ -560,11 +555,20 @@ function SessionTree({
className={css.sessionOverflowButton}
data-row-key={`overflow:${group.key}`}
aria-expanded={sessionsExpanded}
onClick={() => { setExpandedSessionGroups(keys => toggled(keys, group.key)) }}
onClick={() => {
setSessionLimits(limits => ({
...limits,
[group.key]: sessionsExpanded
? COLLAPSED_SESSION_LIMIT
: visible.hiddenCount <= COLLAPSED_SESSION_LIMIT
? Infinity
: (limits[group.key] ?? COLLAPSED_SESSION_LIMIT) + COLLAPSED_SESSION_LIMIT,
}))
}}
>
{sessionsExpanded
? t('sessions.collapse')
: t('sessions.expand', { n: collapsed.hiddenCount })}
: t('sessions.expand', { n: visible.hiddenCount })}
</button>
)}
</div>
@@ -580,7 +584,7 @@ function SessionTree({
label={t('section.sessions')}
rowKeys={rowKeys}
ready={list.phase === 'ready' && workspaceReady && !nativeDragActive}
resetKey={JSON.stringify([animationResetKey, expandedSessionGroups])}
resetKey={JSON.stringify([animationResetKey, sessionLimits])}
>
{groups.length === 0 && (
<div className={css.empty} data-row-key="empty">{t('empty.none')}</div>