fix(ui): paginate ambiguous run candidates

This commit is contained in:
joshavant
2026-08-11 06:26:44 -05:00
committed by Josh Avant
parent 5f7ea6b548
commit 9008f9b0e2
7 changed files with 168 additions and 23 deletions
+28 -6
View File
@@ -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");
+4 -1
View File
@@ -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: {
+63 -4
View File
@@ -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<AuditRunInspectResult>("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),
})
+5 -1
View File
@@ -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"
@@ -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<HTMLAnchorElement>(
'a[href*="execution="]',
);
const onLoadMoreExecutions = vi.fn();
const container = renderState({ status: "ready", result }, onLoadMoreExecutions);
const link = container.querySelector<HTMLAnchorElement>('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",
);
});
});
+32 -6
View File
@@ -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
)}
</ol>
${result.nextExecutionCursor
? html`<div class="run-inspector__pagination" role="note">
${t("activity.runInspector.candidates.more")}
? html`<div class="run-inspector__pagination">
<span>${t("activity.runInspector.candidates.more")}</span>
<button
type="button"
class="btn"
?disabled=${executionPageStatus === "loading"}
@click=${onLoadMoreExecutions}
>
${executionPageStatus === "loading"
? t("activity.runInspector.candidates.loadingMore")
: t("activity.runInspector.candidates.loadMore")}
</button>
${executionPageStatus === "error"
? html`<span role="alert">
${t("activity.runInspector.candidates.loadMoreError")}
</span>`
: nothing}
</div>`
: nothing}
`
@@ -482,7 +503,12 @@ function renderUnavailableResult(result: AuditRunInspectResult, basePath: string
`;
}
function renderReady(result: AuditRunInspectResult, basePath: string) {
function renderReady(
state: Extract<RunInspectorState, { status: "ready" }>,
basePath: string,
onLoadMoreExecutions: () => void,
) {
const result = state.result;
const currentCoverageLabel = coverageLabel(result.coverage.state);
return html`
<div
@@ -509,7 +535,7 @@ function renderReady(result: AuditRunInspectResult, basePath: string) {
</section>
${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;
}
+8
View File
@@ -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;