diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 3ff2be80930f..d8c7dd279143 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -226,7 +226,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: **Threads** for the agent's 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. 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 Threads, and assigning a session to a custom group always wins over the automatic zone classification. The Threads header holds the 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, 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 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 Threads. +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, 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 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 @@ -238,7 +238,7 @@ Choose **Set icon** from a single session's context menu to give its sidebar row ## New session page -The **+** in the sidebar session-list header opens a full-page draft at `/new`: nothing is created until you send the first message. A unified **Place** picker chooses the working folder and, for admin operators, the execution destination: **Gateway · local**, a paired node that exposes `system.run`, or an available cloud profile. The folder defaults to the agent workspace. Connections with `operator.write` can browse, restore recent folders, and start sessions anywhere inside a configured agent workspace; another absolute Gateway path requires `operator.admin` but can run directly without being a Git checkout. When the selected Gateway folder is a Git checkout, the same picker offers optional **Worktree** isolation with a base-branch picker backed by `worktrees.branches` (no fetch) and an optional worktree name (the branch becomes `openclaw/`). Cloud workers require that managed-worktree path; paired nodes and their filesystem browsing remain admin-only. The composer footer chooses the new session's model and reasoning level. +The **+** in the sidebar's **Sessions** toolbar opens a full-page draft at `/new`: nothing is created until you send the first message. A unified **Place** picker chooses the working folder and, for admin operators, the execution destination: **Gateway · local**, a paired node that exposes `system.run`, or an available cloud profile. The folder defaults to the agent workspace. Connections with `operator.write` can browse, restore recent folders, and start sessions anywhere inside a configured agent workspace; another absolute Gateway path requires `operator.admin` but can run directly without being a Git checkout. When the selected Gateway folder is a Git checkout, the same picker offers optional **Worktree** isolation with a base-branch picker backed by `worktrees.branches` (no fetch) and an optional worktree name (the branch becomes `openclaw/`). Cloud workers require that managed-worktree path; paired nodes and their filesystem browsing remain admin-only. The composer footer chooses the new session's model and reasoning level. Unsent text and staged attachments can be recovered only in the same browser profile and Gateway credential scope; they are never stored on the Gateway or synced across devices. The browser keeps the 20 most recently edited draft scopes per Gateway credential scope for up to seven days, with at most 25 MiB of attachment data per draft, but it can evict browser storage sooner. A successful send or New Session creation, explicit attachment removal, or confirmed session deletion retires the corresponding browser draft. If cleanup fails after deletion, clear site data for the Control UI origin to remove it. Clearing site data also removes every other browser draft. If a draft's attachments exceed the cap, the current tab keeps them and shows the existing storage warning, but only the text is restart-recoverable. OpenClaw **Incognito** drafts are never durable. In a private browser window, IndexedDB availability and lifetime are controlled by the browser and stored data is normally cleared when the private session ends. The **Incognito** toggle in the new-session page's top-right control rail retires that browser draft and creates a web-only thread whose session entry, transcript, and compaction state stay in memory until the Gateway restarts; OpenClaw also skips its automatic memory flush. The agent keeps its normal tools, so an explicit save request or tool-driven file write can still persist data. The model provider still processes messages, and content-free audit metadata is still recorded. Cloud starts persist their model and reasoning choices before dispatching the session to its worker. diff --git a/package.json b/package.json index 273a3d1d557a..a580ad538bd2 100644 --- a/package.json +++ b/package.json @@ -1990,6 +1990,7 @@ "ui:i18n:verify": "node --import tsx scripts/control-ui-i18n-verify.ts verify", "ui:proof:composer-mic-hover": "node --import tsx scripts/capture-composer-mic-hover-proof.mts", "ui:proof:model-picker": "node --import tsx scripts/capture-model-picker-proof.mts", + "ui:proof:session-toolbar": "node --import tsx scripts/capture-session-toolbar-proof.mts", "ui:proof:workboard": "node --import tsx scripts/capture-workboard-ui-proof.mts", "native:i18n:baseline": "node --import tsx scripts/native-app-i18n.ts baseline --write", "native:i18n:check": "node --import tsx scripts/native-app-i18n.ts check", diff --git a/scripts/capture-session-toolbar-proof.mts b/scripts/capture-session-toolbar-proof.mts new file mode 100644 index 000000000000..a7222670a97a --- /dev/null +++ b/scripts/capture-session-toolbar-proof.mts @@ -0,0 +1,309 @@ +#!/usr/bin/env node +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { chromium, type Page } from "playwright"; +import { + canRunPlaywrightChromium, + installMockGateway, + resolvePlaywrightChromiumExecutablePath, + startControlUiE2eServer, +} from "../ui/src/test-helpers/control-ui-e2e.ts"; + +type CaptureMode = "after" | "before"; + +function readOption(name: string): string | undefined { + const prefix = `--${name}=`; + const inline = process.argv.slice(2).find((arg) => arg.startsWith(prefix)); + if (inline) { + return inline.slice(prefix.length); + } + const index = process.argv.indexOf(`--${name}`); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +function readMode(): CaptureMode { + const value = readOption("mode") ?? "after"; + if (value !== "after" && value !== "before") { + throw new Error(`Expected --mode after|before, received ${value}`); + } + return value; +} + +function sessionRow( + key: string, + label: string, + updatedAt: number, + extra: Record = {}, +) { + return { + contextTokens: 200_000, + displayName: label, + hasActiveRun: false, + key, + kind: "direct", + label, + model: "gpt-5.6-luna", + modelProvider: "openai", + status: "done", + totalTokens: 0, + updatedAt, + ...extra, + }; +} + +function sessionsListResponse(sessions: unknown[]) { + return { + count: sessions.length, + defaults: { + contextTokens: 200_000, + model: "gpt-5.6-luna", + modelProvider: "openai", + }, + hasMore: false, + limitApplied: 50, + nextOffset: null, + offset: 0, + path: "", + sessions, + totalCount: sessions.length, + ts: Date.parse("2026-08-17T20:00:00.000Z"), + }; +} + +const baseTime = Date.parse("2026-08-17T20:00:00.000Z"); +const groupedSessions = [ + sessionRow("agent:main:main", "Main", baseTime), + sessionRow("agent:main:jesse-roadmap", "Roadmap review", baseTime - 60_000, { + category: "Jesse", + }), + sessionRow("agent:main:jesse-launch", "Launch checklist", baseTime - 120_000, { + category: "Jesse", + }), + sessionRow("agent:main:josh-design", "Design handoff", baseTime - 180_000, { + category: "Josh", + }), + sessionRow("agent:main:josh-feedback", "Customer feedback", baseTime - 240_000, { + category: "Josh", + }), + sessionRow("agent:main:weekly-planning", "Weekly planning", baseTime - 300_000), + sessionRow("agent:main:travel-notes", "Travel notes", baseTime - 360_000), + sessionRow("agent:main:reading-list", "Reading list", baseTime - 420_000), + sessionRow("agent:main:toolbar-cleanup", "Toolbar cleanup", baseTime - 480_000, { + worktree: { + branch: "feat/session-toolbar", + id: "wt-session-toolbar", + repoRoot: "/Users/demo/Projects/openclaw", + }, + }), + sessionRow("agent:main:filter-followup", "Filter menu follow-up", baseTime - 540_000, { + worktree: { + branch: "fix/filter-menu", + id: "wt-filter-menu", + repoRoot: "/Users/demo/Projects/openclaw", + }, + }), +]; + +const ungroupedSessions = [ + sessionRow("agent:main:main", "Main", baseTime), + sessionRow("agent:main:weekly-planning", "Weekly planning", baseTime - 60_000), + sessionRow("agent:main:travel-notes", "Travel notes", baseTime - 120_000), + sessionRow("agent:main:reading-list", "Reading list", baseTime - 180_000), +]; + +const mode = readMode(); +const outputDir = path.resolve( + readOption("output-dir") ?? ".artifacts/control-ui-e2e/session-toolbar-proof", +); +const executablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); +if (!canRunPlaywrightChromium(executablePath)) { + throw new Error(`Playwright Chromium is unavailable at ${executablePath}`); +} + +await mkdir(outputDir, { recursive: true }); +const server = await startControlUiE2eServer(undefined, { source: true }); +const browser = await chromium.launch({ executablePath }); +const captured: string[] = []; + +async function settle(page: Page): Promise { + await page.evaluate(async () => { + await document.fonts.ready; + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + }); +} + +async function capture(page: Page, name: string): Promise { + await settle(page); + const target = path.join(outputDir, name); + await page.screenshot({ + animations: "disabled", + clip: { x: 0, y: 0, width: 560, height: 900 }, + path: target, + }); + captured.push(target); +} + +async function openScenario(sessions: unknown[], groups: string[] = []) { + const context = await browser.newContext({ + colorScheme: "dark", + locale: "en-US", + reducedMotion: "reduce", + serviceWorkers: "block", + viewport: { width: 1280, height: 900 }, + }); + const page = await context.newPage(); + page.setDefaultTimeout(30_000); + await installMockGateway(page, { + methodResponses: { + "sessions.list": sessionsListResponse(sessions), + }, + sessionArchiveFiltering: true, + sessionGroups: groups, + sessionKey: "agent:main:main", + }); + await page.goto(`${server.baseUrl}chat`); + await page.locator("openclaw-app-sidebar").waitFor({ state: "visible" }); + await page.waitForFunction(() => document.documentElement.dataset.theme === "dark"); + return { context, page }; +} + +async function expandCoding(page: Page): Promise { + const coding = page.locator('[data-session-section="work"]'); + await coding.waitFor({ state: "visible" }); + const toggle = coding.getByRole("button", { name: "Coding", exact: true }); + if ((await toggle.getAttribute("aria-expanded")) !== "true") { + await toggle.click(); + } + await page.waitForFunction( + () => + document.querySelectorAll('[data-session-section="work"] .sidebar-recent-session').length === + 2, + ); +} + +try { + if (mode === "after") { + const grouped = await openScenario(groupedSessions, ["Jesse", "Josh"]); + try { + await grouped.page.waitForFunction(() => { + const counts = ["Jesse", "Josh"].map( + (name) => + document.querySelectorAll( + `[data-session-section="category:${name}"] .sidebar-recent-session`, + ).length, + ); + return counts.every((count) => count === 2); + }); + await grouped.page.waitForFunction( + () => + document.querySelectorAll('[data-session-section="ungrouped"] .sidebar-recent-session') + .length === 3, + ); + await expandCoding(grouped.page); + + const toolbar = grouped.page.locator(".sidebar-session-toolbar"); + await toolbar.getByText("Sessions", { exact: true }).waitFor(); + const filter = toolbar.getByRole("button", { name: "Filter & sort" }); + const add = toolbar.getByRole("button", { name: "New session" }); + await grouped.page.mouse.move(1_000, 850); + const toolbarOpacity = await Promise.all([ + filter.evaluate((element) => Number.parseFloat(getComputedStyle(element).opacity)), + add.evaluate((element) => Number.parseFloat(getComputedStyle(element).opacity)), + ]); + if (toolbarOpacity.some((opacity) => opacity <= 0)) { + throw new Error( + `Toolbar controls are not visible without hover: ${toolbarOpacity.join(", ")}`, + ); + } + await grouped.page.getByText("Other", { exact: true }).waitFor(); + await capture(grouped.page, "after-grouped.png"); + + await filter.click(); + await grouped.page.locator(".sidebar-session-sort-menu").waitFor({ state: "visible" }); + await capture(grouped.page, "after-toolbar-menu.png"); + + await grouped.page.getByRole("menuitemradio", { name: "All", exact: true }).click(); + await grouped.page.waitForFunction( + () => + document + .querySelector(".sidebar-session-toolbar .sidebar-session-sort") + ?.classList.contains("sidebar-session-sort--filtered") === true, + ); + await grouped.page.mouse.move(1_000, 850); + await capture(grouped.page, "after-filter-active.png"); + } finally { + await grouped.context.close(); + } + + const ungrouped = await openScenario(ungroupedSessions); + try { + await ungrouped.page.waitForFunction( + () => + document.querySelectorAll('[data-session-section="ungrouped"] .sidebar-recent-session') + .length === 3, + ); + if ( + (await ungrouped.page + .locator('[data-session-section="ungrouped"] > .sidebar-recent-sessions__head') + .count()) !== 0 + ) { + throw new Error("Ungrouped-only state unexpectedly rendered a section header"); + } + await ungrouped.page.getByText("Sessions", { exact: true }).waitFor(); + await capture(ungrouped.page, "after-ungrouped-only.png"); + } finally { + await ungrouped.context.close(); + } + } else { + const grouped = await openScenario(groupedSessions, ["Jesse", "Josh"]); + try { + await grouped.page.waitForFunction( + () => + document.querySelectorAll('[data-session-section="ungrouped"] .sidebar-recent-session') + .length === 3, + ); + await expandCoding(grouped.page); + const header = grouped.page.locator( + '[data-session-section="ungrouped"] > .sidebar-recent-sessions__head', + ); + const filter = header.getByRole("button", { name: "Sort sessions" }); + const add = header.getByRole("button", { name: "New session" }); + + await grouped.page.mouse.move(1_000, 850); + await settle(grouped.page); + const idleOpacity = await Promise.all([ + filter.evaluate((element) => Number.parseFloat(getComputedStyle(element).opacity)), + add.evaluate((element) => Number.parseFloat(getComputedStyle(element).opacity)), + ]); + if (idleOpacity.some((opacity) => opacity !== 0)) { + throw new Error(`Legacy controls are not hover-hidden: ${idleOpacity.join(", ")}`); + } + await capture(grouped.page, "before-grouped.png"); + + await header.hover(); + await grouped.page.waitForFunction(() => { + const controls = [ + ...document.querySelectorAll( + '[data-session-section="ungrouped"] > .sidebar-recent-sessions__head .sidebar-session-group-actions', + ), + ]; + return ( + controls.length === 2 && + controls.every((element) => getComputedStyle(element).opacity === "1") + ); + }); + await capture(grouped.page, "before-grouped-hover.png"); + } finally { + await grouped.context.close(); + } + } +} finally { + await browser.close(); + await server.close(); +} + +console.log( + JSON.stringify({ captured, fixture: "custom installMockGateway scenario", mode }, null, 2), +); diff --git a/ui/src/components/app-sidebar-session-list-render.ts b/ui/src/components/app-sidebar-session-list-render.ts index a552214b9b89..6ca7f320d8f8 100644 --- a/ui/src/components/app-sidebar-session-list-render.ts +++ b/ui/src/components/app-sidebar-session-list-render.ts @@ -28,6 +28,7 @@ type RenderableSessionSection = SidebarSessionSection & { visibleRowCount: number; visibleLimit: number; collapsedVisibleRowCount: number; + renderHeader: boolean; }; type SidebarSessionListHost = SessionListHost & { @@ -58,16 +59,16 @@ function renderSessionSection(params: { const { host, section } = params; const totalRowCount = section.totalRowCount; const group = section.category; - // zonedVisibleSections removes pinned rows; AppSidebar renders them through - // renderPinnedSidebarSession, so every section here has a header. - const collapsed = host.collapsedSessionSections.has(section.id); + // Pinned rows render in the nav zone; renderHeader records whether this list + // section owns collapse UI or sits directly below the global toolbar. + const collapsed = section.renderHeader && host.collapsedSessionSections.has(section.id); const label = section.groups ? t("chat.sidebar.groups") : section.work ? t("chat.sidebar.coding") : group ? group - : t("chat.sidebar.threads"); + : t("chat.sidebar.otherSessions"); const zone = section.groups ? "groups" : section.work ? "coding" : group ? "category" : "threads"; // Collapsed Coding still signals live runs so background work stays visible. const collapsedRunningDot = @@ -112,128 +113,95 @@ function renderSessionSection(params: { ? (event: DragEvent) => host.sectionDrop(event, section.id, group) : nothing} > - ${renderSidebarSessionSectionHeader({ - sectionId: section.id, - disabledReason: groupWriteAccess.allowed ? undefined : groupWriteAccess.reason, - onStartDrag: (sectionId) => host.startSidebarSectionDrag(sectionId), - onFinishDrag: () => host.finishSidebarSectionDrag(), - onContextMenu: group - ? (event: MouseEvent) => { - event.preventDefault(); - host.sidebarMenus.openSessionGroupMenu(group, event.clientX, event.clientY, null); - } - : undefined, - content: html` - - ${section.id === "ungrouped" - ? html` - - - ` - : nothing} - ${group - ? html` - - - ` - : nothing} - `, - })} + + ${label} + ${collapsed && totalRowCount > 0 + ? html`${totalRowCount}` + : nothing} + ${collapsedRunningDot + ? html`` + : nothing} + ${collapsedAttentionDot + ? html`` + : nothing} + + ${group + ? html` + + + ` + : nothing} + `, + }) + : nothing} ${collapsed ? nothing : html` @@ -389,11 +357,6 @@ function renderSessionListBody(params: { className: "sidebar-session-error sidebar-session-catalog-error", }) : nothing; - // Categorized threads still need the global sort and new-thread actions, - // which belong to Threads even when that section has no rows of its own. - const hasCategorizedThreads = params.sections.some( - (section) => Boolean(section.category) && section.totalRowCount > 0, - ); return html` ${params.sections.map((section, index) => { if (section.id.startsWith("catalog:")) { @@ -415,13 +378,11 @@ function renderSessionListBody(params: { } return renderSessionSection({ host, section }); } - // Hide an empty Threads header only when it does not own reachable - // actions for categorized threads, collaborators, or an active drag. + // Empty Other remains useful only as a collaborator or drag destination. if ( section.id === "ungrouped" && section.totalRowCount === 0 && !params.nativeSessionsHaveMore && - !hasCategorizedThreads && !host.sessionOwnershipVisible && host.sessionsStatusFilter === "active" && host.sessionOrganizer.draggingSessionKey === null @@ -438,6 +399,42 @@ function renderSessionListBody(params: { `; } +function renderSessionListToolbar(host: SidebarSessionListHost) { + const newSessionAccess = host.readNewSessionAccess(); + const filtered = host.sessionOwnerFilterActive || host.sessionsStatusFilter !== "active"; + return html` + + `; +} + export function renderSessionList(params: { host: SidebarSessionListHost; empty: boolean; @@ -456,6 +453,7 @@ export function renderSessionList(params: { @dragleave=${(event: DragEvent) => host.handleSessionListDragLeave(event)} @drop=${(event: DragEvent) => host.handleSessionListDrop(event)} > + ${renderSessionListToolbar(host)} ${host.sessionData.sessionMutationError ? html`
boolean; visibleSessionLimits: ReadonlyMap; }): SidebarVisibleSections { - const isCollapsed = (sectionId: string) => - sidebarSectionHasHeader(sectionId, input.grouping) && input.collapsedSections.has(sectionId); const sections = groupSidebarSessionRows(input.rows, { grouping: input.grouping, knownGroups: input.knownGroups, @@ -324,6 +322,15 @@ export function partitionSidebarVisibleSections(input: { section.id !== "pinned" && !input.hideEmptyOwnerFilteredGroup(section.category, section.rows.length), ); + // A lone catch-all sits directly under the global Sessions toolbar. Empty + // Coding does not render, while empty custom/Groups sections remain targets. + const ungroupedHasPeerHeader = sections.some( + (section) => section.id !== "ungrouped" && (section.id !== "work" || section.rows.length > 0), + ); + // Accepted tradeoff: headerless means no collapse control, so a stored + // ungrouped-collapsed preference is deliberately inert here — honoring it + // would blank the whole list with no affordance to undo. It re-applies + // unchanged once a peer section returns. const expandedRows: SidebarRecentSession[] = []; const visibleRows: SidebarRecentSession[] = []; // totalRowCount is the pre-pagination size: headers and empty-zone @@ -331,13 +338,15 @@ export function partitionSidebarVisibleSections(input: { const limitedSections: SidebarVisibleSections["sections"] = []; for (const section of sections) { const totalRowCount = section.rows.length; + const renderHeader = section.id !== "ungrouped" || ungroupedHasPeerHeader; + const collapsed = renderHeader && input.collapsedSections.has(section.id); const visibleLimit = input.visibleSessionLimits.get(section.id) ?? SIDEBAR_SESSION_PAGE_SIZE; const collapsedVisibleRowCount = limitSidebarSessionRows( section.rows, SIDEBAR_SESSION_PAGE_SIZE, ).length; let visibleRowCount = 0; - if (!isCollapsed(section.id)) { + if (!collapsed) { expandedRows.push(...section.rows); section.rows = limitSidebarSessionRows(section.rows, visibleLimit); visibleRows.push(...section.rows); @@ -349,6 +358,7 @@ export function partitionSidebarVisibleSections(input: { visibleRowCount, visibleLimit, collapsedVisibleRowCount, + renderHeader, }), ); } diff --git a/ui/src/components/app-sidebar-session-navigation.ts b/ui/src/components/app-sidebar-session-navigation.ts index 3e1ede7788fe..0b6daf437340 100644 --- a/ui/src/components/app-sidebar-session-navigation.ts +++ b/ui/src/components/app-sidebar-session-navigation.ts @@ -338,7 +338,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { catalogIds: this.sessionsStatusFilter === "archived" ? [] - : this.sessionData.sessionCatalogs.map((catalog) => catalog.id), + : this.visibleSessionCatalogs().map((catalog) => catalog.id), collapsedSections: this.collapsedSessionSections, hideEmptyOwnerFilteredGroup: (category, rowCount) => this.sessionOwnerFilterActive && Boolean(category) && rowCount === 0, diff --git a/ui/src/e2e/chat-flow.navigation-presentation.e2e.test.ts b/ui/src/e2e/chat-flow.navigation-presentation.e2e.test.ts index 1e290b5953ff..7441cdbe46e9 100644 --- a/ui/src/e2e/chat-flow.navigation-presentation.e2e.test.ts +++ b/ui/src/e2e/chat-flow.navigation-presentation.e2e.test.ts @@ -676,19 +676,16 @@ suite.define(() => { .evaluate((label) => getComputedStyle(label).fontWeight); expect(activeWeight).toBe(inactiveWeight); - const sortThreads = page.getByRole("button", { name: "Sort sessions" }); - await sortThreads.locator("..").hover(); - await sortThreads.click(); + const filterAndSort = page.getByRole("button", { name: "Filter & sort" }); + await filterAndSort.click(); await page.getByRole("menuitemradio", { name: "Last updated" }).click(); await expect.poll(() => sidebarSessionOrder(page)).toEqual(updatedOrder); - await sortThreads.locator("..").hover(); - await sortThreads.click(); + await filterAndSort.click(); await page.getByRole("menuitemradio", { name: "Created" }).click(); await expect.poll(() => sidebarSessionOrder(page)).toEqual(createdOrder); - await sortThreads.locator("..").hover(); - await sortThreads.click(); + await filterAndSort.click(); await page.getByRole("main").click(); await expect.poll(() => page.getByRole("menuitemradio", { name: "Created" }).count()).toBe(0); } finally { diff --git a/ui/src/e2e/chat-message-actions.e2e.test.ts b/ui/src/e2e/chat-message-actions.e2e.test.ts index d2d4c2989775..27cfb1295540 100644 --- a/ui/src/e2e/chat-message-actions.e2e.test.ts +++ b/ui/src/e2e/chat-message-actions.e2e.test.ts @@ -237,7 +237,10 @@ describeControlUiE2e("Control UI chat message actions", () => { await page.goto(`${server.baseUrl}chat`); await setThemeMode(page, "dark"); const commandPaletteShortcut = process.platform === "darwin" ? "⌘K" : "Ctrl K"; - await expectHoverTooltip(page.getByRole("button", { name: "New session" }), "New session"); + await expectHoverTooltip( + page.locator(".sidebar-brand").getByRole("button", { name: "New session" }), + "New session", + ); await expectHoverTooltip( page.getByRole("button", { name: "Open command palette" }), `Open command palette (${commandPaletteShortcut})`, diff --git a/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts b/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts index 28802e1a11b7..aa5ee40aa0a1 100644 --- a/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts +++ b/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts @@ -270,12 +270,39 @@ suite.define(() => { .toBe(true); const secondShortcut = secondModel.locator('[data-chat-model-shortcut-number="2"]'); await expect.poll(() => secondShortcut.count()).toBe(1); - const menuBoxBeforeFocus = await page.locator(".chat-controls__model-menu").boundingBox(); - const actionBoxBeforeFocus = await secondModel - .locator(".chat-controls__model-option-action") - .boundingBox(); - expect(menuBoxBeforeFocus).not.toBeNull(); - expect(actionBoxBeforeFocus).not.toBeNull(); + // Async page loads can still shift the whole layout mid-test; measure the + // menu and action relative to the picker anchor in one synchronous pass so + // only a focus-induced menu move can change the snapshot. + const menuGeometry = () => + page.evaluate(() => { + const anchor = document.querySelector('[data-chat-model-select="true"]'); + const menu = document.querySelector(".chat-controls__model-menu"); + const action = document.querySelector( + '[data-chat-model-option="anthropic/claude-sonnet-4-6"] .chat-controls__model-option-action', + ); + if (!anchor || !menu || !action) { + return null; + } + const anchorBox = anchor.getBoundingClientRect(); + const menuBox = menu.getBoundingClientRect(); + const actionBox = action.getBoundingClientRect(); + return { + menu: { + dx: menuBox.x - anchorBox.x, + dy: menuBox.y - anchorBox.y, + width: menuBox.width, + height: menuBox.height, + }, + action: { + dx: actionBox.x - menuBox.x, + dy: actionBox.y - menuBox.y, + width: actionBox.width, + height: actionBox.height, + }, + }; + }); + const geometryBeforeFocus = await menuGeometry(); + expect(geometryBeforeFocus).not.toBeNull(); await expect .poll(() => secondShortcut.evaluate((element) => getComputedStyle(element).opacity)) .toBe("1"); @@ -287,12 +314,7 @@ suite.define(() => { await expect .poll(() => secondShortcut.evaluate((element) => getComputedStyle(element).opacity)) .toBe("0"); - expect(await page.locator(".chat-controls__model-menu").boundingBox()).toEqual( - menuBoxBeforeFocus, - ); - expect( - await secondModel.locator(".chat-controls__model-option-action").boundingBox(), - ).toEqual(actionBoxBeforeFocus); + expect(await menuGeometry()).toEqual(geometryBeforeFocus); await search.press("1"); await expect.poll(() => search.inputValue()).toBe("1"); await expect.poll(() => picker.getAttribute("open")).toBe(""); @@ -786,6 +808,9 @@ suite.define(() => { await waitForCommittedNewSessionDraft(page, "keep both remembered choices", 0); await page.reload(); + // The composer persists drafts across hard reloads; refilling here races + // the async restore, which can append the stored draft to the typed text. + // Waiting for the restored value asserts the documented persistence. await expect .poll(() => page.locator(".new-session-page__message").inputValue()) .toBe("keep both remembered choices"); diff --git a/ui/src/e2e/session-management.groups.e2e.test.ts b/ui/src/e2e/session-management.groups.e2e.test.ts index ac4214cf2fa2..de3c644ebbd3 100644 --- a/ui/src/e2e/session-management.groups.e2e.test.ts +++ b/ui/src/e2e/session-management.groups.e2e.test.ts @@ -636,22 +636,17 @@ suite.define(() => { .toBe(2); // Group by "None" flattens the category sections into the plain list. The - // confirm left the pointer over the dialog rather than the sidebar, and - // section actions only surface on hover, so reveal this one first. - const sortSessionsButton = page.locator( - "button.sidebar-session-sort:not(.sidebar-session-new)", - ); - await page - .locator('[data-session-section="ungrouped"] .sidebar-recent-sessions__head') - .hover(); - await sortSessionsButton.click(); + // confirm left the pointer over the dialog rather than the sidebar; the + // global toolbar remains available without revealing a section action. + const filterAndSortButton = page.getByRole("button", { name: "Filter & sort" }); + await filterAndSortButton.click(); const showAutomationSessions = page.getByRole("menuitemcheckbox", { name: "Show automation sessions", }); await activateSelfRemovingControl(showAutomationSessions); - await expect.poll(() => sortSessionsButton.getAttribute("aria-expanded")).toBe("false"); + await expect.poll(() => filterAndSortButton.getAttribute("aria-expanded")).toBe("false"); - await sortSessionsButton.click(); + await filterAndSortButton.click(); await expect.poll(() => showAutomationSessions.getAttribute("aria-checked")).toBe("true"); await page.getByRole("menuitemradio", { name: "None" }).waitFor({ state: "visible" }); await captureUiProof(page, "sidebar-groupby-sort-menu.png"); @@ -677,12 +672,12 @@ suite.define(() => { return Math.abs(automationRight - groupingRight); }) .toBeLessThanOrEqual(1); - await sortSessionsButton.click(); - await expect.poll(() => sortSessionsButton.getAttribute("aria-expanded")).toBe("false"); + await filterAndSortButton.click(); + await expect.poll(() => filterAndSortButton.getAttribute("aria-expanded")).toBe("false"); await expect.poll(() => page.getByRole("menuitemradio", { name: "None" }).count()).toBe(0); await captureUiProof(page, "sidebar-groupby-sort-menu-closed.png"); - await sortSessionsButton.click(); + await filterAndSortButton.click(); await activateSelfRemovingControl(page.getByRole("menuitemradio", { name: "None" })); await expect.poll(() => groups.count()).toBe(1); await expect.poll(() => groups.first().locator(".sidebar-recent-session").count()).toBe(3); @@ -911,9 +906,8 @@ suite.define(() => { await expect.poll(() => page.locator(".sidebar-recent-session").count()).toBe(11); const patchCountBeforeFlatDrag = (await gateway.getRequests("sessions.patch")).length; - const sortSessionsButton = page.getByRole("button", { name: "Sort sessions" }); - await sortSessionsButton.locator("..").hover(); - await sortSessionsButton.click(); + const filterAndSortButton = page.getByRole("button", { name: "Filter & sort" }); + await filterAndSortButton.click(); await activateSelfRemovingControl(page.getByRole("menuitemradio", { name: "None" })); const flatSection = page.locator('[data-session-section="ungrouped"]'); await flatSection diff --git a/ui/src/e2e/session-management.sidebar.e2e.test.ts b/ui/src/e2e/session-management.sidebar.e2e.test.ts index 1328fc4b7af6..552d338205b0 100644 --- a/ui/src/e2e/session-management.sidebar.e2e.test.ts +++ b/ui/src/e2e/session-management.sidebar.e2e.test.ts @@ -693,11 +693,10 @@ suite.define(() => { await page.mouse.move(sourceBox.x + sourceBox.width / 2 + 12, sourceBox.y + 12, { steps: 4, }); - const sessionList = page.locator(".sidebar-sessions"); - await sessionList.waitFor({ state: "visible" }); - const targetBox = await sessionList.boundingBox(); + await chatsGroup.waitFor({ state: "visible" }); + const targetBox = await chatsGroup.boundingBox(); if (!targetBox) { - throw new Error("expected session list bounds"); + throw new Error("expected ungrouped session bounds"); } await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { steps: 8, diff --git a/ui/src/e2e/session-ownership.e2e.test.ts b/ui/src/e2e/session-ownership.e2e.test.ts index e0fa60aaba9d..ba7d4d6a65a6 100644 --- a/ui/src/e2e/session-ownership.e2e.test.ts +++ b/ui/src/e2e/session-ownership.e2e.test.ts @@ -80,10 +80,9 @@ async function captureUiProof(targetPage: Page, fileName: string) { } async function openSidebarSortMenu(targetPage: Page) { - const sortThreads = targetPage.getByRole("button", { name: "Sort sessions" }); - await expect.poll(() => sortThreads.count(), { timeout: 2_000 }).toBe(1); - await sortThreads.locator("..").hover(); - await sortThreads.click(); + const filterAndSort = targetPage.getByRole("button", { name: "Filter & sort" }); + await expect.poll(() => filterAndSort.count(), { timeout: 2_000 }).toBe(1); + await filterAndSort.click(); const menu = targetPage.locator(".sidebar-session-sort-menu"); await menu.waitFor(); return menu; @@ -196,7 +195,7 @@ suite.define(() => { expect(await currentPage.locator("openclaw-session-owner-chip").count()).toBe(0); }); - it("keeps grouped single-owner thread actions accessible to keyboard users", async () => { + it("keeps global session actions accessible to keyboard users", async () => { const context = await suite.browser.newContext({ viewport: { height: 800, width: 1200 } }); const currentPage = await context.newPage(); page = currentPage; @@ -211,10 +210,8 @@ suite.define(() => { await currentPage.getByText("Ada research", { exact: true }).first().waitFor(); await currentPage.getByText("Bob operations", { exact: true }).first().waitFor(); - const threads = currentPage.locator('[data-session-section="ungrouped"]'); - await expect.poll(() => threads.count(), { timeout: 2_000 }).toBe(1); - const sortThreads = threads.getByRole("button", { name: "Sort sessions" }); - await sortThreads.focus(); + const filterAndSort = currentPage.getByRole("button", { name: "Filter & sort" }); + await filterAndSort.focus(); await currentPage.keyboard.press("Enter"); const menu = currentPage.locator(".sidebar-session-sort-menu"); @@ -225,9 +222,12 @@ suite.define(() => { await expect .poll(() => currentPage.locator('[data-session-section^="category:"]').count()) .toBe(0); + const threads = currentPage.locator('[data-session-section="ungrouped"]'); await expect.poll(() => threads.locator(".sidebar-recent-session").count()).toBe(2); - const newThread = threads.getByRole("button", { name: "New session" }); + const newThread = currentPage + .locator(".sidebar-session-toolbar") + .getByRole("button", { name: "New session" }); await newThread.focus(); await currentPage.keyboard.press("Enter"); await expect.poll(() => new URL(currentPage.url()).pathname).toBe("/new"); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 0660b4e6d6d8..43ef5b26ed10 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -5318,6 +5318,7 @@ export const en: TranslationMap = { serverUpdatedTitle: "Server updated", serverUpdatedRefresh: "Refresh for full capabilities", threads: "Sessions", + otherSessions: "Other", groups: "Groups", coding: "Coding", noSessionsForAgent: "No sessions found for this agent", @@ -5332,7 +5333,7 @@ export const en: TranslationMap = { openSessionMenu: "Open session menu", sortBy: "Sort by", sortCreated: "Created", - sortSessions: "Sort sessions", + sortSessions: "Filter & sort", sortUpdated: "Last updated", sessionMenu: "Actions for {session}", sessionMenuMany: "Actions for {count} sessions", diff --git a/ui/src/lib/sessions/grouping.ts b/ui/src/lib/sessions/grouping.ts index dc554b78e69b..ec7c647b1334 100644 --- a/ui/src/lib/sessions/grouping.ts +++ b/ui/src/lib/sessions/grouping.ts @@ -97,20 +97,6 @@ export function moveSessionSection( return moveSessionOrderEntry(order, source, target, position); } -/** - * Sections that render a header (and therefore can collapse). Pinned rows - * render headerless like the nav entries above them; every other zone shows - * one — Threads hosts the sort and new-session actions on its header. - * Shared by the renderer and keyboard-order walker so collapse behavior - * cannot drift between them. - */ -export function sidebarSectionHasHeader( - sectionId: string, - _grouping: SidebarSessionsGrouping, -): boolean { - return sectionId !== "pinned"; -} - export function normalizeSessionsGroupBy(raw: unknown): SessionsGroupBy { return SESSION_GROUP_MODES.includes(raw as SessionsGroupBy) ? (raw as SessionsGroupBy) : "none"; } @@ -216,7 +202,7 @@ export function categoryClearReturnsToGroups( /** * Zone partition: pinned, named categories (persisted `knownGroups` order, - * new ones alphabetical), threads ("ungrouped" — the agent's chat sessions), + * new ones alphabetical), other sessions ("ungrouped"), * group conversations, then coding (worktree/exec-node/ACP). An explicit user * category wins over the smart group/coding classification so manual curation * sticks. `grouping: "none"` only disables categories; the kind-based Groups diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 6c263010777e..981973f76eee 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -1400,6 +1400,11 @@ body.update-dialog-open .sidebar-update-card__status { } .sidebar-sessions { + --sidebar-inset: 8px; + --sidebar-lead: 20px; + --sidebar-row-gap: 8px; + --sidebar-text-offset: calc(var(--sidebar-inset) + var(--sidebar-lead) + var(--sidebar-row-gap)); + display: flex; flex-direction: column; gap: 8px; @@ -1416,6 +1421,61 @@ body.update-dialog-open .sidebar-update-card__status { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--muted) 35%, transparent); } +.sidebar-session-toolbar { + display: flex; + align-items: center; + gap: 2px; + min-height: 24px; + margin-inline: -8px 0; + padding: 0 10px 0 var(--sidebar-text-offset); +} + +.sidebar-session-toolbar__button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + flex: 0 0 auto; + padding: 0; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--muted); + opacity: 0.55; + transition: + opacity var(--duration-fast) ease, + background var(--duration-fast) ease, + color var(--duration-fast) ease; +} + +.sidebar-session-toolbar__button:first-of-type { + margin-left: auto; +} + +.sidebar-session-toolbar__button:hover, +.sidebar-session-toolbar__button:focus-visible, +.sidebar-session-toolbar__button[aria-expanded="true"], +.sidebar-session-toolbar__button.sidebar-session-sort--filtered { + background: color-mix(in srgb, var(--bg-hover) 78%, transparent); + color: var(--text); + opacity: 1; +} + +.sidebar-session-toolbar__button:disabled { + cursor: not-allowed; +} + +.sidebar-session-toolbar__button svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 1.7px; + stroke-linecap: round; + stroke-linejoin: round; +} + .sidebar-session-error { flex: 0 0 auto; padding: 8px 10px; @@ -1435,11 +1495,6 @@ body.update-dialog-open .sidebar-update-card__status { } .sidebar-recent-sessions { - --sidebar-inset: 8px; - --sidebar-lead: 20px; - --sidebar-row-gap: 8px; - --sidebar-text-offset: calc(var(--sidebar-inset) + var(--sidebar-lead) + var(--sidebar-row-gap)); - display: flex; flex-direction: column; gap: var(--sidebar-group-gap); @@ -1680,19 +1735,6 @@ body.update-dialog-open .sidebar-update-card__status { .sidebar-session-sort { position: relative; - display: inline-flex; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; - flex: 0 0 auto; - border: none; - border-radius: var(--radius-md); - background: transparent; - color: var(--muted); - transition: - background var(--duration-fast) ease, - color var(--duration-fast) ease; } .sidebar-session-sort--filtered::after { @@ -1707,23 +1749,6 @@ body.update-dialog-open .sidebar-update-card__status { content: ""; } -.sidebar-session-sort:hover, -.sidebar-session-sort[aria-expanded="true"], -.sidebar-session-sort[aria-pressed="true"] { - background: color-mix(in srgb, var(--bg-hover) 78%, transparent); - color: var(--text); -} - -.sidebar-session-sort svg { - width: 14px; - height: 14px; - stroke: currentColor; - fill: none; - stroke-width: 1.7px; - stroke-linecap: round; - stroke-linejoin: round; -} - /* Group header kebab: hidden until hover/keyboard focus like row actions, but always reachable on touch pointers where hover does not exist. */ .sidebar-session-group-actions { diff --git a/ui/src/test-helpers/app-sidebar-cases/attention.ts b/ui/src/test-helpers/app-sidebar-cases/attention.ts index c76962344554..b23362bc9945 100644 --- a/ui/src/test-helpers/app-sidebar-cases/attention.ts +++ b/ui/src/test-helpers/app-sidebar-cases/attention.ts @@ -318,13 +318,25 @@ describe("AppSidebar session attention", () => { JSON.stringify(["ungrouped"]), ); const sessionsHarness = createSessionsHarness("main", [sessionKey]); - setRows(sessionsHarness, [agentAttentionRow()]); + setRows(sessionsHarness, [ + agentAttentionRow(), + { + key: "agent:main:peer", + kind: "direct", + updatedAt: 1, + worktree: { id: "peer", branch: "main", repoRoot: "/repo" }, + }, + ]); const { sidebar } = await mountSidebar( createGateway({} as GatewayBrowserClient), sessionsHarness.sessions, ); const section = sidebar.querySelector('[data-session-section="ungrouped"]'); + expect(sidebar.querySelector('[data-session-section="work"]')).not.toBeNull(); + expect( + section?.querySelector(".sidebar-session-group-toggle")?.getAttribute("aria-expanded"), + ).toBe("false"); expect(section?.querySelector(".sidebar-session-group-attention")).not.toBeNull(); expect(section?.querySelector(".sidebar-recent-session")).toBeNull(); }); @@ -340,6 +352,12 @@ describe("AppSidebar session attention", () => { const sessionsHarness = createSessionsHarness("main", [parentKey]); setRows(sessionsHarness, [ { key: parentKey, kind: "direct", updatedAt: 1, childSessions: [childKey] }, + { + key: "agent:main:peer", + kind: "direct", + updatedAt: 0, + worktree: { id: "peer", branch: "main", repoRoot: "/repo" }, + }, ]); const approval = { id: "approval-child", @@ -375,6 +393,7 @@ describe("AppSidebar session attention", () => { ), ).not.toBeNull(); expect(sidebar.querySelector(`[data-session-key="${childKey}"]`)).toBeNull(); + expect(sidebar.querySelector('[data-session-section="work"]')).not.toBeNull(); sidebar.querySelector(".sidebar-session-group-toggle")?.click(); await sidebar.updateComplete; expect( diff --git a/ui/src/test-helpers/app-sidebar-cases/basics.ts b/ui/src/test-helpers/app-sidebar-cases/basics.ts index 2fc2eb98ebf1..a5c07547243f 100644 --- a/ui/src/test-helpers/app-sidebar-cases/basics.ts +++ b/ui/src/test-helpers/app-sidebar-cases/basics.ts @@ -364,10 +364,10 @@ describe("AppSidebar brand actions", () => { brandButton?.click(); expect(onOpenNewSession).toHaveBeenCalledExactlyOnceWith("research"); - const headerButton = sidebar.querySelector( - '[data-session-section="ungrouped"] .sidebar-new-session', + const toolbarButton = sidebar.querySelector( + ".sidebar-session-toolbar .sidebar-new-session", ); - expect(headerButton?.getAttribute("aria-label")).toBe("New session"); + expect(toolbarButton?.getAttribute("aria-label")).toBe("New session"); }); }); diff --git a/ui/src/test-helpers/app-sidebar-cases/section-reordering.ts b/ui/src/test-helpers/app-sidebar-cases/section-reordering.ts index f65338295ad2..57070b4fd00b 100644 --- a/ui/src/test-helpers/app-sidebar-cases/section-reordering.ts +++ b/ui/src/test-helpers/app-sidebar-cases/section-reordering.ts @@ -165,9 +165,9 @@ describe("AppSidebar section reordering", () => { }); it("does not start a section drag from a header action button", async () => { - const { sidebar } = await mountWithGroups([]); + const { sidebar } = await mountWithGroups(["Alpha"]); const dataTransfer = createDataTransferStub(); - const newSessionButton = groupHeader(sidebar, "ungrouped").querySelector( + const newSessionButton = groupHeader(sidebar, "category:Alpha").querySelector( ".sidebar-new-session", ); if (!newSessionButton) { @@ -175,7 +175,7 @@ describe("AppSidebar section reordering", () => { } newSessionButton.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); - dispatchDragEvent(groupHeader(sidebar, "ungrouped"), "dragstart", dataTransfer); + dispatchDragEvent(groupHeader(sidebar, "category:Alpha"), "dragstart", dataTransfer); expect(dataTransfer.types).toEqual([]); expect(sidebar.sessionOrganizer.draggingSidebarSection).toBeNull(); diff --git a/ui/src/test-helpers/app-sidebar-cases/session-list-sections.ts b/ui/src/test-helpers/app-sidebar-cases/session-list-sections.ts index bbe4e4af8364..163b973626e2 100644 --- a/ui/src/test-helpers/app-sidebar-cases/session-list-sections.ts +++ b/ui/src/test-helpers/app-sidebar-cases/session-list-sections.ts @@ -30,6 +30,9 @@ describe("AppSidebar session section visibility", () => { expect(category?.querySelectorAll(".sidebar-recent-session")).toHaveLength(10); expect(threads?.querySelectorAll(".sidebar-recent-session")).toHaveLength(10); + expect(threads?.querySelector(".sidebar-recent-sessions__label-text")?.textContent).toBe( + "Other", + ); expect(category?.querySelector('[aria-label="Show more"]')).not.toBeNull(); expect(threads?.querySelector('[aria-label="Show more"]')).not.toBeNull(); expect(sidebar.querySelectorAll(".sidebar-session-pagination")).toHaveLength(2); @@ -43,7 +46,7 @@ describe("AppSidebar session section visibility", () => { expect(threads?.querySelector('[aria-label="Show more"]')).toBeNull(); }); - it("keeps global thread actions when every unpinned thread has a custom group", async () => { + it("keeps global session actions when every unpinned thread has a custom group", async () => { const harness = createSessionsHarness("main", [ "agent:main:main", "agent:main:research", @@ -69,18 +72,62 @@ describe("AppSidebar session section visibility", () => { expect(sidebar.querySelector('[data-session-section="category:Research"]')).not.toBeNull(); expect(sidebar.querySelector('[data-session-section="category:Operations"]')).not.toBeNull(); - expect(threads).not.toBeNull(); - expect(threads?.querySelectorAll(".sidebar-recent-session")).toHaveLength(0); + expect(threads).toBeNull(); - const sort = threads?.querySelector('[aria-label="Sort sessions"]'); - expect(sort).not.toBeNull(); - expect(threads?.querySelector('[aria-label="New session"]')).not.toBeNull(); - sort?.click(); + const toolbar = sidebar.querySelector(".sidebar-session-toolbar"); + expect(toolbar?.querySelector(".sidebar-recent-sessions__label-text")?.textContent).toBe( + "Sessions", + ); + const filter = toolbar?.querySelector(".sidebar-session-sort"); + expect(filter).not.toBeNull(); + expect(filter?.getAttribute("aria-label")).toBe("Filter & sort"); + expect(toolbar?.querySelector('[aria-label="New session"]')).not.toBeNull(); + filter?.click(); await sidebar.updateComplete; expect(sidebar.querySelector(".sidebar-session-sort-menu")).not.toBeNull(); }); - it("hides empty Threads at rest but keeps empty categories and the drag drop target", async () => { + it("renders a lone ungrouped list without a header despite stale collapsed state", async () => { + const gateway = createGateway({} as GatewayBrowserClient); + const { sidebar } = await mountSidebar( + gateway, + createSessions("main", ["agent:main:main", "agent:main:other"]), + ); + + sidebar.sessionOrganizer.saveCollapsedSessionSections(new Set(["ungrouped"])); + await sidebar.updateComplete; + + const ungrouped = sidebar.querySelector('[data-session-section="ungrouped"]'); + expect(ungrouped?.querySelector(".sidebar-recent-sessions__head")).toBeNull(); + expect(ungrouped?.querySelector('[data-session-key="agent:main:other"]')).not.toBeNull(); + }); + + it("marks the toolbar filter when the status is not active", async () => { + const gateway = createGateway({} as GatewayBrowserClient); + const { sidebar } = await mountSidebar( + gateway, + createSessions("main", ["agent:main:main", "agent:main:other"]), + ); + const filter = sidebar.querySelector( + ".sidebar-session-toolbar .sidebar-session-sort", + ); + expect(filter?.getAttribute("aria-label")).toBe("Filter & sort"); + expect(filter?.classList.contains("sidebar-session-sort--filtered")).toBe(false); + + filter?.click(); + await sidebar.updateComplete; + sidebar.querySelector(".sidebar-session-sort-menu")?.dispatchEvent( + new CustomEvent("wa-select", { + bubbles: true, + detail: { item: { value: "status:all" } }, + }), + ); + await sidebar.updateComplete; + + expect(filter?.classList.contains("sidebar-session-sort--filtered")).toBe(true); + }); + + it("hides empty Other at rest but keeps empty categories and the drag drop target", async () => { const harness = createSessionsHarness("main", ["agent:main:main", "agent:main:alpha"]); const result = harness.sessions.state.result; const alpha = result?.sessions.find((row) => row.key === "agent:main:alpha"); @@ -94,7 +141,7 @@ describe("AppSidebar session section visibility", () => { const { sidebar } = await mountSidebar(gateway, harness.sessions); // Empty user-created groups stay visible (creation and drag targets); - // only the bare Threads header disappears while nothing lives in it. + // only the bare Other header disappears while nothing lives in it. expect(sidebar.querySelector('[data-session-section="category:Empty"]')).not.toBeNull(); expect(sidebar.querySelector('[data-session-section="ungrouped"]')).toBeNull(); diff --git a/ui/src/test-helpers/app-sidebar-cases/session-ownership-filtering.ts b/ui/src/test-helpers/app-sidebar-cases/session-ownership-filtering.ts index 514987acaffe..ab7fd8a0d6c7 100644 --- a/ui/src/test-helpers/app-sidebar-cases/session-ownership-filtering.ts +++ b/ui/src/test-helpers/app-sidebar-cases/session-ownership-filtering.ts @@ -147,7 +147,9 @@ describe("AppSidebar session ownership filtering", () => { expect(sidebar.querySelector('[data-session-key="agent:main:bob"]')).toBeNull(); expect(sidebar.querySelector('[data-session-section="category:Research"]')).not.toBeNull(); expect(sidebar.querySelector('[data-session-section="category:Operations"]')).toBeNull(); - expect(sidebar.querySelector(".sidebar-session-sort--filtered")).not.toBeNull(); + expect( + sidebar.querySelector(".sidebar-session-toolbar .sidebar-session-sort--filtered"), + ).not.toBeNull(); }); it("filters adopted catalog rows by authoritative live ownership", async () => { diff --git a/ui/src/test-helpers/app-sidebar-cases/sessions.ts b/ui/src/test-helpers/app-sidebar-cases/sessions.ts index 87c771b2219a..5a1d32dc1d28 100644 --- a/ui/src/test-helpers/app-sidebar-cases/sessions.ts +++ b/ui/src/test-helpers/app-sidebar-cases/sessions.ts @@ -388,7 +388,7 @@ describe("AppSidebar session accessibility", () => { const row = sidebar.querySelector(`[data-session-key="${key}"]`); const tree = row?.closest(".sidebar-session-tree"); const link = row?.querySelector(".sidebar-recent-session__link"); - expect(list?.getAttribute("aria-label")).toBe("Sessions"); + expect(list?.getAttribute("aria-label")).toBe("Other"); expect(tree?.parentElement).toBe(list); expect(tree?.getAttribute("role")).toBe("listitem"); expect(row?.hasAttribute("role")).toBe(false);