From 9008f9b0e23f08ba13cb9df0341262c63572bbe0 Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:26:44 -0500 Subject: [PATCH] fix(ui): paginate ambiguous run candidates --- ui/src/e2e/activity-run-inspector.e2e.test.ts | 34 ++++++++-- ui/src/i18n/locales/en-activity.ts | 5 +- ui/src/pages/activity/activity-page.ts | 67 +++++++++++++++++-- ui/src/pages/activity/run-inspector-model.ts | 6 +- .../pages/activity/run-inspector-view.test.ts | 33 +++++++-- ui/src/pages/activity/run-inspector-view.ts | 38 +++++++++-- ui/src/pages/activity/run-inspector.css | 8 +++ 7 files changed, 168 insertions(+), 23 deletions(-) diff --git a/ui/src/e2e/activity-run-inspector.e2e.test.ts b/ui/src/e2e/activity-run-inspector.e2e.test.ts index 38927a02a6c9..109e5e1ad7f5 100644 --- a/ui/src/e2e/activity-run-inspector.e2e.test.ts +++ b/ui/src/e2e/activity-run-inspector.e2e.test.ts @@ -128,7 +128,11 @@ function unavailableResult(params: { }; } -function ambiguousResult(runId: string, executionId: string): AuditRunInspectResult { +function ambiguousResult( + runId: string, + executionId: string, + nextExecutionCursor?: string, +): AuditRunInspectResult { return { schemaVersion: 1, run: { runId, status: "known" }, @@ -148,6 +152,7 @@ function ambiguousResult(runId: string, executionId: string): AuditRunInspectRes }, decisions: [], coverage: { state: "unknown", missingEvidence: ["execution.selection"] }, + ...(nextExecutionCursor ? { nextExecutionCursor } : {}), }; } @@ -299,14 +304,22 @@ describeControlUiE2e("Control UI durable Activity run inspector", () => { reasonCode: "identity_context_corrupt", }), }, + { + match: { runId: "ambiguous", executionCursor: "50" }, + response: ambiguousResult("ambiguous", "execution-candidate-51"), + }, { match: { runId: "ambiguous" }, - response: ambiguousResult("ambiguous", "execution-candidate-1"), + response: ambiguousResult("ambiguous", "execution-candidate-1", "50"), }, { match: { executionId: "execution-candidate-1" }, response: presentResult("ambiguous", "execution-candidate-1"), }, + { + match: { executionId: "execution-candidate-51" }, + response: presentResult("ambiguous", "execution-candidate-51"), + }, ], }, }, @@ -331,11 +344,20 @@ describeControlUiE2e("Control UI durable Activity run inspector", () => { await page.goto(`${server.baseUrl}activity?view=run&run=ambiguous`); await page.getByRole("heading", { name: "Multiple executions match this run" }).waitFor(); await screenshot(page, "11-ambiguous.png"); - await page.getByRole("link", { name: "execution-candidate-1" }).click(); - await page.getByRole("heading", { name: "Identity and authority" }).waitFor(); - expect(new URL(page.url()).searchParams.get("execution")).toBe("execution-candidate-1"); + await page.getByRole("button", { name: "Load more executions" }).click(); + await page.getByRole("link", { name: "execution-candidate-51" }).waitFor(); expect((await gateway.getRequests("audit.run.inspect")).at(-1)?.params).toEqual({ - executionId: "execution-candidate-1", + runId: "ambiguous", + executionCursor: "50", + decisionLimit: 50, + executionLimit: 50, + }); + expect(await page.getByRole("button", { name: "Load more executions" }).count()).toBe(0); + await page.getByRole("link", { name: "execution-candidate-51" }).click(); + await page.getByRole("heading", { name: "Identity and authority" }).waitFor(); + expect(new URL(page.url()).searchParams.get("execution")).toBe("execution-candidate-51"); + expect((await gateway.getRequests("audit.run.inspect")).at(-1)?.params).toEqual({ + executionId: "execution-candidate-51", decisionLimit: 50, }); await screenshot(page, "12-exact-selection.png"); diff --git a/ui/src/i18n/locales/en-activity.ts b/ui/src/i18n/locales/en-activity.ts index b3a0390cbe13..7c211d33791f 100644 --- a/ui/src/i18n/locales/en-activity.ts +++ b/ui/src/i18n/locales/en-activity.ts @@ -173,7 +173,10 @@ const enActivity = { listLabel: "Matching executions", recorded: "Recorded {date}", executionReference: "Inspect execution", - more: "More matching executions exist beyond this bounded page. Use the audit CLI to continue discovery and select one exact execution.", + more: "More matching executions exist beyond this bounded page.", + loadMore: "Load more executions", + loadingMore: "Loading executions…", + loadMoreError: "More executions could not be loaded. Try again.", }, panels: { empty: { diff --git a/ui/src/pages/activity/activity-page.ts b/ui/src/pages/activity/activity-page.ts index f1e73b4676ec..209ce9ce32ce 100644 --- a/ui/src/pages/activity/activity-page.ts +++ b/ui/src/pages/activity/activity-page.ts @@ -209,13 +209,16 @@ class ActivityPage extends OpenClawLightDomElement { gateway: ApplicationContext["gateway"], client: GatewayBrowserClient, selector: RunInspectorSelector, + previousResult?: AuditRunInspectResult, ) { this.cancelInspectorRequest(); const epoch = this.inspectorEpoch; const abort = new AbortController(); this.inspectorAbort = abort; this.inspectorClient = client; - this.runInspector = { status: "loading", waitingForGateway: false }; + this.runInspector = previousResult + ? { status: "ready", result: previousResult, executionPageStatus: "loading" } + : { status: "loading", waitingForGateway: false }; const requestSelectorKey = selectorKey(selector); const isCurrent = () => this.inspectorEpoch === epoch && @@ -227,13 +230,42 @@ class ActivityPage extends OpenClawLightDomElement { try { const params = selector.kind === "run" - ? { runId: selector.id, decisionLimit: 50, executionLimit: 50 } + ? { + runId: selector.id, + decisionLimit: 50, + executionLimit: 50, + ...(previousResult?.nextExecutionCursor + ? { executionCursor: previousResult.nextExecutionCursor } + : {}), + } : { executionId: selector.id, decisionLimit: 50 }; const result = await client.request("audit.run.inspect", params, { signal: abort.signal, }); if (isCurrent()) { - this.runInspector = { status: "ready", result }; + if ( + previousResult?.identity.state === "ambiguous" && + result.identity.state === "ambiguous" + ) { + const candidates = new Map( + previousResult.identity.candidates.map((candidate) => [ + candidate.executionId, + candidate, + ]), + ); + for (const candidate of result.identity.candidates) { + candidates.set(candidate.executionId, candidate); + } + this.runInspector = { + status: "ready", + result: { + ...result, + identity: { ...result.identity, candidates: [...candidates.values()] }, + }, + }; + } else { + this.runInspector = { status: "ready", result }; + } } } catch (error) { if (!isCurrent() || abort.signal.aborted) { @@ -243,7 +275,9 @@ class ActivityPage extends OpenClawLightDomElement { ? { status: "unauthorized" } : this.isUnknownInspectMethod(error) ? { status: "unsupported" } - : { status: "error" }; + : previousResult + ? { status: "ready", result: previousResult, executionPageStatus: "error" } + : { status: "error" }; } finally { if (this.inspectorAbort === abort) { this.inspectorAbort = null; @@ -251,6 +285,30 @@ class ActivityPage extends OpenClawLightDomElement { } } + private loadMoreExecutions() { + const route = this.routeData; + const snapshot = this.context.gateway.snapshot; + const inspectorState = this.runInspector; + if ( + route?.mode !== "run" || + route.selector?.kind !== "run" || + snapshot.phase !== "connected" || + !snapshot.client || + inspectorState.status !== "ready" || + inspectorState.executionPageStatus === "loading" || + inspectorState.result.identity.state !== "ambiguous" || + !inspectorState.result.nextExecutionCursor + ) { + return; + } + void this.loadRunInspector( + this.context.gateway, + snapshot.client, + route.selector, + inspectorState.result, + ); + } + private selectMode(mode: "live" | "run") { if (mode === "live") { this.context.navigate("activity", { search: "" }); @@ -397,6 +455,7 @@ class ActivityPage extends OpenClawLightDomElement { ? renderRunInspector({ basePath: this.context.basePath, state: this.runInspector, + onLoadMoreExecutions: () => this.loadMoreExecutions(), onRetry: () => this.syncRunInspector(this.context.gateway, this.context.gateway.snapshot, true), }) diff --git a/ui/src/pages/activity/run-inspector-model.ts b/ui/src/pages/activity/run-inspector-model.ts index 3eb1661ba1a2..ab8ef3b8c678 100644 --- a/ui/src/pages/activity/run-inspector-model.ts +++ b/ui/src/pages/activity/run-inspector-model.ts @@ -29,7 +29,11 @@ export type RunInspectorState = | { status: "unauthorized" } | { status: "unsupported" } | { status: "error" } - | { status: "ready"; result: AuditRunInspectResult }; + | { + status: "ready"; + result: AuditRunInspectResult; + executionPageStatus?: "loading" | "error"; + }; type RunInspectorDiagnosticKind = | "present" diff --git a/ui/src/pages/activity/run-inspector-view.test.ts b/ui/src/pages/activity/run-inspector-view.test.ts index 9924bd6d1edf..12770593c135 100644 --- a/ui/src/pages/activity/run-inspector-view.test.ts +++ b/ui/src/pages/activity/run-inspector-view.test.ts @@ -108,10 +108,18 @@ function unavailableResult( }; } -function renderState(state: RunInspectorState) { +function renderState(state: RunInspectorState, onLoadMoreExecutions = vi.fn()) { const container = document.createElement("div"); document.body.append(container); - render(renderRunInspector({ basePath: "/operator", state, onRetry: vi.fn() }), container); + render( + renderRunInspector({ + basePath: "/operator", + state, + onLoadMoreExecutions, + onRetry: vi.fn(), + }), + container, + ); return container; } @@ -208,14 +216,29 @@ describe("renderRunInspector", () => { }, decisions: [], coverage: { state: "unknown", missingEvidence: ["execution.selection"] }, + nextExecutionCursor: "opaque-cursor", }; - const link = renderState({ status: "ready", result }).querySelector( - 'a[href*="execution="]', - ); + const onLoadMoreExecutions = vi.fn(); + const container = renderState({ status: "ready", result }, onLoadMoreExecutions); + const link = container.querySelector('a[href*="execution="]'); expect(link?.textContent).toContain("execution:a/b"); expect(link?.getAttribute("href")).toBe( "/operator/activity?view=run&execution=execution%3Aa%2Fb", ); + const loadMore = [...container.querySelectorAll("button")].find((button) => + button.textContent?.includes("Load more executions"), + ); + loadMore?.click(); + expect(onLoadMoreExecutions).toHaveBeenCalledOnce(); + + const loading = renderState({ status: "ready", result, executionPageStatus: "loading" }); + expect(loading.querySelector("button")?.disabled).toBe(true); + expect(loading.textContent).toContain("Loading executions…"); + + const failed = renderState({ status: "ready", result, executionPageStatus: "error" }); + expect(failed.querySelector('[role="alert"]')?.textContent).toContain( + "More executions could not be loaded", + ); }); }); diff --git a/ui/src/pages/activity/run-inspector-view.ts b/ui/src/pages/activity/run-inspector-view.ts index 1ee2843e18cf..25e21a312007 100644 --- a/ui/src/pages/activity/run-inspector-view.ts +++ b/ui/src/pages/activity/run-inspector-view.ts @@ -17,6 +17,7 @@ type EvidenceState = "present" | "absent" | "unknown" | "unsupported"; type RunInspectorProps = { basePath: string; state: RunInspectorState; + onLoadMoreExecutions: () => void; onRetry: () => void; }; @@ -435,7 +436,12 @@ function diagnosticCopy(result: AuditRunInspectResult) { return unreachable; } -function renderUnavailableResult(result: AuditRunInspectResult, basePath: string) { +function renderUnavailableResult( + result: AuditRunInspectResult, + basePath: string, + executionPageStatus: "loading" | "error" | undefined, + onLoadMoreExecutions: () => void, +) { const copy = diagnosticCopy(result); if (!copy || result.identity.state === "present") { return nothing; @@ -472,8 +478,23 @@ function renderUnavailableResult(result: AuditRunInspectResult, basePath: string )} ${result.nextExecutionCursor - ? html`
- ${t("activity.runInspector.candidates.more")} + ? html`
+ ${t("activity.runInspector.candidates.more")} + + ${executionPageStatus === "error" + ? html` + ${t("activity.runInspector.candidates.loadMoreError")} + ` + : nothing}
` : nothing} ` @@ -482,7 +503,12 @@ function renderUnavailableResult(result: AuditRunInspectResult, basePath: string `; } -function renderReady(result: AuditRunInspectResult, basePath: string) { +function renderReady( + state: Extract, + basePath: string, + onLoadMoreExecutions: () => void, +) { + const result = state.result; const currentCoverageLabel = coverageLabel(result.coverage.state); return html`
${renderMissingEvidence(result.coverage.missingEvidence)} ${renderDecisions(result)} ` - : renderUnavailableResult(result, basePath)} + : renderUnavailableResult(result, basePath, state.executionPageStatus, onLoadMoreExecutions)} `; } @@ -582,7 +608,7 @@ export function renderRunInspector(props: RunInspectorProps) { ); break; case "ready": - content = renderReady(state.result, props.basePath); + content = renderReady(state, props.basePath, props.onLoadMoreExecutions); break; } diff --git a/ui/src/pages/activity/run-inspector.css b/ui/src/pages/activity/run-inspector.css index f8825554f874..93b58d3a4f0b 100644 --- a/ui/src/pages/activity/run-inspector.css +++ b/ui/src/pages/activity/run-inspector.css @@ -46,6 +46,14 @@ line-height: 1.5; } +.run-inspector__pagination { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + align-items: center; + justify-content: space-between; +} + .run-inspector__coverage { display: flex; flex-wrap: wrap;