diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 9affe3fcd1a8..26668504c52a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -4023,6 +4023,24 @@ public struct SessionCatalogDescriptor: Codable, Sendable { } } +public struct SessionCatalogPullRequestSummary: Codable, Sendable { + public let numbers: [Int] + public let state: AnyCodable + + public init( + numbers: [Int], + state: AnyCodable) + { + self.numbers = numbers + self.state = state + } + + private enum CodingKeys: String, CodingKey { + case numbers + case state + } +} + public struct SessionCatalogSession: Codable, Sendable { public let threadid: String public let name: String? @@ -4036,6 +4054,7 @@ public struct SessionCatalogSession: Codable, Sendable { public let cliversion: String? public let gitbranch: String? public let customgroup: String? + public let pullrequest: SessionCatalogPullRequestSummary? public let archived: Bool public let sessionkey: String? public let cancontinue: Bool @@ -4055,6 +4074,7 @@ public struct SessionCatalogSession: Codable, Sendable { cliversion: String? = nil, gitbranch: String? = nil, customgroup: String? = nil, + pullrequest: SessionCatalogPullRequestSummary? = nil, archived: Bool, sessionkey: String? = nil, cancontinue: Bool, @@ -4073,6 +4093,7 @@ public struct SessionCatalogSession: Codable, Sendable { self.cliversion = cliversion self.gitbranch = gitbranch self.customgroup = customgroup + self.pullrequest = pullrequest self.archived = archived self.sessionkey = sessionkey self.cancontinue = cancontinue @@ -4093,6 +4114,7 @@ public struct SessionCatalogSession: Codable, Sendable { case cliversion = "cliVersion" case gitbranch = "gitBranch" case customgroup = "customGroup" + case pullrequest = "pullRequest" case archived case sessionkey = "sessionKey" case cancontinue = "canContinue" diff --git a/extensions/anthropic/session-catalog-types.ts b/extensions/anthropic/session-catalog-types.ts index 0137a29ed33c..87cac35e1b46 100644 --- a/extensions/anthropic/session-catalog-types.ts +++ b/extensions/anthropic/session-catalog-types.ts @@ -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; }; diff --git a/extensions/anthropic/session-catalog.test.ts b/extensions/anthropic/session-catalog.test.ts index b9bf267ae12f..f5276290e971 100644 --- a/extensions/anthropic/session-catalog.test.ts +++ b/extensions/anthropic/session-catalog.test.ts @@ -854,6 +854,7 @@ describe("Claude session catalog", () => { status: "stored", source: "claude-cli", modelProvider: "anthropic", + pullRequest: { numbers: [1234], state: "open" }, archived: false, }, ], @@ -890,6 +891,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, }); @@ -1157,6 +1159,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: 1_000 }, (_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 + 982), 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"; diff --git a/extensions/anthropic/session-catalog.ts b/extensions/anthropic/session-catalog.ts index e88e59a7a108..5688a23d221a 100644 --- a/extensions/anthropic/session-catalog.ts +++ b/extensions/anthropic/session-catalog.ts @@ -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,95 @@ 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 visibleByNumber = new Map(); + const dismissed = new Set(); + 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); + visibleByNumber.delete(number); + continue; + } + if (!dismissed.has(number) && !visibleByNumber.has(number)) { + visibleByNumber.set(number, pullRequestState(entry.state)); + } + } + } + const currentNumber = pullRequestNumber(metadata.prNumber); + let currentState = currentNumber ? visibleByNumber.get(currentNumber) : undefined; + if (currentNumber && !dismissed.has(currentNumber)) { + currentState = pullRequestState(metadata.prState) ?? currentState; + // Reinsert the current PR at the tail so truncation always retains it. + visibleByNumber.delete(currentNumber); + visibleByNumber.set(currentNumber, currentState); + } + const visible = [...visibleByNumber].map(([number, state]) => ({ number, state })); + if (visible.length === 0) { + return undefined; + } + const state = currentState ?? 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 +687,7 @@ async function listClaudeSessions(homeDir = currentHomeDir()): Promise; export type SessionCatalogLocator = Static; export type SessionCatalogDescriptor = Static; +export type SessionCatalogPullRequestSummary = Static< + typeof SessionCatalogPullRequestSummarySchema +>; export type SessionCatalogSession = Static; export type SessionCatalogHost = Static; export type SessionCatalog = Static; diff --git a/src/plugin-sdk/session-catalog.ts b/src/plugin-sdk/session-catalog.ts index e6f6469db537..dfc5ca947efd 100644 --- a/src/plugin-sdk/session-catalog.ts +++ b/src/plugin-sdk/session-catalog.ts @@ -17,6 +17,7 @@ export type { SessionCatalogDescriptor, SessionCatalogHost, SessionCatalogLocator, + SessionCatalogPullRequestSummary, SessionCatalogSession, SessionCatalogTranscriptItem, SessionsCatalogArchiveParams, diff --git a/ui/src/components/app-sidebar-session-catalogs.ts b/ui/src/components/app-sidebar-session-catalogs.ts index 3f16e86ae363..a40de4725559 100644 --- a/ui/src/components/app-sidebar-session-catalogs.ts +++ b/ui/src/components/app-sidebar-session-catalogs.ts @@ -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( ${label} + ${renderSessionRowBadges({ + hasAutomation: false, + pullRequest: session.pullRequest, + })} diff --git a/ui/src/components/app-sidebar-session-list.ts b/ui/src/components/app-sidebar-session-list.ts index f1dac0f625db..a1ec0b0b6e43 100644 --- a/ui/src/components/app-sidebar-session-list.ts +++ b/ui/src/components/app-sidebar-session-list.ts @@ -158,6 +158,7 @@ export abstract class AppSidebarSessionListElement extends AppSidebarSessionNarr > ${renderSessionRowBadges({ ...session, + pullRequest: session.pullRequest ?? display?.pullRequest, hasApproval: sessionHasPendingApproval(this.approvalBadgeSnapshot(), session.key), })} ${pinnedState} diff --git a/ui/src/components/app-sidebar-session-narration.test.ts b/ui/src/components/app-sidebar-session-narration.test.ts index 60e6717177b9..cdf927afc002 100644 --- a/ui/src/components/app-sidebar-session-narration.test.ts +++ b/ui/src/components/app-sidebar-session-narration.test.ts @@ -22,7 +22,6 @@ function runningRow(key: string): SidebarRecentSession { pinned: false, cloudWorkerActive: false, hasAutomation: false, - hasOpenPullRequest: false, unread: false, attention: { kind: "none" }, startedAt: 1, diff --git a/ui/src/components/app-sidebar-session-navigation.ts b/ui/src/components/app-sidebar-session-navigation.ts index 15ad368b245c..c2441c418952 100644 --- a/ui/src/components/app-sidebar-session-navigation.ts +++ b/ui/src/components/app-sidebar-session-navigation.ts @@ -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, diff --git a/ui/src/components/app-sidebar-session-types.ts b/ui/src/components/app-sidebar-session-types.ts index 466d684a3621..346944cea21d 100644 --- a/ui/src/components/app-sidebar-session-types.ts +++ b/ui/src/components/app-sidebar-session-types.ts @@ -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; diff --git a/ui/src/components/session-row-badges.test.ts b/ui/src/components/session-row-badges.test.ts index 29e2e9302eff..468fa7b683b9 100644 --- a/ui/src/components/session-row-badges.test.ts +++ b/ui/src/components/session-row-badges.test.ts @@ -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", }), diff --git a/ui/src/components/session-row-badges.ts b/ui/src/components/session-row-badges.ts index 9d00b2488e23..e4136eb4d7ea 100644 --- a/ui/src/components/session-row-badges.ts +++ b/ui/src/components/session-row-badges.ts @@ -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; + } +} + +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}` : nothing} - ${params.hasOpenPullRequest + ${pullRequestLabel ? html`${icons.gitPullRequest}` : nothing} diff --git a/ui/src/lib/sessions/index.ts b/ui/src/lib/sessions/index.ts index fe119313d825..39d459e20c66 100644 --- a/ui/src/lib/sessions/index.ts +++ b/ui/src/lib/sessions/index.ts @@ -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; 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; deleteMany: (targets: readonly SessionDeleteTarget[]) => Promise; reset: (key: string, options?: SessionResetOptions) => Promise; @@ -733,8 +738,8 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil { token: symbol; previous: string | null | undefined } >(); const swarmActivity = new SwarmActivityTracker(); - const openPullRequestSessionKeys = new Set(); - const openPullRequestEpochs = new Map(); + const pullRequestSummaries = new Map(); + const pullRequestEpochs = new Map(); 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(); diff --git a/ui/src/lib/sessions/pull-request-state.test.ts b/ui/src/lib/sessions/pull-request-state.test.ts index 210275760124..bc584c4db4f6 100644 --- a/ui/src/lib/sessions/pull-request-state.test.ts +++ b/ui/src/lib/sessions/pull-request-state.test.ts @@ -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(); }); }); diff --git a/ui/src/pages/chat/chat-pane-pull-requests.test.ts b/ui/src/pages/chat/chat-pane-pull-requests.test.ts new file mode 100644 index 000000000000..067b12f811ed --- /dev/null +++ b/ui/src/pages/chat/chat-pane-pull-requests.test.ts @@ -0,0 +1,186 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { SessionCapability } from "../../lib/sessions/index.ts"; +import { createTestChatPane } from "./chat-pane.test-support.ts"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("chat pane pull request refresh", () => { + it("forwards an explicit refresh and publishes live PR state", async () => { + const request = vi.fn().mockResolvedValue({ + pullRequests: [ + { + number: 111772, + owner: "openclaw", + repo: "openclaw", + branch: "claude/pr-detection", + title: "Detect pull requests", + 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 setPullRequestSummary = vi.fn(); + const sessions = { + capturePullRequestEpoch: vi.fn(() => epoch), + setPullRequestSummary, + } as unknown as SessionCapability; + const { pane } = createTestChatPane({ client, sessions }); + pane.context.gateway.snapshot.hello = { + features: { methods: ["controlUi.sessionPullRequests"] }, + } as never; + + await pane.refreshSessionPullRequests({ refresh: true }); + + expect(request).toHaveBeenCalledWith( + "controlUi.sessionPullRequests", + expect.objectContaining({ sessionKey: "agent:main:current", refresh: true }), + ); + 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", () => { + const client = {} as GatewayBrowserClient; + const { pane } = createTestChatPane({ client, sessions: {} as SessionCapability }); + pane.sessionPullRequests = [ + { + number: 111532, + owner: "openclaw", + repo: "openclaw", + branch: "claude/pr-detection", + title: "Detect pull requests", + url: "https://github.com/openclaw/openclaw/pull/111532", + state: "open", + }, + ]; + + pane.applyGatewaySnapshot({ + ...pane.context.gateway.snapshot, + connected: false, + }); + + expect(pane.sessionPullRequests).toEqual([]); + }); + + it("preserves shared PR state for an empty rate-limited snapshot", async () => { + const request = vi.fn().mockResolvedValue({ pullRequests: [], rateLimited: true }); + const setPullRequestSummary = vi.fn(); + const { pane } = createTestChatPane({ + client: { request } as unknown as GatewayBrowserClient, + sessions: { + capturePullRequestEpoch: vi.fn(() => Symbol("pr-refresh")), + setPullRequestSummary, + } as unknown as SessionCapability, + }); + pane.context.gateway.snapshot.hello = { + features: { methods: ["controlUi.sessionPullRequests"] }, + } as never; + + await pane.refreshSessionPullRequests(); + + expect(setPullRequestSummary).not.toHaveBeenCalled(); + }); + + it("publishes merged PR state after the PR settles", async () => { + const request = vi.fn().mockResolvedValue({ + pullRequests: [ + { + number: 111532, + owner: "openclaw", + repo: "openclaw", + branch: "claude/pr-detection", + title: "Detect pull requests", + url: "https://github.com/openclaw/openclaw/pull/111532", + state: "merged", + }, + ], + 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: [111532], state: "merged" }, + epoch, + ); + }); +}); diff --git a/ui/src/pages/chat/chat-pane.test.ts b/ui/src/pages/chat/chat-pane.test.ts index 3d80f0f65c86..2625dd64440d 100644 --- a/ui/src/pages/chat/chat-pane.test.ts +++ b/ui/src/pages/chat/chat-pane.test.ts @@ -99,119 +99,6 @@ function nativeHistoryMessage(seq: number, text = `message ${seq}`) { }; } -describe("chat pane pull request refresh", () => { - it("forwards an explicit refresh and publishes live PR state", async () => { - const request = vi.fn().mockResolvedValue({ - pullRequests: [ - { - number: 111532, - owner: "openclaw", - repo: "openclaw", - branch: "claude/pr-detection", - title: "Detect pull requests", - url: "https://github.com/openclaw/openclaw/pull/111532", - state: "open", - }, - ], - rateLimited: false, - }); - const client = { request } as unknown as GatewayBrowserClient; - const epoch = Symbol("pr-refresh"); - const setOpenPullRequest = vi.fn(); - const sessions = { - captureOpenPullRequestEpoch: vi.fn(() => epoch), - setOpenPullRequest, - } as unknown as SessionCapability; - const { pane } = createTestChatPane({ client, sessions }); - pane.context.gateway.snapshot.hello = { - features: { methods: ["controlUi.sessionPullRequests"] }, - } as never; - - await pane.refreshSessionPullRequests({ refresh: true }); - - expect(request).toHaveBeenCalledWith( - "controlUi.sessionPullRequests", - expect.objectContaining({ sessionKey: "agent:main:current", refresh: true }), - ); - expect(setOpenPullRequest).toHaveBeenCalledWith("agent:main:current", true, epoch); - }); - - it("clears the pane snapshot when the Gateway source disconnects", () => { - const client = {} as GatewayBrowserClient; - const { pane } = createTestChatPane({ client, sessions: {} as SessionCapability }); - pane.sessionPullRequests = [ - { - number: 111532, - owner: "openclaw", - repo: "openclaw", - branch: "claude/pr-detection", - title: "Detect pull requests", - url: "https://github.com/openclaw/openclaw/pull/111532", - state: "open", - }, - ]; - - pane.applyGatewaySnapshot({ - ...pane.context.gateway.snapshot, - connected: false, - }); - - expect(pane.sessionPullRequests).toEqual([]); - }); - - 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 { pane } = createTestChatPane({ - client: { request } as unknown as GatewayBrowserClient, - sessions: { - captureOpenPullRequestEpoch: vi.fn(() => Symbol("pr-refresh")), - setOpenPullRequest, - } as unknown as SessionCapability, - }); - pane.context.gateway.snapshot.hello = { - features: { methods: ["controlUi.sessionPullRequests"] }, - } as never; - - await pane.refreshSessionPullRequests(); - - expect(setOpenPullRequest).not.toHaveBeenCalled(); - }); - - it("clears shared live PR state after the PR settles", async () => { - const request = vi.fn().mockResolvedValue({ - pullRequests: [ - { - number: 111532, - owner: "openclaw", - repo: "openclaw", - branch: "claude/pr-detection", - title: "Detect pull requests", - url: "https://github.com/openclaw/openclaw/pull/111532", - state: "merged", - }, - ], - rateLimited: false, - }); - const epoch = Symbol("pr-refresh"); - const setOpenPullRequest = vi.fn(); - const { pane } = createTestChatPane({ - client: { request } as unknown as GatewayBrowserClient, - sessions: { - captureOpenPullRequestEpoch: vi.fn(() => epoch), - setOpenPullRequest, - } as unknown as SessionCapability, - }); - pane.context.gateway.snapshot.hello = { - features: { methods: ["controlUi.sessionPullRequests"] }, - } as never; - - await pane.refreshSessionPullRequests(); - - expect(setOpenPullRequest).toHaveBeenCalledWith("agent:main:current", false, epoch); - }); -}); - describe("chat pane header state", () => { it("commits a trimmed label and clears with null", async () => { const patch = vi.fn(async () => ({})); diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index 6bc214b7b524..37a978246c9a 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -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, @@ -357,6 +358,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() @@ -634,7 +650,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( "controlUi.sessionPullRequests", @@ -653,10 +669,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; diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 421e322d0ae2..c247b2500702 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -5218,11 +5218,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; diff --git a/ui/src/test-helpers/app-sidebar-cases/catalog-live.ts b/ui/src/test-helpers/app-sidebar-cases/catalog-live.ts index 3a508aeea3b5..fbf9f43b06a6 100644 --- a/ui/src/test-helpers/app-sidebar-cases/catalog-live.ts +++ b/ui/src/test-helpers/app-sidebar-cases/catalog-live.ts @@ -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 })); diff --git a/ui/src/test-helpers/app-sidebar-cases/pull-request-state.ts b/ui/src/test-helpers/app-sidebar-cases/pull-request-state.ts index 4044b4d22d93..7b93795fd68d 100644 --- a/ui/src/test-helpers/app-sidebar-cases/pull-request-state.ts +++ b/ui/src/test-helpers/app-sidebar-cases/pull-request-state.ts @@ -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', diff --git a/ui/src/test-helpers/app-sidebar.ts b/ui/src/test-helpers/app-sidebar.ts index 2a94605964e4..a5991b1cd5a6 100644 --- a/ui/src/test-helpers/app-sidebar.ts +++ b/ui/src/test-helpers/app-sidebar.ts @@ -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(); + const pullRequestSummaries = new Map(); const groupsPut = vi.fn(() => Promise.resolve()); const groupsRename = vi.fn(() => Promise.resolve("completed")); const groupsDelete = vi.fn(() => Promise.resolve("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);