mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
feat(ui): show PR status on catalog threads
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user