mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-18 17:01:40 +00:00
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:
committed by
GitHub
parent
de8cfd2bf2
commit
a5aa7bc670
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user