fix(ui): stop claiming queued sessions need a slot (#129081)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
RoboClaw
2026-08-25 00:19:58 -07:00
committed by GitHub
parent 49c5bb21c3
commit 49c7fc5bae
8 changed files with 28 additions and 67 deletions
File diff suppressed because one or more lines are too long
@@ -40,7 +40,7 @@ describe("resolveSidebarSessionSubtitle", () => {
).toEqual({ subtitle: "~/Projects/openclaw", narration: undefined });
});
it("explains when admitted work is waiting for a concurrency slot", () => {
it("does not replace the work subtitle for queued sessions", () => {
expect(
resolveSidebarSessionSubtitle({
session: { ...workSession(), hasActiveRun: true, status: "queued" },
@@ -50,7 +50,7 @@ describe("resolveSidebarSessionSubtitle", () => {
showPreview: true,
narrationLine: undefined,
}),
).toEqual({ subtitle: "Waiting for a concurrency slot", narration: undefined });
).toEqual({ subtitle: "~/Projects/openclaw", narration: undefined });
});
it.each(["stuck", "waiting-on-user"] as const)(
@@ -108,8 +108,7 @@ describe("resolveSidebarSessionSubtitle", () => {
).toEqual({ subtitle: undefined, narration: undefined });
});
it("keeps the concurrency-slot explanation when previews are hidden", () => {
// Without it a queued run reads as an idle session: a visible non-outcome.
it("does not force a queued subtitle when previews are hidden", () => {
expect(
resolveSidebarSessionSubtitle({
session: { ...workSession(), hasActiveRun: true, status: "queued" },
@@ -119,7 +118,7 @@ describe("resolveSidebarSessionSubtitle", () => {
showPreview: false,
narrationLine: undefined,
}),
).toEqual({ subtitle: "Waiting for a concurrency slot", narration: undefined });
).toEqual({ subtitle: undefined, narration: undefined });
});
it("uses attention, agent status, observer, narration, then work subtitle precedence", () => {
+8 -11
View File
@@ -1,7 +1,6 @@
import { html, nothing } from "lit";
import { keyed } from "lit/directives/keyed.js";
import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js";
import { t } from "../i18n/index.ts";
import { isCriticalObserverHealth, pickFreshestObserverDigest } from "../lib/observer-digest.ts";
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
import { sessionAttentionSubtitle } from "./session-attention-presentation.ts";
@@ -27,8 +26,6 @@ export function resolveSidebarSessionSubtitle(params: {
const { session } = params;
const attention = sessionAttentionSubtitle(session.attention);
const running = session.hasActiveRun;
const queued =
running && session.status === "queued" ? t("sessionsView.waitingForConcurrency") : undefined;
const activeRunIds = session.activeRunIds ?? [];
const digestMatchesActiveRun = (
digest: typeof params.observerDigest,
@@ -49,15 +46,15 @@ export function resolveSidebarSessionSubtitle(params: {
(session.lastReadAt ?? 0) < projectedDigest.updatedAt,
);
const observer = running || finalDigestUnread ? projectedDigest?.headline : undefined;
// Preview off hides ambient text only. Attention, the queued explanation, and a
// critical observer headline survive the toggle: an error, a pending approval, a run
// sitting on a slot, and the stuck / waiting-on-user health states are all things the
// operator must act on. isCriticalObserverHealth owns that classification and the chat
// pane announces the same two states, so a display preference must not silence them
// here — that would turn a visible non-outcome into a silent one.
// Preview off hides ambient text only. Attention and a critical observer headline
// survive the toggle: errors, pending approvals, and the stuck / waiting-on-user
// health states are things the operator must act on. isCriticalObserverHealth owns
// that classification and the chat pane announces the same two states, so a display
// preference must not silence them here — that would turn a visible non-outcome into
// a silent one.
if (!params.showPreview) {
const critical = isCriticalObserverHealth(projectedDigest?.health) ? observer : undefined;
return { subtitle: attention ?? queued ?? critical, narration: undefined };
return { subtitle: attention ?? critical, narration: undefined };
}
// Agent-declared status (sessions tool) outranks live narration: it is an
// explicit message to the user, not ambient activity.
@@ -74,7 +71,7 @@ export function resolveSidebarSessionSubtitle(params: {
const finalReply =
!running && !params.hasDisplay ? session.lastMessagePreview?.trim() || undefined : undefined;
const subtitle = running
? (attention ?? agentStatus ?? queued ?? observer ?? narration ?? workSubtitle)
? (attention ?? agentStatus ?? observer ?? narration ?? workSubtitle)
: (attention ?? agentStatus ?? observer ?? finalReply ?? workSubtitle);
return { subtitle, narration };
}
@@ -14,7 +14,7 @@ import {
const suite = createSessionManagementE2eSuite();
suite.define(() => {
it("shows admitted sessions waiting for a concurrency slot", async () => {
it("removes the concurrency subtitle while preserving queued state", async () => {
const mainKey = "agent:main:main";
const queuedKey = "agent:main:queued-repair";
const context = await suite.browser.newContext({
@@ -45,12 +45,15 @@ suite.define(() => {
await page.goto(controlUiSessionUrl(suite.server.baseUrl, mainKey));
const row = page.locator(`[data-session-key="${queuedKey}"]`);
await row.waitFor({ state: "visible", timeout: 10_000 });
await row.getByText("Waiting for a concurrency slot", { exact: true }).waitFor();
expect(await row.getByText("Waiting for a concurrency slot", { exact: true }).count()).toBe(
0,
);
expect(await row.locator(".sidebar-recent-session__subtitle").count()).toBe(0);
const queuedIcon = row.locator(".sidebar-child-session__status--queued");
await queuedIcon.waitFor();
expect(await queuedIcon.getAttribute("aria-label")).toBe("Queued");
expect(await row.getByRole("img", { name: "Active run" }).count()).toBe(0);
await captureUiProof(page, "queued-concurrency-session.png");
await captureUiProof(page, "queued-session-without-subtitle.png");
const listRequests = (await gateway.getRequests("sessions.list")).length;
await gateway.setMethodResponse(
@@ -73,15 +76,13 @@ suite.define(() => {
.poll(async () => (await gateway.getRequests("sessions.list")).length)
.toBeGreaterThan(listRequests);
await row.locator(".session-run-spinner").waitFor();
expect(await row.getByText("Waiting for a concurrency slot", { exact: true }).count()).toBe(
0,
);
expect(await row.locator(".sidebar-recent-session__subtitle").count()).toBe(0);
expect(await queuedIcon.count()).toBe(0);
await captureUiProof(page, "queued-concurrency-running.png");
await captureUiProof(page, "queued-session-running.png");
} finally {
await context.close();
if (proofVideo) {
await proofVideo.saveAs(path.join(uiProofArtifactDir, "queued-concurrency-session.webm"));
await proofVideo.saveAs(path.join(uiProofArtifactDir, "queued-session.webm"));
}
}
});
-1
View File
@@ -1127,7 +1127,6 @@ export const en: TranslationMap = {
statusTimeout: "Timed out",
waitingForAnswer: "Waiting for your answer",
waitingForApproval: "Waiting for approval",
waitingForConcurrency: "Waiting for a concurrency slot",
runFailedReason: "Run failed: {reason}",
runErrorTimedOut: "Timed out",
runErrorUnknown: "Unknown error",
+4 -4
View File
@@ -67,12 +67,12 @@ const eventLabelKeys: Record<WorkboardEvent["kind"], string> = {
type LifecycleCopy = readonly [
labelKey: string,
detailKey: string,
detailKey: string | undefined,
tone: "blocked" | "done" | "idle" | "live",
];
const lifecycleCopy = {
queued: ["sessionsView.statusQueued", "sessionsView.waitingForConcurrency", "idle"],
queued: ["sessionsView.statusQueued", undefined, "idle"],
running: ["workboard.lifecycleRunning", "workboard.lifecycleRunningDetail", "live"],
succeeded: ["workboard.lifecycleDone", "workboard.lifecycleDoneDetail", "done"],
failed: ["workboard.lifecycleNeedsReview", "workboard.lifecycleNeedsReviewDetail", "blocked"],
@@ -226,11 +226,11 @@ export function engineBlockedByRuntime(
export function formatLifecycle(lifecycle: WorkboardLifecycle): {
label: string;
detail: string;
detail: string | undefined;
tone: "blocked" | "done" | "idle" | "live";
} {
const [labelKey, detailKey, tone] = lifecycleCopy[lifecycle.state];
return { label: t(labelKey), detail: t(detailKey), tone };
return { label: t(labelKey), detail: detailKey === undefined ? undefined : t(detailKey), tone };
}
export function taskDetail(task: WorkboardTaskSummary): string {
+2 -2
View File
@@ -1434,7 +1434,7 @@ describe("renderWorkboard", () => {
expect(container.textContent).toContain("Ready for operator review.");
});
it("renders a queued linked session without running copy", () => {
it("renders a queued linked session without a concurrency claim", () => {
const { state, container, renderView } = createWorkboardView({
sessions: [
{
@@ -1456,7 +1456,7 @@ describe("renderWorkboard", () => {
expect(container.querySelector(".workboard-lifecycle")?.textContent?.trim()).toBe("Queued");
expect(container.querySelector(".workboard-card__lifecycle-detail")?.textContent?.trim()).toBe(
"Waiting for a concurrency slot",
"",
);
});
@@ -282,41 +282,6 @@ describe("AppSidebar session indicators", () => {
expect(home?.querySelector(".nav-item__state .session-row-badge--draft")).not.toBeNull();
});
it("shows when an admitted session is queued for a concurrency slot", async () => {
const sessionKey = "agent:main:thread:queued";
const gateway = createGatewayHarness({} as GatewayBrowserClient).gateway;
const harness = createSessionsHarness("main", ["agent:main:main", sessionKey]);
const { sidebar } = await mountSidebar(gateway, harness.sessions);
sidebar.connected = true;
harness.publishList({
result: {
ts: 2,
path: "",
count: 2,
defaults: { modelProvider: null, model: null, contextTokens: null },
sessions: [
{ key: "agent:main:main", kind: "direct", updatedAt: 4 },
{
key: sessionKey,
kind: "direct",
label: "Queued repair",
updatedAt: 5,
hasActiveRun: true,
status: "queued",
},
],
},
agentId: "main",
});
await sidebar.updateComplete;
const row = sidebar.querySelector(`[data-session-key="${sessionKey}"]`);
expect(row?.textContent).toContain("Waiting for a concurrency slot");
const queued = row?.querySelector(".sidebar-child-session__status--queued");
expect(queued?.getAttribute("aria-label")).toBe("Queued");
expect(row?.querySelector(".session-run-spinner")).toBeNull();
});
it("preserves child PR indicators and leads a pinned child like any other", async () => {
const parentKey = "agent:main:parent";
const pinnedKey = "agent:main:pinned-child";