diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 4205705485fc..f386e35e800b 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -244,7 +244,7 @@ and [Linux](/platforms/linux) desktop apps, the ## Sidebar navigation -The sidebar organizes everything around the agent. The identity row at the top is the active agent; below it, the **Pages** section starts with **Home** — the agent's rolling main session, badged with its unread or running state — followed by the pinned destinations (**Automations** and **Plugins** by default). The customize control on the Pages header opens a menu with every other destination, including **Usage** and plugin-provided tabs, plus **Edit pinned items**; right-clicking the navigation area opens the pin editor directly. The session list below splits into zones: **Other** for the agent's ungrouped chat sessions (the main session stays behind Home; sessions it spawned appear here as top-level threads, and named threads show without a type prefix), **Groups** for group and room conversations, and **Coding** for sessions bound to a managed worktree or exec node (rows show a `repo ⎇ branch` line plus the node host), ACP-backed harness sessions, and the Codex/Claude CLI catalogs. The **Other** heading is omitted when it is the only section. Coding starts collapsed on first run and remembers your choice; its collapsed header keeps the true count and shows a running indicator while contained sessions work. Custom groups (the session `category`) and **Pinned** rows sit above Other, and assigning a session to a custom group always wins over the automatic zone classification. The global **Sessions** toolbar holds the filter and sort control (Created, Last updated, or People when the Gateway reports multiple server identities), Group by, a persisted **Status** filter for Active, Archived, or All, and the **+** that opens the New session page. People orders owner groups by name and keeps Created order within each group. On multi-user gateways the same menu adds an **Owners** filter: **All owners**, one specific person or agent, or **Involving me** — sessions you own plus sessions you have prompted, evaluated by the Gateway against the full participant history (see [Multi-user mode](/concepts/multi-user#finding-sessions-by-owner)). Archived rows stay inline, dimmed with an archive glyph; they do not contribute unread or attention state and stay outside lineage promotion. Opening a session moves the selection highlight without reordering rows. Parent sessions with recent child runs show a disclosure and child count; expand it to inspect nested child sessions, live or terminal status, and runtime without leaving the sidebar. Selecting a child opens its chat and automatically reveals its ancestor path. Child rows stay outside root grouping, pinning, dragging, multi-select, and pagination; collapsed zones do not consume the visible page budget. Sessions with new activity since they were last read show an unread dot, and opening one marks it read. Admitted work waiting for `agents.defaults.maxConcurrent` shows **Queued**, an hourglass, and **Waiting for a concurrency slot** until execution starts. A session holding composer text you typed but never sent shows a pencil badge until the draft is sent or cleared; the active session hides it because its composer is already in view. An agent can also publish a short expiring status line and optionally request attention with a curated amber icon; that declaration clears when you open the session, send the next message, clear it explicitly, or its TTL expires. Cloud-worker lifecycle states use a globe badge; local and reclaimed sessions omit a placement badge because local execution is the default. Each root session row has a context menu (kebab button or right-click) with Pin/Unpin, Mark as unread/read, Rename, Assign to me and Assign to… on multi-user gateways ([session ownership](/concepts/multi-user#assigning-an-owner)), Set icon, Fork, Copy session ID, Move to group (including New group and Remove from group), Archive or Unarchive, and Delete; touch layouts keep the direct pin and menu controls visible. Cmd/Ctrl-click opens a session in a new browser tab. Alt/Option-click toggles root rows into a multi-select and Shift-click extends it across the visible order; opening the menu on a selected row then offers batch actions (Mark N as unread/read, Move N to group, Archive N, Delete N) that apply to every selected session, with a single confirmation for batch delete. Drag a root session onto **Pinned** to pin it, or onto a custom group to move it. Custom group headers can be collapsed, expanded, or dragged to reorder them; group names, order, and New Session defaults live in the gateway (`sessions.groups.*`), so they follow you across browsers, while collapsed state stays in the browser profile. Each custom group header has a **+** that opens the normal New Session page and assigns the created session to that group. **New session defaults** in the group menu sets its working directory and Local or Worktree preference; the page prefills those values but leaves them editable. Leaving the directory empty uses the selected agent's workspace. The menu also has Rename group, New group, and Delete group; renaming or deleting a group updates every member session server-side, including archived ones, and deleting a group keeps its sessions and moves them back to Other. +The sidebar organizes everything around the agent. The identity row at the top is the active agent; below it, the **Pages** section starts with **Home** — the agent's rolling main session, badged with its unread or running state — followed by the pinned destinations (**Automations** and **Plugins** by default). The customize control on the Pages header opens a menu with every other destination, including **Usage** and plugin-provided tabs, plus **Edit pinned items**; right-clicking the navigation area opens the pin editor directly. The session list below splits into zones: **Other** for the agent's ungrouped chat sessions (the main session stays behind Home; sessions it spawned appear here as top-level threads, and named threads show without a type prefix), **Groups** for group and room conversations, and **Coding** for sessions bound to a managed worktree or exec node (rows show a `repo ⎇ branch` line plus the node host), ACP-backed harness sessions, and the Codex/Claude CLI catalogs. The **Other** heading is omitted when it is the only section. Coding starts collapsed on first run and remembers your choice; its collapsed header keeps the true count and shows a running indicator while contained sessions work. Custom groups (the session `category`) and **Pinned** rows sit above Other, and assigning a session to a custom group always wins over the automatic zone classification. The global **Sessions** toolbar holds the filter and sort control (Created, Last updated, or People when the Gateway reports multiple server identities), Group by, a persisted **Status** filter for Active, Archived, or All, and the **+** that opens the New session page. People orders owner groups by name and keeps Created order within each group. On multi-user gateways the same menu adds an **Owners** filter: **All owners**, one specific person or agent, or **Involving me** — sessions you own plus sessions you have prompted, evaluated by the Gateway against the full participant history (see [Multi-user mode](/concepts/multi-user#finding-sessions-by-owner)). Archived rows stay inline, dimmed with an archive glyph; they do not contribute unread or attention state and stay outside lineage promotion. Opening a session moves the selection highlight without reordering rows. Parent sessions with recent child runs show a disclosure and child count; expand it to inspect nested child sessions, live or terminal status, and runtime without leaving the sidebar. Selecting a child opens its chat and automatically reveals its ancestor path. Child rows stay outside root grouping, pinning, dragging, multi-select, and pagination; collapsed zones do not consume the visible page budget. Sessions with new activity since they were last read show an unread dot, and opening one marks it read. Admitted work that has not started shows **Queued** with an hourglass until execution starts. This state does not by itself mean every concurrency slot is occupied. A session holding composer text you typed but never sent shows a pencil badge until the draft is sent or cleared; the active session hides it because its composer is already in view. An agent can also publish a short expiring status line and optionally request attention with a curated amber icon; that declaration clears when you open the session, send the next message, clear it explicitly, or its TTL expires. Cloud-worker lifecycle states use a globe badge; local and reclaimed sessions omit a placement badge because local execution is the default. Each root session row has a context menu (kebab button or right-click) with Pin/Unpin, Mark as unread/read, Rename, Assign to me and Assign to… on multi-user gateways ([session ownership](/concepts/multi-user#assigning-an-owner)), Set icon, Fork, Copy session ID, Move to group (including New group and Remove from group), Archive or Unarchive, and Delete; touch layouts keep the direct pin and menu controls visible. Cmd/Ctrl-click opens a session in a new browser tab. Alt/Option-click toggles root rows into a multi-select and Shift-click extends it across the visible order; opening the menu on a selected row then offers batch actions (Mark N as unread/read, Move N to group, Archive N, Delete N) that apply to every selected session, with a single confirmation for batch delete. Drag a root session onto **Pinned** to pin it, or onto a custom group to move it. Custom group headers can be collapsed, expanded, or dragged to reorder them; group names, order, and New Session defaults live in the gateway (`sessions.groups.*`), so they follow you across browsers, while collapsed state stays in the browser profile. Each custom group header has a **+** that opens the normal New Session page and assigns the created session to that group. **New session defaults** in the group menu sets its working directory and Local or Worktree preference; the page prefills those values but leaves them editable. Leaving the directory empty uses the selected agent's workspace. The menu also has Rename group, New group, and Delete group; renaming or deleting a group updates every member session server-side, including archived ones, and deleting a group keeps its sessions and moves them back to Other. ### Session placement diff --git a/ui/src/components/session-row-subtitle.test.ts b/ui/src/components/session-row-subtitle.test.ts index ada1f3072ffb..1fb49228ae68 100644 --- a/ui/src/components/session-row-subtitle.test.ts +++ b/ui/src/components/session-row-subtitle.test.ts @@ -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", () => { diff --git a/ui/src/components/session-row-subtitle.ts b/ui/src/components/session-row-subtitle.ts index 9fefeab922da..cba1c1e1874c 100644 --- a/ui/src/components/session-row-subtitle.ts +++ b/ui/src/components/session-row-subtitle.ts @@ -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 }; } diff --git a/ui/src/e2e/session-management.queue.e2e.test.ts b/ui/src/e2e/session-management.queue.e2e.test.ts index 5349d2c6bdd1..016b210aa43e 100644 --- a/ui/src/e2e/session-management.queue.e2e.test.ts +++ b/ui/src/e2e/session-management.queue.e2e.test.ts @@ -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")); } } }); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index eac878432f7e..8829ccfbe101 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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", diff --git a/ui/src/pages/workboard/view-helpers.ts b/ui/src/pages/workboard/view-helpers.ts index 2ebf992f5abc..2e6d6929e40f 100644 --- a/ui/src/pages/workboard/view-helpers.ts +++ b/ui/src/pages/workboard/view-helpers.ts @@ -67,12 +67,12 @@ const eventLabelKeys: Record = { 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 { diff --git a/ui/src/pages/workboard/view.test.ts b/ui/src/pages/workboard/view.test.ts index 1f0ed36ea776..ab044530aab4 100644 --- a/ui/src/pages/workboard/view.test.ts +++ b/ui/src/pages/workboard/view.test.ts @@ -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", + "", ); }); diff --git a/ui/src/test-helpers/app-sidebar-cases/session-indicators.ts b/ui/src/test-helpers/app-sidebar-cases/session-indicators.ts index 58fd80710bf1..d364fdf42212 100644 --- a/ui/src/test-helpers/app-sidebar-cases/session-indicators.ts +++ b/ui/src/test-helpers/app-sidebar-cases/session-indicators.ts @@ -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";