refactor(ui): move sidebar session data into controller (#112853)

This commit is contained in:
Peter Steinberger
2026-07-22 23:45:55 -04:00
committed by GitHub
parent 4c5e8f3b85
commit efc19faca4
16 changed files with 801 additions and 596 deletions

View File

@@ -370,7 +370,7 @@ export abstract class AppSidebarMenusElement extends AppSidebarSessionGroupsElem
themeMode: this.themeMode,
agentUnreadCount: (agentId) => this.agentUnreadCount(agentId),
agentApprovalCount: (agentId) =>
this.approvalBadgeSnapshot().agentCounts.get(normalizeAgentId(agentId)) ?? 0,
this.sessionData.approvalBadgeSnapshot().agentCounts.get(normalizeAgentId(agentId)) ?? 0,
onFilterChange: (next) => {
this.agentMenuFilter = next;
},
@@ -428,7 +428,7 @@ export abstract class AppSidebarMenusElement extends AppSidebarSessionGroupsElem
.anchor=${menu}
.trigger=${this.sessionMenuTrigger}
.disabled=${!this.connected}
.forkDisabled=${this.sessionsLoading || session.modelSelectionLocked}
.forkDisabled=${this.sessionData.sessionsLoading || session.modelSelectionLocked}
.archiveAllowed=${archiveAllowed}
.cloudWorkerStopAllowed=${Boolean(
!batchRows &&

View File

@@ -1,293 +0,0 @@
import { state } from "lit/decorators.js";
import type {
SessionCatalog,
SessionsCatalogListResult,
} from "../../../packages/gateway-protocol/src/index.ts";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import {
CATALOG_SESSION_CONTINUED_EVENT,
type CatalogSessionContinuedDetail,
} from "../lib/sessions/catalog-key.ts";
import { AppSidebarBase } from "./app-sidebar-base.ts";
import {
refreshSessionCatalogsLive,
SESSION_CATALOG_CHANGED_REFRESH_MS,
SessionCatalogLiveState,
sessionCatalogListClient,
} from "./app-sidebar-session-catalog-live.ts";
import {
mergeSessionCatalogPage,
sessionCatalogRequestError,
} from "./app-sidebar-session-catalog-state.ts";
import { bindAdoptedCatalogSession } from "./app-sidebar-session-catalogs.ts";
import { sessionCatalogHostKey } from "./app-sidebar-session-types.ts";
/** Gateway-backed external session-catalog synchronization. */
export abstract class AppSidebarSessionCatalogDataElement extends AppSidebarBase {
@state() protected sessionCatalogs: SessionCatalog[] = [];
@state() protected loadingMoreSessionCatalogIds: ReadonlySet<string> = new Set();
private readonly sessionCatalogLive = new SessionCatalogLiveState();
private sessionCatalogAgentId: string | null = null;
private sessionCatalogGeneration = 0;
private sessionCatalogRevision = 0;
private readonly sessionCatalogPageDepths = new Map<string, number>();
private readonly sessionCatalogRevisions = new Map<string, number>();
protected abstract expandedAgentId(): string;
protected abstract sessionCatalogGatewayClient(): GatewayBrowserClient | null;
protected connectSessionCatalogListeners() {
// The chat pane announces catalog adoptions so the catalog row binds to
// the new session key before the next catalog poll.
document.addEventListener(
CATALOG_SESSION_CONTINUED_EVENT,
this.handleCatalogSessionContinued as EventListener,
);
document.addEventListener("visibilitychange", this.handleSessionCatalogPageActivation);
globalThis.addEventListener("focus", this.handleSessionCatalogPageActivation);
}
protected disconnectSessionCatalogListeners() {
document.removeEventListener(
CATALOG_SESSION_CONTINUED_EVENT,
this.handleCatalogSessionContinued as EventListener,
);
document.removeEventListener("visibilitychange", this.handleSessionCatalogPageActivation);
globalThis.removeEventListener("focus", this.handleSessionCatalogPageActivation);
}
protected retireSessionCatalogData() {
this.sessionCatalogGeneration += 1;
this.sessionCatalogLive.clear();
}
protected resetSessionCatalogConnection() {
this.sessionCatalogGeneration += 1;
this.sessionCatalogRevision += 1;
this.sessionCatalogLive.resetConnection();
this.sessionCatalogs = [];
this.loadingMoreSessionCatalogIds = new Set();
this.sessionCatalogPageDepths.clear();
this.sessionCatalogRevisions.clear();
}
protected updateSessionCatalogData() {
if (this.context) {
this.synchronizeSessionCatalogAgent(this.expandedAgentId());
}
if (
!this.visibleSessionCatalogClient() ||
this.sessionCatalogLive.timer ||
this.sessionCatalogLive.requestGeneration === this.sessionCatalogGeneration
) {
return;
}
void this.refreshSessionCatalogs();
}
protected handleSessionCatalogHostEvent(payload: unknown) {
this.applySessionCatalogHostEvent(payload);
}
protected handleSessionCatalogPresence(payload: unknown) {
if (this.sessionCatalogLive.observePresence(payload)) {
this.requestSessionCatalogRefresh();
}
}
private visibleSessionCatalogClient(): GatewayBrowserClient | null {
if (document.visibilityState === "hidden") {
return null;
}
return sessionCatalogListClient(this.context?.gateway.snapshot, this.connected);
}
private synchronizeSessionCatalogAgent(agentId: string) {
if (agentId === this.sessionCatalogAgentId) {
return;
}
this.sessionCatalogAgentId = agentId;
this.sessionCatalogGeneration += 1;
this.sessionCatalogRevision += 1;
this.sessionCatalogLive.clear();
this.loadingMoreSessionCatalogIds = new Set();
if (this.sessionCatalogs.some((catalog) => catalog.capabilities.createSession)) {
this.sessionCatalogs = this.sessionCatalogs.map((catalog) => {
const { createSession: _createSession, ...capabilities } = catalog.capabilities;
return { ...catalog, capabilities };
});
}
}
private readonly handleCatalogSessionContinued = (
event: CustomEvent<CatalogSessionContinuedDetail>,
) => {
const detail = event.detail;
if (!detail?.sessionKey) {
return;
}
this.sessionCatalogs = bindAdoptedCatalogSession(this.sessionCatalogs, detail);
// Invalidate in-flight polls and load-more merges so a pre-adoption
// snapshot cannot clobber the patched rows; the 30s poll reconfirms.
this.sessionCatalogRevision += 1;
this.sessionCatalogRevisions.set(
detail.catalogId,
(this.sessionCatalogRevisions.get(detail.catalogId) ?? 0) + 1,
);
};
private readonly handleSessionCatalogPageActivation = () => {
if (document.visibilityState === "hidden") {
this.sessionCatalogLive.cancelScheduledRefreshes();
return;
}
this.sessionCatalogLive.scheduleActivation(() => this.requestSessionCatalogRefresh());
};
private requestSessionCatalogRefresh() {
const snapshot = this.context?.gateway.snapshot;
this.sessionCatalogLive.requestRefresh({
visible: document.visibilityState !== "hidden",
connected: this.isConnected && Boolean(sessionCatalogListClient(snapshot, this.connected)),
generation: this.sessionCatalogGeneration,
refresh: () => void this.refreshSessionCatalogs(),
});
}
private applySessionCatalogHostEvent(payload: unknown) {
const update = this.sessionCatalogLive.applyHost({
payload,
agentId: this.sessionCatalogAgentId ?? "",
catalogs: this.sessionCatalogs,
pageDepths: this.sessionCatalogPageDepths,
});
if (!update) {
return;
}
this.sessionCatalogs = update.catalogs;
this.sessionCatalogRevision += this.sessionCatalogLive.refetching ? 1 : 0;
const catalogRevision = this.sessionCatalogRevisions.get(update.catalogId) ?? 0;
this.sessionCatalogRevisions.set(update.catalogId, catalogRevision + 1);
if (this.sessionCatalogLive.requestGeneration !== this.sessionCatalogGeneration) {
this.sessionCatalogLive.schedule(
SESSION_CATALOG_CHANGED_REFRESH_MS,
this.isConnected,
() => void this.refreshSessionCatalogs(),
);
}
}
private async refreshSessionCatalogs() {
// Hidden pages resume through the coalesced activation handler. Starting
// here without a timer makes catalog state updates poll at request latency.
const client = this.visibleSessionCatalogClient();
if (!client) {
return;
}
const generation = this.sessionCatalogGeneration;
const revision = this.sessionCatalogRevision;
const agentId = this.sessionCatalogAgentId ?? this.expandedAgentId();
await refreshSessionCatalogsLive({
live: this.sessionCatalogLive,
client,
agentId,
generation,
revision,
currentGeneration: () => this.sessionCatalogGeneration,
currentRevision: () => this.sessionCatalogRevision,
currentClient: () => this.sessionCatalogGatewayClient(),
catalogs: () => this.sessionCatalogs,
pageDepths: this.sessionCatalogPageDepths,
connected: () => this.isConnected,
applyFinal: (catalogs, revisedCatalogIds) => {
this.sessionCatalogs = catalogs;
for (const catalogId of revisedCatalogIds) {
this.sessionCatalogRevisions.set(
catalogId,
(this.sessionCatalogRevisions.get(catalogId) ?? 0) + 1,
);
}
this.sessionCatalogRevision += 1;
},
refresh: () => void this.refreshSessionCatalogs(),
});
}
async loadMoreSessionCatalog(catalogId: string) {
if (this.loadingMoreSessionCatalogIds.has(catalogId)) {
return;
}
const catalog = this.sessionCatalogs.find((candidate) => candidate.id === catalogId);
const cursors = Object.fromEntries(
(catalog?.hosts ?? []).flatMap((host) =>
host.nextCursor ? [[host.hostId, host.nextCursor] as const] : [],
),
);
if (!catalog || Object.keys(cursors).length === 0) {
return;
}
const client = this.context?.gateway.snapshot.client;
if (!client || !this.connected) {
return;
}
const generation = this.sessionCatalogGeneration;
const agentId = this.sessionCatalogAgentId ?? this.expandedAgentId();
const revision = this.sessionCatalogRevisions.get(catalogId) ?? 0;
this.loadingMoreSessionCatalogIds = new Set([...this.loadingMoreSessionCatalogIds, catalogId]);
try {
const result = await client.request<SessionsCatalogListResult>("sessions.catalog.list", {
agentId,
catalogId,
cursors,
});
if (
generation !== this.sessionCatalogGeneration ||
revision !== (this.sessionCatalogRevisions.get(catalogId) ?? 0) ||
client !== this.sessionCatalogGatewayClient()
) {
return;
}
const page = result.catalogs.find((candidate) => candidate.id === catalogId);
if (!page) {
return;
}
const current = this.sessionCatalogs.find((candidate) => candidate.id === catalogId);
if (!current) {
return;
}
const merged = mergeSessionCatalogPage({ current, page, cursors });
for (const hostId of merged.advancedHostIds) {
const key = sessionCatalogHostKey(catalogId, hostId);
this.sessionCatalogPageDepths.set(key, (this.sessionCatalogPageDepths.get(key) ?? 0) + 1);
}
this.sessionCatalogs = this.sessionCatalogs.map((candidate) =>
candidate.id === catalogId ? merged.catalog : candidate,
);
this.sessionCatalogRevisions.set(catalogId, revision + 1);
this.sessionCatalogRevision += 1;
} catch (error) {
if (
generation !== this.sessionCatalogGeneration ||
revision !== (this.sessionCatalogRevisions.get(catalogId) ?? 0) ||
client !== this.sessionCatalogGatewayClient()
) {
return;
}
// Preserve rows and cursors: retrying Load More requests this page again.
this.sessionCatalogs = this.sessionCatalogs.map((candidate) =>
candidate.id === catalogId
? { ...candidate, error: sessionCatalogRequestError(error) }
: candidate,
);
this.sessionCatalogRevisions.set(catalogId, revision + 1);
this.sessionCatalogRevision += 1;
} finally {
if (generation === this.sessionCatalogGeneration) {
const loading = new Set(this.loadingMoreSessionCatalogIds);
loading.delete(catalogId);
this.loadingMoreSessionCatalogIds = loading;
}
}
}
}

View File

@@ -21,7 +21,6 @@ import { normalizeOptionalString } from "../lib/string-coerce.ts";
import { AppSidebarSessionMutationsElement } from "./app-sidebar-session-mutations.ts";
import {
loadStoredCollapsedSessionSections,
SIDEBAR_SESSION_PAGE_SIZE,
storeSidebarSessionStatusFilter,
storeCollapsedSessionSections,
storeSidebarSessionsGrouping,
@@ -223,12 +222,12 @@ export abstract class AppSidebarSessionGroupsElement extends AppSidebarSessionMu
}
try {
await scope.sessions.groupsPut([...groups, name]);
return this.isSessionMutationScopeCurrent(scope) ? "completed" : "stale";
return this.sessionData.isSessionMutationScopeCurrent(scope) ? "completed" : "stale";
} catch (error) {
if (!this.isSessionMutationScopeCurrent(scope)) {
if (!this.sessionData.isSessionMutationScopeCurrent(scope)) {
return "stale";
}
this.publishSessionMutationError(scope, error);
this.sessionData.publishSessionMutationError(scope, error);
return "failed";
}
}
@@ -246,7 +245,7 @@ export abstract class AppSidebarSessionGroupsElement extends AppSidebarSessionMu
if (!name) {
return;
}
const scope = this.beginSessionMutation();
const scope = this.sessionData.beginSessionMutation();
if (!scope) {
return;
}
@@ -256,7 +255,7 @@ export abstract class AppSidebarSessionGroupsElement extends AppSidebarSessionMu
}
if (sessions.length > 0) {
await this.patchSessions(sessions, { category: name }, scope);
} else if (this.isSessionMutationScopeCurrent(scope)) {
} else if (this.sessionData.isSessionMutationScopeCurrent(scope)) {
// Header-created groups start empty; re-render so the section shows up.
this.requestUpdate();
}
@@ -268,7 +267,7 @@ export abstract class AppSidebarSessionGroupsElement extends AppSidebarSessionMu
if (!next || next === group) {
return;
}
const scope = this.beginSessionMutation();
const scope = this.sessionData.beginSessionMutation();
if (!scope) {
return;
}
@@ -277,7 +276,7 @@ export abstract class AppSidebarSessionGroupsElement extends AppSidebarSessionMu
void (async () => {
try {
const outcome = await scope.sessions.groupsRename(group, next);
if (outcome !== "completed" || !this.isSessionMutationScopeCurrent(scope)) {
if (outcome !== "completed" || !this.sessionData.isSessionMutationScopeCurrent(scope)) {
return;
}
const from = `category:${group}`;
@@ -289,7 +288,7 @@ export abstract class AppSidebarSessionGroupsElement extends AppSidebarSessionMu
}
this.requestUpdate();
} catch (error) {
this.publishSessionMutationError(scope, error);
this.sessionData.publishSessionMutationError(scope, error);
}
})();
}
@@ -298,14 +297,14 @@ export abstract class AppSidebarSessionGroupsElement extends AppSidebarSessionMu
if (!window.confirm(t("sessionsView.deleteGroupConfirm", { group }))) {
return;
}
const scope = this.beginSessionMutation();
const scope = this.sessionData.beginSessionMutation();
if (!scope) {
return;
}
void (async () => {
try {
const outcome = await scope.sessions.groupsDelete(group);
if (outcome !== "completed" || !this.isSessionMutationScopeCurrent(scope)) {
if (outcome !== "completed" || !this.sessionData.isSessionMutationScopeCurrent(scope)) {
return;
}
const collapsed = new Set(this.collapsedSessionSections);
@@ -313,7 +312,7 @@ export abstract class AppSidebarSessionGroupsElement extends AppSidebarSessionMu
this.saveCollapsedSessionSections(collapsed);
this.requestUpdate();
} catch (error) {
this.publishSessionMutationError(scope, error);
this.sessionData.publishSessionMutationError(scope, error);
}
})();
}
@@ -339,18 +338,18 @@ export abstract class AppSidebarSessionGroupsElement extends AppSidebarSessionMu
private reorderSessionGroup(source: string, target: string, position: "before" | "after") {
const groups = reorderSessionCustomGroups(this.knownSessionGroups(), source, target, position);
const scope = this.beginSessionMutation();
const scope = this.sessionData.beginSessionMutation();
if (!scope) {
return;
}
void (async () => {
try {
await scope.sessions.groupsPut(groups);
if (this.isSessionMutationScopeCurrent(scope)) {
if (this.sessionData.isSessionMutationScopeCurrent(scope)) {
this.requestUpdate();
}
} catch (error) {
this.publishSessionMutationError(scope, error);
this.sessionData.publishSessionMutationError(scope, error);
}
})();
}
@@ -360,7 +359,7 @@ export abstract class AppSidebarSessionGroupsElement extends AppSidebarSessionMu
category: string | null,
patch: { pinned?: boolean } = {},
) {
const scope = this.beginSessionMutation();
const scope = this.sessionData.beginSessionMutation();
if (!scope) {
return;
}
@@ -422,7 +421,7 @@ export abstract class AppSidebarSessionGroupsElement extends AppSidebarSessionMu
if (active) {
return active;
}
for (const rows of Object.values(this.sessionRowsByAgent)) {
for (const rows of Object.values(this.sessionData.sessionRowsByAgent)) {
const row = rows.find((candidate) => candidate.key === sessionKey);
if (row) {
return navigationState.toSidebarSession(row);
@@ -494,21 +493,12 @@ export abstract class AppSidebarSessionGroupsElement extends AppSidebarSessionMu
}
this.sessionsStatusFilter = statusFilter;
this.clearSessionSelection();
this.visibleSessionLimit = SIDEBAR_SESSION_PAGE_SIZE;
this.childSessionRowsByParent = {};
this.loadedChildSessionKeys = new Set();
this.failedChildSessionKeys = new Set();
this.loadingChildSessionKeys = new Set();
this.sessionRowsByAgent = {};
if (statusFilter === "active" && this.context) {
this.sessionsResult = this.context.sessions.state.result;
this.sessionsAgentId = this.context.sessions.state.agentId;
}
this.sessionData.resetForStatusFilter(statusFilter);
try {
storeSidebarSessionStatusFilter(statusFilter);
} catch {
// Keep the in-memory preference when storage is unavailable.
}
void this.refreshSidebarSessions();
void this.sessionData.refreshSidebarSessions();
}
}

View File

@@ -312,7 +312,7 @@ function renderSessionCatalogs(params: {
}),
onToggleSection: (sectionId) => host.toggleSection(sectionId),
onToggleProjectGrouping: () => host.toggleCatalogProjectGrouping(),
onLoadMore: (catalogId) => void host.loadMoreSessionCatalog(catalogId),
onLoadMore: (catalogId) => void host.sessionData.loadMoreSessionCatalog(catalogId),
onOpenNewSession: host.onOpenNewSession,
onNavigate: host.onNavigate,
catalogOpenTarget: snapshot.catalogOpenTarget,
@@ -388,14 +388,14 @@ export function renderSessionList(params: {
@dragleave=${(event: DragEvent) => host.handleSessionListDragLeave(event)}
@drop=${(event: DragEvent) => host.handleSessionListDrop(event)}
>
${host.sessionMutationError
${host.sessionData.sessionMutationError
? html`
<div
class="sidebar-session-error callout danger callout--dismissible"
role="alert"
data-sidebar-session-error
>
<span class="callout__content">${host.sessionMutationError}</span>
<span class="callout__content">${host.sessionData.sessionMutationError}</span>
<openclaw-tooltip .content=${t("chat.actions.dismissError")}>
<button
class="callout__dismiss"

View File

@@ -74,11 +74,11 @@ export abstract class AppSidebarSessionListElement
}
setVisibleSessionLimit(limit: number): void {
this.visibleSessionLimit = limit;
this.sessionData.setVisibleSessionLimit(limit);
}
dismissSessionMutationError(): void {
this.sessionMutationError = null;
this.sessionData.dismissSessionMutationError();
}
toggleCatalogProjectGrouping(): void {
@@ -108,8 +108,8 @@ export abstract class AppSidebarSessionListElement
const visibleSessions = this.selectedAgentSessionRows(navigationState);
const expandedAgentId = this.expandedAgentId();
const liveRows = [
...(this.sessionsResult?.sessions ?? []),
...Object.values(this.sessionRowsByAgent).flat(),
...(this.sessionData.sessionsResult?.sessions ?? []),
...Object.values(this.sessionData.sessionRowsByAgent).flat(),
];
const sidebarRowsByKey = new Map<string, SidebarRecentSession>();
for (const row of liveRows) {
@@ -136,11 +136,11 @@ export abstract class AppSidebarSessionListElement
},
}),
catalogs: {
catalogs: this.sessionCatalogs,
catalogs: this.sessionData.sessionCatalogs,
basePath: this.basePath,
routeSessionKey: this.activeRouteId === "chat" ? this.getRouteSessionKey() : "",
newSessionAgentId: expandedAgentId,
loadingMoreCatalogIds: this.loadingMoreSessionCatalogIds,
loadingMoreCatalogIds: this.sessionData.loadingMoreSessionCatalogIds,
projectGrouping: this.catalogProjectGrouping,
liveRows,
sidebarRowsByKey,

View File

@@ -19,7 +19,7 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
protected readonly patchSession = async (
session: SidebarRecentSession,
patch: SidebarSessionPatch,
scope: SidebarSessionMutationScope | null = this.beginSessionMutation(),
scope: SidebarSessionMutationScope | null = this.sessionData.beginSessionMutation(),
): Promise<SidebarSessionMutationResult> => {
if (!scope) {
return "stale";
@@ -27,12 +27,12 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
const agentId = parseAgentSessionKey(session.key)?.agentId ?? scope.selectedAgentId;
try {
const patched = await scope.sessions.patch(session.key, patch, { agentId });
if (!this.isSessionMutationScopeCurrent(scope)) {
if (!this.sessionData.isSessionMutationScopeCurrent(scope)) {
return "stale";
}
if (!patched) {
if (scope.sessions.state.error) {
this.publishSessionMutationError(scope, scope.sessions.state.error);
this.sessionData.publishSessionMutationError(scope, scope.sessions.state.error);
}
return "failed";
}
@@ -44,8 +44,8 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
this.pruneSidebarSessionEntry(session.key);
}
if (this.sidebarSessionStatusFilter() !== "active") {
await this.refreshSidebarSessions(agentId);
if (!this.isSessionMutationScopeCurrent(scope)) {
await this.sessionData.refreshSidebarSessions(agentId);
if (!this.sessionData.isSessionMutationScopeCurrent(scope)) {
return "stale";
}
}
@@ -63,10 +63,10 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
);
return "completed";
} catch (error) {
if (!this.isSessionMutationScopeCurrent(scope)) {
if (!this.sessionData.isSessionMutationScopeCurrent(scope)) {
return "stale";
}
this.publishSessionMutationError(scope, error);
this.sessionData.publishSessionMutationError(scope, error);
return "failed";
}
};
@@ -74,7 +74,7 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
protected async patchSessions(
rows: readonly SidebarRecentSession[],
patch: SidebarSessionPatch,
scope: SidebarSessionMutationScope | null = this.beginSessionMutation(),
scope: SidebarSessionMutationScope | null = this.sessionData.beginSessionMutation(),
): Promise<SidebarSessionMutationResult> {
if (!scope) {
return "stale";
@@ -95,12 +95,12 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
}
protected async archiveSessionWithUndo(session: SidebarRecentSession) {
const scope = this.beginSessionMutation();
const scope = this.sessionData.beginSessionMutation();
if (!scope) {
return;
}
const result = await this.patchSession(session, { archived: true }, scope);
if (result !== "completed" || !this.isSessionMutationScopeCurrent(scope)) {
if (result !== "completed" || !this.sessionData.isSessionMutationScopeCurrent(scope)) {
return;
}
showToast({
@@ -113,7 +113,7 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
}
private async archiveSessionsWithUndo(rows: readonly SidebarRecentSession[]) {
const scope = this.beginSessionMutation();
const scope = this.sessionData.beginSessionMutation();
if (!scope) {
return;
}
@@ -127,7 +127,7 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
archived.push({ session, pinned: session.pinned });
}
}
if (archived.length === 0 || !this.isSessionMutationScopeCurrent(scope)) {
if (archived.length === 0 || !this.sessionData.isSessionMutationScopeCurrent(scope)) {
return;
}
showToast({
@@ -144,7 +144,7 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
archived: readonly { session: SidebarRecentSession; pinned: boolean }[],
scope: SidebarSessionMutationScope,
) {
if (!this.isSessionMutationScopeCurrent(scope)) {
if (!this.sessionData.isSessionMutationScopeCurrent(scope)) {
return;
}
let restoredActiveKey: string | null = null;
@@ -161,7 +161,7 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
restoredActiveKey = session.key;
}
}
if (restoredActiveKey && this.isSessionMutationScopeCurrent(scope)) {
if (restoredActiveKey && this.sessionData.isSessionMutationScopeCurrent(scope)) {
this.replaceCurrentSession(restoredActiveKey);
}
}
@@ -174,7 +174,7 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
if (!window.confirm(t("sessionsView.deleteSessionsConfirm", { count: String(rows.length) }))) {
return;
}
const scope = this.beginSessionMutation();
const scope = this.sessionData.beginSessionMutation();
if (!scope) {
return;
}
@@ -187,12 +187,12 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
...(row.archived === true ? { archivedOnly: true } : {}),
})),
);
if (!this.isSessionMutationScopeCurrent(scope)) {
if (!this.sessionData.isSessionMutationScopeCurrent(scope)) {
return;
}
if (this.sidebarSessionStatusFilter() !== "active") {
await this.refreshSidebarSessions(scope.selectedAgentId);
if (!this.isSessionMutationScopeCurrent(scope)) {
await this.sessionData.refreshSidebarSessions(scope.selectedAgentId);
if (!this.sessionData.isSessionMutationScopeCurrent(scope)) {
return;
}
}
@@ -203,7 +203,7 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
branches: result.preservedWorktrees.map((worktree) => worktree.branch).join(", "),
}),
);
if (!this.isSessionMutationScopeCurrent(scope)) {
if (!this.sessionData.isSessionMutationScopeCurrent(scope)) {
return;
}
}
@@ -220,10 +220,10 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
);
}
if (result.errors.length > 0) {
this.publishSessionMutationError(scope, result.errors.join("; "));
this.sessionData.publishSessionMutationError(scope, result.errors.join("; "));
}
} catch (error) {
this.publishSessionMutationError(scope, error);
this.sessionData.publishSessionMutationError(scope, error);
}
}
@@ -261,7 +261,7 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
}
protected async forkSession(session: SidebarRecentSession) {
const scope = this.beginSessionMutation();
const scope = this.sessionData.beginSessionMutation();
if (!scope) {
return;
}
@@ -272,19 +272,19 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
fork: true,
agentId,
});
if (!this.isSessionMutationScopeCurrent(scope)) {
if (!this.sessionData.isSessionMutationScopeCurrent(scope)) {
return;
}
if (key) {
this.selectSession(key);
} else {
this.publishSessionMutationError(
this.sessionData.publishSessionMutationError(
scope,
scope.sessions.state.error ?? t("newSession.createFailed"),
);
}
} catch (error) {
this.publishSessionMutationError(scope, error);
this.sessionData.publishSessionMutationError(scope, error);
}
}
@@ -296,7 +296,7 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
) {
return;
}
const scope = this.beginSessionMutation();
const scope = this.sessionData.beginSessionMutation();
if (!scope) {
return;
}
@@ -307,12 +307,12 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
{ key: session.key, agentId },
{ timeoutMs: 10 * 60_000 },
);
if (!this.isSessionMutationScopeCurrent(scope)) {
if (!this.sessionData.isSessionMutationScopeCurrent(scope)) {
return;
}
await scope.sessions.refreshReplacement(agentId);
} catch (error) {
this.publishSessionMutationError(scope, error);
this.sessionData.publishSessionMutationError(scope, error);
}
}
@@ -320,7 +320,7 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
if (!window.confirm(t("sessionsView.deleteSessionConfirm", { session: session.label }))) {
return;
}
const scope = this.beginSessionMutation();
const scope = this.sessionData.beginSessionMutation();
if (!scope) {
return;
}
@@ -331,12 +331,12 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
deleteTranscript: true,
...(session.archived === true ? { archivedOnly: true } : {}),
});
if (!this.isSessionMutationScopeCurrent(scope)) {
if (!this.sessionData.isSessionMutationScopeCurrent(scope)) {
return;
}
if (this.sidebarSessionStatusFilter() !== "active") {
await this.refreshSidebarSessions(agentId);
if (!this.isSessionMutationScopeCurrent(scope)) {
await this.sessionData.refreshSidebarSessions(agentId);
if (!this.sessionData.isSessionMutationScopeCurrent(scope)) {
return;
}
}
@@ -348,7 +348,7 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
t("sessionsView.deletePreservedWorktreeConfirm", { branch: preserved.branch }),
)
) {
if (!this.isSessionMutationScopeCurrent(scope)) {
if (!this.sessionData.isSessionMutationScopeCurrent(scope)) {
return;
}
try {
@@ -357,9 +357,9 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
force: true,
});
} catch (error) {
this.publishSessionMutationError(scope, error);
this.sessionData.publishSessionMutationError(scope, error);
}
if (!this.isSessionMutationScopeCurrent(scope)) {
if (!this.sessionData.isSessionMutationScopeCurrent(scope)) {
return;
}
}
@@ -377,7 +377,7 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
}),
);
} catch (error) {
this.publishSessionMutationError(scope, error);
this.sessionData.publishSessionMutationError(scope, error);
}
}

View File

@@ -1,3 +1,4 @@
import type { PropertyValues } from "lit";
import { state } from "lit/decorators.js";
import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js";
import { SubscriptionsController } from "../lit/subscriptions-controller.ts";
@@ -85,8 +86,8 @@ export abstract class AppSidebarSessionNarrationElement extends AppSidebarMenusE
});
}
override updated() {
super.updated();
override updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (!this.narration) {
if (this.sidebarLiveActivity) {
this.ensureNarrationController();

View File

@@ -1,3 +1,4 @@
import type { PropertyValues } from "lit";
import { state } from "lit/decorators.js";
import type { GatewaySessionRow, SessionsListResult } from "../api/types.ts";
import { SIDEBAR_NAV_ROUTES, serializeSidebarEntry } from "../app-navigation.ts";
@@ -75,8 +76,8 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
return this.context;
}
override updated() {
super.updated();
override updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
const activeRouteKey = this.activeRouteId === "chat" ? this.getRouteSessionKey() : "";
if (activeRouteKey !== this.collapsedActiveRouteKey) {
this.collapsedActiveRouteKey = activeRouteKey;
@@ -85,7 +86,7 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
}
}
if (this.activeRouteId === "chat") {
void this.loadActiveSessionLineage(activeRouteKey);
void this.sessionData.loadActiveSessionLineage(activeRouteKey);
}
const pending = [...this.visibleSessionRowsInOrder()];
while (pending.length > 0) {
@@ -97,11 +98,11 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
if (
session.childSessionKeys.length > 0 &&
this.isSessionChildrenExpanded(session) &&
!this.loadedChildSessionKeys.has(session.key) &&
!this.failedChildSessionKeys.has(session.key) &&
!this.loadingChildSessionKeys.has(session.key)
!this.sessionData.loadedChildSessionKeys.has(session.key) &&
!this.sessionData.failedChildSessionKeys.has(session.key) &&
!this.sessionData.loadingChildSessionKeys.has(session.key)
) {
void this.loadChildSessions(session.key);
void this.sessionData.loadChildSessions(session.key);
}
}
// The main session hides behind the identity card, so nothing in the list
@@ -110,11 +111,11 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
if (
mainRow &&
(mainRow.childSessions?.length ?? 0) > 0 &&
!this.loadedChildSessionKeys.has(mainRow.key) &&
!this.failedChildSessionKeys.has(mainRow.key) &&
!this.loadingChildSessionKeys.has(mainRow.key)
!this.sessionData.loadedChildSessionKeys.has(mainRow.key) &&
!this.sessionData.failedChildSessionKeys.has(mainRow.key) &&
!this.sessionData.loadingChildSessionKeys.has(mainRow.key)
) {
void this.loadChildSessions(mainRow.key);
void this.sessionData.loadChildSessions(mainRow.key);
}
}
@@ -134,8 +135,8 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
const context = this.context;
const routeSessionKey = this.getRouteSessionKey();
const navigation = resolveSessionNavigation({
result: this.sessionsResult,
resultAgentId: this.sessionsAgentId,
result: this.sessionData.sessionsResult,
resultAgentId: this.sessionData.sessionsAgentId,
sessionKey: routeSessionKey,
assistantAgentId:
context?.agentSelection.state.selectedId ?? context?.gateway.snapshot.assistantAgentId,
@@ -211,7 +212,7 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
childSessionKeys: row.archived === true ? [] : (row.childSessions ?? []),
children: [],
isChild,
loadingChildren: this.loadingChildSessionKeys.has(row.key),
loadingChildren: this.sessionData.loadingChildSessionKeys.has(row.key),
containsActiveDescendant: false,
runningChildCount: 0,
failedChildCount: 0,
@@ -226,11 +227,11 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
};
}
protected selectedAgentIdForSessions(): string {
selectedAgentIdForSessions(): string {
return this.getSessionNavigationState().selectedAgentId;
}
protected sidebarSessionStatusFilter(): SidebarSessionStatusFilter {
sidebarSessionStatusFilter(): SidebarSessionStatusFilter {
return this.sessionsStatusFilter;
}
@@ -270,7 +271,7 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
const expandedRows = sections.flatMap((section) =>
this.isSessionSectionCollapsed(section.id) ? [] : section.rows,
);
const visibleRows = limitSidebarSessionRows(expandedRows, this.visibleSessionLimit);
const visibleRows = limitSidebarSessionRows(expandedRows, this.sessionData.visibleSessionLimit);
const keep = new Set(visibleRows.map((row) => row.key));
// totalRowCount is the pre-pagination size: headers and empty-zone
// checks must not mistake a page-filtered section for an empty one.
@@ -439,12 +440,12 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
}
this.clearSessionSelection();
this.expandedChildSessionKeys = new Set();
this.visibleSessionLimit = SIDEBAR_SESSION_PAGE_SIZE;
this.sessionData.setVisibleSessionLimit(SIDEBAR_SESSION_PAGE_SIZE);
context.agentSelection.set(nextAgentId);
void this.refreshSidebarSessions(nextAgentId);
void this.sessionData.refreshSidebarSessions(nextAgentId);
};
protected expandedAgentId(): string {
expandedAgentId(): string {
const selected = normalizeOptionalString(this.context?.agentSelection.state.selectedId);
return selected
? normalizeAgentId(selected)
@@ -462,9 +463,9 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
private latestAgentSessionRow(agentId: string): SessionsListResult["sessions"][number] | null {
const normalized = normalizeAgentId(agentId);
const rows =
normalized === normalizeAgentId(this.sessionsAgentId ?? "")
? (this.sessionsResult?.sessions ?? [])
: (this.sessionRowsByAgent[normalized] ?? []);
normalized === normalizeAgentId(this.sessionData.sessionsAgentId ?? "")
? (this.sessionData.sessionsResult?.sessions ?? [])
: (this.sessionData.sessionRowsByAgent[normalized] ?? []);
// Unprefixed keys belong to the system default agent. Keeping them for
// another agent would resume the wrong conversation with the raw key.
const visible = filterVisibleSessionRows(rows, {
@@ -533,7 +534,7 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
protected knownSessionGroups(): string[] {
const catalog = this.context?.sessions.state.groups ?? [];
const catalogSet = new Set(catalog);
const discovered = (this.sessionsResult?.sessions ?? [])
const discovered = (this.sessionData.sessionsResult?.sessions ?? [])
.map((row) => normalizeOptionalString(row.category))
.filter((name): name is string => typeof name === "string" && !catalogSet.has(name))
.toSorted((a, b) => a.localeCompare(b));
@@ -544,14 +545,14 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
protected selectedAgentSessionRows(
navigationState: ReturnType<AppSidebarSessionNavigationElement["getSessionNavigationState"]>,
): SidebarRecentSession[] {
const adopted = adoptedCatalogSessionKeys(this.sessionCatalogs);
const adopted = adoptedCatalogSessionKeys(this.sessionData.sessionCatalogs);
const selected = this.expandedAgentId();
const loadedAgentId = normalizeAgentId(this.sessionsAgentId ?? "");
const loadedAgentId = normalizeAgentId(this.sessionData.sessionsAgentId ?? "");
const routeAgentId = normalizeAgentId(navigationState.selectedAgentId);
const rows =
selected === loadedAgentId
? (this.sessionsResult?.sessions ?? [])
: (this.sessionRowsByAgent[selected] ?? []);
? (this.sessionData.sessionsResult?.sessions ?? [])
: (this.sessionData.sessionRowsByAgent[selected] ?? []);
const rowsByKey = new Map(rows.map((row) => [row.key, row]));
const rootRows =
selected === routeAgentId && selected === loadedAgentId
@@ -582,7 +583,7 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
}
return true;
});
const lineageRoot = this.activeSessionLineageRoot;
const lineageRoot = this.sessionData.activeSessionLineageRoot;
const lineageAgentId = normalizeAgentId(
parseAgentSessionKey(lineageRoot?.key ?? "")?.agentId ?? "",
);
@@ -604,18 +605,19 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
// the same visibility rules and sort order as ordinary roots so archived
// or cron children cannot sneak in and pagination stays deterministic.
const scopedRootKeys = new Set(scopedRootRows.map((row) => row.key));
const promotedRows = [...rows, ...Object.values(this.childSessionRowsByParent).flat()].filter(
(row) => {
const parentKey = row.spawnedBy ?? row.parentSessionKey;
return (
parentKey != null &&
mainSessionKeys.has(parentKey) &&
!scopedRootKeys.has(row.key) &&
!row.archived &&
(this.sessionsShowCron || !isCronSessionKey(row.key))
);
},
);
const promotedRows = [
...rows,
...Object.values(this.sessionData.childSessionRowsByParent).flat(),
].filter((row) => {
const parentKey = row.spawnedBy ?? row.parentSessionKey;
return (
parentKey != null &&
mainSessionKeys.has(parentKey) &&
!scopedRootKeys.has(row.key) &&
!row.archived &&
(this.sessionsShowCron || !isCronSessionKey(row.key))
);
});
for (const row of promotedRows) {
if (!scopedRootKeys.has(row.key)) {
scopedRootKeys.add(row.key);
@@ -632,13 +634,15 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
const projected = projectSessionTree({
roots: orderedRootRows.filter((row) => !adopted.has(row.key)),
agentRows: rows,
childRowsByParent: this.childSessionRowsByParent,
loadingChildKeys: this.loadingChildSessionKeys,
childRowsByParent: this.sessionData.childSessionRowsByParent,
loadingChildKeys: this.sessionData.loadingChildSessionKeys,
knownSessionAttention: this.attention.knownSessionAttention(),
toSidebarSession: navigationState.toSidebarSession,
});
const creatorFacet =
rows === this.sessionsResult?.sessions ? this.sessionsResult.creators : undefined;
rows === this.sessionData.sessionsResult?.sessions
? this.sessionData.sessionsResult.creators
: undefined;
return this.applySessionCreatorFilter(projected, rows, creatorFacet);
}
@@ -664,9 +668,9 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
const normalized = normalizeAgentId(agentId ?? this.expandedAgentId());
const mainKey = this.selectedAgentMainSessionKey(normalized);
const rows =
normalized === normalizeAgentId(this.sessionsAgentId ?? "")
? (this.sessionsResult?.sessions ?? [])
: (this.sessionRowsByAgent[normalized] ?? []);
normalized === normalizeAgentId(this.sessionData.sessionsAgentId ?? "")
? (this.sessionData.sessionsResult?.sessions ?? [])
: (this.sessionData.sessionRowsByAgent[normalized] ?? []);
return rows.find((row) => areUiSessionKeysEquivalent(row.key, mainKey)) ?? null;
}
@@ -697,23 +701,11 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
if (session.containsActiveDescendant) {
collapsedActive.add(session.key);
}
if (this.childSessionRowsByParent[session.key]?.length === 0) {
const childRows = { ...this.childSessionRowsByParent };
delete childRows[session.key];
this.childSessionRowsByParent = childRows;
const loadedKeys = new Set(this.loadedChildSessionKeys);
loadedKeys.delete(session.key);
this.loadedChildSessionKeys = loadedKeys;
}
this.sessionData.discardEmptyChildSessionSnapshot(session.key);
} else {
next.add(session.key);
collapsedActive.delete(session.key);
if (this.failedChildSessionKeys.has(session.key)) {
const failedKeys = new Set(this.failedChildSessionKeys);
failedKeys.delete(session.key);
this.failedChildSessionKeys = failedKeys;
}
void this.loadChildSessions(session.key);
this.sessionData.retryChildSessions(session.key);
}
this.expandedChildSessionKeys = next;
this.collapsedActiveChildSessionKeys = collapsedActive;
@@ -725,7 +717,7 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
}
protected agentUnreadCount(agentId: string): number {
const rows = this.sessionRowsByAgent[normalizeAgentId(agentId)] ?? [];
const rows = this.sessionData.sessionRowsByAgent[normalizeAgentId(agentId)] ?? [];
return rows.filter((row) => row.unread === true && row.archived !== true).length;
}

View File

@@ -1,3 +1,4 @@
import type { PropertyValues } from "lit";
import { state } from "lit/decorators.js";
import { AppSidebarSessionProjectionElement } from "./app-sidebar-session-projection.ts";
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
@@ -16,10 +17,10 @@ export abstract class AppSidebarSessionOwnershipElement extends AppSidebarSessio
protected sessionCreatorFilterActive = false;
sessionOwnershipVisible = false;
override updated() {
super.updated();
override updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
const selectedId = this.sessionCreatorFilterId;
const creators = this.sessionsResult?.creators;
const creators = this.sessionData.sessionsResult?.creators;
if (
selectedId &&
creators &&
@@ -44,7 +45,7 @@ export abstract class AppSidebarSessionOwnershipElement extends AppSidebarSessio
pending.push(...row.children);
}
}
const completeFacet = creatorFacet ?? this.sessionsResult?.creators;
const completeFacet = creatorFacet ?? this.sessionData.sessionsResult?.creators;
this.sessionCreatorOptions = listSessionCreators([
...(completeFacet ?? []).map((creator) => ({
createdActor: { type: "human" as const, ...creator },

View File

@@ -1,15 +1,21 @@
import { state } from "lit/decorators.js";
import type { GatewaySessionRow, SessionsListResult } from "../api/types.ts";
import { compareSessionRowsByUpdatedAt } from "../lib/sessions/index.ts";
import { AppSidebarBase } from "./app-sidebar-base.ts";
import { adoptedCatalogSessionKeys } from "./app-sidebar-session-catalogs.ts";
import { AppSidebarSessionDataElement } from "./app-sidebar-session-data.ts";
import { SessionPullRequestIndicatorsController } from "./app-sidebar-session-pr-indicators.ts";
import type { SidebarRecentSession, SidebarSessionSortMode } from "./app-sidebar-session-types.ts";
import type {
SidebarRecentSession,
SidebarSessionSortMode,
SidebarSessionStatusFilter,
} from "./app-sidebar-session-types.ts";
import { SessionDataController } from "./session-data-controller.ts";
/** Shared ordering and PR-state projection used by sidebar navigation. */
export abstract class AppSidebarSessionProjectionElement extends AppSidebarSessionDataElement {
export abstract class AppSidebarSessionProjectionElement extends AppSidebarBase {
@state() protected sessionSortMode: SidebarSessionSortMode = "created";
readonly sessionData = new SessionDataController(this);
private readonly sessionPullRequestIndicators = new SessionPullRequestIndicatorsController(this, {
getConnected: () => this.connected,
getRows: () => this.visibleSessionPullRequestRows(),
@@ -17,6 +23,10 @@ export abstract class AppSidebarSessionProjectionElement extends AppSidebarSessi
getSnapshot: () => this.context?.gateway.snapshot,
});
get sessionDataContext() {
return this.context;
}
protected readonly compareSidebarSessionRows = (
a: SessionsListResult["sessions"][number],
b: SessionsListResult["sessions"][number],
@@ -25,22 +35,22 @@ export abstract class AppSidebarSessionProjectionElement extends AppSidebarSessi
return compareSessionRowsByUpdatedAt(a, b);
}
return (
(this.sessionCreatedOrder.get(a.key) ?? Number.MAX_SAFE_INTEGER) -
(this.sessionCreatedOrder.get(b.key) ?? Number.MAX_SAFE_INTEGER)
(this.sessionData.sessionCreatedOrder.get(a.key) ?? Number.MAX_SAFE_INTEGER) -
(this.sessionData.sessionCreatedOrder.get(b.key) ?? Number.MAX_SAFE_INTEGER)
);
};
protected promoteCreatedSession(sessionKey: string) {
const currentOrder = this.sessionCreatedOrder.get(sessionKey);
promoteCreatedSession(sessionKey: string) {
const currentOrder = this.sessionData.sessionCreatedOrder.get(sessionKey);
if (currentOrder === 0) {
return;
}
for (const [key, order] of this.sessionCreatedOrder) {
for (const [key, order] of this.sessionData.sessionCreatedOrder) {
if (key !== sessionKey && (currentOrder === undefined || order < currentOrder)) {
this.sessionCreatedOrder.set(key, order + 1);
this.sessionData.sessionCreatedOrder.set(key, order + 1);
}
}
this.sessionCreatedOrder.set(sessionKey, 0);
this.sessionData.sessionCreatedOrder.set(sessionKey, 0);
this.requestUpdate();
}
@@ -50,14 +60,14 @@ export abstract class AppSidebarSessionProjectionElement extends AppSidebarSessi
private visibleSessionPullRequestRows(): SidebarRecentSession[] {
const rows = this.visibleSessionRowsInOrder();
const adopted = adoptedCatalogSessionKeys(this.sessionCatalogs);
const adopted = adoptedCatalogSessionKeys(this.sessionData.sessionCatalogs);
if (adopted.size === 0) {
return rows;
}
const byKey = new Map(rows.map((row) => [row.key, row]));
const liveRows = [
...(this.sessionsResult?.sessions ?? []),
...Object.values(this.sessionRowsByAgent).flat(),
...(this.sessionData.sessionsResult?.sessions ?? []),
...Object.values(this.sessionData.sessionRowsByAgent).flat(),
];
for (const row of liveRows) {
if (adopted.has(row.key) && !byKey.has(row.key)) {
@@ -69,4 +79,8 @@ export abstract class AppSidebarSessionProjectionElement extends AppSidebarSessi
protected abstract visibleSessionRowsInOrder(): SidebarRecentSession[];
protected abstract projectSidebarSession(row: GatewaySessionRow): SidebarRecentSession;
abstract dismissTransientMenus(): boolean;
abstract expandedAgentId(): string;
abstract selectedAgentIdForSessions(): string;
abstract sidebarSessionStatusFilter(): SidebarSessionStatusFilter;
}

View File

@@ -2,10 +2,8 @@ import { html, nothing, type TemplateResult } from "lit";
import { keyed } from "lit/directives/keyed.js";
import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js";
import type { NavigationRouteId } from "../app-navigation.ts";
import type { ApprovalBadgeSnapshot } from "../app/approval-presentation.ts";
import { sessionHasPendingApproval } from "../app/approval-presentation.ts";
import type { ApplicationNavigationOptions } from "../app/context.ts";
import type { PresencePayload } from "../app/user-profile.ts";
import { t } from "../i18n/index.ts";
import { sessionHasBoard } from "../lib/board/provider.ts";
import { formatDurationCompact } from "../lib/format.ts";
@@ -25,6 +23,7 @@ import {
type SidebarSessionStatusFilter,
} from "./app-sidebar-session-types.ts";
import { icons } from "./icons.ts";
import type { SessionDataController } from "./session-data-controller.ts";
import { renderSessionLeadingState } from "./session-leading-indicator.ts";
import type { SessionPullRequestIndicatorState } from "./session-menu-work.ts";
import { renderSessionOwnerChip } from "./session-owner-chip.ts";
@@ -44,8 +43,14 @@ export interface SessionListHost {
readonly selectedSessionKeys: ReadonlySet<string>;
readonly draggingSessionKey: string | null;
readonly connected: boolean;
readonly presencePayload: PresencePayload | undefined;
readonly presenceInstanceId?: string;
readonly sessionData: Pick<
SessionDataController,
| "approvalBadgeSnapshot"
| "loadMoreSessionCatalog"
| "presenceInstanceId"
| "presencePayload"
| "sessionMutationError"
>;
readonly fullyShownChildSessionKeys: ReadonlySet<string>;
readonly sessionsGrouping: SidebarSessionsGrouping;
readonly collapsedSessionSections: ReadonlySet<string>;
@@ -57,7 +62,6 @@ export interface SessionListHost {
readonly sessionGroupMenu: { readonly group: string } | null;
readonly sessionsStatusFilter: SidebarSessionStatusFilter;
readonly sessionListRemovalDrop: boolean;
readonly sessionMutationError: string | null;
readonly sessionOwnershipVisible: boolean;
readonly onOpenNewSession?: (agentId: string, target?: NewSessionTarget) => void;
readonly onNavigate?: (
@@ -69,7 +73,6 @@ export interface SessionListHost {
sessionKey: string,
worktreeId: string,
): SessionPullRequestIndicatorState;
approvalBadgeSnapshot(): ApprovalBadgeSnapshot;
isSessionChildrenExpanded(session: SidebarRecentSession): boolean;
startSessionDrag(session: SidebarRecentSession): void;
finishSessionDrag(): void;
@@ -99,7 +102,6 @@ export interface SessionListHost {
handleSessionListDrop(event: DragEvent): void;
dismissSessionMutationError(): void;
toggleCatalogProjectGrouping(): void;
loadMoreSessionCatalog(catalogId: string): Promise<void>;
openCatalogMenu(
request: CatalogSessionMenuRequest,
x: number,
@@ -233,8 +235,8 @@ export function renderRecentSession(params: {
>`
: nothing}
<openclaw-viewer-facepile
.presencePayload=${host.presencePayload}
.selfInstanceId=${host.presenceInstanceId}
.presencePayload=${host.sessionData.presencePayload}
.selfInstanceId=${host.sessionData.presenceInstanceId}
.sessionKey=${session.key}
.maxVisible=${3}
variant="session"
@@ -242,7 +244,10 @@ export function renderRecentSession(params: {
${renderSessionRowBadges({
...session,
pullRequest: session.pullRequest ?? display?.pullRequest,
hasApproval: sessionHasPendingApproval(host.approvalBadgeSnapshot(), session.key),
hasApproval: sessionHasPendingApproval(
host.sessionData.approvalBadgeSnapshot(),
session.key,
),
})}
${pinnedState}
</a>

View File

@@ -140,6 +140,22 @@ export type SidebarSessionGroupMenuState = {
export type SidebarSessionSortMode = "created" | "updated";
export type SidebarSessionStatusFilter = "active" | "archived" | "all";
export type SidebarSessionsScrollState = "none" | "top" | "middle" | "bottom";
export function resolveSidebarSessionsScrollState(
element: HTMLElement,
): SidebarSessionsScrollState {
const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
if (maxScrollTop <= 1) {
return "none";
}
if (element.scrollTop <= 1) {
return "top";
}
if (element.scrollTop >= maxScrollTop - 1) {
return "bottom";
}
return "middle";
}
export type SidebarSessionGroupDropTarget = {
group: string;
position: "before" | "after";

View File

@@ -127,7 +127,8 @@ class AppSidebar extends AppSidebarSessionListElement {
return agentId !== cardAgentId && this.agentUnreadCount(agentId) > 0;
});
const cardName = cardAgent ? normalizeAgentLabel(cardAgent) : cardAgentId;
const approvalCount = this.approvalBadgeSnapshot().agentCounts.get(cardAgentId) ?? 0;
const approvalCount =
this.sessionData.approvalBadgeSnapshot().agentCounts.get(cardAgentId) ?? 0;
const cardAvatarText =
(cardAgent ? resolveAgentTextAvatar(cardAgent) : null) ??
(cardName || cardAgentId).slice(0, 1).toUpperCase();
@@ -184,7 +185,10 @@ class AppSidebar extends AppSidebarSessionListElement {
const agentId = this.activeChipAgent().activeId;
const mainKey = this.selectedAgentMainSessionKey(agentId);
const mainRow = this.mainSessionRow(agentId);
const approvalNeeded = sessionHasPendingApproval(this.approvalBadgeSnapshot(), mainKey);
const approvalNeeded = sessionHasPendingApproval(
this.sessionData.approvalBadgeSnapshot(),
mainKey,
);
const outboxCount = this.outboxCountForSessionKey(mainKey);
const active =
this.activeRouteId === "chat" &&
@@ -275,8 +279,8 @@ class AppSidebar extends AppSidebarSessionListElement {
const selfUser = this.connected
? resolveCurrentSelfUser({
snapshotUser: this.context?.gateway.snapshot.selfUser,
presenceEntries: readPresenceEntries(this.presencePayload),
presenceInstanceId: this.presenceInstanceId,
presenceEntries: readPresenceEntries(this.sessionData.presencePayload),
presenceInstanceId: this.sessionData.presenceInstanceId,
})
: null;
const selfLabel = selfUser?.name ?? selfUser?.email ?? selfUser?.id;
@@ -308,8 +312,8 @@ class AppSidebar extends AppSidebarSessionListElement {
</openclaw-tooltip>`
: nothing}
<openclaw-viewer-facepile
.presencePayload=${this.presencePayload}
.selfInstanceId=${this.presenceInstanceId}
.presencePayload=${this.sessionData.presencePayload}
.selfInstanceId=${this.sessionData.presenceInstanceId}
.buildInfo=${CONTROL_UI_BUILD_INFO}
.gatewayVersion=${this.gatewayVersion}
.maxVisible=${5}
@@ -439,9 +443,10 @@ class AppSidebar extends AppSidebarSessionListElement {
<div class="sidebar-shell" @mousedown=${beginNativeWindowDragFromTopInset}>
${this.renderBrand()}
<div
class="sidebar-shell__body sidebar-shell__body--scroll-${this.sessionsScrollState}"
class="sidebar-shell__body sidebar-shell__body--scroll-${this.sessionData
.sessionsScrollState}"
@scroll=${(event: Event) =>
this.updateSessionsScrollState(event.currentTarget as HTMLElement)}
this.sessionData.updateSessionsScrollState(event.currentTarget as HTMLElement)}
>
<nav class="sidebar-nav" @contextmenu=${this.openCustomizeMenuFromContext}>
${this.renderPagesHead()}
@@ -478,8 +483,11 @@ class AppSidebar extends AppSidebarSessionListElement {
></openclaw-sidebar-update-card>
<openclaw-lobster-pet
.seed=${lobsterPetSeed(this.sessionKey)}
.mode=${resolveLobsterPetMode(!this.offline, this.sessionsResult?.sessions)}
.runOutcome=${resolveLobsterRunOutcome(this.sessionsResult?.sessions)}
.mode=${resolveLobsterPetMode(
!this.offline,
this.sessionData.sessionsResult?.sessions,
)}
.runOutcome=${resolveLobsterRunOutcome(this.sessionData.sessionsResult?.sessions)}
.visitsEnabled=${this.lobsterPetVisits}
.soundsEnabled=${this.lobsterPetSounds}
.gatewayVersion=${this.gatewayVersion}

View File

@@ -0,0 +1,243 @@
import type {
SessionCatalog,
SessionsCatalogListResult,
} from "../../../packages/gateway-protocol/src/index.ts";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { RouteId } from "../app-route-paths.ts";
import type { ApplicationContext } from "../app/context.ts";
import {
refreshSessionCatalogsLive,
SESSION_CATALOG_CHANGED_REFRESH_MS,
SessionCatalogLiveState,
sessionCatalogListClient,
} from "./app-sidebar-session-catalog-live.ts";
import {
mergeSessionCatalogPage,
sessionCatalogRequestError,
} from "./app-sidebar-session-catalog-state.ts";
import { sessionCatalogHostKey } from "./app-sidebar-session-types.ts";
import type { SidebarSessionStatusFilter } from "./app-sidebar-session-types.ts";
export interface SessionDataControllerHost extends ReactiveControllerHost {
readonly isConnected: boolean;
readonly connected: boolean;
readonly sessionDataContext: ApplicationContext<RouteId> | undefined;
dismissTransientMenus(): boolean;
expandedAgentId(): string;
promoteCreatedSession(sessionKey: string): void;
selectedAgentIdForSessions(): string;
sidebarSessionStatusFilter(): SidebarSessionStatusFilter;
querySelector(selectors: string): Element | null;
}
export interface SessionCatalogDataOwner {
readonly context: ApplicationContext<RouteId> | undefined;
readonly isSessionDataHostConnected: boolean;
readonly sessionDataHostConnected: boolean;
sessionCatalogs: SessionCatalog[];
loadingMoreSessionCatalogIds: ReadonlySet<string>;
readonly sessionCatalogLive: SessionCatalogLiveState;
sessionCatalogAgentId: string | null;
sessionCatalogGeneration: number;
sessionCatalogRevision: number;
readonly sessionCatalogPageDepths: Map<string, number>;
readonly sessionCatalogRevisions: Map<string, number>;
expandedAgentId(): string;
sessionCatalogGatewayClient(): GatewayBrowserClient | null;
requestSessionDataUpdate(): void;
refreshSessionCatalogs(): Promise<void>;
}
export function visibleSessionCatalogClient(
owner: SessionCatalogDataOwner,
): GatewayBrowserClient | null {
if (document.visibilityState === "hidden") {
return null;
}
return sessionCatalogListClient(owner.context?.gateway.snapshot, owner.sessionDataHostConnected);
}
export function synchronizeSessionCatalogAgent(
owner: SessionCatalogDataOwner,
agentId: string,
): void {
if (agentId === owner.sessionCatalogAgentId) {
return;
}
owner.sessionCatalogAgentId = agentId;
owner.sessionCatalogGeneration += 1;
owner.sessionCatalogRevision += 1;
owner.sessionCatalogLive.clear();
owner.loadingMoreSessionCatalogIds = new Set();
if (owner.sessionCatalogs.some((catalog) => catalog.capabilities.createSession)) {
owner.sessionCatalogs = owner.sessionCatalogs.map((catalog) => {
const { createSession: _createSession, ...capabilities } = catalog.capabilities;
return { ...catalog, capabilities };
});
}
owner.requestSessionDataUpdate();
}
export function requestSessionCatalogRefresh(owner: SessionCatalogDataOwner): void {
const snapshot = owner.context?.gateway.snapshot;
owner.sessionCatalogLive.requestRefresh({
visible: document.visibilityState !== "hidden",
connected:
owner.isSessionDataHostConnected &&
Boolean(sessionCatalogListClient(snapshot, owner.sessionDataHostConnected)),
generation: owner.sessionCatalogGeneration,
refresh: () => void owner.refreshSessionCatalogs(),
});
}
export function applySessionCatalogHostEvent(
owner: SessionCatalogDataOwner,
payload: unknown,
): void {
const update = owner.sessionCatalogLive.applyHost({
payload,
agentId: owner.sessionCatalogAgentId ?? "",
catalogs: owner.sessionCatalogs,
pageDepths: owner.sessionCatalogPageDepths,
});
if (!update) {
return;
}
owner.sessionCatalogs = update.catalogs;
owner.requestSessionDataUpdate();
owner.sessionCatalogRevision += owner.sessionCatalogLive.refetching ? 1 : 0;
const catalogRevision = owner.sessionCatalogRevisions.get(update.catalogId) ?? 0;
owner.sessionCatalogRevisions.set(update.catalogId, catalogRevision + 1);
if (owner.sessionCatalogLive.requestGeneration !== owner.sessionCatalogGeneration) {
owner.sessionCatalogLive.schedule(
SESSION_CATALOG_CHANGED_REFRESH_MS,
owner.isSessionDataHostConnected,
() => void owner.refreshSessionCatalogs(),
);
}
}
export async function refreshSessionCatalogs(owner: SessionCatalogDataOwner): Promise<void> {
// Hidden pages resume through the coalesced activation handler. Starting
// here without a timer makes catalog state updates poll at request latency.
const client = visibleSessionCatalogClient(owner);
if (!client) {
return;
}
const generation = owner.sessionCatalogGeneration;
const revision = owner.sessionCatalogRevision;
const agentId = owner.sessionCatalogAgentId ?? owner.expandedAgentId();
await refreshSessionCatalogsLive({
live: owner.sessionCatalogLive,
client,
agentId,
generation,
revision,
currentGeneration: () => owner.sessionCatalogGeneration,
currentRevision: () => owner.sessionCatalogRevision,
currentClient: () => owner.sessionCatalogGatewayClient(),
catalogs: () => owner.sessionCatalogs,
pageDepths: owner.sessionCatalogPageDepths,
connected: () => owner.isSessionDataHostConnected,
applyFinal: (catalogs, revisedCatalogIds) => {
owner.sessionCatalogs = catalogs;
owner.requestSessionDataUpdate();
for (const catalogId of revisedCatalogIds) {
owner.sessionCatalogRevisions.set(
catalogId,
(owner.sessionCatalogRevisions.get(catalogId) ?? 0) + 1,
);
}
owner.sessionCatalogRevision += 1;
},
refresh: () => void owner.refreshSessionCatalogs(),
});
}
export async function loadMoreSessionCatalog(
owner: SessionCatalogDataOwner,
catalogId: string,
): Promise<void> {
if (owner.loadingMoreSessionCatalogIds.has(catalogId)) {
return;
}
const catalog = owner.sessionCatalogs.find((candidate) => candidate.id === catalogId);
const cursors = Object.fromEntries(
(catalog?.hosts ?? []).flatMap((host) =>
host.nextCursor ? [[host.hostId, host.nextCursor] as const] : [],
),
);
if (!catalog || Object.keys(cursors).length === 0) {
return;
}
const client = owner.context?.gateway.snapshot.client;
if (!client || !owner.sessionDataHostConnected) {
return;
}
const generation = owner.sessionCatalogGeneration;
const agentId = owner.sessionCatalogAgentId ?? owner.expandedAgentId();
const revision = owner.sessionCatalogRevisions.get(catalogId) ?? 0;
owner.loadingMoreSessionCatalogIds = new Set([...owner.loadingMoreSessionCatalogIds, catalogId]);
owner.requestSessionDataUpdate();
try {
const result = await client.request<SessionsCatalogListResult>("sessions.catalog.list", {
agentId,
catalogId,
cursors,
});
if (!isCurrentSessionCatalogRequest(owner, catalogId, client, generation, revision)) {
return;
}
const page = result.catalogs.find((candidate) => candidate.id === catalogId);
const current = owner.sessionCatalogs.find((candidate) => candidate.id === catalogId);
if (!page || !current) {
return;
}
const merged = mergeSessionCatalogPage({ current, page, cursors });
for (const hostId of merged.advancedHostIds) {
const key = sessionCatalogHostKey(catalogId, hostId);
owner.sessionCatalogPageDepths.set(key, (owner.sessionCatalogPageDepths.get(key) ?? 0) + 1);
}
owner.sessionCatalogs = owner.sessionCatalogs.map((candidate) =>
candidate.id === catalogId ? merged.catalog : candidate,
);
owner.requestSessionDataUpdate();
owner.sessionCatalogRevisions.set(catalogId, revision + 1);
owner.sessionCatalogRevision += 1;
} catch (error) {
if (!isCurrentSessionCatalogRequest(owner, catalogId, client, generation, revision)) {
return;
}
// Preserve rows and cursors: retrying Load More requests this page again.
owner.sessionCatalogs = owner.sessionCatalogs.map((candidate) =>
candidate.id === catalogId
? { ...candidate, error: sessionCatalogRequestError(error) }
: candidate,
);
owner.requestSessionDataUpdate();
owner.sessionCatalogRevisions.set(catalogId, revision + 1);
owner.sessionCatalogRevision += 1;
} finally {
if (generation === owner.sessionCatalogGeneration) {
const loading = new Set(owner.loadingMoreSessionCatalogIds);
loading.delete(catalogId);
owner.loadingMoreSessionCatalogIds = loading;
owner.requestSessionDataUpdate();
}
}
}
function isCurrentSessionCatalogRequest(
owner: SessionCatalogDataOwner,
catalogId: string,
client: GatewayBrowserClient,
generation: number,
revision: number,
): boolean {
return (
generation === owner.sessionCatalogGeneration &&
revision === (owner.sessionCatalogRevisions.get(catalogId) ?? 0) &&
client === owner.sessionCatalogGatewayClient()
);
}
import type { ReactiveControllerHost } from "lit";

View File

@@ -1,4 +1,5 @@
import { state } from "lit/decorators.js";
import type { ReactiveController } from "lit";
import type { SessionCatalog } from "../../../packages/gateway-protocol/src/index.ts";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { GatewaySessionRow, SessionsListResult } from "../api/types.ts";
import type { RouteId } from "../app-route-paths.ts";
@@ -8,6 +9,10 @@ import {
} from "../app/approval-presentation.ts";
import type { ApplicationContext } from "../app/context.ts";
import { readPresenceEntries, type PresencePayload } from "../app/user-profile.ts";
import {
CATALOG_SESSION_CONTINUED_EVENT,
type CatalogSessionContinuedDetail,
} from "../lib/sessions/catalog-key.ts";
import type { SessionCapability } from "../lib/sessions/index.ts";
import { normalizeAgentId } from "../lib/sessions/session-key.ts";
import { SubscriptionsController } from "../lit/subscriptions-controller.ts";
@@ -17,35 +22,56 @@ import {
fetchSessionLineage,
mergeChildSessionRows,
} from "./app-sidebar-child-session-data.ts";
import { AppSidebarSessionCatalogDataElement } from "./app-sidebar-session-catalog-data.ts";
import { SessionCatalogLiveState } from "./app-sidebar-session-catalog-live.ts";
import { bindAdoptedCatalogSession } from "./app-sidebar-session-catalogs.ts";
import {
SIDEBAR_AGENT_SESSION_LIST_LIMIT,
SIDEBAR_SESSION_PAGE_SIZE,
resolveSidebarSessionsScrollState,
type SidebarSessionMutationScope,
type SidebarSessionStatusFilter,
type SidebarSessionsScrollState,
} from "./app-sidebar-session-types.ts";
/** Gateway-backed session and external-catalog synchronization. */
export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCatalogDataElement {
@state() protected visibleSessionLimit = SIDEBAR_SESSION_PAGE_SIZE;
@state() protected sessionsResult: SessionsListResult | null = null;
@state() protected sessionsAgentId: string | null = null;
@state() protected sessionsLoading = false;
@state() protected childSessionRowsByParent: Readonly<
Record<string, readonly GatewaySessionRow[]>
> = {};
@state() protected loadedChildSessionKeys: ReadonlySet<string> = new Set();
@state() protected failedChildSessionKeys: ReadonlySet<string> = new Set();
@state() protected loadingChildSessionKeys: ReadonlySet<string> = new Set();
@state() protected activeSessionLineageRoot: GatewaySessionRow | null = null;
@state() protected sessionsScrollState: SidebarSessionsScrollState = "none";
@state() sessionMutationError: string | null = null;
@state() presencePayload: PresencePayload | undefined;
@state() presenceInstanceId?: string;
import {
applySessionCatalogHostEvent as applySessionCatalogHostEventToData,
loadMoreSessionCatalog as loadMoreSessionCatalogData,
refreshSessionCatalogs as refreshSessionCatalogData,
requestSessionCatalogRefresh as requestSessionCatalogDataRefresh,
synchronizeSessionCatalogAgent,
type SessionCatalogDataOwner,
type SessionDataControllerHost,
visibleSessionCatalogClient,
} from "./session-data-controller-catalog.ts";
protected sessionRowsByAgent: Record<string, SessionsListResult["sessions"]> = {};
protected sessionCreatedOrder = new Map<string, number>();
private readonly subscriptions = new SubscriptionsController(this);
/** Gateway-backed session-list and external-catalog data ownership. */
export class SessionDataController implements ReactiveController, SessionCatalogDataOwner {
sessionCatalogs: SessionCatalog[] = [];
loadingMoreSessionCatalogIds: ReadonlySet<string> = new Set();
visibleSessionLimit = SIDEBAR_SESSION_PAGE_SIZE;
sessionsResult: SessionsListResult | null = null;
sessionsAgentId: string | null = null;
sessionsLoading = false;
childSessionRowsByParent: Readonly<Record<string, readonly GatewaySessionRow[]>> = {};
loadedChildSessionKeys: ReadonlySet<string> = new Set();
failedChildSessionKeys: ReadonlySet<string> = new Set();
loadingChildSessionKeys: ReadonlySet<string> = new Set();
activeSessionLineageRoot: GatewaySessionRow | null = null;
sessionsScrollState: SidebarSessionsScrollState = "none";
sessionMutationError: string | null = null;
presencePayload: PresencePayload | undefined;
presenceInstanceId?: string;
// These caches were not Lit state on the element and stay non-reactive here.
sessionRowsByAgent: Record<string, SessionsListResult["sessions"]> = {};
sessionCreatedOrder = new Map<string, number>();
private readonly subscriptions: SubscriptionsController;
readonly sessionCatalogLive = new SessionCatalogLiveState();
sessionCatalogAgentId: string | null = null;
sessionCatalogGeneration = 0;
sessionCatalogRevision = 0;
readonly sessionCatalogPageDepths = new Map<string, number>();
readonly sessionCatalogRevisions = new Map<string, number>();
private sessionsSource: SessionCapability | null = null;
private childSessionGeneration = 0;
private sidebarListRequestToken: symbol | null = null;
@@ -67,13 +93,18 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
[];
private approvalBadges: ApprovalBadgeSnapshot = deriveApprovalBadgeSnapshot([]);
abstract dismissTransientMenus(): boolean;
protected abstract promoteCreatedSession(sessionKey: string): void;
protected abstract selectedAgentIdForSessions(): string;
protected abstract sidebarSessionStatusFilter(): SidebarSessionStatusFilter;
constructor() {
super();
constructor(private readonly host: SessionDataControllerHost) {
host.addController(this);
// The element used to enter subscriptions before connecting catalog listeners,
// then tear subscriptions down after all session cleanup. Keep that ordering.
this.subscriptions = new SubscriptionsController({
addController: () => undefined,
removeController: () => undefined,
requestUpdate: () => host.requestUpdate(),
get updateComplete() {
return host.updateComplete;
},
});
this.subscriptions
.watch(
() => this.context?.gateway,
@@ -87,7 +118,7 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
)
.effect(
() => this.context?.sessions,
(sessions) => sessions.subscribeCreated((key) => this.promoteCreatedSession(key)),
(sessions) => sessions.subscribeCreated((key) => host.promoteCreatedSession(key)),
)
.effect(
() => this.context?.gateway,
@@ -100,6 +131,7 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
if (event.event === "presence") {
const presence = readPresenceEntries(event.payload);
this.presencePayload = presence ? { presence } : undefined;
this.notify();
this.handleSessionCatalogPresence(event.payload);
}
}),
@@ -118,27 +150,45 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
);
}
approvalBadgeSnapshot(): ApprovalBadgeSnapshot {
const queue = this.context?.overlays?.snapshot.approvalQueue ?? [];
if (queue !== this.approvalBadgeQueue) {
this.approvalBadgeQueue = queue;
this.approvalBadges = deriveApprovalBadgeSnapshot(queue);
}
return this.approvalBadges;
get context(): ApplicationContext<RouteId> | undefined {
return this.host.sessionDataContext;
}
protected sessionCatalogGatewayClient(): GatewayBrowserClient | null {
return this.gatewayClient;
get isSessionDataHostConnected(): boolean {
return this.host.isConnected;
}
override connectedCallback() {
super.connectedCallback();
get sessionDataHostConnected(): boolean {
return this.host.connected;
}
expandedAgentId(): string {
return this.host.expandedAgentId();
}
requestSessionDataUpdate(): void {
this.host.requestUpdate();
}
private readonly notify = () => this.requestSessionDataUpdate();
hostConnected(): void {
this.subscriptions.hostConnected();
this.connectSessionCatalogListeners();
}
override disconnectedCallback() {
hostUpdate(): void {
this.subscriptions.hostUpdate();
}
hostUpdated(): void {
this.syncSessionsScrollObserver();
this.updateSessionCatalogData();
}
hostDisconnected(): void {
this.disconnectSessionCatalogListeners();
this.dismissTransientMenus();
this.host.dismissTransientMenus();
this.invalidateSessionMutations();
this.gatewaySource = null;
this.gatewayClient = null;
@@ -155,16 +205,118 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
globalThis.clearTimeout(this.activeSessionLineageRetryTimer);
this.activeSessionLineageRetryTimer = null;
}
super.disconnectedCallback();
this.subscriptions.hostDisconnected();
}
override updated() {
this.syncSessionsScrollObserver();
this.updateSessionCatalogData();
approvalBadgeSnapshot(): ApprovalBadgeSnapshot {
const queue = this.context?.overlays?.snapshot.approvalQueue ?? [];
if (queue !== this.approvalBadgeQueue) {
this.approvalBadgeQueue = queue;
this.approvalBadges = deriveApprovalBadgeSnapshot(queue);
}
return this.approvalBadges;
}
private syncSessionsScrollObserver() {
const element = this.querySelector<HTMLElement>(".sidebar-shell__body");
sessionCatalogGatewayClient(): GatewayBrowserClient | null {
return this.gatewayClient;
}
connectSessionCatalogListeners(): void {
// The chat pane announces catalog adoptions so the catalog row binds to
// the new session key before the next catalog poll.
document.addEventListener(
CATALOG_SESSION_CONTINUED_EVENT,
this.handleCatalogSessionContinued as EventListener,
);
document.addEventListener("visibilitychange", this.handleSessionCatalogPageActivation);
globalThis.addEventListener("focus", this.handleSessionCatalogPageActivation);
}
disconnectSessionCatalogListeners(): void {
document.removeEventListener(
CATALOG_SESSION_CONTINUED_EVENT,
this.handleCatalogSessionContinued as EventListener,
);
document.removeEventListener("visibilitychange", this.handleSessionCatalogPageActivation);
globalThis.removeEventListener("focus", this.handleSessionCatalogPageActivation);
}
retireSessionCatalogData(): void {
this.sessionCatalogGeneration += 1;
this.sessionCatalogLive.clear();
}
resetSessionCatalogConnection(): void {
this.sessionCatalogGeneration += 1;
this.sessionCatalogRevision += 1;
this.sessionCatalogLive.resetConnection();
this.sessionCatalogs = [];
this.loadingMoreSessionCatalogIds = new Set();
this.sessionCatalogPageDepths.clear();
this.sessionCatalogRevisions.clear();
this.notify();
}
updateSessionCatalogData(): void {
if (this.context) {
synchronizeSessionCatalogAgent(this, this.host.expandedAgentId());
}
if (
!visibleSessionCatalogClient(this) ||
this.sessionCatalogLive.timer ||
this.sessionCatalogLive.requestGeneration === this.sessionCatalogGeneration
) {
return;
}
void this.refreshSessionCatalogs();
}
handleSessionCatalogHostEvent(payload: unknown): void {
applySessionCatalogHostEventToData(this, payload);
}
handleSessionCatalogPresence(payload: unknown): void {
if (this.sessionCatalogLive.observePresence(payload)) {
requestSessionCatalogDataRefresh(this);
}
}
private readonly handleCatalogSessionContinued = (
event: CustomEvent<CatalogSessionContinuedDetail>,
) => {
const detail = event.detail;
if (!detail?.sessionKey) {
return;
}
this.sessionCatalogs = bindAdoptedCatalogSession(this.sessionCatalogs, detail);
this.notify();
// Invalidate in-flight polls and load-more merges so a pre-adoption
// snapshot cannot clobber the patched rows; the 30s poll reconfirms.
this.sessionCatalogRevision += 1;
this.sessionCatalogRevisions.set(
detail.catalogId,
(this.sessionCatalogRevisions.get(detail.catalogId) ?? 0) + 1,
);
};
private readonly handleSessionCatalogPageActivation = () => {
if (document.visibilityState === "hidden") {
this.sessionCatalogLive.cancelScheduledRefreshes();
return;
}
this.sessionCatalogLive.scheduleActivation(() => requestSessionCatalogDataRefresh(this));
};
refreshSessionCatalogs(): Promise<void> {
return refreshSessionCatalogData(this);
}
loadMoreSessionCatalog(catalogId: string): Promise<void> {
return loadMoreSessionCatalogData(this, catalogId);
}
private syncSessionsScrollObserver(): void {
const element = this.host.querySelector(".sidebar-shell__body") as HTMLElement | null;
if (element !== this.sessionsScrollElement) {
this.sessionsScrollResizeObserver?.disconnect();
this.sessionsScrollElement = element;
@@ -182,7 +334,7 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
}
// One rAF-coalesced scroll read rides paint layout instead of flushing every update.
private scheduleSessionsScrollStateSync() {
private scheduleSessionsScrollStateSync(): void {
if (this.sessionsScrollStateFrame !== null) {
return;
}
@@ -195,20 +347,11 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
});
}
protected updateSessionsScrollState(element: HTMLElement) {
const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
let nextState: SidebarSessionsScrollState = "none";
if (maxScrollTop > 1) {
if (element.scrollTop <= 1) {
nextState = "top";
} else if (element.scrollTop >= maxScrollTop - 1) {
nextState = "bottom";
} else {
nextState = "middle";
}
}
updateSessionsScrollState(element: HTMLElement): void {
const nextState = resolveSidebarSessionsScrollState(element);
if (nextState !== this.sessionsScrollState) {
this.sessionsScrollState = nextState;
this.notify();
}
}
@@ -230,9 +373,10 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
globalThis.clearTimeout(this.activeSessionLineageRetryTimer);
this.activeSessionLineageRetryTimer = null;
}
this.notify();
}
const snapshot = sessions.state;
if (this.sidebarSessionStatusFilter() !== "active") {
if (this.host.sidebarSessionStatusFilter() !== "active") {
return;
}
const gateway = this.context?.gateway;
@@ -266,9 +410,10 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
}
}
this.sessionsLoading = snapshot.loading;
this.notify();
};
private synchronizeSessions(sessions: SessionCapability) {
private synchronizeSessions(sessions: SessionCapability): void {
if (sessions !== this.sessionsSource) {
this.invalidateSessionMutations();
this.clearSessionCache();
@@ -278,13 +423,13 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
if (this.context?.gateway.snapshot.connected) {
// Group catalog hydration is idempotent per connection.
void sessions.groupsLoad();
if (this.sidebarSessionStatusFilter() !== "active") {
if (this.host.sidebarSessionStatusFilter() !== "active") {
void this.refreshSidebarSessions();
}
}
}
private synchronizeGateway(gateway: ApplicationContext<RouteId>["gateway"]) {
private synchronizeGateway(gateway: ApplicationContext<RouteId>["gateway"]): void {
const client = gateway.snapshot.client;
const connected = gateway.snapshot.connected;
const clientChanged = client !== this.gatewayClient;
@@ -305,17 +450,18 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
const presence = readPresenceEntries(gateway.snapshot.hello?.snapshot);
this.presencePayload = presence ? { presence } : undefined;
}
this.notify();
if (!sourceOrClientChanged) {
return;
}
this.clearSessionCache();
this.resetSessionCatalogConnection();
if (connected && this.sessionsSource && this.sidebarSessionStatusFilter() !== "active") {
if (connected && this.sessionsSource && this.host.sidebarSessionStatusFilter() !== "active") {
void this.refreshSidebarSessions();
}
}
private clearSessionCache() {
private clearSessionCache(): void {
this.sidebarListRequestToken = null;
this.childSessionGeneration += 1;
this.childSessionCanonicalListRevision = null;
@@ -337,14 +483,15 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
}
this.sessionCreatedOrder.clear();
this.visibleSessionLimit = SIDEBAR_SESSION_PAGE_SIZE;
this.notify();
}
protected async refreshSidebarSessions(agentId = this.expandedAgentId()): Promise<void> {
async refreshSidebarSessions(agentId = this.host.expandedAgentId()): Promise<void> {
const sessions = this.context?.sessions;
if (!sessions) {
return;
}
const archivedFilter = this.sidebarSessionStatusFilter();
const archivedFilter = this.host.sidebarSessionStatusFilter();
const options = {
agentId,
archivedFilter,
@@ -364,12 +511,13 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
const token = Symbol(agentId);
this.sidebarListRequestToken = token;
this.sessionsLoading = true;
this.notify();
try {
const result = await sessions.list(options);
if (
token !== this.sidebarListRequestToken ||
sessions !== this.context?.sessions ||
archivedFilter !== this.sidebarSessionStatusFilter()
archivedFilter !== this.host.sidebarSessionStatusFilter()
) {
return;
}
@@ -383,18 +531,21 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
}
}
}
this.notify();
} catch (error) {
if (token === this.sidebarListRequestToken) {
this.sessionMutationError = String(error);
this.notify();
}
} finally {
if (token === this.sidebarListRequestToken) {
this.sessionsLoading = false;
this.notify();
}
}
}
protected async loadChildSessions(parentKey: string): Promise<void> {
async loadChildSessions(parentKey: string): Promise<void> {
if (
!parentKey ||
this.loadedChildSessionKeys.has(parentKey) ||
@@ -409,14 +560,11 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
}
const generation = this.childSessionGeneration;
this.loadingChildSessionKeys = new Set([...this.loadingChildSessionKeys, parentKey]);
this.notify();
try {
const isCurrent = () =>
generation === this.childSessionGeneration && sessions === this.context?.sessions;
const rows = await fetchChildSessionRows({
sessions,
parentKey,
isCurrent,
});
const rows = await fetchChildSessionRows({ sessions, parentKey, isCurrent });
if (!rows || !isCurrent()) {
return;
}
@@ -432,6 +580,7 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
failedKeys.delete(parentKey);
this.failedChildSessionKeys = failedKeys;
}
this.notify();
} catch {
if (generation !== this.childSessionGeneration || sessions !== this.context?.sessions) {
return;
@@ -443,16 +592,18 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
[parentKey]: this.childSessionRowsByParent[parentKey] ?? [],
};
this.failedChildSessionKeys = new Set([...this.failedChildSessionKeys, parentKey]);
this.notify();
} finally {
if (generation === this.childSessionGeneration && sessions === this.context?.sessions) {
const next = new Set(this.loadingChildSessionKeys);
next.delete(parentKey);
this.loadingChildSessionKeys = next;
this.notify();
}
}
}
protected async loadActiveSessionLineage(sessionKey: string): Promise<void> {
async loadActiveSessionLineage(sessionKey: string): Promise<void> {
const normalizedKey = sessionKey.trim();
if (normalizedKey !== this.activeSessionLineageRouteKey) {
this.activeSessionLineageRouteKey = normalizedKey;
@@ -463,6 +614,7 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
globalThis.clearTimeout(this.activeSessionLineageRetryTimer);
this.activeSessionLineageRetryTimer = null;
}
this.notify();
}
const gateway = this.context?.gateway;
const client = gateway?.snapshot.client;
@@ -503,12 +655,13 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
lineage.rowsByParent,
);
this.activeSessionLineageRoot = lineage.topmostRow;
this.notify();
this.activeSessionLineageRequestToken = null;
if (lineage.lookupFailed) {
this.activeSessionLineageRetryTimer = globalThis.setTimeout(() => {
this.activeSessionLineageRetryTimer = null;
if (this.activeSessionLineageRouteKey === normalizedKey) {
this.requestUpdate();
this.notify();
}
}, 5_000);
return;
@@ -516,14 +669,61 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
this.activeSessionLineageLoaded = true;
}
private invalidateSessionMutations() {
this.sessionMutationEpoch += 1;
this.sessionMutationError = null;
setVisibleSessionLimit(limit: number): void {
this.visibleSessionLimit = limit;
this.notify();
}
protected beginSessionMutation(): SidebarSessionMutationScope | null {
dismissSessionMutationError(): void {
this.sessionMutationError = null;
this.notify();
}
resetForStatusFilter(statusFilter: SidebarSessionStatusFilter): void {
this.visibleSessionLimit = SIDEBAR_SESSION_PAGE_SIZE;
this.childSessionRowsByParent = {};
this.loadedChildSessionKeys = new Set();
this.failedChildSessionKeys = new Set();
this.loadingChildSessionKeys = new Set();
this.sessionRowsByAgent = {};
if (statusFilter === "active" && this.context) {
this.sessionsResult = this.context.sessions.state.result;
this.sessionsAgentId = this.context.sessions.state.agentId;
}
this.notify();
}
discardEmptyChildSessionSnapshot(sessionKey: string): void {
if (this.childSessionRowsByParent[sessionKey]?.length === 0) {
const childRows = { ...this.childSessionRowsByParent };
delete childRows[sessionKey];
this.childSessionRowsByParent = childRows;
const loadedKeys = new Set(this.loadedChildSessionKeys);
loadedKeys.delete(sessionKey);
this.loadedChildSessionKeys = loadedKeys;
this.notify();
}
}
retryChildSessions(sessionKey: string): void {
if (this.failedChildSessionKeys.has(sessionKey)) {
const failedKeys = new Set(this.failedChildSessionKeys);
failedKeys.delete(sessionKey);
this.failedChildSessionKeys = failedKeys;
this.notify();
}
void this.loadChildSessions(sessionKey);
}
private invalidateSessionMutations(): void {
this.sessionMutationEpoch += 1;
this.sessionMutationError = null;
this.notify();
}
beginSessionMutation(): SidebarSessionMutationScope | null {
const context = this.context;
if (!context || !this.connected) {
if (!context || !this.host.connected) {
return null;
}
const gateway = context.gateway;
@@ -532,21 +732,22 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
return null;
}
this.sessionMutationError = null;
this.notify();
return {
epoch: this.sessionMutationEpoch,
context,
gateway,
sessions: context.sessions,
client,
selectedAgentId: this.selectedAgentIdForSessions(),
selectedAgentId: this.host.selectedAgentIdForSessions(),
};
}
protected isSessionMutationScopeCurrent(scope: SidebarSessionMutationScope): boolean {
isSessionMutationScopeCurrent(scope: SidebarSessionMutationScope): boolean {
const context = this.context;
const gateway = context?.gateway;
return (
this.connected &&
this.host.connected &&
this.sessionMutationEpoch === scope.epoch &&
context === scope.context &&
gateway === scope.gateway &&
@@ -556,9 +757,10 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
);
}
protected publishSessionMutationError(scope: SidebarSessionMutationScope, error: unknown) {
publishSessionMutationError(scope: SidebarSessionMutationScope, error: unknown): void {
if (this.isSessionMutationScopeCurrent(scope)) {
this.sessionMutationError = String(error);
this.notify();
}
}
}

View File

@@ -1,6 +1,5 @@
import { afterEach, beforeEach, vi } from "vitest";
import type {
SessionCatalog,
SessionCatalogPullRequestSummary,
SessionsCatalogListResult,
} from "../../../packages/gateway-protocol/src/index.ts";
@@ -19,6 +18,7 @@ import type {
SidebarWorkboardBoard,
SidebarWorkboardRenderers,
} from "../components/app-sidebar-workboard.ts";
import type { SessionDataController } from "../components/session-data-controller.ts";
import type { SessionCapability } from "../lib/sessions/index.ts";
import { createApplicationContextProvider } from "./application-context.ts";
import { createStorageMock } from "./storage.ts";
@@ -31,44 +31,69 @@ export type SessionGroupMutationResult = Awaited<ReturnType<SessionCapability["g
type SessionDeleteResult = Awaited<ReturnType<SessionCapability["delete"]>>;
type SessionState = SessionCapability["state"];
export type SidebarLifecycleState = HTMLElement & {
activeRouteId?: string;
activeWorkboardBoardId: string;
enabledRouteIds?: readonly NavigationRouteId[];
connected: boolean;
offline: boolean;
outboxCountForSession: (sessionKey: string) => number;
queuedOutboxCount: number;
lastError: string | null;
terminalAvailable: boolean;
catalogOpenTarget: "viewer" | "terminal";
canPairDevice: boolean;
sidebarEntries: readonly string[];
workboardBoards: readonly SidebarWorkboardBoard[];
workboardBoardsReady: boolean;
workboardRenderers?: SidebarWorkboardRenderers;
sidebarLiveActivity: boolean;
onUpdateSidebarEntries?: (entries: string[]) => void;
pinnedAgentIds: readonly string[];
sessionKey: string;
onNavigate: (
routeId: string,
options?: { pathname?: string; search?: string; hash?: string },
) => void;
sessionCatalogs: SessionCatalog[];
sessionRowsByAgent: Record<string, SessionsListResult["sessions"]>;
sessionCreatedOrder: Map<string, number>;
sessionsAgentId: string | null;
sessionsResult: SessionsListResult | null;
requestUpdate: () => void;
updateComplete: Promise<boolean>;
updateAvailable: { currentVersion: string; latestVersion: string; channel: string } | null;
updateRunning: boolean;
onUpdate: () => void;
onRetryConnect?: () => void;
onOpenNewSession?: (agentId: string, target?: { catalogId: string }) => void;
variant: "panel" | "drawer";
};
type SidebarTestSessionDataKey =
| "sessionCatalogs"
| "sessionCreatedOrder"
| "sessionRowsByAgent"
| "sessionsAgentId"
| "sessionsResult";
export type SidebarLifecycleState = HTMLElement &
Pick<SessionDataController, SidebarTestSessionDataKey> & {
activeRouteId?: string;
activeWorkboardBoardId: string;
enabledRouteIds?: readonly NavigationRouteId[];
connected: boolean;
offline: boolean;
outboxCountForSession: (sessionKey: string) => number;
queuedOutboxCount: number;
lastError: string | null;
terminalAvailable: boolean;
catalogOpenTarget: "viewer" | "terminal";
canPairDevice: boolean;
sidebarEntries: readonly string[];
workboardBoards: readonly SidebarWorkboardBoard[];
workboardBoardsReady: boolean;
workboardRenderers?: SidebarWorkboardRenderers;
sidebarLiveActivity: boolean;
onUpdateSidebarEntries?: (entries: string[]) => void;
pinnedAgentIds: readonly string[];
sessionKey: string;
onNavigate: (
routeId: string,
options?: { pathname?: string; search?: string; hash?: string },
) => void;
readonly sessionData: SessionDataController;
requestUpdate: () => void;
updateComplete: Promise<boolean>;
updateAvailable: { currentVersion: string; latestVersion: string; channel: string } | null;
updateRunning: boolean;
onUpdate: () => void;
onRetryConnect?: () => void;
onOpenNewSession?: (agentId: string, target?: { catalogId: string }) => void;
variant: "panel" | "drawer";
};
const sidebarTestSessionDataKeys: readonly SidebarTestSessionDataKey[] = [
"sessionCatalogs",
"sessionCreatedOrder",
"sessionRowsByAgent",
"sessionsAgentId",
"sessionsResult",
];
function exposeSessionDataForSidebarTests(sidebar: SidebarLifecycleState): void {
for (const key of sidebarTestSessionDataKeys) {
Object.defineProperty(sidebar, key, {
configurable: true,
get: () => sidebar.sessionData[key],
set: (value: SessionDataController[SidebarTestSessionDataKey]) => {
Reflect.set(sidebar.sessionData, key, value);
sidebar.sessionData.requestSessionDataUpdate();
},
});
}
}
export type LobsterPetElement = HTMLElement & {
runOutcome: "ok" | "error" | "aborted";
@@ -329,6 +354,7 @@ export async function mountSidebar(
const sidebar = document.createElement(
"openclaw-app-sidebar",
) as unknown as SidebarLifecycleState;
exposeSessionDataForSidebarTests(sidebar);
sidebar.variant = variant;
provider.append(sidebar);
document.body.append(provider);