fix(ui): contain the native session sidebar and truncate its rows (#104587)

The Codex/Claude sidebar sections leaked long titles across the row
timestamps and past the sidebar edge because the row body was never a
flex container, so the title span stayed inline and ellipsis rules did
not apply. The sections also rendered every loaded session (Codex swept
up to 100 catalog pages per host every 30s) with no height cap, growing
past the sidebar shell and painting over the footer.

Rows now reuse the regular session-row __text anatomy so titles
ellipsize, the sidebar renders only the newest 10 sessions per host
with a derived truncation note (the full catalog stays on the sessions
page), each section shrinks and scrolls inside the sidebar body, and
the shell body clips as a final guard. The sidebar full-catalog
hydration loop and the one-off __body class are deleted.

Formatting proof: oxfmt --check clean on changed files via Testbox
tbx_01kx92cwpr1x9ccp2fgn441y8h (local hook skipped: no node_modules).

Fixes #104584
This commit is contained in:
Peter Steinberger
2026-07-11 10:33:32 -07:00
committed by GitHub
parent de8cfd2bf2
commit a5aa7bc670
8 changed files with 77 additions and 171 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -2133,7 +2133,7 @@ class AppSidebar extends OpenClawLightDomContentsElement {
this.onNavigate?.("chat");
}}
>
<span class="sidebar-recent-session__body">
<span class="sidebar-recent-session__text">
<span class="sidebar-recent-session__name">${t("nav.chat")}</span>
</span>
</a>

View File

@@ -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");

View File

@@ -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 () => {

View File

@@ -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<string>();
private readonly controllerHost = {};
private loadedClient: GatewayBrowserClient | null = null;
@@ -114,15 +112,6 @@ export class CodexSidebar extends OpenClawLightDomContentsElement {
}
}
private async loadMoreSessions(hostId: string): Promise<void> {
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<void> {
const sessionsState = this.sessionsState();
const seenCursors = new Set<string>();
const pageCounts = new Map<string, number>();
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}
</button>
</div>
${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`
<div class="sidebar-recent-sessions__group" data-codex-host-id=${host.hostId}>
<div class="sidebar-recent-sessions__head">
<span class="sidebar-recent-sessions__label-text">${host.label}</span>
<span class="sidebar-session-group-count">${host.sessions.length}</span>
</div>
${this.truncatedHostIds.has(host.hostId)
? html`<div class="sidebar-codex-sessions__truncated">
${this.catalogKind === "claude"
? t("claudeSessions.sidebar.truncated")
: t("codexSessions.sidebar.truncated")}
</div>`
: nothing}
<div class="sidebar-recent-sessions__list">
${host.sessions.map((session) => {
${sessions.map((session) => {
const active =
this.selectedHostId === host.hostId &&
this.selectedThreadId === session.threadId;
@@ -272,7 +215,7 @@ export class CodexSidebar extends OpenClawLightDomContentsElement {
this.onOpenSession?.(host.hostId, session.threadId);
}}
>
<span class="sidebar-recent-session__body">
<span class="sidebar-recent-session__text">
<span class="sidebar-recent-session__name"
>${sessionTitle(session, this.catalogKind)}</span
>
@@ -282,9 +225,16 @@ export class CodexSidebar extends OpenClawLightDomContentsElement {
`;
})}
</div>
${truncated
? html`<div class="sidebar-codex-sessions__truncated">
${this.catalogKind === "claude"
? t("claudeSessions.sidebar.truncated")
: t("codexSessions.sidebar.truncated")}
</div>`
: nothing}
</div>
`,
)}
`;
})}
</section>
`;
}

View File

@@ -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;

View File

@@ -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 {