fix(ui): stabilize the sidebar session list with a single projection owner (#129150)

* fix(ui): stabilize sidebar session list with a single projection owner

The sidebar flickered as gateway events arrived: rows popped out when runs
ended (active/pinned page bypass), rows jumped within createdAt ties after
paging (per-publish created-order pruning), child trees auto-collapsed when
a descendant went idle (per-render derivation), and running-row subtitles
blank-flashed between observer/narration updates.

SidebarSessionProjection now owns all presentation stability for the
sidebar lifetime: sticky page membership with user-action reset boundaries,
a monotonic bounded created-order registry, latched child expansion with an
explicit collapsed-by-user mode, and a last-subtitle hold per active run.
The scattered mechanisms it replaces are deleted (limitSidebarSessionRows,
partitionSidebarVisibleSections, per-publish order pruning, three expansion
key sets).

* fix(ui): hold running-row subtitles across run rotation with a display floor

Live A/B on a dev gateway showed two residual subtitle defects: the hold
was keyed to the exact active-run-id set, so the queued->running rotation
still blanked the row, and racing ambient sources (observer digest,
narration, work path) swapped the line A->B->A within a second.

The hold now lives for the session's running lifetime and ambient
replacements respect a 2s minimum display floor (matching the narration
throttle); attention, agent status, and the queued explanation bypass the
floor because the operator must act on them.

* test(ui): assert subtitle holds on non-localized sources

The run-rotation and critical-replacement tests asserted the localized
queued string through the shared i18n singleton; in the striped ui CI lane
(shared module graph, isolate=false) that resolution is environment-
sensitive and failed in checks-node-compact-large-11. Narration and
agentStatusNote exercise the same hold invariants with raw strings.

* fix(ui): reset sticky sidebar membership when grouping changes

ClawSweeper finding: grouping switches can re-emit the same section id
(e.g. ungrouped) with a different row population, so sticky keys must not
carry across; grouping now joins the membership reset boundary with a
regression row in the boundary table. The created-sort e2e migrates to the
sticky contract: an externally discovered newest session still lands on
top, and the previously visible page is retained instead of evicted.
This commit is contained in:
Peter Steinberger
2026-08-25 02:12:42 -07:00
committed by GitHub
parent d647a9b6a7
commit 7e8412e080
14 changed files with 1144 additions and 265 deletions
@@ -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<SidebarRecentSession> & {
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<string>;
hideEmptyOwnerFilteredGroup: (category: string | undefined, rowCount: number) => boolean;
visibleSessionLimits: ReadonlyMap<string, number>;
}): 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<string, number>,
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"];
@@ -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<string> = new Set();
@state() protected expandedChildSessionKeys: ReadonlySet<string> = new Set();
@state() protected collapsedActiveChildSessionKeys: ReadonlySet<string> = new Set();
@state() fullyShownChildSessionKeys: ReadonlySet<string> = 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<GatewaySessionRow, number>();
private readonly attention = new SessionAttentionController(this);
declare readonly sidebarNarrationLines: ReadonlyMap<string, string>;
declare readonly sidebarObserverDigests: ReadonlyMap<string, SessionObserverDigest>;
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 {
@@ -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<SidebarSessionProjection["project"]>[0];
const connectionIdentity = {};
const listSource = {};
function sessionRow(
key: string,
overrides: Partial<SidebarRecentSession> = {},
): 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> = {},
): 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> = {}): SidebarRecentSession[] {
return Array.from({ length: 11 }, (_, index) =>
sessionRow(`session-${index}`, index === 10 ? overrides : {}),
);
}
function subtitleParams(
session: SidebarRecentSession,
overrides: Partial<Parameters<SidebarSessionProjection["resolveSubtitle"]>[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<ProjectionInput>][])(
"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<ProjectionInput> = { 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,
});
});
});
@@ -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<typeof resolveSidebarSessionSubtitle>[0];
type SidebarSubtitleValue = ReturnType<typeof resolveSidebarSessionSubtitle>;
type SidebarProjectionInput = {
rows: SidebarRecentSession[];
grouping: SidebarSessionsGrouping;
knownGroups: string[] | undefined;
selfOwnerId?: string | null;
catalogIds?: readonly string[];
sectionOrder?: readonly string[];
collapsedSections: ReadonlySet<string>;
hideEmptyOwnerFilteredGroup: (category: string | undefined, rowCount: number) => boolean;
visibleSessionLimits: ReadonlyMap<string, number>;
sortMode: SidebarSessionSortMode;
statusFilter: SidebarSessionStatusFilter;
agentId: string;
connectionIdentity: object | null;
listSource: object | null;
subtitle: {
sidebarLiveActivity: boolean;
showPreview: boolean;
narrationLines: ReadonlyMap<string, string>;
observerDigests: ReadonlyMap<string, SessionObserverDigest>;
};
};
export type SidebarVisibleSections = {
sections: (SidebarSessionSection<SidebarRecentSession> & {
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<string, number>();
private nextCreatedOrder = 0;
private readonly stickySections = new Map<string, Set<string>>();
private readonly childModes = new Map<string, SidebarExpansionMode>();
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<string>();
get createdOrder(): ReadonlyMap<string, number> {
return this.observedOrder;
}
observeRows(results: readonly { sessions: readonly { key: string }[] }[]): void {
const retainedKeys = new Set<string>();
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<string>();
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<string>(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<string>();
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,
});
}
}
@@ -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<string, string>;
readonly sidebarObserverDigests: ReadonlyMap<string, SessionObserverDigest>;
readonly sessionProjection: Pick<SidebarSessionProjection, "resolveSubtitle">;
readonly selectedSessionKeys: ReadonlySet<string>;
readonly connected: boolean;
readonly sessionData: Pick<
@@ -72,7 +71,6 @@ export interface SessionListHost {
| "sessionCatalogRefreshStatus"
| "sessionMutationError"
>;
readonly fullyShownChildSessionKeys: ReadonlySet<string>;
readonly sessionsGrouping: SidebarSessionsGrouping;
readonly collapsedSessionSections: ReadonlySet<string>;
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<string>;
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`<div
@@ -256,23 +256,6 @@ const SIDEBAR_HIDDEN_SESSION_CATALOGS_STORAGE_KEY = "openclaw:sidebar:sessions:h
export const SIDEBAR_HIDDEN_SESSION_CATALOGS_CHANGED_EVENT =
"openclaw:sidebar-hidden-catalogs-changed";
export function limitSidebarSessionRows(rows: SidebarRecentSession[], limit: number) {
const requiredCount = rows.filter((row) => 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),
+28 -3
View File
@@ -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<string, string> = new Map();
@state() sidebarObserverDigests: ReadonlyMap<string, SessionObserverDigest> = new Map();
@state() override sidebarNarrationLines: ReadonlyMap<string, string> = new Map();
@state() override sidebarObserverDigests: ReadonlyMap<string, SessionObserverDigest> = 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<void> | 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<this>) {
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<this>) {
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);
}
@@ -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<string, number>(),
sessionResultsByAgent: {} as Record<string, SessionsListResult>,
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();
});
});
@@ -11,7 +11,6 @@ import {
type SidebarSessionListOwner = {
readonly context: ApplicationContext<RouteId> | undefined;
readonly sessionCreatedOrder: Map<string, number>;
sessionResultsByAgent: Record<string, NonNullable<SessionListSnapshot["result"]>>;
sessionsResult: SessionListSnapshot["result"];
sessionsAgentId: SessionListSnapshot["agentId"];
@@ -21,20 +20,6 @@ type SidebarSessionListOwner = {
requestSessionDataUpdate(): void;
};
function pruneSidebarSessionOrder(
owner: SidebarSessionListOwner,
retainedResults: readonly NonNullable<SessionListSnapshot["result"]>[],
): 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(
@@ -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();
});
@@ -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<string, SessionsListResult> = {};
sessionCreatedOrder = new Map<string, number>();
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();
}
@@ -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")),
@@ -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"));
}
}
});
});
@@ -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<HTMLElement>("[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);
});
});