Files
openclaw/ui/src/components/app-sidebar-session-pr-indicators.ts
Peter Steinberger 6d01ae5168 fix(ui): refresh chat checkout side panels (#125785)
* fix(ui): refresh chat checkout side panels

Retire checkout-owned PR, branch, Files, and Review state when a
structural session mutation or logical connection epoch replaces the
checkout. Surface an actionable reload path for transient sidebar chunk
failures while keeping chat usable.

Release note: Control UI checkout side panels now refresh reliably after
session replacement and reconnect, and failed sidebar chunks can recover
without leaving blank content.

Refs #125767

* fix(ui): keep structural refresh within startup budget

Record the structural-session classification once at the canonical event
parser and carry that fact to PR, session, and chat consumers. This removes
duplicate payload classification and keeps the checkout refresh repair within
the Control UI startup bundle budget.

Refs #125767

* test(ui): align worker refresh with chunk recovery

Advance the mock Gateway build identity with replacement assets so stale
sidebar chunks can recover without creating an artificial version-skew loop.
Assert that the catalog-owned terminal intent survives both refresh owners and
opens exactly once.

Refs #125767

* fix(ui): retire checkout summaries at consumers

Keep structural mutation policy in the PR snapshot store, match every watched
session alias, and retire derived summaries through fenced chat/sidebar
consumers. Preserve canonical summary identity and avoid adding structural
event policy to the startup session parser.

This also restores the Control UI startup bundle below its enforced budget on
the latest main base.

Refs #125767

* test(ui): accept either update recovery owner

Allow stale-chunk document recovery or service-worker activation to win the
pre-activation race, while still requiring the catalog terminal intent to
settle exactly once after both recovery owners finish.

Refs #125767

* refactor(ui): keep workspace agent resolver private

The split workspace state owner consumes the pane agent resolver internally;
do not retain a stale export after adapting the checkout-ownership repair.

Refs #125767
2026-08-18 08:54:26 -07:00

205 lines
6.1 KiB
TypeScript

import type { ReactiveController, ReactiveControllerHost } from "lit";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { ApplicationGateway } from "../app/gateway.ts";
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
import {
scopedSessionPullRequestKey,
SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD,
sessionPullRequestsForGateway,
type SessionPullRequestSnapshotStore,
} from "../lib/session-pull-requests.ts";
import type { SessionCapability } from "../lib/sessions/index.ts";
import { parseAgentSessionKey } from "../lib/sessions/session-key.ts";
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
import {
resolveSessionPullRequestIndicatorState,
type SessionPullRequestIndicatorState,
} from "./session-menu-work.ts";
type IndicatorEntry = {
state: SessionPullRequestIndicatorState;
worktreeId: string;
};
type SessionPullRequestIndicatorsOptions = {
getConnected: () => boolean;
getRows: () => readonly SidebarRecentSession[];
getSelectedAgentId: () => string;
getGateway: () => ApplicationGateway | undefined;
getSessions: () => SessionCapability | undefined;
};
/** Projects pushed PR snapshots for the currently visible worktree rows. */
export class SessionPullRequestIndicatorsController implements ReactiveController {
private readonly states = new Map<string, IndicatorEntry>();
private gateway: ApplicationGateway | null = null;
private client: GatewayBrowserClient | null = null;
private agentId: string | null = null;
private store: SessionPullRequestSnapshotStore | null = null;
private stopStoreUpdates: (() => void) | null = null;
private connected = false;
private refreshScheduled = false;
constructor(
private readonly host: ReactiveControllerHost,
private readonly options: SessionPullRequestIndicatorsOptions,
) {
host.addController(this);
}
hostConnected(): void {
this.connected = true;
}
hostUpdated(): void {
this.scheduleRefresh();
}
hostDisconnected(): void {
this.connected = false;
this.releaseStore();
this.reset(false);
}
state(sessionKey: string, worktreeId: string): SessionPullRequestIndicatorState {
const entry = this.states.get(sessionKey);
return entry?.worktreeId === worktreeId ? entry.state : "none";
}
private scheduleRefresh(): void {
if (this.refreshScheduled) {
return;
}
this.refreshScheduled = true;
globalThis.setTimeout(() => {
this.refreshScheduled = false;
if (this.connected) {
this.refreshVisible();
}
}, 0);
}
private releaseStore(): void {
this.store?.unwatch(this);
this.stopStoreUpdates?.();
this.stopStoreUpdates = null;
this.store = null;
this.gateway = null;
this.client = null;
this.agentId = null;
}
private reset(requestUpdate: boolean): void {
if (this.states.size === 0) {
return;
}
this.states.clear();
if (requestUpdate) {
this.host.requestUpdate();
}
}
private eligibleRows(): readonly SidebarRecentSession[] {
return this.options.getRows().filter((session) => !session.isChild && session.worktreeId);
}
private scopedKey(sessionKey: string): string {
return scopedSessionPullRequestKey(
sessionKey,
parseAgentSessionKey(sessionKey)?.agentId ?? this.options.getSelectedAgentId(),
);
}
private applySnapshots(): void {
const store = this.store;
if (!store) {
return;
}
let changed = false;
for (const session of this.eligibleRows()) {
if (!session.worktreeId) {
continue;
}
const snapshot = store.get(this.scopedKey(session.key));
// Empty failure snapshots retain the rendered chip; snapshots carrying
// last-known PRs can also hydrate a newly mounted row.
if (!snapshot) {
const removed = this.states.delete(session.key);
if (removed) {
const sessions = this.options.getSessions();
if (sessions) {
sessions.setPullRequestSummary(
session.key,
undefined,
sessions.capturePullRequestEpoch(session.key),
);
}
}
changed = removed || changed;
continue;
}
if (snapshot.status !== "ready" && snapshot.pullRequests.length === 0) {
continue;
}
const entry = {
state: resolveSessionPullRequestIndicatorState(snapshot.pullRequests),
worktreeId: session.worktreeId,
};
const current = this.states.get(session.key);
if (current?.state !== entry.state || current.worktreeId !== entry.worktreeId) {
this.states.set(session.key, entry);
changed = true;
}
}
if (changed) {
this.host.requestUpdate();
}
}
private refreshVisible(): void {
const gateway = this.options.getGateway();
if (
!gateway ||
!this.options.getConnected() ||
isGatewayMethodAdvertised(gateway.snapshot, SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD) !== true
) {
this.releaseStore();
this.reset(true);
return;
}
if (gateway !== this.gateway) {
this.releaseStore();
this.gateway = gateway;
this.store = sessionPullRequestsForGateway(gateway);
this.stopStoreUpdates = this.store.subscribe(() => this.applySnapshots());
}
if (gateway.snapshot.client !== this.client) {
this.client = gateway.snapshot.client;
this.reset(true);
}
const selectedAgentId = this.options.getSelectedAgentId();
if (selectedAgentId !== this.agentId) {
this.agentId = selectedAgentId;
this.reset(true);
}
const eligibleRows = this.eligibleRows();
const eligibleKeys = new Set(eligibleRows.map((session) => session.key));
let removed = false;
for (const sessionKey of this.states.keys()) {
if (!eligibleKeys.has(sessionKey)) {
this.states.delete(sessionKey);
removed = true;
}
}
if (removed) {
this.host.requestUpdate();
}
this.store?.watch(
this,
eligibleRows.map((session) => this.scopedKey(session.key)),
);
this.applySnapshots();
}
}