mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
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:
committed by
GitHub
parent
d647a9b6a7
commit
7e8412e080
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user