feat(ui): show PR status on catalog threads

This commit is contained in:
Peter Steinberger
2026-07-20 22:10:21 -07:00
parent a0f03b6f9f
commit 0169407af5
23 changed files with 472 additions and 90 deletions
@@ -1,3 +1,4 @@
import type { SessionCatalogPullRequestSummary } from "openclaw/plugin-sdk/session-catalog";
import type { ClaudeTranscriptItem } from "./session-catalog-transcript.js";
type ClaudeSessionSource = "claude-cli" | "claude-desktop";
@@ -15,6 +16,7 @@ export type ClaudeSessionCatalogSession = {
cliVersion?: string;
gitBranch?: string;
customGroup?: string;
pullRequest?: SessionCatalogPullRequestSummary;
archived: false;
};
@@ -774,6 +774,7 @@ describe("Claude session catalog", () => {
status: "stored",
source: "claude-cli",
modelProvider: "anthropic",
pullRequest: { numbers: [1234], state: "open" },
archived: false,
},
],
@@ -810,6 +811,7 @@ describe("Claude session catalog", () => {
const hosts = await provider?.list({ hostIds: ["node:node-a"] });
expect(hosts?.[0]?.sessions[0]).toMatchObject({
threadId,
pullRequest: { numbers: [1234], state: "open" },
canContinue: true,
canOpenTerminal: true,
});
@@ -1077,6 +1079,87 @@ describe("Claude session catalog", () => {
});
});
it("retains the current Claude Desktop pull request when history is truncated", async () => {
const home = await createHome();
const sessionId = "desktop-pull-requests";
await writeProject({
home,
entries: [
{
sessionId,
fullPath: path.join(home, ".claude", "projects", "-workspace", `${sessionId}.jsonl`),
projectPath: "/work/openclaw",
isSidechain: false,
},
],
transcripts: { [sessionId]: [message(sessionId, "user", "pull request prompt", 1)] },
});
await writeDesktopMetadata(home, "pull-requests", {
sessionId: "local_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
cliSessionId: sessionId,
cwd: "/work/openclaw",
title: "Desktop pull requests",
prNumber: 111772,
prs: [
{ prNumber: 111772, state: "MERGED" },
{ prNumber: 111179, state: "MERGED", dismissed: true },
...Array.from({ length: 20 }, (_value, index) => ({
prNumber: index + 1,
state: "CLOSED",
})),
],
});
await expect(listLocalClaudeSessionPage({}, home)).resolves.toMatchObject({
sessions: [
{
threadId: sessionId,
pullRequest: {
numbers: [...Array.from({ length: 19 }, (_value, index) => index + 2), 111772],
state: "merged",
},
source: "claude-desktop",
},
],
});
});
it("adds the current Claude Desktop pull request when history omits it", async () => {
const home = await createHome();
const sessionId = "desktop-current-pull-request";
await writeProject({
home,
entries: [
{
sessionId,
fullPath: path.join(home, ".claude", "projects", "-workspace", `${sessionId}.jsonl`),
projectPath: "/work/openclaw",
isSidechain: false,
},
],
transcripts: { [sessionId]: [message(sessionId, "user", "draft prompt", 1)] },
});
await writeDesktopMetadata(home, "current-pull-request", {
sessionId: "local_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
cliSessionId: sessionId,
cwd: "/work/openclaw",
title: "Desktop pull request",
prNumber: 107302,
prState: "OPEN",
prs: [{ prNumber: 107301, state: "CLOSED" }],
});
await expect(listLocalClaudeSessionPage({}, home)).resolves.toMatchObject({
sessions: [
{
threadId: sessionId,
pullRequest: { numbers: [107301, 107302], state: "open" },
source: "claude-desktop",
},
],
});
});
it("skips custom group names spliced with decoder garbage", async () => {
const home = await createHome();
const sessionId = "desktop-garbage-group";
+109
View File
@@ -8,6 +8,7 @@ import { withTimeout } from "openclaw/plugin-sdk/security-runtime";
import type {
SessionCatalogHost,
SessionCatalogProvider,
SessionCatalogPullRequestSummary,
SessionCatalogTranscriptItem,
} from "openclaw/plugin-sdk/session-catalog";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -57,6 +58,7 @@ const MAX_TRANSCRIPT_LIMIT = 50;
const MAX_HOSTS = 100;
const MAX_STRING_LENGTH = 4096;
const MAX_SEARCH_LENGTH = 500;
const MAX_SESSION_PULL_REQUESTS = 20;
const MAX_CATALOG_DISCOVERY_FILES = 10_000;
const MAX_CATALOG_DISCOVERY_CACHE_ENTRIES = 20_000;
const CLAUDE_METADATA_PREFIX_BYTES = 1024 * 1024;
@@ -98,6 +100,15 @@ type DesktopSessionMetadata = {
isArchived?: unknown;
title?: unknown;
customGroup?: unknown;
prNumber?: unknown;
prState?: unknown;
prs?: unknown;
};
type DesktopPullRequestMetadata = {
prNumber?: unknown;
state?: unknown;
dismissed?: unknown;
};
type CatalogRecord = ClaudeSessionCatalogSession & {
@@ -146,6 +157,99 @@ function optionalString(value: unknown, maxLength = MAX_STRING_LENGTH): string |
return trimmed && trimmed.length <= maxLength ? trimmed : undefined;
}
function pullRequestState(value: unknown): SessionCatalogPullRequestSummary["state"] | undefined {
if (typeof value !== "string") {
return undefined;
}
switch (value.trim().toLowerCase()) {
case "open":
case "draft":
case "merged":
case "closed":
return value.trim().toLowerCase() as SessionCatalogPullRequestSummary["state"];
default:
return undefined;
}
}
function pullRequestNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
}
// Desktop retains historical PRs in order and marks hidden ones as dismissed;
// the top-level pair identifies the current PR whose state labels the row.
function desktopPullRequestSummary(
metadata: DesktopSessionMetadata,
): SessionCatalogPullRequestSummary | undefined {
const visible: Array<{ number: number; state?: SessionCatalogPullRequestSummary["state"] }> = [];
const dismissed = new Set<number>();
if (Array.isArray(metadata.prs)) {
for (const value of metadata.prs) {
if (!isRecord(value)) {
continue;
}
const entry = value as DesktopPullRequestMetadata;
const number = pullRequestNumber(entry.prNumber);
if (!number) {
continue;
}
if (entry.dismissed === true) {
dismissed.add(number);
continue;
}
if (!visible.some((candidate) => candidate.number === number)) {
visible.push({ number, state: pullRequestState(entry.state) });
}
}
}
const currentNumber = pullRequestNumber(metadata.prNumber);
let current = currentNumber
? visible.find((candidate) => candidate.number === currentNumber)
: undefined;
if (currentNumber && !dismissed.has(currentNumber)) {
if (current) {
visible.splice(visible.indexOf(current), 1);
} else {
current = { number: currentNumber, state: pullRequestState(metadata.prState) };
}
visible.push(current);
}
if (visible.length === 0) {
return undefined;
}
const state =
(current ? (pullRequestState(metadata.prState) ?? current.state) : undefined) ??
visible.at(-1)?.state;
if (!state) {
return undefined;
}
return {
numbers: visible.slice(-MAX_SESSION_PULL_REQUESTS).map((entry) => entry.number),
state,
};
}
function parsePullRequestSummary(value: unknown): SessionCatalogPullRequestSummary | undefined {
if (value === undefined) {
return undefined;
}
if (!isRecord(value) || !Array.isArray(value.numbers)) {
throw new Error("Claude node returned an invalid pull request summary");
}
const numbers = value.numbers.map(pullRequestNumber);
const state = pullRequestState(value.state);
if (
numbers.length === 0 ||
numbers.length > MAX_SESSION_PULL_REQUESTS ||
numbers.some((number) => number === undefined) ||
new Set(numbers).size !== numbers.length ||
!state
) {
throw new Error("Claude node returned an invalid pull request summary");
}
return { numbers: numbers as number[], state };
}
function timestampMs(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
@@ -587,6 +691,7 @@ async function listClaudeSessions(homeDir = currentHomeDir()): Promise<CatalogRe
const createdAt = timestampMs(metadata.createdAt) ?? existing?.createdAt;
const updatedAt = timestampMs(metadata.lastActivityAt) ?? existing?.updatedAt;
const customGroup = optionalString(metadata.customGroup, 500);
const pullRequest = desktopPullRequestSummary(metadata);
records.set(sessionId, {
...(existing ?? {
threadId: sessionId,
@@ -599,6 +704,7 @@ async function listClaudeSessions(homeDir = currentHomeDir()): Promise<CatalogRe
...(createdAt !== undefined ? { createdAt } : {}),
...(updatedAt !== undefined ? { updatedAt, recencyAt: updatedAt } : {}),
...(customGroup ? { customGroup } : {}),
...(pullRequest ? { pullRequest } : {}),
source: "claude-desktop",
filePath,
});
@@ -914,6 +1020,7 @@ function parseCatalogPage(value: unknown): ClaudeSessionCatalogPage {
const recencyAt = parseNumberField("recencyAt", true);
const cliVersion = parseStringField("cliVersion", 256);
const gitBranch = parseStringField("gitBranch", 500);
const pullRequest = parsePullRequestSummary(candidate.pullRequest);
return {
threadId,
status: "stored",
@@ -927,6 +1034,7 @@ function parseCatalogPage(value: unknown): ClaudeSessionCatalogPage {
...(recencyAt !== undefined ? { recencyAt } : {}),
...(cliVersion ? { cliVersion } : {}),
...(gitBranch ? { gitBranch } : {}),
...(pullRequest ? { pullRequest } : {}),
};
});
const nextCursor = readNodePageCursor(value, "Claude node returned an invalid session page");
@@ -1456,6 +1564,7 @@ function toGenericClaudeHost(
...(session.cliVersion ? { cliVersion: session.cliVersion } : {}),
...(session.gitBranch ? { gitBranch: session.gitBranch } : {}),
...(session.customGroup ? { customGroup: session.customGroup } : {}),
...(session.pullRequest ? { pullRequest: session.pullRequest } : {}),
archived: session.archived,
...(continuable && existingSessionKey ? { sessionKey: existingSessionKey } : {}),
canContinue: continuable,
+2
View File
@@ -459,6 +459,7 @@ import {
SessionCatalogDescriptorSchema,
SessionCatalogHostSchema,
SessionCatalogLocatorSchema,
SessionCatalogPullRequestSummarySchema,
SessionCatalogSessionSchema,
SessionCatalogTranscriptItemSchema,
SessionsCatalogArchiveParamsSchema,
@@ -1081,6 +1082,7 @@ export {
SessionsListParamsSchema,
SessionCatalogCapabilitiesSchema,
SessionCatalogDescriptorSchema,
SessionCatalogPullRequestSummarySchema,
SessionCatalogSessionSchema,
SessionCatalogHostSchema,
SessionCatalogLocatorSchema,
@@ -5,6 +5,7 @@ export type {
SessionCatalogDescriptor,
SessionCatalogHost,
SessionCatalogLocator,
SessionCatalogPullRequestSummary,
SessionCatalogSession,
SessionCatalogTranscriptItem,
SessionsCatalogArchiveParams,
@@ -463,6 +463,7 @@ import {
SessionCatalogCapabilitiesSchema,
SessionCatalogDescriptorSchema,
SessionCatalogHostSchema,
SessionCatalogPullRequestSummarySchema,
SessionCatalogSchema,
SessionCatalogSessionSchema,
SessionCatalogTranscriptItemSchema,
@@ -762,6 +763,7 @@ export const ProtocolSchemas = {
SessionsListParams: SessionsListParamsSchema,
SessionCatalogCapabilities: SessionCatalogCapabilitiesSchema,
SessionCatalogDescriptor: SessionCatalogDescriptorSchema,
SessionCatalogPullRequestSummary: SessionCatalogPullRequestSummarySchema,
SessionCatalogSession: SessionCatalogSessionSchema,
SessionCatalogHost: SessionCatalogHostSchema,
SessionCatalog: SessionCatalogSchema,
@@ -25,6 +25,20 @@ export const SessionCatalogDescriptorSchema = closedObject({
capabilities: SessionCatalogCapabilitiesSchema,
});
export const SessionCatalogPullRequestSummarySchema = closedObject({
numbers: Type.Array(Type.Integer({ minimum: 1 }), {
minItems: 1,
maxItems: 20,
uniqueItems: true,
}),
state: Type.Union([
Type.Literal("open"),
Type.Literal("draft"),
Type.Literal("merged"),
Type.Literal("closed"),
]),
});
export const SessionCatalogSessionSchema = closedObject({
threadId: NonEmptyString,
name: Type.Optional(Type.String()),
@@ -38,6 +52,7 @@ export const SessionCatalogSessionSchema = closedObject({
cliVersion: Type.Optional(Type.String()),
gitBranch: Type.Optional(Type.String()),
customGroup: Type.Optional(Type.String()),
pullRequest: Type.Optional(SessionCatalogPullRequestSummarySchema),
archived: Type.Boolean(),
sessionKey: Type.Optional(NonEmptyString),
canContinue: Type.Boolean(),
@@ -140,6 +155,9 @@ export const SessionsCatalogArchiveResultSchema = closedObject({ ok: Type.Litera
export type SessionCatalogCapabilities = Static<typeof SessionCatalogCapabilitiesSchema>;
export type SessionCatalogLocator = Static<typeof SessionCatalogLocatorSchema>;
export type SessionCatalogDescriptor = Static<typeof SessionCatalogDescriptorSchema>;
export type SessionCatalogPullRequestSummary = Static<
typeof SessionCatalogPullRequestSummarySchema
>;
export type SessionCatalogSession = Static<typeof SessionCatalogSessionSchema>;
export type SessionCatalogHost = Static<typeof SessionCatalogHostSchema>;
export type SessionCatalog = Static<typeof SessionCatalogSchema>;
+1
View File
@@ -17,6 +17,7 @@ export type {
SessionCatalogDescriptor,
SessionCatalogHost,
SessionCatalogLocator,
SessionCatalogPullRequestSummary,
SessionCatalogSession,
SessionCatalogTranscriptItem,
SessionsCatalogArchiveParams,
@@ -23,6 +23,7 @@ import { searchForSession } from "../lib/sessions/index.ts";
import type { NewSessionTarget } from "../pages/new-session/location.ts";
import { shouldHandleNavigationClick } from "./app-sidebar-nav-menus.ts";
import { icons } from "./icons.ts";
import { renderSessionRowBadges } from "./session-row-badges.ts";
export function formatSidebarTimestamp(timestampMs: number | null | undefined): string {
const value = formatRelativeTimestamp(timestampMs, { fallback: "" });
@@ -53,6 +54,7 @@ export type CatalogBackingSessionDisplay = {
subtitle?: string;
meta: string;
title: string;
pullRequest?: SessionCatalogSession["pullRequest"];
};
export type CatalogSessionMenuRequest = {
@@ -349,6 +351,7 @@ function renderCatalogSessionRow(
label,
meta: formatSidebarTimestamp(timestamp),
title: `${label} · ${host.label}`,
...(session.pullRequest ? { pullRequest: session.pullRequest } : {}),
});
}
const catalogKey = {
@@ -403,6 +406,10 @@ function renderCatalogSessionRow(
<span class="sidebar-recent-session__text">
<span class="sidebar-recent-session__name hover-marquee">${label}</span>
</span>
${renderSessionRowBadges({
hasAutomation: false,
pullRequest: session.pullRequest,
})}
</a>
<span class="sidebar-recent-session__aside session-row-aside">
<span class="session-row-actions">
@@ -158,6 +158,7 @@ export abstract class AppSidebarSessionListElement extends AppSidebarSessionNarr
></openclaw-viewer-facepile>
${renderSessionRowBadges({
...session,
pullRequest: session.pullRequest ?? display?.pullRequest,
hasApproval: sessionHasPendingApproval(this.approvalBadgeSnapshot(), session.key),
})}
${pinnedState}
@@ -22,7 +22,6 @@ function runningRow(key: string): SidebarRecentSession {
pinned: false,
cloudWorkerActive: false,
hasAutomation: false,
hasOpenPullRequest: false,
unread: false,
attention: { kind: "none" },
startedAt: 1,
@@ -173,7 +173,7 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
: undefined,
cloudWorkerActive: isStoppableCloudWorkerPlacement(row.placement),
hasAutomation: row.hasAutomation === true,
hasOpenPullRequest: context?.sessions.hasOpenPullRequest?.(row.key) === true,
pullRequest: context?.sessions.pullRequestSummary(row.key),
unread: row.unread === true,
attention: this.resolveSessionAttention(row),
agentStatusNote: this.resolveSessionAgentStatus(row)?.note,
@@ -1,3 +1,4 @@
import type { SessionCatalogPullRequestSummary } from "../../../packages/gateway-protocol/src/schema/sessions-catalog.js";
import type { SessionAgentAttentionIconId } from "../../../packages/gateway-protocol/src/session-icon.js";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { SessionRunStatus } from "../api/types.ts";
@@ -71,7 +72,7 @@ export type SidebarRecentSession = {
workspaceConflictCount?: number;
cloudWorkerActive: boolean;
hasAutomation: boolean;
hasOpenPullRequest: boolean;
pullRequest?: SessionCatalogPullRequestSummary;
unread: boolean;
attention: SidebarSessionAttention;
agentStatusNote?: string;
+26 -3
View File
@@ -74,16 +74,39 @@ describe("session row placement badges", () => {
render(
renderSessionRowBadges({
hasAutomation: false,
hasOpenPullRequest: true,
pullRequest: { numbers: [111532], state: "open" },
}),
container,
);
const badge = container.querySelector(".session-row-badge--pull-request");
expect(badge?.getAttribute("aria-label")).toBe("Open PR");
expect(badge?.getAttribute("aria-label")).toBe("#111532 · Open");
expect(badge?.getAttribute("title")).toBe("#111532 · Open");
expect(badge?.getAttribute("data-pull-request-state")).toBe("open");
expect(badge?.querySelector("svg")).not.toBeNull();
});
it.each([
{ state: "draft" as const, label: "#107302 · Draft" },
{ state: "merged" as const, label: "#111751, #111772 · Merged" },
])("renders catalog pull request metadata for $state threads", ({ state, label }) => {
render(
renderSessionRowBadges({
hasAutomation: false,
pullRequest: {
numbers: state === "draft" ? [107302] : [111751, 111772],
state,
},
}),
container,
);
const badge = container.querySelector(".session-row-badge--pull-request");
expect(badge?.getAttribute("aria-label")).toBe(label);
expect(badge?.getAttribute("title")).toBe(label);
expect(badge?.getAttribute("data-pull-request-state")).toBe(state);
});
it("renders a warning-colored approval-needed indicator", () => {
render(
renderSessionRowBadges({
@@ -103,7 +126,7 @@ describe("session row placement badges", () => {
renderSessionRowBadges({
isChild: true,
hasAutomation: true,
hasOpenPullRequest: true,
pullRequest: { numbers: [111532], state: "open" },
hasApproval: true,
placementState: "active",
}),
+31 -5
View File
@@ -2,6 +2,7 @@ import { html, nothing } from "lit";
// Deep import on purpose: the protocol barrel carries typebox and every
// schema, which must stay out of the Control UI startup bundle.
import { isCloudWorkerPlacementState } from "../../../packages/gateway-protocol/src/schema/session-placement-state.js";
import type { SessionCatalogPullRequestSummary } from "../../../packages/gateway-protocol/src/schema/sessions-catalog.js";
import type { GatewaySessionRow } from "../api/types.ts";
import { t } from "../i18n/index.ts";
import { icons } from "./icons.ts";
@@ -16,15 +17,39 @@ export function isStoppableCloudWorkerPlacement(
return placement?.state === "active";
}
function pullRequestStateLabel(state: SessionCatalogPullRequestSummary["state"]): string {
switch (state) {
case "open":
return t("chat.pullRequests.open");
case "draft":
return t("chat.pullRequests.draft");
case "merged":
return t("chat.pullRequests.merged");
case "closed":
return t("chat.pullRequests.closed");
default:
return state satisfies never;
}
}
export function formatSessionPullRequestSummary(summary: SessionCatalogPullRequestSummary): string {
const numbers = summary.numbers.map((number) => `#${number}`).join(", ");
return `${numbers} · ${pullRequestStateLabel(summary.state)}`;
}
export function renderSessionRowBadges(params: {
isChild?: boolean;
hasAutomation: boolean;
hasOpenPullRequest?: boolean;
pullRequest?: SessionCatalogPullRequestSummary;
hasApproval?: boolean;
placementState?: SessionPlacementState;
workspaceConflictCount?: number;
}) {
const hasAutomation = !params.isChild && params.hasAutomation;
const pullRequestLabel = params.pullRequest
? formatSessionPullRequestSummary(params.pullRequest)
: undefined;
const pullRequestState = params.pullRequest?.state;
const placementState = params.isChild ? undefined : params.placementState;
const cloudPlacementState = isCloudWorkerPlacementState(placementState)
? placementState
@@ -36,7 +61,7 @@ export function renderSessionRowBadges(params: {
const hasWorkspaceConflict = workspaceConflictCount > 0;
if (
!hasAutomation &&
!params.hasOpenPullRequest &&
!pullRequestLabel &&
!params.hasApproval &&
!displayedPlacementState &&
!hasWorkspaceConflict
@@ -73,12 +98,13 @@ export function renderSessionRowBadges(params: {
>${icons.clock}</span
>`
: nothing}
${params.hasOpenPullRequest
${pullRequestLabel
? html`<span
class="session-row-badge session-row-badge--pull-request"
data-pull-request-state=${pullRequestState ?? nothing}
role="img"
aria-label=${t("sessionsView.openPullRequest")}
title=${t("sessionsView.openPullRequest")}
aria-label=${pullRequestLabel}
title=${pullRequestLabel}
>${icons.gitPullRequest}</span
>`
: nothing}
+47 -33
View File
@@ -1,3 +1,4 @@
import type { SessionCatalogPullRequestSummary } from "../../../../packages/gateway-protocol/src/schema/sessions-catalog.js";
import {
GatewayRequestError,
type GatewayBrowserClient,
@@ -203,9 +204,13 @@ export type SessionCapability = {
create: (params?: SessionCreateParams) => Promise<string | null>;
patch: SessionPatchRoute;
setModelOverride: (key: string, value: string | null | undefined) => void;
hasOpenPullRequest: (key: string) => boolean;
captureOpenPullRequestEpoch: (key: string) => symbol;
setOpenPullRequest: (key: string, hasOpenPullRequest: boolean, epoch?: symbol) => void;
pullRequestSummary: (key: string) => SessionCatalogPullRequestSummary | undefined;
capturePullRequestEpoch: (key: string) => symbol;
setPullRequestSummary: (
key: string,
summary: SessionCatalogPullRequestSummary | undefined,
epoch?: symbol,
) => void;
delete: (key: string, options?: SessionDeleteOptions) => Promise<SessionDeleteOutcome>;
deleteMany: (targets: readonly SessionDeleteTarget[]) => Promise<SessionDeleteBatchResult>;
reset: (key: string, options?: SessionResetOptions) => Promise<SessionResetResult>;
@@ -733,8 +738,8 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
{ token: symbol; previous: string | null | undefined }
>();
const swarmActivity = new SwarmActivityTracker();
const openPullRequestSessionKeys = new Set<string>();
const openPullRequestEpochs = new Map<string, symbol>();
const pullRequestSummaries = new Map<string, SessionCatalogPullRequestSummary>();
const pullRequestEpochs = new Map<string, symbol>();
let subscribedClient: GatewayBrowserClient | null = null;
let lastListOptions: SessionListOptions = {};
let hasForegroundListOptions = false;
@@ -777,34 +782,43 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
}
};
const hasOpenPullRequest = (key: string): boolean => openPullRequestSessionKeys.has(key.trim());
const pullRequestSummary = (key: string): SessionCatalogPullRequestSummary | undefined =>
pullRequestSummaries.get(key.trim());
const captureOpenPullRequestEpoch = (key: string): symbol => {
const capturePullRequestEpoch = (key: string): symbol => {
const normalizedKey = key.trim();
const epoch = Symbol(normalizedKey);
openPullRequestEpochs.set(normalizedKey, epoch);
pullRequestEpochs.set(normalizedKey, epoch);
return epoch;
};
const retireOpenPullRequest = (key: string) => {
const retirePullRequestSummary = (key: string) => {
const normalizedKey = key.trim();
openPullRequestEpochs.delete(normalizedKey);
openPullRequestSessionKeys.delete(normalizedKey);
pullRequestEpochs.delete(normalizedKey);
pullRequestSummaries.delete(normalizedKey);
};
const setOpenPullRequest = (key: string, open: boolean, epoch?: symbol) => {
const setPullRequestSummary = (
key: string,
summary: SessionCatalogPullRequestSummary | undefined,
epoch?: symbol,
) => {
const normalizedKey = key.trim();
if (
!normalizedKey ||
(epoch !== undefined && openPullRequestEpochs.get(normalizedKey) !== epoch) ||
openPullRequestSessionKeys.has(normalizedKey) === open
) {
if (!normalizedKey || (epoch !== undefined && pullRequestEpochs.get(normalizedKey) !== epoch)) {
return;
}
if (open) {
openPullRequestSessionKeys.add(normalizedKey);
const previous = pullRequestSummaries.get(normalizedKey);
const unchanged =
previous?.state === summary?.state &&
previous?.numbers.length === summary?.numbers.length &&
previous?.numbers.every((number, index) => number === summary?.numbers[index]);
if (unchanged || (!previous && !summary)) {
return;
}
if (summary) {
pullRequestSummaries.set(normalizedKey, summary);
} else {
openPullRequestSessionKeys.delete(normalizedKey);
pullRequestSummaries.delete(normalizedKey);
}
publish({ ...state });
};
@@ -1314,7 +1328,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
row: base.row ? result?.sessions.find((row) => row.key === base.row?.key) : undefined,
};
if (reconciled.deletedKey) {
retireOpenPullRequest(reconciled.deletedKey);
retirePullRequestSummary(reconciled.deletedKey);
}
if (reconciled.applied && (reconciled.result !== state.result || reconciled.deletedKey)) {
publish({
@@ -1357,7 +1371,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
if (!confirmsSessionDeletion(response)) {
return { deleted: false };
}
retireOpenPullRequest(key);
retirePullRequestSummary(key);
publish({ ...state, deletedSessions: [{ key, agentId: options.agentId }] });
setModelOverride(key, undefined);
await refreshReplacement(options.agentId);
@@ -1406,7 +1420,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
}
if (deleted.length > 0 && isCurrentConnection(scope)) {
for (const key of deleted) {
retireOpenPullRequest(key);
retirePullRequestSummary(key);
}
publish({
...state,
@@ -1656,18 +1670,18 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
connectionClient = next.client;
connectionConnected = next.connected;
if (connectionChanged) {
const hadOpenPullRequests = openPullRequestSessionKeys.size > 0;
const hadPullRequestSummaries = pullRequestSummaries.size > 0;
connectionEpoch += 1;
invalidateGroupsLoad();
swarmActivity.clear();
inFlight = null;
queuedRefresh = null;
rollbackPendingModelPatches();
openPullRequestSessionKeys.clear();
openPullRequestEpochs.clear();
pullRequestSummaries.clear();
pullRequestEpochs.clear();
// A connected client replacement needs its own invalidation publish;
// disconnects publish the cleared state in the branch immediately below.
if (hadOpenPullRequests && next.connected && next.client) {
if (hadPullRequestSummaries && next.connected && next.client) {
publish({ ...state });
}
}
@@ -1743,7 +1757,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
return;
}
if (reconciled.deletedKey) {
retireOpenPullRequest(reconciled.deletedKey);
retirePullRequestSummary(reconciled.deletedKey);
// Preserve remote-deletion navigation before the canonical refresh
// clears transient event state.
publish({
@@ -1776,9 +1790,9 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
create,
patch,
setModelOverride,
hasOpenPullRequest,
captureOpenPullRequestEpoch,
setOpenPullRequest,
pullRequestSummary,
capturePullRequestEpoch,
setPullRequestSummary,
delete: remove,
deleteMany: removeMany,
reset,
@@ -1818,8 +1832,8 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
subscribedClient = null;
pendingModelPatches.clear();
swarmActivity.clear();
openPullRequestSessionKeys.clear();
openPullRequestEpochs.clear();
pullRequestSummaries.clear();
pullRequestEpochs.clear();
stopGateway();
stopEvents();
createdListeners.clear();
+17 -16
View File
@@ -49,24 +49,25 @@ describe("session pull-request state", () => {
const harness = createGatewayHarness({} as GatewayBrowserClient);
const sessions = createSessionCapability(harness.gateway);
const listener = vi.fn();
const summary = { numbers: [111532], state: "open" as const };
sessions.subscribe(listener);
sessions.setOpenPullRequest("agent:main:pr-session", true);
expect(sessions.hasOpenPullRequest("agent:main:pr-session")).toBe(true);
sessions.setPullRequestSummary("agent:main:pr-session", summary);
expect(sessions.pullRequestSummary("agent:main:pr-session")).toEqual(summary);
expect(listener).toHaveBeenCalledTimes(1);
sessions.setOpenPullRequest("agent:main:pr-session", true);
sessions.setPullRequestSummary("agent:main:pr-session", summary);
expect(listener).toHaveBeenCalledTimes(1);
const publicationsBeforeReplacement = listener.mock.calls.length;
harness.publish(true, {} as GatewayBrowserClient);
expect(sessions.hasOpenPullRequest("agent:main:pr-session")).toBe(false);
expect(sessions.pullRequestSummary("agent:main:pr-session")).toBeUndefined();
expect(listener.mock.calls.length).toBeGreaterThan(publicationsBeforeReplacement);
sessions.setOpenPullRequest("agent:main:pr-session", true);
sessions.setPullRequestSummary("agent:main:pr-session", summary);
const publicationsBeforeDisconnect = listener.mock.calls.length;
harness.publish(false);
expect(sessions.hasOpenPullRequest("agent:main:pr-session")).toBe(false);
expect(sessions.pullRequestSummary("agent:main:pr-session")).toBeUndefined();
expect(listener.mock.calls.length).toBeGreaterThan(publicationsBeforeDisconnect);
sessions.dispose();
@@ -77,13 +78,13 @@ describe("session pull-request state", () => {
createGatewayHarness({} as GatewayBrowserClient).gateway,
);
const key = "agent:main:shared-session";
const olderEpoch = sessions.captureOpenPullRequestEpoch(key);
const newerEpoch = sessions.captureOpenPullRequestEpoch(key);
const olderEpoch = sessions.capturePullRequestEpoch(key);
const newerEpoch = sessions.capturePullRequestEpoch(key);
sessions.setOpenPullRequest(key, true, newerEpoch);
sessions.setOpenPullRequest(key, false, olderEpoch);
sessions.setPullRequestSummary(key, { numbers: [111532], state: "draft" }, newerEpoch);
sessions.setPullRequestSummary(key, undefined, olderEpoch);
expect(sessions.hasOpenPullRequest(key)).toBe(true);
expect(sessions.pullRequestSummary(key)).toEqual({ numbers: [111532], state: "draft" });
sessions.dispose();
});
@@ -101,14 +102,14 @@ describe("session pull-request state", () => {
const sessions = createSessionCapability(
createGatewayHarness({ request } as unknown as GatewayBrowserClient).gateway,
);
const epoch = sessions.captureOpenPullRequestEpoch(key);
sessions.setOpenPullRequest(key, true, epoch);
const epoch = sessions.capturePullRequestEpoch(key);
sessions.setPullRequestSummary(key, { numbers: [111532], state: "open" }, epoch);
await expect(sessions.delete(key)).resolves.toEqual({ deleted: true });
expect(sessions.hasOpenPullRequest(key)).toBe(false);
expect(sessions.pullRequestSummary(key)).toBeUndefined();
sessions.setOpenPullRequest(key, true, epoch);
expect(sessions.hasOpenPullRequest(key)).toBe(false);
sessions.setPullRequestSummary(key, { numbers: [111532], state: "open" }, epoch);
expect(sessions.pullRequestSummary(key)).toBeUndefined();
sessions.dispose();
});
});
+79 -16
View File
@@ -104,23 +104,32 @@ describe("chat pane pull request refresh", () => {
const request = vi.fn().mockResolvedValue({
pullRequests: [
{
number: 111532,
number: 111772,
owner: "openclaw",
repo: "openclaw",
branch: "claude/pr-detection",
title: "Detect pull requests",
url: "https://github.com/openclaw/openclaw/pull/111532",
state: "open",
url: "https://github.com/openclaw/openclaw/pull/111772",
state: "draft",
},
{
number: 111751,
owner: "openclaw",
repo: "openclaw",
branch: "claude/pr-detection",
title: "Earlier pull request",
url: "https://github.com/openclaw/openclaw/pull/111751",
state: "closed",
},
],
rateLimited: false,
});
const client = { request } as unknown as GatewayBrowserClient;
const epoch = Symbol("pr-refresh");
const setOpenPullRequest = vi.fn();
const setPullRequestSummary = vi.fn();
const sessions = {
captureOpenPullRequestEpoch: vi.fn(() => epoch),
setOpenPullRequest,
capturePullRequestEpoch: vi.fn(() => epoch),
setPullRequestSummary,
} as unknown as SessionCapability;
const { pane } = createTestChatPane({ client, sessions });
pane.context.gateway.snapshot.hello = {
@@ -133,7 +142,57 @@ describe("chat pane pull request refresh", () => {
"controlUi.sessionPullRequests",
expect.objectContaining({ sessionKey: "agent:main:current", refresh: true }),
);
expect(setOpenPullRequest).toHaveBeenCalledWith("agent:main:current", true, epoch);
expect(setPullRequestSummary).toHaveBeenCalledWith(
"agent:main:current",
{ numbers: [111751, 111772], state: "draft" },
epoch,
);
});
it("retains the current PR when a live summary is truncated", async () => {
const current = {
number: 999,
owner: "openclaw",
repo: "openclaw",
branch: "claude/pr-detection",
title: "Current pull request",
url: "https://github.com/openclaw/openclaw/pull/999",
state: "draft" as const,
};
const older = Array.from({ length: 20 }, (_value, index) => ({
...current,
number: index + 1,
title: `Earlier pull request ${index + 1}`,
url: `https://github.com/openclaw/openclaw/pull/${index + 1}`,
state: "closed" as const,
}));
const request = vi.fn().mockResolvedValue({
pullRequests: [current, ...older],
rateLimited: false,
});
const epoch = Symbol("pr-refresh");
const setPullRequestSummary = vi.fn();
const { pane } = createTestChatPane({
client: { request } as unknown as GatewayBrowserClient,
sessions: {
capturePullRequestEpoch: vi.fn(() => epoch),
setPullRequestSummary,
} as unknown as SessionCapability,
});
pane.context.gateway.snapshot.hello = {
features: { methods: ["controlUi.sessionPullRequests"] },
} as never;
await pane.refreshSessionPullRequests();
expect(setPullRequestSummary).toHaveBeenCalledWith(
"agent:main:current",
{
numbers: [...Array.from({ length: 19 }, (_value, index) => index + 1), 999],
state: "draft",
},
epoch,
);
});
it("clears the pane snapshot when the Gateway source disconnects", () => {
@@ -161,12 +220,12 @@ describe("chat pane pull request refresh", () => {
it("preserves shared PR state for an empty rate-limited snapshot", async () => {
const request = vi.fn().mockResolvedValue({ pullRequests: [], rateLimited: true });
const setOpenPullRequest = vi.fn();
const setPullRequestSummary = vi.fn();
const { pane } = createTestChatPane({
client: { request } as unknown as GatewayBrowserClient,
sessions: {
captureOpenPullRequestEpoch: vi.fn(() => Symbol("pr-refresh")),
setOpenPullRequest,
capturePullRequestEpoch: vi.fn(() => Symbol("pr-refresh")),
setPullRequestSummary,
} as unknown as SessionCapability,
});
pane.context.gateway.snapshot.hello = {
@@ -175,10 +234,10 @@ describe("chat pane pull request refresh", () => {
await pane.refreshSessionPullRequests();
expect(setOpenPullRequest).not.toHaveBeenCalled();
expect(setPullRequestSummary).not.toHaveBeenCalled();
});
it("clears shared live PR state after the PR settles", async () => {
it("publishes merged PR state after the PR settles", async () => {
const request = vi.fn().mockResolvedValue({
pullRequests: [
{
@@ -194,12 +253,12 @@ describe("chat pane pull request refresh", () => {
rateLimited: false,
});
const epoch = Symbol("pr-refresh");
const setOpenPullRequest = vi.fn();
const setPullRequestSummary = vi.fn();
const { pane } = createTestChatPane({
client: { request } as unknown as GatewayBrowserClient,
sessions: {
captureOpenPullRequestEpoch: vi.fn(() => epoch),
setOpenPullRequest,
capturePullRequestEpoch: vi.fn(() => epoch),
setPullRequestSummary,
} as unknown as SessionCapability,
});
pane.context.gateway.snapshot.hello = {
@@ -208,7 +267,11 @@ describe("chat pane pull request refresh", () => {
await pane.refreshSessionPullRequests();
expect(setOpenPullRequest).toHaveBeenCalledWith("agent:main:current", false, epoch);
expect(setPullRequestSummary).toHaveBeenCalledWith(
"agent:main:current",
{ numbers: [111532], state: "merged" },
epoch,
);
});
});
+20 -4
View File
@@ -5,6 +5,7 @@ import { property, state as litState } from "lit/decorators.js";
import {
GATEWAY_SERVER_CAPS,
type SessionCatalogHost,
type SessionCatalogPullRequestSummary,
type SessionCatalogSession,
type SessionCatalogTranscriptItem,
type SessionDiscussionInfo,
@@ -354,6 +355,21 @@ const NEW_SESSION_LIST_LOADING_MESSAGE =
const NEW_SESSION_CREATE_FAILED_MESSAGE =
"New Chat could not create a new thread. Try again in a moment.";
function summarizeSessionPullRequests(
pullRequests: readonly ControlUiSessionPullRequest[],
): SessionCatalogPullRequestSummary | undefined {
const current = pullRequests[0];
if (!current) {
return undefined;
}
return {
numbers: [...new Set(pullRequests.map((pullRequest) => pullRequest.number))]
.slice(0, 20)
.toSorted((left, right) => left - right),
state: current.state,
};
}
function keyboardEventPathMatches(event: KeyboardEvent, selector: string): boolean {
return event
.composedPath()
@@ -631,7 +647,7 @@ class ChatPane extends OpenClawLightDomElement {
this.requestUpdate();
return;
}
const openPullRequestEpoch = scope.context.sessions.captureOpenPullRequestEpoch(sessionKey);
const pullRequestEpoch = scope.context.sessions.capturePullRequestEpoch(sessionKey);
try {
const result = await scope.client.request<ControlUiSessionPullRequests>(
"controlUi.sessionPullRequests",
@@ -650,10 +666,10 @@ class ChatPane extends OpenClawLightDomElement {
}
this.sessionPullRequests = result.pullRequests;
if (!result.rateLimited || result.pullRequests.length > 0) {
scope.context.sessions.setOpenPullRequest(
scope.context.sessions.setPullRequestSummary(
sessionKey,
result.pullRequests.some((item) => item.state === "open" || item.state === "draft"),
openPullRequestEpoch,
summarizeSessionPullRequests(result.pullRequests),
pullRequestEpoch,
);
}
this.sessionPullRequestsBranch = result.branch;
+5 -1
View File
@@ -5215,11 +5215,15 @@ td.data-table-key-col {
color: var(--muted);
}
.session-row-badge--pull-request,
.session-row-badge--pull-request[data-pull-request-state="open"],
.session-row-badge--cloud[data-placement-state="active"] {
color: var(--ok);
}
.session-row-badge--pull-request[data-pull-request-state="merged"] {
color: var(--accent);
}
.session-row-badge--approval,
.session-approval-badge {
display: inline-flex;
@@ -176,6 +176,7 @@ describe("AppSidebar session catalog pagination", () => {
threadId: "local-thread",
name: "Local plan",
status: "stored",
pullRequest: { numbers: [111751, 111772], state: "merged" },
archived: false,
canContinue: true,
canArchive: false,
@@ -223,6 +224,9 @@ describe("AppSidebar session catalog pagination", () => {
const remote = section?.querySelector('[data-session-catalog-host="node:build"]');
expect(local?.textContent).toContain("Gateway Mac");
expect(local?.textContent).toContain("Local plan");
expect(local?.querySelector(".session-row-badge--pull-request")?.getAttribute("title")).toBe(
"#111751, #111772 · Merged",
);
expect(local?.textContent).not.toContain("Remote review");
expect(remote?.textContent).toContain("Build Node");
expect(remote?.textContent).toContain("Remote review");
@@ -263,6 +267,7 @@ describe("AppSidebar session catalog pagination", () => {
threadId: "claude-thread",
name: "Claude session",
status: "stored",
pullRequest: { numbers: [107302], state: "draft" },
archived: false,
sessionKey: backingSessionKey,
canContinue: true,
@@ -296,6 +301,9 @@ describe("AppSidebar session catalog pagination", () => {
`[data-session-key="${backingSessionKey}"]`,
);
expect(linkedRow?.getAttribute("draggable")).toBe("true");
expect(
linkedRow?.querySelector(".session-row-badge--pull-request")?.getAttribute("title"),
).toBe("#107302 · Draft");
expect(linkedRow?.querySelector('[data-sidebar-session-pin="true"]')).not.toBeNull();
expect(linkedRow?.querySelector('[data-session-menu="true"]')).not.toBeNull();
linkedRow?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
@@ -4,7 +4,7 @@ import { createGateway, createSessionsHarness, mountSidebar } from "../app-sideb
import "../../components/app-sidebar.ts";
describe("AppSidebar pull request state", () => {
it("shows the green PR indicator for the matching session", async () => {
it("shows the PR summary for the matching session", async () => {
const key = "agent:main:pr-detection";
const gateway = createGateway({} as GatewayBrowserClient);
const sessions = createSessionsHarness("main", [key, "agent:main:other"]);
@@ -13,11 +13,11 @@ describe("AppSidebar pull request state", () => {
expect(row?.querySelector(".session-row-badge--pull-request")).toBeNull();
sessions.sessions.setOpenPullRequest(key, true);
sessions.sessions.setPullRequestSummary(key, { numbers: [111532], state: "draft" });
await sidebar.updateComplete;
const badge = row?.querySelector(".session-row-badge--pull-request");
expect(badge?.getAttribute("aria-label")).toBe("Open PR");
expect(badge?.getAttribute("aria-label")).toBe("#111532 · Draft");
expect(
sidebar.querySelector(
'[data-session-key="agent:main:other"] .session-row-badge--pull-request',
+7 -6
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, vi } from "vitest";
import type {
SessionCatalog,
SessionCatalogPullRequestSummary,
SessionsCatalogListResult,
} from "../../../packages/gateway-protocol/src/index.ts";
import type { GatewayBrowserClient } from "../api/gateway.ts";
@@ -165,7 +166,7 @@ export function createSessionsHarness(agentId: string, keys: string[]) {
let state = createSessionState(agentId, keys);
let canonicalListRevision = 1;
const listeners = new Set<(next: SessionState) => void>();
const openPullRequestSessionKeys = new Set<string>();
const pullRequestSummaries = new Map<string, SessionCatalogPullRequestSummary>();
const groupsPut = vi.fn(() => Promise.resolve());
const groupsRename = vi.fn(() => Promise.resolve<SessionGroupMutationResult>("completed"));
const groupsDelete = vi.fn(() => Promise.resolve<SessionGroupMutationResult>("completed"));
@@ -206,12 +207,12 @@ export function createSessionsHarness(agentId: string, keys: string[]) {
return () => listeners.delete(listener);
},
subscribeCreated: () => () => undefined,
hasOpenPullRequest: (key: string) => openPullRequestSessionKeys.has(key),
setOpenPullRequest(key: string, hasOpenPullRequest: boolean) {
if (hasOpenPullRequest) {
openPullRequestSessionKeys.add(key);
pullRequestSummary: (key: string) => pullRequestSummaries.get(key),
setPullRequestSummary(key: string, summary: SessionCatalogPullRequestSummary | undefined) {
if (summary) {
pullRequestSummaries.set(key, summary);
} else {
openPullRequestSessionKeys.delete(key);
pullRequestSummaries.delete(key);
}
for (const listener of listeners) {
listener(state);