diff --git a/ui/src/components/app-sidebar-session-navigation-logic.ts b/ui/src/components/app-sidebar-session-navigation-logic.ts index 667604d1afd3..d8eb32d23127 100644 --- a/ui/src/components/app-sidebar-session-navigation-logic.ts +++ b/ui/src/components/app-sidebar-session-navigation-logic.ts @@ -14,11 +14,6 @@ import { resolveSessionWorkSubtitle, } from "../lib/session-display.ts"; import { isSessionRunActive } from "../lib/session-run-state.ts"; -import { - groupSidebarSessionRows, - type SidebarSessionSection, - type SidebarSessionsGrouping, -} from "../lib/sessions/grouping.ts"; import { compareSessionRowsByUpdatedAt, filterVisibleSessionRows, @@ -43,9 +38,7 @@ import { } from "../lib/sessions/session-key.ts"; import { reconcileSidebarZone } from "../lib/sidebar-zone.ts"; import { - limitSidebarSessionRows, SIDEBAR_SESSION_NO_ATTENTION, - SIDEBAR_SESSION_PAGE_SIZE, type SidebarRecentSession, type SidebarSessionSortMode, type SidebarSessionStatusFilter, @@ -296,79 +289,6 @@ export function buildSidebarSessionNavigationState(input: { }; } -export type SidebarVisibleSections = { - sections: (SidebarSessionSection & { - totalRowCount: number; - visibleRowCount: number; - visibleLimit: number; - collapsedVisibleRowCount: number; - renderHeader: boolean; - })[]; - expandedRows: SidebarRecentSession[]; - visibleRows: SidebarRecentSession[]; -}; - -export function partitionSidebarVisibleSections(input: { - rows: SidebarRecentSession[]; - grouping: SidebarSessionsGrouping; - knownGroups: string[] | undefined; - selfOwnerId?: string | null; - catalogIds?: readonly string[]; - sectionOrder?: readonly string[]; - collapsedSections: ReadonlySet; - hideEmptyOwnerFilteredGroup: (category: string | undefined, rowCount: number) => boolean; - visibleSessionLimits: ReadonlyMap; -}): SidebarVisibleSections { - const { grouping, knownGroups, selfOwnerId, sectionOrder, catalogIds } = input; - const sectionOptions = { grouping, knownGroups, selfOwnerId, sectionOrder, catalogIds }; - const sections = groupSidebarSessionRows(input.rows, sectionOptions).filter( - (section) => - section.id !== "pinned" && - !input.hideEmptyOwnerFilteredGroup(section.category, section.rows.length), - ); - // A lone catch-all sits directly under the global Sessions toolbar. Empty - // Coding does not render, while empty custom/Groups sections remain targets. - const ungroupedHasPeerHeader = sections.some( - (section) => section.id !== "ungrouped" && (section.id !== "work" || section.rows.length > 0), - ); - // Accepted tradeoff: headerless means no collapse control, so a stored - // ungrouped-collapsed preference is deliberately inert here — honoring it - // would blank the whole list with no affordance to undo. It re-applies - // unchanged once a peer section returns. - const expandedRows: SidebarRecentSession[] = []; - const visibleRows: SidebarRecentSession[] = []; - // totalRowCount is the pre-pagination size: headers and empty-zone - // checks must not mistake a page-filtered section for an empty one. - const limitedSections: SidebarVisibleSections["sections"] = []; - for (const section of sections) { - const totalRowCount = section.rows.length; - const renderHeader = section.id !== "ungrouped" || ungroupedHasPeerHeader; - const collapsed = renderHeader && input.collapsedSections.has(section.id); - const visibleLimit = input.visibleSessionLimits.get(section.id) ?? SIDEBAR_SESSION_PAGE_SIZE; - const collapsedVisibleRowCount = limitSidebarSessionRows( - section.rows, - SIDEBAR_SESSION_PAGE_SIZE, - ).length; - let visibleRowCount = 0; - if (!collapsed) { - expandedRows.push(...section.rows); - section.rows = limitSidebarSessionRows(section.rows, visibleLimit); - visibleRows.push(...section.rows); - visibleRowCount = section.rows.length; - } - limitedSections.push( - Object.assign(section, { - totalRowCount, - visibleRowCount, - visibleLimit, - collapsedVisibleRowCount, - renderHeader, - }), - ); - } - return { sections: limitedSections, expandedRows, visibleRows }; -} - export function buildReconciledSidebarZone(input: { sidebarEntries: readonly string[]; rows: SidebarRecentSession[]; @@ -657,23 +577,6 @@ export function findProjectedSidebarSession(input: { return undefined; } -export function promoteSidebarSessionCreatedOrder( - createdOrder: Map, - sessionKey: string, -): boolean { - const currentOrder = createdOrder.get(sessionKey); - if (currentOrder === 0) { - return false; - } - for (const [key, order] of createdOrder) { - if (key !== sessionKey && (currentOrder === undefined || order < currentOrder)) { - createdOrder.set(key, order + 1); - } - } - createdOrder.set(sessionKey, 0); - return true; -} - export function applySidebarSessionOwnerFilter(input: { projected: SidebarRecentSession[]; ownerFacet: SessionsListResult["owners"]; diff --git a/ui/src/components/app-sidebar-session-navigation.ts b/ui/src/components/app-sidebar-session-navigation.ts index 4edc0d6b7ff8..069a267b0f39 100644 --- a/ui/src/components/app-sidebar-session-navigation.ts +++ b/ui/src/components/app-sidebar-session-navigation.ts @@ -1,6 +1,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { PropertyValues } from "lit"; import { state } from "lit/decorators.js"; +import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js"; import type { GatewaySessionRow, SessionsListResult } from "../api/types.ts"; import { serializeSidebarEntry } from "../app-navigation.ts"; import { isSessionRouteId } from "../app-route-paths.ts"; @@ -45,8 +46,6 @@ import { findSidebarMainSessionRow, findProjectedSidebarSession, someSidebarSessionInTree, - partitionSidebarVisibleSections, - promoteSidebarSessionCreatedOrder, resolveSidebarAgentChipSubtitle, resolveActiveSidebarAgent, resolveSidebarHomeAttention, @@ -55,9 +54,12 @@ import { resolveSidebarMainSessionKey, toggleSidebarSessionSelection, type SidebarSessionNavigationState, - type SidebarVisibleSections, } from "./app-sidebar-session-navigation-logic.ts"; import { SessionPullRequestIndicatorsController } from "./app-sidebar-session-pr-indicators.ts"; +import { + SidebarSessionProjection, + type SidebarVisibleSections, +} from "./app-sidebar-session-projection.ts"; import { projectSessionTree } from "./app-sidebar-session-tree.ts"; import { loadStoredHiddenSessionCatalogIds, @@ -83,6 +85,7 @@ import type { SidebarMenusController } from "./sidebar-menus-controller.ts"; export class AppSidebarSessionNavigationElement extends AppSidebarBase { @state() sessionSortMode: SidebarSessionSortMode = loadStoredSidebarSessionSortMode(); + readonly sessionProjection = new SidebarSessionProjection(); readonly sessionData = new SessionDataController(this); private readonly sessionPullRequestIndicators = new SessionPullRequestIndicatorsController(this, { getConnected: () => this.connected, @@ -108,7 +111,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { b, sortMode: this.effectiveSessionSortMode(), owners: this.selectedAgentSessionResult()?.owners, - createdOrder: this.sessionData.sessionCreatedOrder, + createdOrder: this.sessionProjection.createdOrder, }); private sessionPeopleSortCapability(): boolean | undefined { @@ -147,9 +150,6 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { sessionOwnershipVisible = false; @state() selectedSessionKeys: ReadonlySet = new Set(); - @state() protected expandedChildSessionKeys: ReadonlySet = new Set(); - @state() protected collapsedActiveChildSessionKeys: ReadonlySet = new Set(); - @state() fullyShownChildSessionKeys: ReadonlySet = new Set(); @state() sessionsGrouping: SidebarSessionsGrouping = loadStoredSidebarSessionsGrouping(); @state() sessionsShowCron = loadStoredSidebarSessionsShowCron(); @state() sessionsShowPreview = loadStoredSidebarSessionsShowPreview(); @@ -166,10 +166,11 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { ); private sessionSelectionAnchor: string | null = null; - private collapsedActiveRouteKey: string | null = null; private readonly runtimeSampledAtByRow = new WeakMap(); private readonly attention = new SessionAttentionController(this); + declare readonly sidebarNarrationLines: ReadonlyMap; + declare readonly sidebarObserverDigests: ReadonlyMap; declare readonly sessionOrganizer: SessionOrganizerController; declare readonly sidebarMenus: SidebarMenusController; @@ -194,7 +195,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { } promoteCreatedSession(sessionKey: string) { - if (promoteSidebarSessionCreatedOrder(this.sessionData.sessionCreatedOrder, sessionKey)) { + if (this.sessionProjection.promoteCreatedSession(sessionKey)) { this.requestUpdate(); } } @@ -218,12 +219,6 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { void this.context?.sessions.setOwnerFilter(null); } const activeRouteKey = isSessionRouteId(this.activeRouteId) ? this.getRouteSessionKey() : ""; - if (activeRouteKey !== this.collapsedActiveRouteKey) { - this.collapsedActiveRouteKey = activeRouteKey; - if (this.collapsedActiveChildSessionKeys.size > 0) { - this.collapsedActiveChildSessionKeys = new Set(); - } - } if (isSessionRouteId(this.activeRouteId)) { void this.sessionData.loadActiveSessionLineage(activeRouteKey); } @@ -333,7 +328,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { /** Collapsed zones keep full rows for true header counts and status dots. */ protected zonedVisibleSections(rows: SidebarRecentSession[]): SidebarVisibleSections { const grouping = this.effectiveSessionsGrouping(); - return partitionSidebarVisibleSections({ + return this.sessionProjection.project({ rows, grouping, knownGroups: grouping === "category" ? this.knownSessionGroups() : [], @@ -348,6 +343,20 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { hideEmptyOwnerFilteredGroup: (category, rowCount) => this.sessionOwnerFilterActive && Boolean(category) && rowCount === 0, visibleSessionLimits: this.sessionData.visibleSessionLimits, + sortMode: this.effectiveSessionSortMode(), + statusFilter: this.sessionsStatusFilter, + agentId: this.expandedAgentId(), + connectionIdentity: + this.context?.gateway.snapshot.phase === "connected" + ? (this.context.gateway.snapshot.client ?? null) + : null, + listSource: this.context?.sessions ?? null, + subtitle: { + sidebarLiveActivity: this.sidebarLiveActivity, + showPreview: this.sessionsShowPreview, + narrationLines: this.sidebarNarrationLines, + observerDigests: this.sidebarObserverDigests, + }, }); } @@ -478,7 +487,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { return; } this.clearSessionSelection(); - this.expandedChildSessionKeys = new Set(); + this.sessionProjection.resetMembership(); this.sessionData.visibleSessionLimits.clear(); context.agentSelection.set(nextAgentId); void this.sessionData.refreshSidebarSessions(nextAgentId); @@ -749,35 +758,25 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { }; isSessionChildrenExpanded(session: SidebarRecentSession): boolean { - return ( - this.expandedChildSessionKeys.has(session.key) || - (session.containsActiveDescendant && !this.collapsedActiveChildSessionKeys.has(session.key)) - ); + return this.sessionProjection.isChildrenExpanded(session.key); + } + + isSessionChildrenFullyShown(sessionKey: string): boolean { + return this.sessionProjection.isChildrenFullyShown(sessionKey); } toggleSessionChildren(session: SidebarRecentSession) { - const next = new Set(this.expandedChildSessionKeys); - const collapsedActive = new Set(this.collapsedActiveChildSessionKeys); - const fullyShown = new Set(this.fullyShownChildSessionKeys); - if (this.isSessionChildrenExpanded(session)) { - next.delete(session.key); - fullyShown.delete(session.key); - if (session.containsActiveDescendant) { - collapsedActive.add(session.key); - } + if (!this.sessionProjection.toggleChildren(session).expanded) { this.sessionData.discardEmptyChildSessionSnapshot(session.key); } else { - next.add(session.key); - collapsedActive.delete(session.key); this.sessionData.retryChildSessions(session.key); } - this.expandedChildSessionKeys = next; - this.collapsedActiveChildSessionKeys = collapsedActive; - this.fullyShownChildSessionKeys = fullyShown; + this.requestUpdate(); } showMoreChildren(sessionKey: string) { - this.fullyShownChildSessionKeys = new Set(this.fullyShownChildSessionKeys).add(sessionKey); + this.sessionProjection.showMoreChildren(sessionKey); + this.requestUpdate(); } agentUnreadCount(agentId: string): number { diff --git a/ui/src/components/app-sidebar-session-projection.test.ts b/ui/src/components/app-sidebar-session-projection.test.ts new file mode 100644 index 000000000000..ce39984b2596 --- /dev/null +++ b/ui/src/components/app-sidebar-session-projection.test.ts @@ -0,0 +1,524 @@ +import { describe, expect, it } from "vitest"; +import { SidebarSessionProjection } from "./app-sidebar-session-projection.ts"; +import type { SidebarRecentSession } from "./app-sidebar-session-types.ts"; + +type ProjectionInput = Parameters[0]; + +const connectionIdentity = {}; +const listSource = {}; + +function sessionRow( + key: string, + overrides: Partial = {}, +): SidebarRecentSession { + return { + key, + label: key, + href: `/chat/${key}`, + active: false, + visuallyActive: false, + hasActiveRun: false, + modelSelectionLocked: false, + pinned: false, + cloudWorkerStopAction: null, + hasAutomation: false, + unread: false, + attention: { kind: "none" }, + childSessionKeys: [], + children: [], + isChild: false, + loadingChildren: false, + containsActiveDescendant: false, + runningChildCount: 0, + failedChildCount: 0, + ...overrides, + }; +} + +function projectionInput( + rows: SidebarRecentSession[], + overrides: Partial = {}, +): ProjectionInput { + return { + rows, + grouping: "category", + knownGroups: [], + collapsedSections: new Set(), + hideEmptyOwnerFilteredGroup: () => false, + visibleSessionLimits: new Map(), + sortMode: "created", + statusFilter: "active", + agentId: "main", + connectionIdentity, + listSource, + subtitle: { + sidebarLiveActivity: true, + showPreview: true, + narrationLines: new Map(), + observerDigests: new Map(), + }, + ...overrides, + }; +} + +function pagedSessions(overrides: Partial = {}): SidebarRecentSession[] { + return Array.from({ length: 11 }, (_, index) => + sessionRow(`session-${index}`, index === 10 ? overrides : {}), + ); +} + +function subtitleParams( + session: SidebarRecentSession, + overrides: Partial[0]> = {}, +) { + return { + session, + hasDisplay: false, + displaySubtitle: undefined, + sidebarLiveActivity: true, + showPreview: true, + narrationLine: undefined, + ...overrides, + }; +} + +describe("SidebarSessionProjection sticky membership", () => { + it("keeps an active row visible after it returns to idle without displacing the natural page", () => { + const projection = new SidebarSessionProjection(); + const runningRows = pagedSessions({ active: true }); + + expect( + projection.project(projectionInput(runningRows)).visibleRows.map((row) => row.key), + ).toEqual([...runningRows.slice(0, 9).map((row) => row.key), "session-10"]); + + const idleRows = pagedSessions(); + const visible = projection.project(projectionInput(idleRows)).visibleRows; + + expect(visible.map((row) => row.key)).toEqual(idleRows.map((row) => row.key)); + expect(projection.project(projectionInput(idleRows)).visibleRows).toHaveLength(11); + }); + + it("retains the previous page when a newly sorted row enters ahead of it", () => { + const projection = new SidebarSessionProjection(); + const original = pagedSessions(); + projection.project(projectionInput(original)); + + const inserted = [sessionRow("newest"), ...original]; + + expect(projection.project(projectionInput(inserted)).visibleRows.map((row) => row.key)).toEqual( + ["newest", ...original.slice(0, 10).map((row) => row.key)], + ); + }); + + it.each([ + // Grouping can re-emit the same section id (e.g. ungrouped) with a + // different row population; sticky keys must not survive the switch. + ["grouping", { grouping: "none" }], + ["sort mode", { sortMode: "updated" }], + ["status filter", { statusFilter: "all" }], + ["agent", { agentId: "other" }], + ["gateway connection", { connectionIdentity: {} }], + ["session-list source", { listSource: {} }], + ] satisfies [string, Partial][])( + "resets retained page membership when the %s changes", + (_boundary, change) => { + const projection = new SidebarSessionProjection(); + projection.project(projectionInput(pagedSessions({ active: true }))); + expect(projection.project(projectionInput(pagedSessions())).visibleRows).toHaveLength(11); + + expect(projection.project(projectionInput(pagedSessions(), change)).visibleRows).toHaveLength( + 10, + ); + }, + ); + + it("clears a section's sticky rows when its user collapses and reopens it", () => { + const projection = new SidebarSessionProjection(); + const categorized = (active: boolean) => + pagedSessions({ active, category: "Team" }).map((row) => + Object.assign(row, { category: "Team" }), + ); + const options: Partial = { knownGroups: ["Team"] }; + projection.project(projectionInput(categorized(true), options)); + expect( + projection.project(projectionInput(categorized(false), options)).visibleRows, + ).toHaveLength(11); + + projection.project( + projectionInput(categorized(false), { + ...options, + collapsedSections: new Set(["category:Team"]), + }), + ); + + expect( + projection.project(projectionInput(categorized(false), options)).visibleRows, + ).toHaveLength(10); + }); + + it("forgets a sticky key once it disappears instead of restoring it when it returns", () => { + const projection = new SidebarSessionProjection(); + projection.project(projectionInput(pagedSessions({ active: true }))); + projection.project(projectionInput(pagedSessions())); + projection.project(projectionInput(pagedSessions().slice(0, 10))); + + expect( + projection.project(projectionInput(pagedSessions())).visibleRows.map((row) => row.key), + ).toEqual( + pagedSessions() + .slice(0, 10) + .map((row) => row.key), + ); + }); + + it("preserves collapsed-header counts without counting retained overflow", () => { + const projection = new SidebarSessionProjection(); + projection.project(projectionInput(pagedSessions({ active: true }))); + + const section = projection + .project(projectionInput(pagedSessions())) + .sections.find((entry) => entry.id === "ungrouped"); + + expect(section).toMatchObject({ visibleRowCount: 11, collapsedVisibleRowCount: 10 }); + }); +}); + +describe("SidebarSessionProjection created order", () => { + it("retains a row's original creation-order index when it leaves and returns below the cap", () => { + const projection = new SidebarSessionProjection(); + projection.observeRows([{ sessions: [{ key: "first" }, { key: "returning" }] }]); + const originalOrder = projection.createdOrder.get("returning"); + + projection.observeRows([{ sessions: [{ key: "first" }, { key: "newer" }] }]); + projection.observeRows([{ sessions: [{ key: "returning" }, { key: "newer" }] }]); + + expect(projection.createdOrder.get("returning")).toBe(originalOrder); + expect(projection.createdOrder.get("newer")).toBeGreaterThan(originalOrder ?? -1); + }); + + it("prunes only absent rows when the bounded registry exceeds its cap", () => { + const projection = new SidebarSessionProjection(); + const original = Array.from({ length: 1_000 }, (_, index) => ({ key: `original-${index}` })); + projection.observeRows([{ sessions: original }]); + + projection.observeRows([{ sessions: [{ key: "replacement" }, original[999]!] }]); + + expect(projection.createdOrder.size).toBe(1_000); + expect(projection.createdOrder.has("original-0")).toBe(false); + expect(projection.createdOrder.get("original-999")).toBe(999); + expect(projection.createdOrder.get("replacement")).toBe(1_000); + }); + + it("promotes newly created sessions without losing the order of existing peers", () => { + const projection = new SidebarSessionProjection(); + projection.observeRows([{ sessions: [{ key: "first" }, { key: "second" }] }]); + + expect(projection.promoteCreatedSession("newest")).toBe(true); + expect(projection.promoteCreatedSession("newest")).toBe(false); + expect([...projection.createdOrder]).toEqual([ + ["first", 1], + ["second", 2], + ["newest", 0], + ]); + }); + + it("keeps observed creation order across sidebar scope replacements", () => { + const projection = new SidebarSessionProjection(); + projection.observeRows([{ sessions: [{ key: "remembered" }] }]); + projection.project(projectionInput([sessionRow("remembered")])); + + projection.project( + projectionInput([sessionRow("replacement")], { + agentId: "other", + connectionIdentity: {}, + listSource: {}, + }), + ); + + expect(projection.createdOrder.get("remembered")).toBe(0); + }); +}); + +describe("SidebarSessionProjection child expansion", () => { + it("latches an active descendant's expansion after that descendant returns to idle", () => { + const projection = new SidebarSessionProjection(); + projection.project(projectionInput([sessionRow("parent", { containsActiveDescendant: true })])); + + projection.project(projectionInput([sessionRow("parent")])); + + expect(projection.isChildrenExpanded("parent")).toBe(true); + }); + + it("keeps an explicitly collapsed parent closed when a later descendant becomes active", () => { + const projection = new SidebarSessionProjection(); + const active = sessionRow("parent", { containsActiveDescendant: true }); + projection.project(projectionInput([active])); + expect(projection.toggleChildren(active)).toEqual({ expanded: false }); + + projection.project(projectionInput([sessionRow("parent")])); + projection.project(projectionInput([active])); + + expect(projection.isChildrenExpanded("parent")).toBe(false); + }); + + it("preserves full child visibility until an explicit collapse resets it", () => { + const projection = new SidebarSessionProjection(); + const parent = sessionRow("parent"); + + expect(projection.toggleChildren(parent)).toEqual({ expanded: true }); + projection.showMoreChildren(parent.key); + projection.project(projectionInput([parent])); + expect(projection.isChildrenFullyShown(parent.key)).toBe(true); + + expect(projection.toggleChildren(parent)).toEqual({ expanded: false }); + expect(projection.isChildrenFullyShown(parent.key)).toBe(false); + expect(projection.toggleChildren(parent)).toEqual({ expanded: true }); + expect(projection.isChildrenFullyShown(parent.key)).toBe(false); + }); + + it("keeps an expanded parent when its same-agent session-list owner is replaced", () => { + const projection = new SidebarSessionProjection(); + const parent = sessionRow("parent"); + projection.project(projectionInput([parent])); + projection.toggleChildren(parent); + + projection.project(projectionInput([parent], { listSource: {} })); + + expect(projection.isChildrenExpanded(parent.key)).toBe(true); + }); + + it("clears expansion when the selected agent changes", () => { + const projection = new SidebarSessionProjection(); + const parent = sessionRow("parent"); + projection.project(projectionInput([parent])); + projection.toggleChildren(parent); + + projection.project(projectionInput([parent], { agentId: "other" })); + + expect(projection.isChildrenExpanded(parent.key)).toBe(false); + }); +}); + +describe("SidebarSessionProjection running subtitle hold", () => { + it("holds the latest narration across an empty running update without losing its remount key", () => { + const projection = new SidebarSessionProjection(); + const running = sessionRow("running", { hasActiveRun: true, activeRunIds: ["run-one"] }); + projection.project( + projectionInput([running], { + subtitle: { + sidebarLiveActivity: true, + showPreview: true, + narrationLines: new Map([[running.key, "Running checks"]]), + observerDigests: new Map(), + }, + }), + ); + projection.project(projectionInput([running])); + + expect(projection.resolveSubtitle(subtitleParams(running))).toEqual({ + subtitle: "Running checks", + narration: "Running checks", + }); + }); + + it.each(["ended", "preview-hidden"] as const)( + "clears held running activity when its run is %s", + (change) => { + const projection = new SidebarSessionProjection(); + const running = sessionRow("running", { hasActiveRun: true, activeRunIds: ["run-one"] }); + projection.project( + projectionInput([running], { + subtitle: { + sidebarLiveActivity: true, + showPreview: true, + narrationLines: new Map([[running.key, "Old run activity"]]), + observerDigests: new Map(), + }, + }), + ); + const changed = change === "ended" ? sessionRow(running.key) : running; + const showPreview = change !== "preview-hidden"; + + projection.project( + projectionInput([changed], { + subtitle: { + sidebarLiveActivity: true, + showPreview, + narrationLines: new Map(), + observerDigests: new Map(), + }, + }), + ); + + expect(projection.resolveSubtitle(subtitleParams(changed, { showPreview }))).toEqual({ + subtitle: undefined, + narration: undefined, + }); + }, + ); + + it("holds the subtitle across a run-id rotation while the session stays running", () => { + // Live repro: queued->running rotates activeRunIds; the row must not blank. + const projection = new SidebarSessionProjection(); + const running = sessionRow("running", { hasActiveRun: true, activeRunIds: ["run-one"] }); + projection.project( + projectionInput([running], { + subtitle: { + sidebarLiveActivity: true, + showPreview: true, + narrationLines: new Map([[running.key, "Pre-rotation activity"]]), + observerDigests: new Map(), + }, + }), + ); + const rotated = sessionRow(running.key, { hasActiveRun: true, activeRunIds: ["run-two"] }); + projection.project(projectionInput([rotated])); + + expect(projection.resolveSubtitle(subtitleParams(rotated)).subtitle).toBe( + "Pre-rotation activity", + ); + }); + + it("floors ambient subtitle replacement at the minimum display time", () => { + let clock = 0; + const projection = new SidebarSessionProjection(() => clock); + const running = sessionRow("running", { hasActiveRun: true, activeRunIds: ["run-one"] }); + const withNarration = (line: string) => ({ + sidebarLiveActivity: true, + showPreview: true, + narrationLines: new Map([[running.key, line]]), + observerDigests: new Map(), + }); + projection.project(projectionInput([running], { subtitle: withNarration("First activity") })); + + clock = 500; + projection.project(projectionInput([running], { subtitle: withNarration("Second activity") })); + expect(projection.resolveSubtitle(subtitleParams(running)).subtitle).toBe("First activity"); + + clock = 2_500; + projection.project(projectionInput([running], { subtitle: withNarration("Second activity") })); + expect(projection.resolveSubtitle(subtitleParams(running)).subtitle).toBe("Second activity"); + }); + + it("lets operator-critical text replace a held subtitle immediately", () => { + let clock = 0; + const projection = new SidebarSessionProjection(() => clock); + const running = sessionRow("running", { hasActiveRun: true, activeRunIds: ["run-one"] }); + projection.project( + projectionInput([running], { + subtitle: { + sidebarLiveActivity: true, + showPreview: true, + narrationLines: new Map([[running.key, "Ambient activity"]]), + observerDigests: new Map(), + }, + }), + ); + + clock = 200; + const needsInput = sessionRow(running.key, { + hasActiveRun: true, + activeRunIds: ["run-one"], + agentStatusNote: "Blocked on operator input", + }); + projection.project(projectionInput([needsInput])); + + expect(projection.resolveSubtitle(subtitleParams(needsInput)).subtitle).toBe( + "Blocked on operator input", + ); + }); + + it("clears held narration when the user disables live activity", () => { + const projection = new SidebarSessionProjection(); + const running = sessionRow("running", { hasActiveRun: true, activeRunIds: ["run-one"] }); + projection.project( + projectionInput([running], { + subtitle: { + sidebarLiveActivity: true, + showPreview: true, + narrationLines: new Map([[running.key, "Running checks"]]), + observerDigests: new Map(), + }, + }), + ); + + projection.project( + projectionInput([running], { + subtitle: { + sidebarLiveActivity: false, + showPreview: true, + narrationLines: new Map(), + observerDigests: new Map(), + }, + }), + ); + + expect( + projection.resolveSubtitle(subtitleParams(running, { sidebarLiveActivity: false })), + ).toEqual({ subtitle: undefined, narration: undefined }); + }); + + it("keeps a held non-narration subtitle when live activity is disabled", () => { + const projection = new SidebarSessionProjection(); + const running = sessionRow("running", { + hasActiveRun: true, + activeRunIds: ["run-one"], + workSession: true, + subtitle: "~/Projects/openclaw", + }); + projection.project(projectionInput([running])); + const missingSubtitle = { ...running, subtitle: undefined }; + + projection.project( + projectionInput([missingSubtitle], { + subtitle: { + sidebarLiveActivity: false, + showPreview: true, + narrationLines: new Map(), + observerDigests: new Map(), + }, + }), + ); + + expect( + projection.resolveSubtitle(subtitleParams(missingSubtitle, { sidebarLiveActivity: false })), + ).toEqual({ subtitle: "~/Projects/openclaw", narration: undefined }); + }); + + it("holds shared running narration across a catalog display override", () => { + const projection = new SidebarSessionProjection(); + const running = sessionRow("running", { hasActiveRun: true, activeRunIds: ["run-one"] }); + projection.project( + projectionInput([running], { + subtitle: { + sidebarLiveActivity: true, + showPreview: true, + narrationLines: new Map([[running.key, "Native sidebar activity"]]), + observerDigests: new Map(), + }, + }), + ); + projection.project(projectionInput([running])); + + expect(projection.resolveSubtitle(subtitleParams(running, { hasDisplay: true }))).toEqual({ + subtitle: "Native sidebar activity", + narration: "Native sidebar activity", + }); + }); + + it("never leaks a held backing-work subtitle into a catalog display that omits one", () => { + const projection = new SidebarSessionProjection(); + const running = sessionRow("running", { + hasActiveRun: true, + activeRunIds: ["run-one"], + workSession: true, + subtitle: "~/Projects/openclaw", + }); + projection.project(projectionInput([running])); + + expect(projection.resolveSubtitle(subtitleParams(running, { hasDisplay: true }))).toEqual({ + subtitle: undefined, + narration: undefined, + }); + }); +}); diff --git a/ui/src/components/app-sidebar-session-projection.ts b/ui/src/components/app-sidebar-session-projection.ts new file mode 100644 index 000000000000..2956b338c062 --- /dev/null +++ b/ui/src/components/app-sidebar-session-projection.ts @@ -0,0 +1,385 @@ +import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js"; +import { + groupSidebarSessionRows, + type SidebarSessionSection, + type SidebarSessionsGrouping, +} from "../lib/sessions/grouping.ts"; +import { + SIDEBAR_SESSION_PAGE_SIZE, + type SidebarRecentSession, + type SidebarSessionSortMode, + type SidebarSessionStatusFilter, +} from "./app-sidebar-session-types.ts"; +import { sessionAttentionSubtitle } from "./session-attention-presentation.ts"; +import { resolveSidebarSessionSubtitle } from "./session-row-subtitle.ts"; + +const SIDEBAR_CREATED_ORDER_CAP = 1_000; +// Ambient subtitle sources (observer digest, narration, work path) race at +// event rate; without a floor the line swaps A->B->A within a second. Matches +// the narration throttle so replacement cadence stays consistent. +const SIDEBAR_SUBTITLE_MIN_DISPLAY_MS = 2_000; + +type SidebarExpansionMode = "collapsed-by-user" | "expanded" | "expanded-fully"; +type SidebarSubtitleParams = Parameters[0]; +type SidebarSubtitleValue = ReturnType; + +type SidebarProjectionInput = { + rows: SidebarRecentSession[]; + grouping: SidebarSessionsGrouping; + knownGroups: string[] | undefined; + selfOwnerId?: string | null; + catalogIds?: readonly string[]; + sectionOrder?: readonly string[]; + collapsedSections: ReadonlySet; + hideEmptyOwnerFilteredGroup: (category: string | undefined, rowCount: number) => boolean; + visibleSessionLimits: ReadonlyMap; + sortMode: SidebarSessionSortMode; + statusFilter: SidebarSessionStatusFilter; + agentId: string; + connectionIdentity: object | null; + listSource: object | null; + subtitle: { + sidebarLiveActivity: boolean; + showPreview: boolean; + narrationLines: ReadonlyMap; + observerDigests: ReadonlyMap; + }; +}; + +export type SidebarVisibleSections = { + sections: (SidebarSessionSection & { + totalRowCount: number; + visibleRowCount: number; + visibleLimit: number; + collapsedVisibleRowCount: number; + renderHeader: boolean; + })[]; + expandedRows: SidebarRecentSession[]; + visibleRows: SidebarRecentSession[]; +}; + +function baselineSessionRows(rows: readonly SidebarRecentSession[], limit: number) { + const requiredCount = rows.filter((row) => row.active || row.pinned).length; + let optionalSlots = Math.max(0, limit - requiredCount); + return rows.filter((row) => { + if (row.active || row.pinned) { + return true; + } + if (optionalSlots === 0) { + return false; + } + optionalSlots -= 1; + return true; + }); +} + +/** Attention, agent-declared status, and the queued explanation are messages + * the operator must act on; they replace a held subtitle immediately. */ +function isOperatorCriticalSubtitle(session: SidebarRecentSession): boolean { + return Boolean( + sessionAttentionSubtitle(session.attention) || + session.agentStatusNote || + (session.hasActiveRun && session.status === "queued"), + ); +} + +export class SidebarSessionProjection { + constructor(private readonly now: () => number = () => Date.now()) {} + + private readonly observedOrder = new Map(); + private nextCreatedOrder = 0; + private readonly stickySections = new Map>(); + private readonly childModes = new Map(); + private readonly heldSubtitles = new Map< + string, + { value: SidebarSubtitleValue; catalogValue?: SidebarSubtitleValue; shownAt: number } + >(); + private previousInput: Pick< + SidebarProjectionInput, + "grouping" | "sortMode" | "statusFilter" | "agentId" | "connectionIdentity" | "listSource" + > | null = null; + private previousCollapsedSections = new Set(); + + get createdOrder(): ReadonlyMap { + return this.observedOrder; + } + + observeRows(results: readonly { sessions: readonly { key: string }[] }[]): void { + const retainedKeys = new Set(); + for (const result of results) { + for (const { key } of result.sessions) { + if (!key) { + continue; + } + retainedKeys.add(key); + if (!this.observedOrder.has(key)) { + this.observedOrder.set(key, this.nextCreatedOrder++); + } + } + } + // Paging gaps must retain their tie-break index; evict absent keys only + // when the sidebar-lifetime registry actually exceeds its memory bound. + if (this.observedOrder.size > SIDEBAR_CREATED_ORDER_CAP) { + for (const key of this.observedOrder.keys()) { + if (this.observedOrder.size <= SIDEBAR_CREATED_ORDER_CAP) { + break; + } + if (!retainedKeys.has(key)) { + this.observedOrder.delete(key); + } + } + } + } + + promoteCreatedSession(key: string): boolean { + const currentOrder = this.observedOrder.get(key); + if (currentOrder === 0) { + return false; + } + for (const [existingKey, order] of this.observedOrder) { + if (existingKey !== key && (currentOrder === undefined || order < currentOrder)) { + this.observedOrder.set(existingKey, order + 1); + this.nextCreatedOrder = Math.max(this.nextCreatedOrder, order + 2); + } + } + this.observedOrder.set(key, 0); + this.nextCreatedOrder = Math.max(this.nextCreatedOrder, 1); + return true; + } + + project(input: SidebarProjectionInput): SidebarVisibleSections { + const previous = this.previousInput; + const scopeChanged = + previous !== null && + (previous.agentId !== input.agentId || + previous.connectionIdentity !== input.connectionIdentity || + previous.listSource !== input.listSource); + if ( + scopeChanged || + (previous !== null && + // Grouping changes can re-emit the same section id (e.g. ungrouped) + // with a different row population; stale sticky keys must not carry over. + (previous.grouping !== input.grouping || + previous.sortMode !== input.sortMode || + previous.statusFilter !== input.statusFilter)) + ) { + this.resetMembership(); + } + if ( + previous !== null && + (previous.agentId !== input.agentId || + previous.connectionIdentity !== input.connectionIdentity) + ) { + this.childModes.clear(); + } + if (scopeChanged) { + this.heldSubtitles.clear(); + } + for (const sectionId of input.collapsedSections) { + if (!this.previousCollapsedSections.has(sectionId)) { + this.resetMembership(sectionId); + } + } + this.previousInput = { + grouping: input.grouping, + sortMode: input.sortMode, + statusFilter: input.statusFilter, + agentId: input.agentId, + connectionIdentity: input.connectionIdentity, + listSource: input.listSource, + }; + this.previousCollapsedSections = new Set(input.collapsedSections); + + const retainedKeys = new Set(); + const observeTree = (session: SidebarRecentSession) => { + retainedKeys.add(session.key); + if (session.containsActiveDescendant && !this.childModes.has(session.key)) { + this.childModes.set(session.key, "expanded"); + } + this.observeSubtitle(session, input.subtitle); + for (const child of session.children) { + observeTree(child); + } + }; + input.rows.forEach(observeTree); + for (const key of this.childModes.keys()) { + if (!retainedKeys.has(key)) { + this.childModes.delete(key); + } + } + for (const key of this.heldSubtitles.keys()) { + if (!retainedKeys.has(key)) { + this.heldSubtitles.delete(key); + } + } + + const { grouping, knownGroups, selfOwnerId, sectionOrder, catalogIds } = input; + const sections = groupSidebarSessionRows(input.rows, { + grouping, + knownGroups, + selfOwnerId, + sectionOrder, + catalogIds, + }).filter( + (section) => + section.id !== "pinned" && + !input.hideEmptyOwnerFilteredGroup(section.category, section.rows.length), + ); + const sectionIds = new Set(sections.map((section) => section.id)); + for (const sectionId of this.stickySections.keys()) { + if (!sectionIds.has(sectionId)) { + this.stickySections.delete(sectionId); + } + } + // A lone catch-all sits directly under the global Sessions toolbar. Empty + // Coding does not render, while empty custom/Groups sections remain targets. + // Headerless means no collapse control, so a stored ungrouped-collapsed + // preference is deliberately inert here; it re-applies once a peer returns. + const ungroupedHasPeerHeader = sections.some( + (section) => section.id !== "ungrouped" && (section.id !== "work" || section.rows.length > 0), + ); + const expandedRows: SidebarRecentSession[] = []; + const visibleRows: SidebarRecentSession[] = []; + const limitedSections: SidebarVisibleSections["sections"] = []; + for (const section of sections) { + // totalRowCount is the pre-pagination size: headers and empty-zone + // checks must not mistake a page-filtered section for an empty one. + const totalRowCount = section.rows.length; + const renderHeader = section.id !== "ungrouped" || ungroupedHasPeerHeader; + const collapsed = renderHeader && input.collapsedSections.has(section.id); + const visibleLimit = input.visibleSessionLimits.get(section.id) ?? SIDEBAR_SESSION_PAGE_SIZE; + const collapsedVisibleRowCount = baselineSessionRows( + section.rows, + SIDEBAR_SESSION_PAGE_SIZE, + ).length; + let visibleRowCount = 0; + if (!collapsed) { + expandedRows.push(...section.rows); + const baselineKeys = new Set( + baselineSessionRows(section.rows, visibleLimit).map((row) => row.key), + ); + const sticky = this.stickySections.get(section.id) ?? new Set(); + const sectionKeys = new Set(section.rows.map((row) => row.key)); + for (const key of sticky) { + if (!sectionKeys.has(key)) { + sticky.delete(key); + } + } + // Union after normal paging keeps newly sorted rows visible without + // evicting rows the operator already saw before a run-state transition. + section.rows = section.rows.filter( + (row) => baselineKeys.has(row.key) || sticky.has(row.key), + ); + for (const row of section.rows) { + sticky.add(row.key); + } + this.stickySections.set(section.id, sticky); + visibleRows.push(...section.rows); + visibleRowCount = section.rows.length; + } + limitedSections.push( + Object.assign(section, { + totalRowCount, + visibleRowCount, + visibleLimit, + collapsedVisibleRowCount, + renderHeader, + }), + ); + } + return { sections: limitedSections, expandedRows, visibleRows }; + } + + resetMembership(sectionId?: string): void { + if (sectionId === undefined) { + this.stickySections.clear(); + } else { + this.stickySections.delete(sectionId); + } + } + + isChildrenExpanded(key: string): boolean { + const mode = this.childModes.get(key); + return mode === "expanded" || mode === "expanded-fully"; + } + + isChildrenFullyShown(key: string): boolean { + return this.childModes.get(key) === "expanded-fully"; + } + + toggleChildren(session: SidebarRecentSession): { expanded: boolean } { + if (this.isChildrenExpanded(session.key)) { + // The explicit closed mode prevents a still-active descendant from + // immediately undoing the user's collapse on the next update pass. + this.childModes.set(session.key, "collapsed-by-user"); + return { expanded: false }; + } + this.childModes.set(session.key, "expanded"); + return { expanded: true }; + } + + showMoreChildren(key: string): void { + if (this.isChildrenExpanded(key)) { + this.childModes.set(key, "expanded-fully"); + } + } + + resolveSubtitle(params: SidebarSubtitleParams): SidebarSubtitleValue { + if (!params.session.hasActiveRun || !params.showPreview) { + return resolveSidebarSessionSubtitle(params); + } + // While a run is live the held value is the display: observeSubtitle + // refreshed it this update pass, applying the minimum-display floor. + const held = this.heldSubtitles.get(params.session.key); + if (!held) { + return resolveSidebarSessionSubtitle(params); + } + return params.hasDisplay + ? (held.catalogValue ?? resolveSidebarSessionSubtitle(params)) + : held.value; + } + + private observeSubtitle( + session: SidebarRecentSession, + environment: SidebarProjectionInput["subtitle"], + ): void { + if (!session.hasActiveRun || !environment.showPreview) { + this.heldSubtitles.delete(session.key); + return; + } + if (!environment.sidebarLiveActivity && this.heldSubtitles.get(session.key)?.value.narration) { + this.heldSubtitles.delete(session.key); + } + const params = { + session, + hasDisplay: false, + displaySubtitle: undefined, + sidebarLiveActivity: environment.sidebarLiveActivity, + showPreview: environment.showPreview, + narrationLine: environment.narrationLines.get(session.key), + observerDigest: environment.observerDigests.get(session.key) ?? null, + } satisfies SidebarSubtitleParams; + const value = resolveSidebarSessionSubtitle(params); + if (!value.subtitle) { + // Transient gaps between event updates keep the last shown line; the + // hold dies with the run (the hasActiveRun branch above). + return; + } + const held = this.heldSubtitles.get(session.key); + const now = this.now(); + const replacing = held !== undefined && held.value.subtitle !== value.subtitle; + if ( + replacing && + now - held.shownAt < SIDEBAR_SUBTITLE_MIN_DISPLAY_MS && + !isOperatorCriticalSubtitle(session) + ) { + return; + } + const catalogValue = resolveSidebarSessionSubtitle({ ...params, hasDisplay: true }); + this.heldSubtitles.set(session.key, { + value, + ...(catalogValue.subtitle ? { catalogValue } : {}), + shownAt: held !== undefined && !replacing ? held.shownAt : now, + }); + } +} diff --git a/ui/src/components/app-sidebar-session-row-render.ts b/ui/src/components/app-sidebar-session-row-render.ts index ad8294152537..40977b8b8ab0 100644 --- a/ui/src/components/app-sidebar-session-row-render.ts +++ b/ui/src/components/app-sidebar-session-row-render.ts @@ -26,6 +26,7 @@ import type { CatalogBackingSessionDisplay, CatalogSessionMenuRequest, } from "./app-sidebar-session-catalogs.ts"; +import type { SidebarSessionProjection } from "./app-sidebar-session-projection.ts"; import { rowDemandsVisibility, sidebarSessionMetaId, @@ -42,10 +43,7 @@ import { import type { SessionPullRequestIndicatorState } from "./session-menu-work.ts"; import type { SessionOrganizerController } from "./session-organizer-controller.ts"; import { renderSessionRowBadges } from "./session-row-badges.ts"; -import { - renderSidebarSessionSubtitle, - resolveSidebarSessionSubtitle, -} from "./session-row-subtitle.ts"; +import { renderSidebarSessionSubtitle } from "./session-row-subtitle.ts"; import type { SidebarMenusController } from "./sidebar-menus-controller.ts"; import "./elapsed-time.ts"; import "./tooltip.ts"; @@ -58,6 +56,7 @@ export interface SessionListHost { readonly sessionsShowPreview: boolean; readonly sidebarNarrationLines: ReadonlyMap; readonly sidebarObserverDigests: ReadonlyMap; + readonly sessionProjection: Pick; readonly selectedSessionKeys: ReadonlySet; readonly connected: boolean; readonly sessionData: Pick< @@ -72,7 +71,6 @@ export interface SessionListHost { | "sessionCatalogRefreshStatus" | "sessionMutationError" >; - readonly fullyShownChildSessionKeys: ReadonlySet; readonly sessionsGrouping: SidebarSessionsGrouping; readonly collapsedSessionSections: ReadonlySet; readonly sessionOrganizer: Pick< @@ -111,6 +109,7 @@ export interface SessionListHost { ): SessionPullRequestIndicatorState; mainSessionRow(): { key: string } | null; isSessionChildrenExpanded(session: SidebarRecentSession): boolean; + isSessionChildrenFullyShown(sessionKey: string): boolean; startSessionDrag(session: SidebarRecentSession): void; finishSessionDrag(): void; handleSessionRowClick(event: MouseEvent, session: SidebarRecentSession): void; @@ -149,11 +148,10 @@ export interface SessionListHost { export function visibleSessionChildren(params: { session: SidebarRecentSession; - fullyShownChildSessionKeys: ReadonlySet; + fullyShown: boolean; }): readonly SidebarRecentSession[] { - const showAllChildren = params.fullyShownChildSessionKeys.has(params.session.key); // Active, running, and attention-bearing branches must bypass the quiet-child cap. - return showAllChildren + return params.fullyShown ? params.session.children : params.session.children.filter( (child, index) => @@ -173,7 +171,7 @@ export function renderRecentSession(params: { params: { key: session.key, pinned: !session.pinned }, }); const label = display?.label ?? session.label; - const { subtitle, narration } = resolveSidebarSessionSubtitle({ + const { subtitle, narration } = host.sessionProjection.resolveSubtitle({ session, hasDisplay: display !== undefined, displaySubtitle: display?.subtitle, @@ -564,7 +562,7 @@ export function renderSessionTree(params: { const expanded = host.isSessionChildrenExpanded(session); const visibleChildren = visibleSessionChildren({ session, - fullyShownChildSessionKeys: host.fullyShownChildSessionKeys, + fullyShown: host.isSessionChildrenFullyShown(session.key), }); const hiddenChildCount = session.children.length - visibleChildren.length; return html`
row.active || row.pinned).length; - let optionalSlots = Math.max(0, limit - requiredCount); - // Active and pinned sessions remain reachable without changing their - // relative order, even when their sort position falls outside the page. - return rows.filter((row) => { - if (row.active || row.pinned) { - return true; - } - if (optionalSlots === 0) { - return false; - } - optionalSlots -= 1; - return true; - }); -} - export function loadStoredSidebarSessionsGrouping(): SidebarSessionsGrouping { return normalizeSidebarSessionsGrouping( getSafeLocalStorage()?.getItem(SIDEBAR_SESSION_GROUPING_STORAGE_KEY), diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index 425789941769..4973d090cb5d 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -43,6 +43,7 @@ import type { } from "./app-sidebar-session-narration.ts"; import type { SidebarSessionNavigationState } from "./app-sidebar-session-navigation-logic.ts"; import { AppSidebarSessionNavigationElement } from "./app-sidebar-session-navigation.ts"; +import type { SidebarVisibleSections } from "./app-sidebar-session-projection.ts"; import { renderSessionTree, type SessionListHost, @@ -52,6 +53,7 @@ import { loadStoredHiddenSessionCatalogIds, loadStoredSidebarCatalogGrouping, SIDEBAR_HIDDEN_SESSION_CATALOGS_CHANGED_EVENT, + SIDEBAR_SESSION_PAGE_SIZE, setStoredSessionCatalogHidden, storeSidebarCatalogGrouping, type SidebarRecentSession, @@ -75,8 +77,8 @@ const sidebarChromeImport = createIdleImport(() => ); class AppSidebar extends AppSidebarSessionNavigationElement implements SessionListHost { - @state() sidebarNarrationLines: ReadonlyMap = new Map(); - @state() sidebarObserverDigests: ReadonlyMap = new Map(); + @state() override sidebarNarrationLines: ReadonlyMap = new Map(); + @state() override sidebarObserverDigests: ReadonlyMap = new Map(); override readonly sessionOrganizer = new SessionOrganizerController(this); override readonly sidebarMenus = new SidebarMenusController(this); @@ -137,6 +139,11 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi private narrationLoad: Promise | null = null; private sessionNavigationState: SidebarSessionNavigationState | undefined; private projectedSessionRows: SidebarRecentSession[] | undefined; + private projectedSessionSections: SidebarVisibleSections = { + sections: [], + expandedRows: [], + visibleRows: [], + }; private readonly narrationSubscriptions = this.createNarrationSubscriptions(); private readonly nativeGatewaysChanged = () => this.sidebarMenus.closeSessionMenu(); private readonly refreshAppearanceSettings = () => this.context?.theme.refresh(); @@ -222,8 +229,14 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi protected override willUpdate(changed: PropertyValues) { super.willUpdate(changed); + const currentResult = this.sessionData.sessionsResult; + this.sessionProjection.observeRows([ + ...(currentResult ? [currentResult] : []), + ...Object.values(this.sessionData.sessionResultsByAgent), + ]); this.sessionNavigationState = super.getSessionNavigationState(); this.projectedSessionRows = super.selectedAgentSessionRows(this.sessionNavigationState); + this.projectedSessionSections = super.zonedVisibleSections(this.projectedSessionRows); const chip = this.activeChipAgent(); // An open switcher tracks roster/reconnect updates; otherwise only hydrate // the active card and avoid background RPCs for every configured agent. @@ -250,6 +263,10 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi return this.projectedSessionRows ?? super.selectedAgentSessionRows(navigationState); } + protected override zonedVisibleSections(_rows: SidebarRecentSession[]): SidebarVisibleSections { + return this.projectedSessionSections; + } + override updated(changedProperties: PropertyValues) { super.updated(changedProperties); if (!this.narration) { @@ -270,7 +287,7 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi if (this.isSessionChildrenExpanded(session)) { visibleSessionChildren({ session, - fullyShownChildSessionKeys: this.fullyShownChildSessionKeys, + fullyShown: this.isSessionChildrenFullyShown(session.key), }).forEach(append); } }; @@ -373,6 +390,9 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi } toggleSection(sectionId: string): void { + if (!this.collapsedSessionSections.has(sectionId)) { + this.sessionProjection.resetMembership(sectionId); + } this.sessionOrganizer.toggleSection(sectionId); } @@ -393,6 +413,11 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi } setVisibleSessionLimit(sectionId: string, limit: number): void { + const previousLimit = + this.sessionData.visibleSessionLimits.get(sectionId) ?? SIDEBAR_SESSION_PAGE_SIZE; + if (limit < previousLimit) { + this.sessionProjection.resetMembership(sectionId); + } this.sessionData.setVisibleSessionLimit(sectionId, limit); } diff --git a/ui/src/components/session-data-controller-events.test.ts b/ui/src/components/session-data-controller-events.test.ts index fa68640b45ba..edaea13a5246 100644 --- a/ui/src/components/session-data-controller-events.test.ts +++ b/ui/src/components/session-data-controller-events.test.ts @@ -1,13 +1,11 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; import type { SessionsListResult } from "../api/types.ts"; -import { compareSidebarSessionRowsByMode } from "./app-sidebar-session-navigation-logic.ts"; import { publishSidebarSessionList } from "./session-data-controller-events.ts"; describe("publishSidebarSessionList", () => { const createOwner = () => ({ context: undefined, - sessionCreatedOrder: new Map(), sessionResultsByAgent: {} as Record, sessionsResult: null as SessionsListResult | null, sessionsAgentId: null as string | null, @@ -28,49 +26,34 @@ describe("publishSidebarSessionList", () => { error: null, }); - it("keeps observed creation order only for rows in the current accumulated result", () => { + it("replaces the current agent's accumulated session result", () => { const owner = createOwner(); publish(owner, "main", ["first", "second"]); publish(owner, "main", ["second", "third"]); - expect([...owner.sessionCreatedOrder.keys()]).toEqual(["second", "third"]); + expect(owner.sessionsAgentId).toBe("main"); + expect(owner.sessionsResult?.sessions.map((row) => row.key)).toEqual(["second", "third"]); + expect(owner.sessionResultsByAgent.main).toBe(owner.sessionsResult); }); - it("keeps observed order after pruning and adding a session", () => { - const owner = createOwner(); - - publish(owner, "main", ["removed", "z-retained"]); - publish(owner, "main", ["z-retained"]); - publish(owner, "main", ["z-retained", "a-added"]); - - const ordered = owner.sessionResultsByAgent.main?.sessions.toSorted((a, b) => - compareSidebarSessionRowsByMode({ - a, - b, - sortMode: "created", - owners: undefined, - createdOrder: owner.sessionCreatedOrder, - }), - ); - expect(ordered?.map((row) => row.key)).toEqual(["z-retained", "a-added"]); - }); - - it("keeps creation order for every retained agent result", () => { + it("keeps the latest scoped session result for every cached agent", () => { const owner = createOwner(); publish(owner, "alpha", ["alpha-first", "alpha-second"]); publish(owner, "beta", ["beta-first"]); - publish(owner, "alpha", ["alpha-first", "alpha-second"]); + publish(owner, "alpha", ["alpha-second"]); - expect([...owner.sessionCreatedOrder.keys()]).toEqual([ - "alpha-first", + expect(owner.sessionsAgentId).toBe("alpha"); + expect(owner.sessionResultsByAgent.alpha?.sessions.map((row) => row.key)).toEqual([ "alpha-second", + ]); + expect(owner.sessionResultsByAgent.beta?.sessions.map((row) => row.key)).toEqual([ "beta-first", ]); }); - it("keeps cached agent order while an uncached agent has no result", () => { + it("keeps cached agent results while an uncached agent has no result", () => { const owner = createOwner(); publish(owner, "alpha", ["alpha-first", "alpha-second"]); @@ -81,19 +64,12 @@ describe("publishSidebarSessionList", () => { error: null, }); - expect([...owner.sessionCreatedOrder.keys()]).toEqual(["alpha-first", "alpha-second"]); - }); - - it("preserves promoted order for an unscoped canonical result", () => { - const owner = createOwner(); - owner.sessionCreatedOrder.set("first", 1); - owner.sessionCreatedOrder.set("second", 0); - - publish(owner, null, ["first", "second"]); - - expect([...owner.sessionCreatedOrder]).toEqual([ - ["first", 1], - ["second", 0], + expect(owner.sessionsAgentId).toBe("beta"); + expect(owner.sessionsResult).toBeNull(); + expect(owner.sessionResultsByAgent.alpha?.sessions.map((row) => row.key)).toEqual([ + "alpha-first", + "alpha-second", ]); + expect(owner.sessionResultsByAgent.beta).toBeUndefined(); }); }); diff --git a/ui/src/components/session-data-controller-events.ts b/ui/src/components/session-data-controller-events.ts index 488ce1487baf..c48cc8afc3a7 100644 --- a/ui/src/components/session-data-controller-events.ts +++ b/ui/src/components/session-data-controller-events.ts @@ -11,7 +11,6 @@ import { type SidebarSessionListOwner = { readonly context: ApplicationContext | undefined; - readonly sessionCreatedOrder: Map; sessionResultsByAgent: Record>; sessionsResult: SessionListSnapshot["result"]; sessionsAgentId: SessionListSnapshot["agentId"]; @@ -21,20 +20,6 @@ type SidebarSessionListOwner = { requestSessionDataUpdate(): void; }; -function pruneSidebarSessionOrder( - owner: SidebarSessionListOwner, - retainedResults: readonly NonNullable[], -): void { - const visibleKeys = new Set( - retainedResults.flatMap((result) => result.sessions.map((row) => row.key).filter(Boolean)), - ); - for (const key of owner.sessionCreatedOrder.keys()) { - if (!visibleKeys.has(key)) { - owner.sessionCreatedOrder.delete(key); - } - } -} - function pruneSidebarAgentSessionCaches( owner: SidebarSessionListOwner, agentIds: readonly string[], @@ -49,11 +34,6 @@ function pruneSidebarAgentSessionCaches( owner.sessionsResult = null; owner.sessionsAgentId = null; } - const retainedResults = Object.values(owner.sessionResultsByAgent); - if (owner.sessionsResult) { - retainedResults.push(owner.sessionsResult); - } - pruneSidebarSessionOrder(owner, retainedResults); } export function subscribeSidebarAgentSessionCaches( @@ -94,23 +74,9 @@ export function publishSidebarSessionList( ): void { owner.sessionsResult = snapshot.result; owner.sessionsAgentId = snapshot.agentId; - const sessions = snapshot.result?.sessions ?? []; if (snapshot.result && snapshot.agentId) { owner.sessionResultsByAgent[normalizeAgentId(snapshot.agentId)] = snapshot.result; } - const retainedResults = snapshot.result - ? [snapshot.result, ...Object.values(owner.sessionResultsByAgent)] - : Object.values(owner.sessionResultsByAgent); - pruneSidebarSessionOrder(owner, retainedResults); - let nextCreatedOrder = 0; - for (const order of owner.sessionCreatedOrder.values()) { - nextCreatedOrder = Math.max(nextCreatedOrder, order + 1); - } - for (const row of sessions) { - if (row.key && !owner.sessionCreatedOrder.has(row.key)) { - owner.sessionCreatedOrder.set(row.key, nextCreatedOrder++); - } - } } export function subscribeFilteredSidebarSessions( diff --git a/ui/src/components/session-data-controller.event-refresh.test.ts b/ui/src/components/session-data-controller.event-refresh.test.ts index 1567cd365922..7be56f6df6eb 100644 --- a/ui/src/components/session-data-controller.event-refresh.test.ts +++ b/ui/src/components/session-data-controller.event-refresh.test.ts @@ -190,10 +190,6 @@ describe("filtered sidebar session event refresh", () => { }; controller.sessionsResult = controller.sessionResultsByAgent.research ?? null; controller.sessionsAgentId = "research"; - controller.sessionCreatedOrder = new Map([ - ["agent:main:kept", 0], - ["agent:research:removed", 1], - ]); publishAgentRoster(null); expect(Object.keys(controller.sessionResultsByAgent)).toEqual(["main", "research"]); @@ -202,7 +198,6 @@ describe("filtered sidebar session event refresh", () => { expect(Object.keys(controller.sessionResultsByAgent)).toEqual(["main"]); expect(controller.sessionsResult).toBeNull(); expect(controller.sessionsAgentId).toBeNull(); - expect([...controller.sessionCreatedOrder.keys()]).toEqual(["agent:main:kept"]); controller.hostDisconnected(); }); @@ -212,11 +207,13 @@ describe("filtered sidebar session event refresh", () => { controller.hostConnected(); controller.sessionsResult = resultForKeys(["agent:main:current"]); controller.sessionsAgentId = "main"; - controller.sessionCreatedOrder = new Map([["agent:main:current", 0]]); publishAgentRoster(["main"]); - expect([...controller.sessionCreatedOrder.keys()]).toEqual(["agent:main:current"]); + expect(controller.sessionsAgentId).toBe("main"); + expect(controller.sessionsResult?.sessions.map((row) => row.key)).toEqual([ + "agent:main:current", + ]); controller.hostDisconnected(); }); diff --git a/ui/src/components/session-data-controller.ts b/ui/src/components/session-data-controller.ts index 05873dbcef3d..f6eb2604aadb 100644 --- a/ui/src/components/session-data-controller.ts +++ b/ui/src/components/session-data-controller.ts @@ -75,7 +75,6 @@ export class SessionDataController implements ReactiveController, SessionCatalog // These caches were not Lit state on the element and stay non-reactive here. sessionResultsByAgent: Record = {}; - sessionCreatedOrder = new Map(); private readonly subscriptions: SubscriptionsController; readonly sessionCatalogLive = new SessionCatalogLiveState(); @@ -508,7 +507,6 @@ export class SessionDataController implements ReactiveController, SessionCatalog this.sessionsAgentId = null; this.sessionResultsByAgent = {}; this.resetChildSessionState(); - this.sessionCreatedOrder.clear(); this.visibleSessionLimits.clear(); this.notify(); } diff --git a/ui/src/e2e/session-management.created-sort.e2e.test.ts b/ui/src/e2e/session-management.created-sort.e2e.test.ts index ce2b61107fc4..2be9ef58e519 100644 --- a/ui/src/e2e/session-management.created-sort.e2e.test.ts +++ b/ui/src/e2e/session-management.created-sort.e2e.test.ts @@ -28,7 +28,9 @@ suite.define(() => { ...sessionRow(newestKey, "External newest", baseTime + 1_000), createdAt: baseTime + 1_000, }; - const expectedVisibleKeys = [newestKey, ...olderRows.slice(0, 9).map((row) => row.key)]; + // Sticky membership: rows the operator already saw stay visible when a + // newer session enters the page, so the full prior page remains. + const expectedVisibleKeys = [newestKey, ...olderRows.map((row) => row.key)]; const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block", @@ -76,7 +78,7 @@ suite.define(() => { .toEqual(expectedVisibleKeys); await captureUiProof(page, "sidebar-created-sort-after-refresh.png"); - expect(await rows.count()).toBe(10); + expect(await rows.count()).toBe(11); expect( await rows.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-session-key")), diff --git a/ui/src/e2e/sidebar-session-stability.e2e.test.ts b/ui/src/e2e/sidebar-session-stability.e2e.test.ts new file mode 100644 index 000000000000..1ba4b16a4ed3 --- /dev/null +++ b/ui/src/e2e/sidebar-session-stability.e2e.test.ts @@ -0,0 +1,134 @@ +import path from "node:path"; +import { expect, it } from "vitest"; +import { + captureUiProof, + captureUiProofEnabled, + controlUiSessionPath, + controlUiSessionUrl, + createSessionManagementE2eSuite, + installMockGateway, + sessionRow, + sessionsListResponse, + uiProofArtifactDir, +} from "./session-management.test-support.ts"; + +const suite = createSessionManagementE2eSuite(); + +suite.define(() => { + it("keeps visible sessions ordered and an active child expanded across run completion", async () => { + const baseTime = Date.parse("2026-08-14T18:00:00.000Z"); + const parentKey = "agent:main:stability-parent"; + const childKey = "agent:main:stability-child"; + const runId = "sidebar-stability-run"; + const siblingRows = Array.from({ length: 10 }, (_, index) => ({ + ...sessionRow(`agent:main:stability-${index}`, `Stable session ${index}`, baseTime - index), + createdAt: baseTime - index, + })); + const parentRow = { + ...sessionRow(parentKey, "Parent session", baseTime + 100, { + childSessions: [childKey], + }), + createdAt: baseTime + 100, + }; + const childRow = { + ...sessionRow(childKey, "Child session", baseTime + 50, { + hasActiveRun: true, + spawnedBy: parentKey, + startedAt: baseTime, + status: "running", + }), + activeRunIds: [runId], + createdAt: baseTime + 50, + }; + const expectedVisibleKeys = [ + parentKey, + childKey, + ...siblingRows.slice(0, 9).map(({ key }) => key), + ]; + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + recordVideo: captureUiProofEnabled + ? { dir: uiProofArtifactDir, size: { height: 900, width: 1280 } } + : undefined, + }); + const page = await context.newPage(); + const proofVideo = page.video(); + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.list": sessionsListResponse([parentRow, childRow, ...siblingRows]), + }, + sessionKey: childKey, + }); + + try { + await page.goto(controlUiSessionUrl(suite.server.baseUrl, childKey)); + const rows = page.locator(".sidebar-recent-session"); + const visibleKeys = () => + rows.evaluateAll((elements) => + elements.map((element) => element.getAttribute("data-session-key")), + ); + const childToggle = page.locator(`[data-child-session-toggle="${parentKey}"]`); + await expect.poll(visibleKeys, { timeout: 10_000 }).toEqual(expectedVisibleKeys); + await expect.poll(() => childToggle.getAttribute("aria-expanded")).toBe("true"); + await captureUiProof(page, "sidebar-session-stability-running.png"); + + await gateway.emitGatewayEvent("agent", { + data: { name: "bash" }, + runId, + sessionKey: childKey, + stream: "tool", + }); + expect(await visibleKeys()).toEqual(expectedVisibleKeys); + + const completedChild = { + ...childRow, + activeRunIds: [], + endedAt: baseTime + 200, + hasActiveRun: false, + status: "done", + updatedAt: baseTime + 200, + }; + await gateway.setMethodResponse( + "sessions.list", + sessionsListResponse([parentRow, completedChild, ...siblingRows]), + ); + const listCount = (await gateway.getRequests("sessions.list")).length; + await gateway.emitGatewayEvent("sessions.changed", { + activeRunIds: [], + endedAt: completedChild.endedAt, + hasActiveRun: false, + key: childKey, + kind: "direct", + reason: "lifecycle", + sessionKey: childKey, + status: "done", + updatedAt: completedChild.updatedAt, + }); + await expect + .poll(async () => (await gateway.getRequests("sessions.list")).length) + .toBeGreaterThan(listCount); + await expect.poll(visibleKeys).toEqual(expectedVisibleKeys); + + const nextSessionKey = siblingRows[0]?.key; + if (!nextSessionKey) { + throw new Error("expected a visible sibling session"); + } + await page + .locator(`[data-session-key="${nextSessionKey}"] a.sidebar-recent-session__link`) + .click(); + await expect + .poll(() => new URL(page.url()).pathname) + .toBe(controlUiSessionPath(nextSessionKey)); + await expect.poll(() => childToggle.getAttribute("aria-expanded")).toBe("true"); + await expect.poll(visibleKeys).toEqual(expectedVisibleKeys); + await captureUiProof(page, "sidebar-session-stability-completed.png"); + } finally { + await context.close(); + if (proofVideo) { + await proofVideo.saveAs(path.join(uiProofArtifactDir, "sidebar-session-stability.webm")); + } + } + }); +}); diff --git a/ui/src/test-helpers/app-sidebar-cases/sessions.ts b/ui/src/test-helpers/app-sidebar-cases/sessions.ts index 5a1d32dc1d28..c42223ab4578 100644 --- a/ui/src/test-helpers/app-sidebar-cases/sessions.ts +++ b/ui/src/test-helpers/app-sidebar-cases/sessions.ts @@ -53,7 +53,7 @@ describe("AppSidebar session pagination", () => { expect(sidebar.querySelector(".sidebar-session-pagination")).toBeNull(); }); - it("shows a newly discovered session above the created-sort pagination boundary", async () => { + it("keeps visible sessions when a newer session enters the created-sort page", async () => { const olderKeys = Array.from({ length: 10 }, (_, index) => `agent:main:older-${index}`); const gateway = createGateway({} as GatewayBrowserClient); const sessions = createSessionsHarness("main", olderKeys); @@ -77,9 +77,9 @@ describe("AppSidebar session pagination", () => { sidebar.querySelectorAll("[data-session-key]"), (row) => row.dataset.sessionKey, ), - ).toEqual(["agent:main:external-new", ...olderKeys.slice(0, 9)]); - expect(sidebar.querySelectorAll(".sidebar-recent-session")).toHaveLength(10); - expect(sidebar.querySelector('button[aria-label="Show more"]')).not.toBeNull(); + ).toEqual(["agent:main:external-new", ...olderKeys]); + expect(sidebar.querySelectorAll(".sidebar-recent-session")).toHaveLength(11); + expect(sidebar.querySelector('button[aria-label="Show more"]')).toBeNull(); }); it("reveals sessions ten at a time and offers Collapse after thirty", async () => { @@ -244,7 +244,7 @@ describe("AppSidebar session source lifecycle", () => { ); }); - it("resets per-agent cached results and creation order when the sessions source changes", async () => { + it("resets per-agent cached results when the sessions source changes", async () => { const client = {} as GatewayBrowserClient; const gateway = createGateway(client); const { provider, sidebar } = await mountSidebar( @@ -253,20 +253,12 @@ describe("AppSidebar session source lifecycle", () => { ); expect(Object.keys(sidebar.sessionData.sessionResultsByAgent)).toEqual(["first"]); - expect([...sidebar.sessionData.sessionCreatedOrder]).toEqual([ - ["first-a", 0], - ["first-b", 1], - ]); // The Gateway and its client stay unchanged while the sessions capability is replaced. provider.setContext(createContext(gateway, createSessions("second", ["second-b", "second-a"]))); await sidebar.updateComplete; expect(Object.keys(sidebar.sessionData.sessionResultsByAgent)).toEqual(["second"]); - expect([...sidebar.sessionData.sessionCreatedOrder]).toEqual([ - ["second-b", 0], - ["second-a", 1], - ]); expect(sidebar.sessionData.sessionsAgentId).toBe("second"); expect(sidebar.sessionData.sessionsResult?.sessions.map((row) => row.key)).toEqual([ "second-b", @@ -294,7 +286,6 @@ describe("AppSidebar session source lifecycle", () => { expect(sidebar.sessionData.sessionResultsByAgent.main?.owners).toEqual([ { type: "human", id: "profile-ada", label: "Ada" }, ]); - expect([...sidebar.sessionData.sessionCreatedOrder.keys()]).toEqual(["main-a", "main-b"]); gateway.publish({ phase: "connected" }); const partial = createSessionState("main", ["main-a"]); @@ -334,7 +325,6 @@ describe("AppSidebar session source lifecycle", () => { expect(sidebar.sessionData.sessionsResult).toBeNull(); expect(sidebar.sessionData.sessionsAgentId).toBeNull(); expect(sidebar.sessionData.sessionResultsByAgent).toEqual({}); - expect(sidebar.sessionData.sessionCreatedOrder.size).toBe(0); }); it("clears every cached session view when the Gateway source is replaced", async () => { @@ -350,7 +340,6 @@ describe("AppSidebar session source lifecycle", () => { expect(sidebar.sessionData.sessionsResult).toBeNull(); expect(sidebar.sessionData.sessionsAgentId).toBeNull(); expect(sidebar.sessionData.sessionResultsByAgent).toEqual({}); - expect(sidebar.sessionData.sessionCreatedOrder.size).toBe(0); }); });