From fa407417f0a2c01308a657bf64ccd774397b4d4c Mon Sep 17 00:00:00 2001 From: ClawSweeper Date: Wed, 19 Aug 2026 14:39:24 -0700 Subject: [PATCH] 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> --- ...p-sidebar-session-navigation-logic.test.ts | 37 +++++ .../app-sidebar-session-navigation-logic.ts | 32 ++++- .../app-sidebar-session-navigation.ts | 42 ++++-- ui/src/components/app-sidebar-session-tree.ts | 13 +- ui/src/components/app-sidebar.test.ts | 1 + ui/src/lib/sessions/index.ts | 1 + ui/src/lib/sessions/navigation.ts | 25 ++-- .../categorized-child-sessions.ts | 130 ++++++++++++++++++ 8 files changed, 256 insertions(+), 25 deletions(-) create mode 100644 ui/src/test-helpers/app-sidebar-cases/categorized-child-sessions.ts diff --git a/ui/src/components/app-sidebar-session-navigation-logic.test.ts b/ui/src/components/app-sidebar-session-navigation-logic.test.ts index 6dcdbebe98f1..43c407c0d486 100644 --- a/ui/src/components/app-sidebar-session-navigation-logic.test.ts +++ b/ui/src/components/app-sidebar-session-navigation-logic.test.ts @@ -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], diff --git a/ui/src/components/app-sidebar-session-navigation-logic.ts b/ui/src/components/app-sidebar-session-navigation-logic.ts index 34beb08c12c8..40e997668ad3 100644 --- a/ui/src/components/app-sidebar-session-navigation-logic.ts +++ b/ui/src/components/app-sidebar-session-navigation-logic.ts @@ -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>; +}): 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>; mainSessionKeys: ReadonlySet; scopedRootKeys: ReadonlySet; 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[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, diff --git a/ui/src/components/app-sidebar-session-navigation.ts b/ui/src/components/app-sidebar-session-navigation.ts index 681ae16d3e25..9dfc0733d639 100644 --- a/ui/src/components/app-sidebar-session-navigation.ts +++ b/ui/src/components/app-sidebar-session-navigation.ts @@ -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 diff --git a/ui/src/components/app-sidebar-session-tree.ts b/ui/src/components/app-sidebar-session-tree.ts index 60a3e802f1d5..84f4294871d9 100644 --- a/ui/src/components/app-sidebar-session-tree.ts +++ b/ui/src/components/app-sidebar-session-tree.ts @@ -44,6 +44,8 @@ export function projectSessionTree(params: { rowsByKey.set(row.key, row); } const childKeysByParent = new Map(); + 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); }) diff --git a/ui/src/components/app-sidebar.test.ts b/ui/src/components/app-sidebar.test.ts index 6a7632c7b0f0..5e4bc418387d 100644 --- a/ui/src/components/app-sidebar.test.ts +++ b/ui/src/components/app-sidebar.test.ts @@ -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"; diff --git a/ui/src/lib/sessions/index.ts b/ui/src/lib/sessions/index.ts index 60b6d5bf137a..1a6334df0cf5 100644 --- a/ui/src/lib/sessions/index.ts +++ b/ui/src/lib/sessions/index.ts @@ -52,6 +52,7 @@ export { isSystemCreatedSessionRow, resolveSessionNavigation, sessionMatchesArchivedFilter, + sessionMatchesVisibleSessionScope, scopedAgentIdForSession, scopedAgentListParamsForRefreshTarget, scopedAgentListParamsForSession, diff --git a/ui/src/lib/sessions/navigation.ts b/ui/src/lib/sessions/navigation.ts index 72af03194f64..158ef4a1ef89 100644 --- a/ui/src/lib/sessions/navigation.ts +++ b/ui/src/lib/sessions/navigation.ts @@ -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 ); }); } diff --git a/ui/src/test-helpers/app-sidebar-cases/categorized-child-sessions.ts b/ui/src/test-helpers/app-sidebar-cases/categorized-child-sessions.ts new file mode 100644 index 000000000000..447a4320b7a5 --- /dev/null +++ b/ui/src/test-helpers/app-sidebar-cases/categorized-child-sessions.ts @@ -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("[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("[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(); + }); +});