mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 01:51:39 -06:00
fix(ui): honor categories for child sessions (#126388)
* fix(ui): honor categories for child sessions * fix(ui): admit categorized child sessions as roots * fix(ui): keep sidebar files within line budget * fix(ui): include cached categorized children --------- Co-authored-by: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com> Co-authored-by: Jason (Json) <263060202+fuller-stack-dev@users.noreply.github.com>
This commit is contained in:
@@ -286,6 +286,43 @@ describe("sidebar navigation lineage ownership", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("promotes an explicitly categorized child to a sidebar section root", () => {
|
||||
const categorizedChild = { ...child, category: "P1 issues from beta feedback" };
|
||||
const projected = projectSessionTree({
|
||||
roots: [navigationParent, categorizedChild],
|
||||
agentRows: [navigationParent, categorizedChild],
|
||||
childRowsByParent: {},
|
||||
loadingChildKeys: new Set(),
|
||||
knownSessionAttention: [],
|
||||
toSidebarSession: (row, isChild) =>
|
||||
({
|
||||
key: row.key,
|
||||
category: row.category,
|
||||
isChild,
|
||||
attention: { kind: "none" },
|
||||
runningChildCount: 0,
|
||||
failedChildCount: 0,
|
||||
}) as SidebarRecentSession,
|
||||
});
|
||||
|
||||
expect(
|
||||
projected.map((row) => ({
|
||||
key: row.key,
|
||||
category: row.category,
|
||||
isChild: row.isChild,
|
||||
children: row.children.map((entry) => entry.key),
|
||||
})),
|
||||
).toEqual([
|
||||
{ key: navigationParent.key, category: undefined, isChild: false, children: [] },
|
||||
{
|
||||
key: categorizedChild.key,
|
||||
category: categorizedChild.category,
|
||||
isChild: false,
|
||||
children: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["legacy active child", { status: "running" }, 1, 0],
|
||||
["stale running child", { status: "running", hasActiveRun: false }, 0, 0],
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
filterVisibleSessionRows,
|
||||
isSystemCreatedSessionRow,
|
||||
resolveSessionNavigation,
|
||||
sessionMatchesVisibleSessionScope,
|
||||
} from "../lib/sessions/index.ts";
|
||||
import {
|
||||
resolveSessionPreferredFace,
|
||||
@@ -494,6 +495,19 @@ export function resolveLatestSidebarAgentSession(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export function collectSidebarSessionCandidateRows(input: {
|
||||
rows: readonly GatewaySessionRow[];
|
||||
childRowsByParent: Readonly<Record<string, readonly GatewaySessionRow[]>>;
|
||||
}): GatewaySessionRow[] {
|
||||
return [
|
||||
...new Map(
|
||||
[...Object.values(input.childRowsByParent).flat(), ...input.rows].map(
|
||||
(row) => [row.key, row] as const,
|
||||
),
|
||||
).values(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote the hidden main session's children to top-level threads, with the
|
||||
* same visibility rules as ordinary roots so archived, cron, or
|
||||
@@ -501,13 +515,12 @@ export function resolveLatestSidebarAgentSession(input: {
|
||||
*/
|
||||
export function collectPromotedMainChildRows(input: {
|
||||
rows: readonly GatewaySessionRow[];
|
||||
childRowsByParent: Readonly<Record<string, readonly GatewaySessionRow[]>>;
|
||||
mainSessionKeys: ReadonlySet<string>;
|
||||
scopedRootKeys: ReadonlySet<string>;
|
||||
showCron: boolean;
|
||||
showSystem: boolean;
|
||||
}): GatewaySessionRow[] {
|
||||
return [...input.rows, ...Object.values(input.childRowsByParent).flat()].filter((row) => {
|
||||
return input.rows.filter((row) => {
|
||||
const parentKey = resolveUiSessionNavigationParentKey(row);
|
||||
return (
|
||||
parentKey != null &&
|
||||
@@ -520,6 +533,21 @@ export function collectPromotedMainChildRows(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export function collectCategorizedChildRootRows(input: {
|
||||
rows: readonly GatewaySessionRow[];
|
||||
scopedRoots: readonly GatewaySessionRow[];
|
||||
visibilityOptions: Parameters<typeof filterVisibleSessionRows>[1];
|
||||
}): GatewaySessionRow[] {
|
||||
const scopedRootKeys = new Set(input.scopedRoots.map((row) => row.key));
|
||||
return input.rows.filter(
|
||||
(row) =>
|
||||
!scopedRootKeys.has(row.key) &&
|
||||
normalizeOptionalString(row.category) != null &&
|
||||
resolveUiSessionNavigationParentKey(row) != null &&
|
||||
sessionMatchesVisibleSessionScope(row, input.visibilityOptions),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSidebarAgentResumeKey(
|
||||
latest: SessionRow | null,
|
||||
agentId: string,
|
||||
|
||||
@@ -29,7 +29,9 @@ import {
|
||||
applySidebarSessionOwnerFilter,
|
||||
buildReconciledSidebarZone,
|
||||
buildSidebarSessionNavigationState,
|
||||
collectCategorizedChildRootRows,
|
||||
collectPromotedMainChildRows,
|
||||
collectSidebarSessionCandidateRows,
|
||||
compareSidebarSessionRowsByMode,
|
||||
collectKnownSidebarSessionCatalogIds,
|
||||
collectKnownSidebarSessionGroups,
|
||||
@@ -588,6 +590,17 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
|
||||
const selected = this.expandedAgentId();
|
||||
const loadedAgentId = normalizeAgentId(this.sessionData.sessionsAgentId ?? "");
|
||||
const routeAgentId = normalizeAgentId(navigationState.selectedAgentId);
|
||||
const visibilityOptions = {
|
||||
agentId: selected,
|
||||
defaultAgentId: resolveUiDefaultAgentId({
|
||||
agentsList: this.context?.agents.state.agentsList,
|
||||
hello: this.context?.gateway.snapshot.hello,
|
||||
}),
|
||||
filterByAgent: true,
|
||||
showCron: this.sessionsShowCron,
|
||||
showSystem: this.sessionsShowSystem,
|
||||
archivedFilter: this.sessionsStatusFilter,
|
||||
} as const;
|
||||
const rows =
|
||||
selected === loadedAgentId
|
||||
? (this.sessionData.sessionsResult?.sessions ?? [])
|
||||
@@ -599,17 +612,9 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
|
||||
const row = rowsByKey.get(session.key);
|
||||
return row ? [row] : [];
|
||||
})
|
||||
: filterVisibleSessionRows(rows, {
|
||||
agentId: selected,
|
||||
defaultAgentId: resolveUiDefaultAgentId({
|
||||
agentsList: this.context?.agents.state.agentsList,
|
||||
hello: this.context?.gateway.snapshot.hello,
|
||||
}),
|
||||
filterByAgent: true,
|
||||
showCron: this.sessionsShowCron,
|
||||
showSystem: this.sessionsShowSystem,
|
||||
archivedFilter: this.sessionsStatusFilter,
|
||||
}).toSorted(this.compareSidebarSessionRows);
|
||||
: filterVisibleSessionRows(rows, visibilityOptions).toSorted(
|
||||
this.compareSidebarSessionRows,
|
||||
);
|
||||
// The identity card replaces the main row; promote children under all equivalent aliases.
|
||||
const mainSessionKey = this.selectedAgentMainSessionKey(selected);
|
||||
const lineageRoot = this.sessionData.activeSessionLineageRoot;
|
||||
@@ -649,10 +654,19 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
|
||||
) {
|
||||
scopedRootRows.push(lineageRoot);
|
||||
}
|
||||
const scopedRootKeys = new Set(scopedRootRows.map((row) => row.key));
|
||||
const promotedRows = collectPromotedMainChildRows({
|
||||
const sessionCandidateRows = collectSidebarSessionCandidateRows({
|
||||
rows,
|
||||
childRowsByParent: this.sessionData.childSessionRowsByParent,
|
||||
});
|
||||
const categorizedChildRows = collectCategorizedChildRootRows({
|
||||
rows: sessionCandidateRows,
|
||||
scopedRoots: scopedRootRows,
|
||||
visibilityOptions,
|
||||
});
|
||||
scopedRootRows.push(...categorizedChildRows);
|
||||
const scopedRootKeys = new Set(scopedRootRows.map((row) => row.key));
|
||||
const promotedRows = collectPromotedMainChildRows({
|
||||
rows: sessionCandidateRows,
|
||||
mainSessionKeys,
|
||||
scopedRootKeys,
|
||||
showCron: this.sessionsShowCron,
|
||||
@@ -665,7 +679,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
|
||||
}
|
||||
}
|
||||
const orderedRootRows =
|
||||
promotedRows.length > 0
|
||||
promotedRows.length > 0 || categorizedChildRows.length > 0
|
||||
? scopedRootRows.toSorted(this.compareSidebarSessionRows)
|
||||
: scopedRootRows;
|
||||
// `adopted` holds only catalog-bound keys (adoptedCatalogSessionKeys), not
|
||||
|
||||
@@ -44,6 +44,8 @@ export function projectSessionTree(params: {
|
||||
rowsByKey.set(row.key, row);
|
||||
}
|
||||
const childKeysByParent = new Map<string, string[]>();
|
||||
const hasExplicitCategory = (row: GatewaySessionRow | undefined) =>
|
||||
typeof row?.category === "string" && row.category.trim().length > 0;
|
||||
const appendChild = (parentKey: string, childKey: string) => {
|
||||
const keys = childKeysByParent.get(parentKey) ?? [];
|
||||
if (!keys.includes(childKey)) {
|
||||
@@ -54,6 +56,12 @@ export function projectSessionTree(params: {
|
||||
for (const row of rowsByKey.values()) {
|
||||
for (const childKey of row.childSessions ?? []) {
|
||||
const child = rowsByKey.get(childKey);
|
||||
// Manual category placement is a first-class sidebar destination. Once
|
||||
// a child is explicitly categorized, render it as a section root rather
|
||||
// than hiding it behind its lineage parent.
|
||||
if (hasExplicitCategory(child)) {
|
||||
continue;
|
||||
}
|
||||
const navigationParentKey = resolveUiSessionNavigationParentKey(child);
|
||||
// Runtime control and sidebar navigation can have different parents;
|
||||
// known children belong to their explicit navigation parent only.
|
||||
@@ -64,7 +72,7 @@ export function projectSessionTree(params: {
|
||||
}
|
||||
for (const row of rowsByKey.values()) {
|
||||
const parentKey = resolveUiSessionNavigationParentKey(row);
|
||||
if (parentKey) {
|
||||
if (parentKey && !hasExplicitCategory(row)) {
|
||||
appendChild(parentKey, row.key);
|
||||
}
|
||||
}
|
||||
@@ -147,6 +155,9 @@ export function projectSessionTree(params: {
|
||||
const rootKeys = new Set(roots.map((row) => row.key));
|
||||
return roots
|
||||
.filter((row) => {
|
||||
if (hasExplicitCategory(row)) {
|
||||
return true;
|
||||
}
|
||||
const parentKey = resolveUiSessionNavigationParentKey(row);
|
||||
return !parentKey || !rootKeys.has(parentKey);
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import "../test-helpers/app-sidebar-cases/catalog-live-state.ts";
|
||||
import "../test-helpers/app-sidebar-cases/catalog-ownership.ts";
|
||||
import "../test-helpers/app-sidebar-cases/catalog-terminal-owner.ts";
|
||||
import "../test-helpers/app-sidebar-cases/catalog-pages.ts";
|
||||
import "../test-helpers/app-sidebar-cases/categorized-child-sessions.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";
|
||||
|
||||
@@ -52,6 +52,7 @@ export {
|
||||
isSystemCreatedSessionRow,
|
||||
resolveSessionNavigation,
|
||||
sessionMatchesArchivedFilter,
|
||||
sessionMatchesVisibleSessionScope,
|
||||
scopedAgentIdForSession,
|
||||
scopedAgentListParamsForRefreshTarget,
|
||||
scopedAgentListParamsForSession,
|
||||
|
||||
@@ -273,6 +273,21 @@ export function sessionMatchesArchivedFilter(
|
||||
return (row.archived === true) === (archivedFilter === "archived");
|
||||
}
|
||||
|
||||
export function sessionMatchesVisibleSessionScope(
|
||||
row: GatewaySessionRow,
|
||||
options: VisibleSessionRowOptions,
|
||||
): boolean {
|
||||
return (
|
||||
sessionMatchesArchivedFilter(row, options.archivedFilter) &&
|
||||
row.kind !== "global" &&
|
||||
row.kind !== "unknown" &&
|
||||
(options.showCron === true || !isCronSessionKey(row.key)) &&
|
||||
(options.showSystem === true || !isSystemCreatedSessionRow(row)) &&
|
||||
(!options.filterByAgent ||
|
||||
isSessionKeyTiedToAgent(row.key, options.agentId, options.defaultAgentId))
|
||||
);
|
||||
}
|
||||
|
||||
export function filterVisibleSessionRows(
|
||||
rows: readonly GatewaySessionRow[],
|
||||
options: VisibleSessionRowOptions,
|
||||
@@ -286,15 +301,9 @@ export function filterVisibleSessionRows(
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
sessionMatchesArchivedFilter(row, options.archivedFilter) &&
|
||||
row.kind !== "global" &&
|
||||
row.kind !== "unknown" &&
|
||||
(options.showCron === true || !isCronSessionKey(row.key)) &&
|
||||
(options.showSystem === true || !isSystemCreatedSessionRow(row)) &&
|
||||
sessionMatchesVisibleSessionScope(row, options) &&
|
||||
!isSubagentSessionKey(row.key) &&
|
||||
!row.spawnedBy &&
|
||||
(!options.filterByAgent ||
|
||||
isSessionKeyTiedToAgent(row.key, options.agentId, options.defaultAgentId))
|
||||
!row.spawnedBy
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
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 categorized child sessions", () => {
|
||||
it("promotes a categorized child loaded through the expanded-parent cache", async () => {
|
||||
const parentKey = "agent:main:parent";
|
||||
const categorizedKey = "agent:main:cached-categorized-child";
|
||||
const ordinaryKey = "agent:main:cached-ordinary-child";
|
||||
const archivedKey = "agent:main:cached-archived-child";
|
||||
const harness = createSessionsHarness("main", [parentKey]);
|
||||
const parent = harness.sessions.state.result?.sessions[0];
|
||||
Object.assign(parent ?? {}, {
|
||||
childSessions: [categorizedKey, ordinaryKey, archivedKey],
|
||||
label: "Parent task",
|
||||
});
|
||||
harness.list.mockResolvedValue({
|
||||
count: 3,
|
||||
defaults: { contextTokens: null, model: null, modelProvider: null },
|
||||
path: "",
|
||||
sessions: [
|
||||
{
|
||||
category: "Research",
|
||||
key: categorizedKey,
|
||||
kind: "direct",
|
||||
label: "Cached categorized child",
|
||||
spawnedBy: parentKey,
|
||||
updatedAt: 3,
|
||||
},
|
||||
{
|
||||
key: ordinaryKey,
|
||||
kind: "direct",
|
||||
label: "Cached ordinary child",
|
||||
spawnedBy: parentKey,
|
||||
updatedAt: 2,
|
||||
},
|
||||
{
|
||||
archived: true,
|
||||
category: "Research",
|
||||
key: archivedKey,
|
||||
kind: "direct",
|
||||
label: "Cached archived child",
|
||||
spawnedBy: parentKey,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
ts: 1,
|
||||
});
|
||||
harness.publish({ groups: ["Research"] });
|
||||
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const { sidebar } = await mountSidebar(gateway, harness.sessions);
|
||||
sidebar.querySelector<HTMLButtonElement>("[data-child-session-toggle]")?.click();
|
||||
await waitForFast(() => expect(harness.list).toHaveBeenCalledOnce());
|
||||
|
||||
const research = sidebar.querySelector('[data-session-section="category:Research"]');
|
||||
await waitForFast(() =>
|
||||
expect(research?.querySelectorAll(`[data-session-key="${categorizedKey}"]`)).toHaveLength(1),
|
||||
);
|
||||
expect(
|
||||
sidebar.querySelector(
|
||||
`[data-session-tree="${parentKey}"] [data-session-key="${ordinaryKey}"]`,
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(sidebar.querySelector(`[data-session-key="${archivedKey}"]`)).toBeNull();
|
||||
});
|
||||
|
||||
it("places a categorized child in its section while keeping ordinary siblings nested", async () => {
|
||||
const harness = createSessionsHarness("main", [
|
||||
"agent:main:parent",
|
||||
"agent:main:categorized-child",
|
||||
"agent:main:ordinary-child",
|
||||
"agent:main:archived-child",
|
||||
]);
|
||||
const result = harness.sessions.state.result;
|
||||
if (!result) {
|
||||
throw new Error("expected child session fixtures");
|
||||
}
|
||||
const rowsByKey = new Map(result.sessions.map((row) => [row.key, row]));
|
||||
Object.assign(rowsByKey.get("agent:main:parent") ?? {}, {
|
||||
label: "Parent task",
|
||||
childSessions: [
|
||||
"agent:main:categorized-child",
|
||||
"agent:main:ordinary-child",
|
||||
"agent:main:archived-child",
|
||||
],
|
||||
});
|
||||
Object.assign(rowsByKey.get("agent:main:categorized-child") ?? {}, {
|
||||
spawnedBy: "agent:main:parent",
|
||||
label: "Categorized child",
|
||||
category: "Research",
|
||||
});
|
||||
Object.assign(rowsByKey.get("agent:main:ordinary-child") ?? {}, {
|
||||
spawnedBy: "agent:main:parent",
|
||||
label: "Ordinary child",
|
||||
});
|
||||
Object.assign(rowsByKey.get("agent:main:archived-child") ?? {}, {
|
||||
spawnedBy: "agent:main:parent",
|
||||
label: "Archived child",
|
||||
category: "Research",
|
||||
archived: true,
|
||||
});
|
||||
harness.publish({ groups: ["Research"] });
|
||||
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const { sidebar } = await mountSidebar(gateway, harness.sessions);
|
||||
|
||||
const research = sidebar.querySelector('[data-session-section="category:Research"]');
|
||||
expect(
|
||||
research?.querySelectorAll('[data-session-key="agent:main:categorized-child"]'),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
research?.querySelector('[data-session-key="agent:main:categorized-child"]')?.classList,
|
||||
).not.toContain("sidebar-recent-session--child");
|
||||
expect(sidebar.querySelector('[data-session-key="agent:main:archived-child"]')).toBeNull();
|
||||
|
||||
const parentTree = sidebar.querySelector('[data-session-tree="agent:main:parent"]');
|
||||
parentTree?.querySelector<HTMLButtonElement>("[data-child-session-toggle]")?.click();
|
||||
await sidebar.updateComplete;
|
||||
|
||||
expect(
|
||||
parentTree?.querySelectorAll('[data-session-key="agent:main:ordinary-child"]'),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
parentTree?.querySelector('[data-session-key="agent:main:categorized-child"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user