mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
committed by
GitHub
parent
347ee45895
commit
f57a8753da
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user