mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
6d01ae5168
* 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
205 lines
6.1 KiB
TypeScript
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();
|
|
}
|
|
}
|