diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md index b7be3bd6b18b..6461d47a0003 100644 --- a/docs/providers/anthropic.md +++ b/docs/providers/anthropic.md @@ -205,8 +205,8 @@ enabled by default; a native macOS node advertises the read-only Claude session commands when the local `~/.claude/projects/` directory exists. Approve the node pairing upgrade when those commands first appear. -The sidebar keeps the newest bounded page from each host. Open **Claude -Sessions** and use **Load more** on a host to browse older catalog pages without +The sidebar shows the newest sessions from each host. Open **Claude +Sessions** and use **Load more** on a host to browse the full catalog without turning each sidebar refresh into a full disk scan. Selecting a row reads the newest transcript page first. **Load older transcript diff --git a/docs/specs/codex-supervision.md b/docs/specs/codex-supervision.md index b1d404b47756..f4a7674aa64a 100644 --- a/docs/specs/codex-supervision.md +++ b/docs/specs/codex-supervision.md @@ -39,8 +39,9 @@ plan: no-other-runner confirmation. - Show active local sources without new-branch or archive controls while still allowing an existing supervised Chat to open. -- Show every row in the main sidebar and provide bounded, cursor-paginated - transcript reads for local and paired-node rows. +- Show the newest rows per host in the main sidebar, keep the full catalog on + the sessions page, and provide bounded, cursor-paginated transcript reads for + local and paired-node rows. - Isolate catalog failures by host. The catalog is the non-archived collection. A row within it can still have an diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index d0b89d136cd8..c97116fde828 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -2133,7 +2133,7 @@ class AppSidebar extends OpenClawLightDomContentsElement { this.onNavigate?.("chat"); }} > - + ${t("nav.chat")} diff --git a/ui/src/e2e/claude-sessions.e2e.test.ts b/ui/src/e2e/claude-sessions.e2e.test.ts index 9b65e5a879e8..cf9a0e51af12 100644 --- a/ui/src/e2e/claude-sessions.e2e.test.ts +++ b/ui/src/e2e/claude-sessions.e2e.test.ts @@ -81,6 +81,15 @@ describeControlUiE2e("Claude Sessions mocked Gateway E2E", () => { const desktop = session("desktop-thread", "Desktop architecture review", "claude-desktop"); const cli = session("cli-thread", "CLI release checklist", "claude-cli"); const olderCli = session("older-cli-thread", "Older CLI investigation", "claude-cli"); + // Long names + more sessions than the sidebar cap: the sidebar must + // ellipsize titles and keep the overflow in the full catalog. + const backlog = Array.from({ length: 10 }, (_, index) => + session( + `backlog-thread-${index + 1}`, + `Backlog investigation ${index + 1} with a deliberately long title that must truncate in the sidebar`, + "claude-cli", + ), + ); const gateway = await installMockGateway(page, { controlUiTabs: [ { @@ -118,7 +127,7 @@ describeControlUiE2e("Claude Sessions mocked Gateway E2E", () => { kind: "gateway", label: "Claude Gateway", nextCursor: "catalog-page-2", - sessions: [desktop, cli], + sessions: [desktop, cli, ...backlog], }, ], }, @@ -164,6 +173,15 @@ describeControlUiE2e("Claude Sessions mocked Gateway E2E", () => { await page.locator('[data-codex-thread-id="cli-thread"]').waitFor(); await page.getByRole("heading", { name: "CLI release checklist" }).waitFor(); await page.getByText("Claude sessions", { exact: true }).last().waitFor(); + // Sidebar shows only the newest sessions per host plus a truncation + // note; the rest stays on the catalog page. + await expect + .poll(() => page.locator(".sidebar-codex-sessions [data-codex-thread-id]").count()) + .toBe(10); + await page + .locator(".sidebar-codex-sessions") + .getByText("More sessions are available in the full catalog.") + .waitFor(); await page.getByRole("button", { name: "Load more" }).click(); await page.getByText("Older CLI investigation", { exact: true }).waitFor(); await captureUiProof(page, "01-sidebar-and-catalog.png"); diff --git a/ui/src/pages/plugin/codex-sidebar.test.ts b/ui/src/pages/plugin/codex-sidebar.test.ts index c5cfd8caf685..00f1ea222e6f 100644 --- a/ui/src/pages/plugin/codex-sidebar.test.ts +++ b/ui/src/pages/plugin/codex-sidebar.test.ts @@ -96,53 +96,7 @@ describe("Codex sidebar", () => { expect(sidebar.textContent).not.toContain("Untitled Codex session"); }); - it("loads every bounded catalog page so all active sessions appear", async () => { - const request = vi - .fn() - .mockResolvedValueOnce({ - hosts: [ - { - hostId: "node:macbook", - label: "MacBook", - kind: "node", - connected: true, - sessions: [ - { threadId: "remote-1", name: "Recent task", status: "idle", archived: false }, - ], - nextCursor: "catalog-page-2", - }, - ], - }) - .mockResolvedValueOnce({ - hosts: [ - { - hostId: "node:macbook", - label: "MacBook", - kind: "node", - connected: true, - sessions: [ - { threadId: "remote-2", name: "Older task", status: "idle", archived: false }, - ], - }, - ], - }); - const sidebar = new CodexSidebar(); - sidebar.client = { request } as unknown as GatewayBrowserClient; - sidebar.connected = true; - document.body.append(sidebar); - - await vi.waitFor(() => - expect(sidebar.querySelectorAll("[data-codex-thread-id]")).toHaveLength(2), - ); - expect(request).toHaveBeenNthCalledWith(1, "codex.sessions.list", { limitPerHost: 40 }); - expect(request).toHaveBeenNthCalledWith(2, "codex.sessions.list", { - limitPerHost: 40, - hostIds: ["node:macbook"], - cursors: { "node:macbook": "catalog-page-2" }, - }); - }); - - it("keeps Claude sidebar hydration to the newest page", async () => { + it("hydrates one page and renders only the newest sessions per host", async () => { const request = vi.fn(async () => ({ hosts: [ { @@ -150,57 +104,29 @@ describe("Codex sidebar", () => { label: "MacBook", kind: "node", connected: true, - sessions: [{ threadId: "claude-recent", name: "Recent Claude task", archived: false }], - nextCursor: "older-claude-sessions", + sessions: Array.from({ length: 12 }, (_, index) => ({ + threadId: `remote-${index + 1}`, + name: `Task ${index + 1}`, + status: "idle", + archived: false, + })), + nextCursor: "catalog-page-2", }, ], })); const sidebar = new CodexSidebar(); - sidebar.catalogKind = "claude"; sidebar.client = { request } as unknown as GatewayBrowserClient; sidebar.connected = true; document.body.append(sidebar); - await vi.waitFor(() => expect(sidebar.textContent).toContain("Recent Claude task")); - await new Promise((resolve) => { - globalThis.setTimeout(resolve, 0); - }); + await vi.waitFor(() => + expect(sidebar.querySelectorAll("[data-codex-thread-id]")).toHaveLength(10), + ); + expect(sidebar.textContent).toContain("Task 1"); + expect(sidebar.textContent).not.toContain("Task 11"); + expect(sidebar.textContent).toContain("More sessions are available in the full catalog."); expect(request).toHaveBeenCalledTimes(1); - expect(sidebar.textContent).toContain("More sessions are available in the full catalog."); - }); - - it("stops automatic catalog hydration at a fixed per-host budget", async () => { - let page = 0; - const request = vi.fn(async () => { - page += 1; - return { - hosts: [ - { - hostId: "node:macbook", - label: "MacBook", - kind: "node", - connected: true, - sessions: [ - { - threadId: `remote-${page}`, - name: `Task ${page}`, - status: "idle", - archived: false, - }, - ], - nextCursor: `catalog-page-${page + 1}`, - }, - ], - }; - }); - const sidebar = new CodexSidebar(); - sidebar.client = { request } as unknown as GatewayBrowserClient; - sidebar.connected = true; - document.body.append(sidebar); - - await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(100)); - expect(sidebar.querySelectorAll("[data-codex-thread-id]")).toHaveLength(100); - expect(sidebar.textContent).toContain("More sessions are available in the full catalog."); + expect(request).toHaveBeenCalledWith("codex.sessions.list", { limitPerHost: 40 }); }); it("clears the previous Gateway catalog before a replacement client loads", async () => { diff --git a/ui/src/pages/plugin/codex-sidebar.ts b/ui/src/pages/plugin/codex-sidebar.ts index 15b27fb4254a..3c7868207076 100644 --- a/ui/src/pages/plugin/codex-sidebar.ts +++ b/ui/src/pages/plugin/codex-sidebar.ts @@ -8,21 +8,20 @@ import { OpenClawLightDomContentsElement } from "../../lit/openclaw-element.ts"; import { getClaudeSessionsState, loadClaudeSessions, - loadMoreClaudeSessions, stopClaudeSessionsPolling, } from "./claude-sessions-controller.ts"; import { getCodexSessionsState, loadCodexSessions, - loadMoreCodexSessions, stopCodexSessionsPolling, type CodexSessionPayload, } from "./codex-sessions-controller.ts"; import { pluginTabSearch } from "./route.ts"; const SIDEBAR_REFRESH_INTERVAL_MS = 30_000; -const MAX_CATALOG_PAGES_PER_HOST = 100; -const MAX_CATALOG_SESSIONS_PER_HOST = 4_000; +/* The sidebar is a quick-jump list: it renders only the newest sessions per + host. The full catalog stays on the sessions page behind "View all". */ +const SIDEBAR_SESSIONS_PER_HOST = 10; function sessionTitle(session: CodexSessionPayload, catalogKind: "codex" | "claude"): string { return ( @@ -49,7 +48,6 @@ export class CodexSidebar extends OpenClawLightDomContentsElement { @property({ attribute: false }) onOpenSession?: (hostId: string, threadId: string) => void; @property({ attribute: false }) onViewAll?: () => void; @state() private revision = 0; - @state() private truncatedHostIds = new Set(); private readonly controllerHost = {}; private loadedClient: GatewayBrowserClient | null = null; @@ -114,15 +112,6 @@ export class CodexSidebar extends OpenClawLightDomContentsElement { } } - private async loadMoreSessions(hostId: string): Promise { - const catalogState = this.sessionsState(); - if (this.catalogKind === "claude") { - await loadMoreClaudeSessions(catalogState, this.client, hostId); - } else { - await loadMoreCodexSessions(catalogState, this.client, hostId); - } - } - private clearRefreshTimer(): void { if (this.refreshTimer) { globalThis.clearTimeout(this.refreshTimer); @@ -132,10 +121,11 @@ export class CodexSidebar extends OpenClawLightDomContentsElement { private beginRefresh(): void { this.clearRefreshTimer(); - this.truncatedHostIds = new Set(); + // Token guards the refresh chain across client switches: a stale load must + // not schedule a second timer next to the replacement client's chain. const token = {}; this.hydrationToken = token; - void this.loadAllPages(token).finally(() => { + void this.loadSessions().finally(() => { if (this.hydrationToken !== token || !this.loadedClient) { return; } @@ -146,48 +136,6 @@ export class CodexSidebar extends OpenClawLightDomContentsElement { }); } - private async loadAllPages(token: object): Promise { - const sessionsState = this.sessionsState(); - const seenCursors = new Set(); - const pageCounts = new Map(); - await this.loadSessions(); - if (this.catalogKind === "claude") { - this.truncatedHostIds = new Set( - sessionsState.hosts.filter((host) => host.nextCursor).map((host) => host.hostId), - ); - return; - } - while (this.hydrationToken === token) { - const hostIds = sessionsState.hosts - .filter((host) => { - const pageCount = pageCounts.get(host.hostId) ?? 1; - const reachedBudget = - pageCount >= MAX_CATALOG_PAGES_PER_HOST || - host.sessions.length >= MAX_CATALOG_SESSIONS_PER_HOST; - if (host.nextCursor && reachedBudget) { - this.truncatedHostIds = new Set(this.truncatedHostIds).add(host.hostId); - return false; - } - const cursorKey = `${host.hostId}\u0000${host.nextCursor ?? ""}`; - if (!host.connected || host.error || !host.nextCursor || seenCursors.has(cursorKey)) { - return false; - } - seenCursors.add(cursorKey); - return true; - }) - .map((host) => host.hostId); - if (hostIds.length === 0) { - return; - } - await Promise.all( - hostIds.map(async (hostId) => { - await this.loadMoreSessions(hostId); - pageCounts.set(hostId, (pageCounts.get(hostId) ?? 1) + 1); - }), - ); - } - } - private sessionSearch(hostId: string, threadId: string): string { return pluginTabSearch({ pluginId: this.catalogKind === "claude" ? "anthropic" : "codex", @@ -231,22 +179,17 @@ export class CodexSidebar extends OpenClawLightDomContentsElement { ${icons.terminal} - ${hosts.map( - (host) => html` + ${hosts.map((host) => { + const sessions = host.sessions.slice(0, SIDEBAR_SESSIONS_PER_HOST); + const truncated = Boolean(host.nextCursor) || host.sessions.length > sessions.length; + return html` - `, - )} + `; + })} `; } diff --git a/ui/src/styles/codex-sessions.css b/ui/src/styles/codex-sessions.css index 17f1be2195b1..3d2c0566b746 100644 --- a/ui/src/styles/codex-sessions.css +++ b/ui/src/styles/codex-sessions.css @@ -612,9 +612,22 @@ } .sidebar-codex-sessions { + display: flex; + flex-direction: column; + /* Shrink + scroll inside the sidebar body: without min-height 0 a long + catalog grows past the shell body and paints over the sidebar footer. */ + min-height: 0; + overflow-y: auto; + scrollbar-width: thin; padding: 0 10px 12px; } +/* Same contract as .sidebar-recent-sessions > *: fixed-height rows must not + flex-shrink inside the scroller or they overlap the following group. */ +.sidebar-codex-sessions > * { + flex: 0 0 auto; +} + .sidebar-codex-sessions__truncated { padding: 2px 12px 6px; color: var(--muted); @@ -629,11 +642,6 @@ text-decoration: none; } -.sidebar-codex-sessions .sidebar-recent-session__body { - min-width: 0; - flex: 1; -} - .codex-transcript { width: min(980px, calc(100% - 32px)); margin: 0 auto; diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 46bc403e1edb..aebb544fea5b 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -817,6 +817,9 @@ html.openclaw-native-nav .shell-nav-expand:focus-visible { flex: 1; display: flex; flex-direction: column; + /* Children manage their own scrolling; clip so an overgrown section can + never paint over the sidebar footer. */ + overflow: hidden; } .sidebar-shell__footer {