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:
ClawSweeper
2026-08-19 14:39:24 -07:00
committed by GitHub
parent a3ca8466ee
commit fa407417f0
8 changed files with 256 additions and 25 deletions
@@ -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
+12 -1
View File
@@ -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);
})
+1
View File
@@ -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";
+1
View File
@@ -52,6 +52,7 @@ export {
isSystemCreatedSessionRow,
resolveSessionNavigation,
sessionMatchesArchivedFilter,
sessionMatchesVisibleSessionScope,
scopedAgentIdForSession,
scopedAgentListParamsForRefreshTarget,
scopedAgentListParamsForSession,
+17 -8
View File
@@ -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();
});
});