mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
fix(ui): stop claiming queued sessions need a slot (#129081)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
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", () => {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user