fix(ui): cap sidebar child sessions with show-more, drop Subagent prefix in tree (#110604)

* fix(ui): cap sidebar child sessions at four with show-more, drop Subagent prefix in tree

* test(ui): split child-session cap cases under the max-lines budget
This commit is contained in:
Peter Steinberger
2026-07-18 11:56:16 +01:00
committed by GitHub
parent 347ee45895
commit f57a8753da
8 changed files with 242 additions and 3 deletions
+32 -1
View File
@@ -27,6 +27,8 @@ import { icons } from "./icons.ts";
import { renderSessionRowBadges } from "./session-row-badges.ts";
import "./elapsed-time.ts";
const SIDEBAR_VISIBLE_CHILD_SESSION_LIMIT = 4;
/** Session-list presentation and catalog renderer wiring. */
export abstract class AppSidebarSessionListElement extends AppSidebarMenusElement {
@state() protected catalogProjectGrouping = loadStoredSidebarCatalogGrouping();
@@ -242,6 +244,22 @@ export abstract class AppSidebarSessionListElement extends AppSidebarMenusElemen
private renderSessionTree(session: SidebarRecentSession): TemplateResult {
const expanded = this.isSessionChildrenExpanded(session);
const showAllChildren = this.fullyShownChildSessionKeys.has(session.key);
// The cap hides quiet children only: the active branch and any branch with
// live runs (runningChildCount is transitive) must stay visible, or an
// auto-expanded parent would omit its own selection or a running session.
const visibleChildren = showAllChildren
? session.children
: session.children.filter(
(child, index) =>
index < SIDEBAR_VISIBLE_CHILD_SESSION_LIMIT ||
child.visuallyActive ||
child.containsActiveDescendant ||
child.hasActiveRun ||
child.status === "running" ||
child.runningChildCount > 0,
);
const hiddenChildCount = session.children.length - visibleChildren.length;
return html`<div class="sidebar-session-tree" data-session-tree=${session.key}>
${this.renderRecentSession(session)}
${expanded
@@ -249,7 +267,20 @@ export abstract class AppSidebarSessionListElement extends AppSidebarMenusElemen
class="sidebar-session-tree__children"
aria-label=${t("sessionsView.childSessions")}
>
${session.children.map((child) => this.renderSessionTree(child))}
${visibleChildren.map((child) => this.renderSessionTree(child))}
${hiddenChildCount > 0
? html`<button
class="sidebar-session-tree__show-more"
type="button"
data-show-more-children=${session.key}
aria-label=${t("sessionsView.showMoreChildren", {
count: String(hiddenChildCount),
})}
@click=${() => this.showAllSessionChildren(session.key)}
>
${t("sessionsView.showMoreChildren", { count: String(hiddenChildCount) })}
</button>`
: nothing}
${session.loadingChildren && session.children.length === 0
? html`<span class="sidebar-session-tree__loading">${t("common.loading")}</span>`
: nothing}
@@ -53,6 +53,7 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
@state() protected selectedSessionKeys: ReadonlySet<string> = new Set();
@state() protected expandedChildSessionKeys: ReadonlySet<string> = new Set();
@state() protected collapsedActiveChildSessionKeys: ReadonlySet<string> = new Set();
@state() protected fullyShownChildSessionKeys: ReadonlySet<string> = new Set();
@state() protected sessionSortMode: SidebarSessionSortMode = "created";
@state() protected sessionsGrouping: SidebarSessionsGrouping =
loadStoredSidebarSessionsGrouping();
@@ -162,7 +163,9 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
}
return {
key: row.key,
label: resolveSessionDisplayName(row.key, row),
label: resolveSessionDisplayName(row.key, row, {
includeSubagentPrefix: !isChild,
}),
meta: formatSidebarTimestamp(row.updatedAt),
subtitle: resolveSessionWorkSubtitle(row),
href: `${pathForRoute("chat", context?.basePath ?? "")}${searchForSession(row.key)}`,
@@ -618,8 +621,10 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
protected toggleSessionChildren(session: SidebarRecentSession) {
const next = new Set(this.expandedChildSessionKeys);
const collapsedActive = new Set(this.collapsedActiveChildSessionKeys);
const fullyShown = new Set(this.fullyShownChildSessionKeys);
if (this.isSessionChildrenExpanded(session)) {
next.delete(session.key);
fullyShown.delete(session.key);
if (session.containsActiveDescendant) {
collapsedActive.add(session.key);
}
@@ -643,6 +648,11 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
}
this.expandedChildSessionKeys = next;
this.collapsedActiveChildSessionKeys = collapsedActive;
this.fullyShownChildSessionKeys = fullyShown;
}
protected showAllSessionChildren(sessionKey: string) {
this.fullyShownChildSessionKeys = new Set(this.fullyShownChildSessionKeys).add(sessionKey);
}
private projectSessionTree(
+1
View File
@@ -8,6 +8,7 @@ import "../test-helpers/app-sidebar-cases/catalog-live-events.ts";
import "../test-helpers/app-sidebar-cases/catalog-live.ts";
import "../test-helpers/app-sidebar-cases/catalog-live-state.ts";
import "../test-helpers/app-sidebar-cases/catalog-pages.ts";
import "../test-helpers/app-sidebar-cases/child-sessions-cap.ts";
import "../test-helpers/app-sidebar-cases/child-sessions.ts";
import "../test-helpers/app-sidebar-cases/group-mutations.ts";
import "../test-helpers/app-sidebar-cases/interactions.ts";
+1
View File
@@ -627,6 +627,7 @@ export const en: TranslationMap = {
showChildSessions: "Show {count} child sessions for {session}",
hideChildSessions: "Hide {count} child sessions for {session}",
childSessions: "Child sessions",
showMoreChildren: "Show {count} more",
archived: "Archived",
pinned: "Pinned",
unread: "Unread",
+28
View File
@@ -77,6 +77,34 @@ describe("resolveSessionDisplayName", () => {
"node-fleet-…8b2e",
);
});
it("can omit only the subagent prefix while preserving its untitled fallback", () => {
const key = "agent:main:subagent:worker";
expect(resolveSessionDisplayName(key, { label: "Research sources" })).toBe(
"Subagent: Research sources",
);
expect(
resolveSessionDisplayName(
key,
{ label: "Subagent: Research sources" },
{
includeSubagentPrefix: false,
},
),
).toBe("Research sources");
expect(resolveSessionDisplayName(key, undefined, { includeSubagentPrefix: false })).toBe(
"Subagent:",
);
expect(
resolveSessionDisplayName(
"agent:main:cron:daily",
{ label: "Daily" },
{
includeSubagentPrefix: false,
},
),
).toBe("Cron: Daily");
});
});
describe("resolveSessionWorkSubtitle", () => {
+12 -1
View File
@@ -92,6 +92,10 @@ type SessionDisplayRow = {
derivedTitle?: string;
} & SessionWorktreeDisplayRow;
type SessionDisplayOptions = {
includeSubagentPrefix?: boolean;
};
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
@@ -169,7 +173,11 @@ function parseSessionKey(key: string): SessionKeyInfo {
return { prefix: "", fallbackName: key };
}
export function resolveSessionDisplayName(key: string, row?: SessionDisplayRow): string {
export function resolveSessionDisplayName(
key: string,
row?: SessionDisplayRow,
options: SessionDisplayOptions = {},
): string {
const label = normalizeOptionalString(row?.label) ?? "";
const displayName = normalizeOptionalString(row?.displayName) ?? "";
const derivedTitle = normalizeOptionalString(row?.derivedTitle) ?? "";
@@ -180,6 +188,9 @@ export function resolveSessionDisplayName(key: string, row?: SessionDisplayRow):
return name;
}
const prefixPattern = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}\\s*`, "i");
if (prefix === "Subagent:" && options.includeSubagentPrefix === false) {
return name.replace(prefixPattern, "").trim() || fallbackName;
}
return prefixPattern.test(name) ? name : `${prefix} ${name}`;
};
+16
View File
@@ -1505,6 +1505,22 @@ html.openclaw-native-macos
font-size: 11px;
}
.sidebar-session-tree__show-more {
min-height: 28px;
padding: 6px 8px;
border: 0;
background: transparent;
color: var(--muted);
font-size: 11px;
font-weight: 400;
text-align: left;
}
.sidebar-session-tree__show-more:hover,
.sidebar-session-tree__show-more:focus-visible {
color: var(--text);
}
.sidebar-recent-sessions__group[data-session-section^="catalog:"] > .sidebar-recent-sessions__list {
gap: 8px;
}
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { createGateway, createSessionsHarness, mountSidebar } from "../app-sidebar.ts";
import { waitForFast } from "../wait-for.ts";
import "../../components/app-sidebar.ts";
describe("AppSidebar child session cap", () => {
it("caps visible children until requested and resets the cap after collapse", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const childKeys = Array.from(
{ length: 6 },
(_, index) => `agent:main:subagent:child-${index + 1}`,
);
const harness = createSessionsHarness("main", ["agent:main:parent"]);
harness.list.mockResolvedValue({
ts: 100_000,
path: "",
count: childKeys.length,
defaults: { modelProvider: null, model: null, contextTokens: null },
sessions: childKeys.map((key, index) => ({
key,
spawnedBy: "agent:main:parent",
kind: "direct" as const,
label: `Subagent: Child ${index + 1}`,
updatedAt: index + 1,
})),
});
const { sidebar } = await mountSidebar(gateway, harness.sessions);
harness.publishList({
result: {
ts: 2,
path: "",
count: 1,
defaults: { modelProvider: null, model: null, contextTokens: null },
sessions: [
{
key: "agent:main:parent",
kind: "direct",
label: "Parent task",
updatedAt: 1,
childSessions: childKeys,
},
],
},
});
await sidebar.updateComplete;
const toggle = sidebar.querySelector<HTMLButtonElement>("[data-child-session-toggle]");
toggle?.click();
await waitForFast(() =>
expect(sidebar.querySelectorAll(".sidebar-recent-session--child")).toHaveLength(4),
);
const showMore = sidebar.querySelector<HTMLButtonElement>("[data-show-more-children]");
expect(showMore?.textContent?.trim()).toBe("Show 2 more");
expect(showMore?.getAttribute("aria-label")).toBe("Show 2 more");
expect(sidebar.textContent).not.toContain("Subagent:");
showMore?.click();
await waitForFast(() =>
expect(sidebar.querySelectorAll(".sidebar-recent-session--child")).toHaveLength(6),
);
expect(sidebar.querySelector("[data-show-more-children]")).toBeNull();
toggle?.click();
await sidebar.updateComplete;
toggle?.click();
await waitForFast(() =>
expect(sidebar.querySelectorAll(".sidebar-recent-session--child")).toHaveLength(4),
);
expect(sidebar.querySelector("[data-show-more-children]")?.textContent).toContain(
"Show 2 more",
);
});
it("keeps live children visible past the cap", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const childKeys = Array.from(
{ length: 6 },
(_, index) => `agent:main:subagent:child-${index + 1}`,
);
const harness = createSessionsHarness("main", ["agent:main:parent"]);
harness.list.mockResolvedValue({
ts: 100_000,
path: "",
count: childKeys.length,
defaults: { modelProvider: null, model: null, contextTokens: null },
sessions: [
...childKeys.map((key, index) => ({
key,
spawnedBy: "agent:main:parent",
kind: "direct" as const,
label: `Subagent: Child ${index + 1}`,
updatedAt: index + 1,
})),
// Quiet child beyond the cap with a RUNNING grandchild: the branch
// must bypass the cap via the transitive runningChildCount.
{
key: "agent:main:subagent:grandchild",
spawnedBy: "agent:main:subagent:child-6",
kind: "direct" as const,
label: "Subagent: Grandchild run",
updatedAt: 10,
status: "running" as const,
hasActiveRun: true,
},
],
});
const { sidebar } = await mountSidebar(gateway, harness.sessions);
harness.publishList({
result: {
ts: 2,
path: "",
count: 1,
defaults: { modelProvider: null, model: null, contextTokens: null },
sessions: [
{
key: "agent:main:parent",
kind: "direct",
label: "Parent task",
updatedAt: 1,
childSessions: childKeys,
},
],
},
});
await sidebar.updateComplete;
sidebar
.querySelector<HTMLButtonElement>('[data-child-session-toggle="agent:main:parent"]')
?.click();
await waitForFast(() =>
expect(
sidebar.querySelector('[data-session-key="agent:main:subagent:child-6"]'),
).not.toBeNull(),
);
expect(sidebar.querySelector('[data-session-key="agent:main:subagent:child-5"]')).toBeNull();
expect(sidebar.querySelector("[data-show-more-children]")?.textContent?.trim()).toBe(
"Show 1 more",
);
});
});