From bdd33799f75b915f79e424391607a1fd1110c651 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Thu, 9 Jul 2026 14:34:30 -0700 Subject: [PATCH 01/10] feat(sidebar): reveal nested subagent threads --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/components/Sidebar.logic.test.ts | 155 +++++++++ apps/web/src/components/Sidebar.logic.ts | 147 ++++++++ apps/web/src/components/Sidebar.tsx | 315 +++++++++++++++--- .../components/settings/SettingsPanels.tsx | 6 + packages/contracts/src/settings.test.ts | 14 + packages/contracts/src/settings.ts | 5 + 7 files changed, 595 insertions(+), 48 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ea7ec6e1512..8ec4a3a5963 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -27,6 +27,7 @@ const clientSettings: ClientSettings = { sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, + sidebarShowSubagentThreads: false, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 9cbe0f18fb4..9f7c94b05dc 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,7 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { createThreadJumpHintVisibilityController, + flattenSidebarSubagentTree, getSidebarThreadIdsToPrewarm, + getSidebarSubagentAncestorKeys, + getSidebarSubagentTreeRoots, getVisibleSidebarThreadIds, resolveAdjacentThreadId, getFallbackThreadIdAfterDelete, @@ -19,6 +22,7 @@ import { resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, + sidebarThreadKey, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, @@ -110,6 +114,157 @@ describe("sidebar thread lineage helpers", () => { expect(getSidebarForkParentThreadId(lineageFork)).toBe(fallbackParentId); expect(getSidebarForkParentThreadId(makeThreadFixture())).toBeNull(); }); + + it("renders expanded subagents as an indented direct-child tree", () => { + const rootId = ThreadId.make("thread-root"); + const childId = ThreadId.make("thread-child"); + const grandchildId = ThreadId.make("thread-grandchild"); + const siblingId = ThreadId.make("thread-sibling"); + const forkId = ThreadId.make("thread-fork"); + const root = makeThreadFixture({ id: rootId, title: "Root" }); + const child = makeThreadFixture({ + id: childId, + title: "Child", + lineage: { + rootThreadId: rootId, + parentThreadId: rootId, + relationshipToParent: "subagent", + }, + }); + const grandchild = makeThreadFixture({ + id: grandchildId, + title: "Grandchild", + lineage: { + rootThreadId: rootId, + parentThreadId: childId, + relationshipToParent: "subagent", + }, + }); + const sibling = makeThreadFixture({ + id: siblingId, + title: "Sibling", + lineage: { + rootThreadId: rootId, + parentThreadId: rootId, + relationshipToParent: "subagent", + }, + }); + const fork = makeThreadFixture({ + id: forkId, + title: "Fork", + lineage: { + rootThreadId: rootId, + parentThreadId: rootId, + relationshipToParent: "fork", + }, + }); + const threads = [root, child, grandchild, sibling, fork]; + const roots = getSidebarSubagentTreeRoots(threads); + const rows = flattenSidebarSubagentTree({ + threads, + roots, + expandedThreadKeys: new Set([sidebarThreadKey(root), sidebarThreadKey(child)]), + threadSortOrder: "created_at", + }); + + expect(roots.map((thread) => thread.id)).toEqual([rootId, forkId]); + expect(rows.map((row) => [row.thread.id, row.depth])).toEqual([ + [rootId, 0], + [siblingId, 1], + [childId, 1], + [grandchildId, 2], + [forkId, 0], + ]); + expect(rows[0]).toMatchObject({ + hasSubagentChildren: true, + isSubagentBranchExpanded: true, + }); + }); + + it("keeps descendants hidden until their branch is expanded", () => { + const rootId = ThreadId.make("thread-root"); + const childId = ThreadId.make("thread-child"); + const root = makeThreadFixture({ id: rootId }); + const child = makeThreadFixture({ + id: childId, + lineage: { + rootThreadId: rootId, + parentThreadId: rootId, + relationshipToParent: "subagent", + }, + }); + + const rows = flattenSidebarSubagentTree({ + threads: [root, child], + roots: getSidebarSubagentTreeRoots([root, child]), + expandedThreadKeys: new Set(), + threadSortOrder: "created_at", + }); + + expect(rows.map((row) => row.thread.id)).toEqual([rootId]); + expect(rows[0]).toMatchObject({ + hasSubagentChildren: true, + isSubagentBranchExpanded: false, + }); + }); + + it("returns every malformed lineage thread exactly once", () => { + const firstId = ThreadId.make("thread-cycle-a"); + const secondId = ThreadId.make("thread-cycle-b"); + const first = makeThreadFixture({ + id: firstId, + lineage: { + rootThreadId: firstId, + parentThreadId: secondId, + relationshipToParent: "subagent", + }, + }); + const second = makeThreadFixture({ + id: secondId, + lineage: { + rootThreadId: firstId, + parentThreadId: firstId, + relationshipToParent: "subagent", + }, + }); + const threads = [first, second]; + const roots = getSidebarSubagentTreeRoots(threads); + const rows = flattenSidebarSubagentTree({ + threads, + roots, + expandedThreadKeys: new Set([sidebarThreadKey(first), sidebarThreadKey(second)]), + threadSortOrder: "created_at", + }); + + expect(rows.map((row) => row.thread.id)).toEqual([firstId, secondId]); + }); + + it("returns every ancestor needed to reveal an active nested child", () => { + const rootId = ThreadId.make("thread-root"); + const childId = ThreadId.make("thread-child"); + const grandchildId = ThreadId.make("thread-grandchild"); + const root = makeThreadFixture({ id: rootId }); + const child = makeThreadFixture({ + id: childId, + lineage: { + rootThreadId: rootId, + parentThreadId: rootId, + relationshipToParent: "subagent", + }, + }); + const grandchild = makeThreadFixture({ + id: grandchildId, + lineage: { + rootThreadId: rootId, + parentThreadId: childId, + relationshipToParent: "subagent", + }, + }); + + expect( + getSidebarSubagentAncestorKeys([root, child, grandchild], sidebarThreadKey(grandchild)), + ).toEqual(new Set([sidebarThreadKey(child), sidebarThreadKey(root)])); + }); }); function makeLatestRun(overrides?: { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 14bd7f7d546..f350d2bc6d3 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,5 +1,6 @@ import * as React from "react"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { getThreadSortTimestamp, sortThreads, @@ -30,6 +31,152 @@ export function isSidebarSubagentThread(thread: Pick, +): string { + return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); +} + +export function sidebarSubagentParentKey(thread: SidebarThreadSummary): string | null { + if (thread.lineage.relationshipToParent !== "subagent") { + return null; + } + const parentThreadId = thread.lineage.parentThreadId; + return parentThreadId === null + ? null + : scopedThreadKey(scopeThreadRef(thread.environmentId, parentThreadId)); +} + +export function getSidebarSubagentAncestorKeys( + threads: readonly SidebarThreadSummary[], + threadKey: string | null, +): ReadonlySet { + if (threadKey === null) { + return new Set(); + } + + const threadByKey = new Map(threads.map((thread) => [sidebarThreadKey(thread), thread] as const)); + const ancestors = new Set(); + const visited = new Set([threadKey]); + let current = threadByKey.get(threadKey); + + while (current !== undefined) { + const parentKey = sidebarSubagentParentKey(current); + if (parentKey === null || visited.has(parentKey)) { + break; + } + ancestors.add(parentKey); + visited.add(parentKey); + current = threadByKey.get(parentKey); + } + + return ancestors; +} + +export function getSidebarSubagentTreeRoots( + threads: readonly SidebarThreadSummary[], +): readonly SidebarThreadSummary[] { + const threadKeys = new Set(threads.map(sidebarThreadKey)); + const childrenByParentKey = new Map(); + for (const thread of threads) { + const parentKey = sidebarSubagentParentKey(thread); + if (parentKey === null || !threadKeys.has(parentKey)) { + continue; + } + const children = childrenByParentKey.get(parentKey); + if (children === undefined) { + childrenByParentKey.set(parentKey, [thread]); + } else { + children.push(thread); + } + } + + const roots = threads.filter((thread) => { + const parentKey = sidebarSubagentParentKey(thread); + return parentKey === null || !threadKeys.has(parentKey); + }); + const placedKeys = new Set(); + const markPlaced = (thread: SidebarThreadSummary) => { + const key = sidebarThreadKey(thread); + if (placedKeys.has(key)) { + return; + } + placedKeys.add(key); + for (const child of childrenByParentKey.get(key) ?? []) { + markPlaced(child); + } + }; + for (const root of roots) { + markPlaced(root); + } + for (const thread of threads) { + if (placedKeys.has(sidebarThreadKey(thread))) { + continue; + } + roots.push(thread); + markPlaced(thread); + } + return roots; +} + +export function flattenSidebarSubagentTree(input: { + readonly threads: readonly SidebarThreadSummary[]; + readonly roots: readonly SidebarThreadSummary[]; + readonly expandedThreadKeys: ReadonlySet; + readonly threadSortOrder: SidebarThreadSortOrder; +}): readonly SidebarThreadTreeRow[] { + const threadKeys = new Set(input.threads.map(sidebarThreadKey)); + const childrenByParentKey = new Map(); + for (const thread of input.threads) { + const parentKey = sidebarSubagentParentKey(thread); + if (parentKey === null || !threadKeys.has(parentKey)) { + continue; + } + const children = childrenByParentKey.get(parentKey); + if (children === undefined) { + childrenByParentKey.set(parentKey, [thread]); + } else { + children.push(thread); + } + } + + for (const [parentKey, children] of childrenByParentKey) { + childrenByParentKey.set(parentKey, sortThreads(children, input.threadSortOrder)); + } + + const rows: SidebarThreadTreeRow[] = []; + const visited = new Set(); + const visit = (thread: SidebarThreadSummary, depth: number) => { + const key = sidebarThreadKey(thread); + if (visited.has(key)) { + return; + } + visited.add(key); + const children = childrenByParentKey.get(key) ?? []; + const hasSubagentChildren = children.length > 0; + const isSubagentBranchExpanded = hasSubagentChildren && input.expandedThreadKeys.has(key); + rows.push({ thread, depth, hasSubagentChildren, isSubagentBranchExpanded }); + if (!isSubagentBranchExpanded) { + return; + } + for (const child of children) { + visit(child, depth + 1); + } + }; + + for (const root of input.roots) { + visit(root, 0); + } + return rows; +} + export function getSidebarForkParentThreadId( thread: Pick, ) { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 55fba6bf00d..23a3dc7e286 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -151,6 +151,7 @@ import { import { Input } from "./ui/input"; import { Menu, + MenuCheckboxItem, MenuGroup, MenuPopup, MenuRadioGroup, @@ -186,7 +187,10 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; import { getSidebarForkParentThreadId, + getSidebarSubagentAncestorKeys, + getSidebarSubagentTreeRoots, getSidebarThreadIdsToPrewarm, + flattenSidebarSubagentTree, isSidebarSubagentThread, resolveAdjacentThreadId, isContextMenuPointerDown, @@ -198,6 +202,9 @@ import { resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, + sidebarThreadKey, + sidebarSubagentParentKey, + type SidebarThreadTreeRow, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, useThreadJumpHintVisibility, @@ -322,6 +329,10 @@ function buildThreadJumpLabelMap(input: { interface SidebarThreadRowProps { thread: SidebarThreadSummary; + depth: number; + hasSubagentChildren: boolean; + isSubagentBranchExpanded: boolean; + showSubagentTree: boolean; projectCwd: string | null; orderedProjectThreadKeys: readonly string[]; isActive: boolean; @@ -356,10 +367,15 @@ interface SidebarThreadRowProps { cancelRename: () => void; attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; openPrLink: (event: React.MouseEvent, prUrl: string) => void; + toggleSubagentBranch: (threadKey: string) => void; } export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { const { + depth, + hasSubagentChildren, + isSubagentBranchExpanded, + showSubagentTree, orderedProjectThreadKeys, isActive, jumpLabel, @@ -382,6 +398,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr cancelRename, attemptArchiveThread, openPrLink, + toggleSubagentBranch, thread, } = props; const threadRef = scopeThreadRef(thread.environmentId, thread.id); @@ -680,12 +697,32 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr }, [attemptArchiveThread, threadRef], ); + const handleToggleSubagentBranch = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + toggleSubagentBranch(threadKey); + }, + [threadKey, toggleSubagentBranch], + ); + const handleSubagentBranchKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") { + return; + } + event.preventDefault(); + event.stopPropagation(); + toggleSubagentBranch(threadKey); + }, + [threadKey, toggleSubagentBranch], + ); const rowButtonRender = useMemo(() =>
, []); return ( 0 ? { paddingLeft: `${Math.min(depth, 6) * 0.75}rem` } : undefined} onMouseLeave={handleMouseLeave} onBlurCapture={handleBlurCapture} > @@ -704,6 +741,37 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr onContextMenu={handleRowContextMenu} >
+ {showSubagentTree ? ( + hasSubagentChildren ? ( + + + + + } + /> + + {isSubagentBranchExpanded ? "Collapse subagents" : "Expand subagents"} + + + ) : ( +
+ +
+ Subagent threads +
+ + Show nested subagents + +
@@ -2877,6 +3011,7 @@ interface SidebarProjectsContentProps { threadSortOrder: SidebarThreadSortOrder; projectGroupingMode: SidebarProjectGroupingMode; threadPreviewCount: SidebarThreadPreviewCount; + showSubagentThreads: boolean; updateSettings: ReturnType; openAddProject: () => void; isManualProjectSorting: boolean; @@ -2898,6 +3033,8 @@ interface SidebarProjectsContentProps { attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; expandThreadListForProject: (projectKey: string) => void; collapseThreadListForProject: (projectKey: string) => void; + expandedSubagentThreadKeys: ReadonlySet; + toggleSubagentBranch: (threadKey: string) => void; dragInProgressRef: React.RefObject; suppressProjectClickAfterDragRef: React.RefObject; suppressProjectClickForContextMenuRef: React.RefObject; @@ -2918,6 +3055,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( threadSortOrder, projectGroupingMode, threadPreviewCount, + showSubagentThreads, updateSettings, openAddProject, isManualProjectSorting, @@ -2939,6 +3077,8 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( attachThreadListAutoAnimateRef, expandThreadListForProject, collapseThreadListForProject, + expandedSubagentThreadKeys, + toggleSubagentBranch, dragInProgressRef, suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef, @@ -2970,6 +3110,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }, [updateSettings], ); + const handleShowSubagentThreadsChange = useCallback( + (showNestedSubagents: boolean) => { + updateSettings({ sidebarShowSubagentThreads: showNestedSubagents }); + }, + [updateSettings], + ); return ( @@ -3031,10 +3177,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( threadSortOrder={threadSortOrder} projectGroupingMode={projectGroupingMode} threadPreviewCount={threadPreviewCount} + showSubagentThreads={showSubagentThreads} onProjectSortOrderChange={handleProjectSortOrderChange} onThreadSortOrderChange={handleThreadSortOrderChange} onProjectGroupingModeChange={handleProjectGroupingModeChange} onThreadPreviewCountChange={handleThreadPreviewCountChange} + onShowSubagentThreadsChange={handleShowSubagentThreadsChange} /> s.sidebarProjectGroupingMode); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); + const sidebarShowSubagentThreads = useClientSettings((s) => s.sidebarShowSubagentThreads); const updateSettings = useUpdateClientSettings(); const handleNewThread = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); @@ -3171,6 +3324,9 @@ export default function Sidebar() { const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); + const [expandedSubagentThreadKeys, setExpandedSubagentThreadKeys] = useState>( + () => new Set(), + ); const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); @@ -3260,6 +3416,33 @@ export default function Sidebar() { ), [sidebarThreads], ); + useEffect(() => { + if (!sidebarShowSubagentThreads || routeThreadKey === null) { + return; + } + const ancestorKeys = getSidebarSubagentAncestorKeys(sidebarThreads, routeThreadKey); + if (ancestorKeys.size === 0) { + return; + } + setExpandedSubagentThreadKeys((current) => { + const next = new Set(current); + for (const key of ancestorKeys) { + next.add(key); + } + return next.size === current.size ? current : next; + }); + }, [routeThreadKey, sidebarShowSubagentThreads, sidebarThreads]); + const toggleSubagentBranch = useCallback((threadKey: string) => { + setExpandedSubagentThreadKeys((current) => { + const next = new Set(current); + if (next.has(threadKey)) { + next.delete(threadKey); + } else { + next.add(threadKey); + } + return next; + }); + }, []); // Resolve the active route's project key to a logical key so it matches the // sidebar's grouped project entries. const activeRouteProjectKey = useMemo(() => { @@ -3280,7 +3463,7 @@ export default function Sidebar() { const threadsByProjectKey = useMemo(() => { const next = new Map(); for (const thread of sidebarThreads) { - if (isSidebarSubagentThread(thread)) { + if (!sidebarShowSubagentThreads && isSidebarSubagentThread(thread)) { continue; } const physicalKey = @@ -3296,7 +3479,12 @@ export default function Sidebar() { } } return next; - }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); + }, [ + sidebarShowSubagentThreads, + sidebarThreads, + physicalToLogicalKey, + projectPhysicalKeyByScopedRef, + ]); const getCurrentSidebarShortcutContext = useCallback( () => ({ terminalFocus: isTerminalFocused(), @@ -3446,43 +3634,71 @@ export default function Sidebar() { const visibleSidebarThreadKeys = useMemo( () => sortedProjects.flatMap((project) => { - const projectThreads = sortThreads( + const sortedProjectThreads = sortThreads( (threadsByProjectKey.get(project.projectKey) ?? []).filter( (thread) => thread.archivedAt === null, ), sidebarThreadSortOrder, ); + const rootProjectThreads = getSidebarSubagentTreeRoots(sortedProjectThreads); const projectExpanded = resolveProjectExpanded( projectExpandedById, projectExpansionPreferenceKeys(project), ); const activeThreadKey = routeThreadKey ?? undefined; - const pinnedCollapsedThread = - !projectExpanded && activeThreadKey - ? (projectThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === - activeThreadKey, - ) ?? null) - : null; + const pinnedCollapsedThread = (() => { + if (projectExpanded || activeThreadKey === undefined) { + return null; + } + const threadByKey = new Map( + sortedProjectThreads.map((thread) => [sidebarThreadKey(thread), thread] as const), + ); + let current = threadByKey.get(activeThreadKey) ?? null; + const visited = new Set(); + while (current !== null) { + const currentKey = sidebarThreadKey(current); + const parentKey = sidebarSubagentParentKey(current); + const parent = parentKey === null ? null : (threadByKey.get(parentKey) ?? null); + if (parent === null || visited.has(currentKey)) { + break; + } + visited.add(currentKey); + current = parent; + } + return current; + })(); const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; if (!shouldShowThreadPanel) { return []; } const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); - const hasOverflowingThreads = projectThreads.length > sidebarThreadPreviewCount; - const previewThreads = + const hasOverflowingThreads = rootProjectThreads.length > sidebarThreadPreviewCount; + const previewRoots = isThreadListExpanded || !hasOverflowingThreads - ? projectThreads - : projectThreads.slice(0, sidebarThreadPreviewCount); - const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; - return renderedThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ? rootProjectThreads + : rootProjectThreads.slice(0, sidebarThreadPreviewCount); + const visibleRootThreadKeys = new Set( + [...previewRoots, ...(pinnedCollapsedThread ? [pinnedCollapsedThread] : [])].map( + sidebarThreadKey, + ), ); + const renderedRoots = pinnedCollapsedThread + ? [pinnedCollapsedThread] + : rootProjectThreads.filter((thread) => + visibleRootThreadKeys.has(sidebarThreadKey(thread)), + ); + return flattenSidebarSubagentTree({ + threads: sortedProjectThreads, + roots: renderedRoots, + expandedThreadKeys: sidebarShowSubagentThreads ? expandedSubagentThreadKeys : new Set(), + threadSortOrder: sidebarThreadSortOrder, + }).map((row) => sidebarThreadKey(row.thread)); }), [ + expandedSubagentThreadKeys, sidebarThreadSortOrder, sidebarThreadPreviewCount, + sidebarShowSubagentThreads, expandedThreadListsByProject, projectExpandedById, routeThreadKey, @@ -3756,6 +3972,7 @@ export default function Sidebar() { threadSortOrder={sidebarThreadSortOrder} projectGroupingMode={sidebarProjectGroupingMode} threadPreviewCount={sidebarThreadPreviewCount} + showSubagentThreads={sidebarShowSubagentThreads} updateSettings={updateSettings} openAddProject={openAddProjectCommandPalette} isManualProjectSorting={isManualProjectSorting} @@ -3777,6 +3994,8 @@ export default function Sidebar() { attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} expandThreadListForProject={expandThreadListForProject} collapseThreadListForProject={collapseThreadListForProject} + expandedSubagentThreadKeys={expandedSubagentThreadKeys} + toggleSubagentBranch={toggleSubagentBranch} dragInProgressRef={dragInProgressRef} suppressProjectClickAfterDragRef={suppressProjectClickAfterDragRef} suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 4376b2026cd..685baeba0d7 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -393,6 +393,10 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount ? ["Visible threads"] : []), + ...(settings.sidebarShowSubagentThreads !== + DEFAULT_UNIFIED_SETTINGS.sidebarShowSubagentThreads + ? ["Nested subagents"] + : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] @@ -437,6 +441,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.automaticGitFetchInterval, settings.enableAssistantStreaming, settings.sidebarThreadPreviewCount, + settings.sidebarShowSubagentThreads, settings.timestampFormat, settings.wordWrap, theme, @@ -459,6 +464,7 @@ export function useSettingsRestore(onRestored?: () => void) { wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, + sidebarShowSubagentThreads: DEFAULT_UNIFIED_SETTINGS.sidebarShowSubagentThreads, autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 729f3537242..4fc4d26a318 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; import { ProviderInstanceId } from "./providerInstance.ts"; import { + ClientSettingsPatch, ClientSettingsSchema, DEFAULT_SERVER_SETTINGS, ServerSettings, @@ -10,6 +11,7 @@ import { } from "./settings.ts"; const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema); +const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch); const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); @@ -31,6 +33,18 @@ describe("ClientSettings word wrap", () => { }); }); +describe("ClientSettings sidebar subagents", () => { + it("hides nested subagents by default for existing settings", () => { + expect(decodeClientSettings({}).sidebarShowSubagentThreads).toBe(false); + }); + + it("accepts a persisted nested-subagent preference", () => { + expect(decodeClientSettingsPatch({ sidebarShowSubagentThreads: true })).toEqual({ + sidebarShowSubagentThreads: true, + }); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults to an empty record so legacy configs without the key still decode", () => { expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index dde393a9308..03508db4061 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -38,6 +38,7 @@ export const SidebarThreadPreviewCount = Schema.Int.check( ); export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; +export const DEFAULT_SIDEBAR_SHOW_SUBAGENT_THREADS = false; export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -88,6 +89,9 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), + sidebarShowSubagentThreads: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_SHOW_SUBAGENT_THREADS)), + ), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), @@ -596,6 +600,7 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), + sidebarShowSubagentThreads: Schema.optionalKey(Schema.Boolean), timestampFormat: Schema.optionalKey(TimestampFormat), wordWrap: Schema.optionalKey(Schema.Boolean), }); From a2f733bc64ba634686e0d72e1d67bbe0a4be843b Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Thu, 9 Jul 2026 19:44:52 -0700 Subject: [PATCH 02/10] refactor(client-runtime): share subagent thread tree projection --- apps/web/src/components/Sidebar.logic.ts | 136 ++--------------- .../src/state/threadRelationships.test.ts | 98 +++++++++++- .../src/state/threadRelationships.ts | 143 ++++++++++++++++++ 3 files changed, 256 insertions(+), 121 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f350d2bc6d3..515d7740c86 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,6 +1,14 @@ import * as React from "react"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; -import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + flattenSubagentThreadTree, + getSubagentThreadAncestorKeys, + getSubagentThreadTreeRoots, + isSubagentThread, + subagentParentThreadKey, + subagentThreadKey, + type SubagentThreadTreeRow, +} from "@t3tools/client-runtime/state/thread-relationships"; import { getThreadSortTimestamp, sortThreads, @@ -28,102 +36,32 @@ type SidebarProject = { export type ThreadTraversalDirection = "previous" | "next"; export function isSidebarSubagentThread(thread: Pick): boolean { - return thread.lineage.relationshipToParent === "subagent"; + return isSubagentThread(thread); } -export interface SidebarThreadTreeRow { - readonly thread: SidebarThreadSummary; - readonly depth: number; - readonly hasSubagentChildren: boolean; - readonly isSubagentBranchExpanded: boolean; -} +export type SidebarThreadTreeRow = SubagentThreadTreeRow; export function sidebarThreadKey( thread: Pick, ): string { - return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + return subagentThreadKey(thread); } export function sidebarSubagentParentKey(thread: SidebarThreadSummary): string | null { - if (thread.lineage.relationshipToParent !== "subagent") { - return null; - } - const parentThreadId = thread.lineage.parentThreadId; - return parentThreadId === null - ? null - : scopedThreadKey(scopeThreadRef(thread.environmentId, parentThreadId)); + return subagentParentThreadKey(thread); } export function getSidebarSubagentAncestorKeys( threads: readonly SidebarThreadSummary[], threadKey: string | null, ): ReadonlySet { - if (threadKey === null) { - return new Set(); - } - - const threadByKey = new Map(threads.map((thread) => [sidebarThreadKey(thread), thread] as const)); - const ancestors = new Set(); - const visited = new Set([threadKey]); - let current = threadByKey.get(threadKey); - - while (current !== undefined) { - const parentKey = sidebarSubagentParentKey(current); - if (parentKey === null || visited.has(parentKey)) { - break; - } - ancestors.add(parentKey); - visited.add(parentKey); - current = threadByKey.get(parentKey); - } - - return ancestors; + return getSubagentThreadAncestorKeys(threads, threadKey); } export function getSidebarSubagentTreeRoots( threads: readonly SidebarThreadSummary[], ): readonly SidebarThreadSummary[] { - const threadKeys = new Set(threads.map(sidebarThreadKey)); - const childrenByParentKey = new Map(); - for (const thread of threads) { - const parentKey = sidebarSubagentParentKey(thread); - if (parentKey === null || !threadKeys.has(parentKey)) { - continue; - } - const children = childrenByParentKey.get(parentKey); - if (children === undefined) { - childrenByParentKey.set(parentKey, [thread]); - } else { - children.push(thread); - } - } - - const roots = threads.filter((thread) => { - const parentKey = sidebarSubagentParentKey(thread); - return parentKey === null || !threadKeys.has(parentKey); - }); - const placedKeys = new Set(); - const markPlaced = (thread: SidebarThreadSummary) => { - const key = sidebarThreadKey(thread); - if (placedKeys.has(key)) { - return; - } - placedKeys.add(key); - for (const child of childrenByParentKey.get(key) ?? []) { - markPlaced(child); - } - }; - for (const root of roots) { - markPlaced(root); - } - for (const thread of threads) { - if (placedKeys.has(sidebarThreadKey(thread))) { - continue; - } - roots.push(thread); - markPlaced(thread); - } - return roots; + return getSubagentThreadTreeRoots(threads); } export function flattenSidebarSubagentTree(input: { @@ -132,49 +70,7 @@ export function flattenSidebarSubagentTree(input: { readonly expandedThreadKeys: ReadonlySet; readonly threadSortOrder: SidebarThreadSortOrder; }): readonly SidebarThreadTreeRow[] { - const threadKeys = new Set(input.threads.map(sidebarThreadKey)); - const childrenByParentKey = new Map(); - for (const thread of input.threads) { - const parentKey = sidebarSubagentParentKey(thread); - if (parentKey === null || !threadKeys.has(parentKey)) { - continue; - } - const children = childrenByParentKey.get(parentKey); - if (children === undefined) { - childrenByParentKey.set(parentKey, [thread]); - } else { - children.push(thread); - } - } - - for (const [parentKey, children] of childrenByParentKey) { - childrenByParentKey.set(parentKey, sortThreads(children, input.threadSortOrder)); - } - - const rows: SidebarThreadTreeRow[] = []; - const visited = new Set(); - const visit = (thread: SidebarThreadSummary, depth: number) => { - const key = sidebarThreadKey(thread); - if (visited.has(key)) { - return; - } - visited.add(key); - const children = childrenByParentKey.get(key) ?? []; - const hasSubagentChildren = children.length > 0; - const isSubagentBranchExpanded = hasSubagentChildren && input.expandedThreadKeys.has(key); - rows.push({ thread, depth, hasSubagentChildren, isSubagentBranchExpanded }); - if (!isSubagentBranchExpanded) { - return; - } - for (const child of children) { - visit(child, depth + 1); - } - }; - - for (const root of input.roots) { - visit(root, 0); - } - return rows; + return flattenSubagentThreadTree(input); } export function getSidebarForkParentThreadId( diff --git a/packages/client-runtime/src/state/threadRelationships.test.ts b/packages/client-runtime/src/state/threadRelationships.test.ts index b601222654c..5c006265ca8 100644 --- a/packages/client-runtime/src/state/threadRelationships.test.ts +++ b/packages/client-runtime/src/state/threadRelationships.test.ts @@ -1,15 +1,111 @@ import { describe, expect, it } from "vite-plus/test"; -import { ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { deriveThreadRelationshipGraph, + flattenSubagentThreadTree, + getSubagentThreadAncestorKeys, + getSubagentThreadTreeRoots, immediateThreadRelationships, + isSubagentThread, relatedThreadIds, resolveMergeBackTargetThreadId, + subagentThreadKey, + type SubagentThreadTreeInput, walkThreadRelationships, } from "./threadRelationships.ts"; +const environmentId = EnvironmentId.make("environment-1"); + +function treeThread(input: { + readonly id: string; + readonly parentId?: string | null; + readonly relationship?: "subagent" | "fork" | null; + readonly updatedAt?: string; +}): SubagentThreadTreeInput { + const id = ThreadId.make(input.id); + const parentThreadId = input.parentId ? ThreadId.make(input.parentId) : null; + return { + environmentId, + id, + lineage: { + rootThreadId: parentThreadId ?? id, + parentThreadId, + relationshipToParent: input.relationship ?? null, + }, + createdAt: input.updatedAt ?? "2026-07-01T00:00:00.000Z", + updatedAt: input.updatedAt ?? "2026-07-01T00:00:00.000Z", + latestUserMessageAt: input.updatedAt ?? null, + }; +} + describe("thread relationships", () => { + it("projects nested subagents while keeping forks as roots", () => { + const root = treeThread({ id: "root" }); + const olderChild = treeThread({ + id: "child-old", + parentId: "root", + relationship: "subagent", + updatedAt: "2026-07-01T00:00:00.000Z", + }); + const newerChild = treeThread({ + id: "child-new", + parentId: "root", + relationship: "subagent", + updatedAt: "2026-07-02T00:00:00.000Z", + }); + const grandchild = treeThread({ + id: "grandchild", + parentId: "child-new", + relationship: "subagent", + }); + const fork = treeThread({ id: "fork", parentId: "root", relationship: "fork" }); + const threads = [root, olderChild, newerChild, grandchild, fork]; + const roots = getSubagentThreadTreeRoots(threads); + const rows = flattenSubagentThreadTree({ + threads, + roots, + expandedThreadKeys: new Set([subagentThreadKey(root), subagentThreadKey(newerChild)]), + threadSortOrder: "updated_at", + }); + + expect(isSubagentThread(olderChild)).toBe(true); + expect(roots.map((thread) => thread.id)).toEqual([root.id, fork.id]); + expect(rows.map((row) => [row.thread.id, row.depth])).toEqual([ + [root.id, 0], + [newerChild.id, 1], + [grandchild.id, 2], + [olderChild.id, 1], + [fork.id, 0], + ]); + expect(getSubagentThreadAncestorKeys(threads, subagentThreadKey(grandchild))).toEqual( + new Set([subagentThreadKey(newerChild), subagentThreadKey(root)]), + ); + }); + + it("keeps orphans and rootless cycles visible exactly once", () => { + const orphan = treeThread({ + id: "orphan", + parentId: "missing", + relationship: "subagent", + }); + const first = treeThread({ id: "cycle-a", parentId: "cycle-b", relationship: "subagent" }); + const second = treeThread({ id: "cycle-b", parentId: "cycle-a", relationship: "subagent" }); + const threads = [orphan, first, second]; + const roots = getSubagentThreadTreeRoots(threads); + const rows = flattenSubagentThreadTree({ + threads, + roots, + expandedThreadKeys: new Set(threads.map(subagentThreadKey)), + threadSortOrder: "created_at", + }); + + expect(roots.map((thread) => thread.id)).toEqual([orphan.id, first.id]); + expect(rows.map((row) => row.thread.id)).toEqual([orphan.id, first.id, second.id]); + expect(new Set(rows.map((row) => subagentThreadKey(row.thread))).size).toBe(3); + expect(rows.at(-1)?.hasSubagentChildren).toBe(false); + }); + it("keeps missing parents and cycles navigable without recursive traversal", () => { const root = ThreadId.make("thread-root"); const child = ThreadId.make("thread-child"); diff --git a/packages/client-runtime/src/state/threadRelationships.ts b/packages/client-runtime/src/state/threadRelationships.ts index d57c5a468bb..d7a91dad04d 100644 --- a/packages/client-runtime/src/state/threadRelationships.ts +++ b/packages/client-runtime/src/state/threadRelationships.ts @@ -1,9 +1,152 @@ import type { + SidebarThreadSortOrder, OrchestrationV2ThreadProjection, OrchestrationV2ThreadShell, ThreadId, } from "@t3tools/contracts"; +import { scopedThreadKey, scopeThreadRef } from "../environment/scoped.ts"; +import type { EnvironmentThreadShell } from "./models.ts"; +import { sortThreads, type ThreadSortInput } from "./threadSort.ts"; + +export type SubagentThreadTreeInput = Pick< + EnvironmentThreadShell, + "environmentId" | "id" | "lineage" +> & + ThreadSortInput; + +export interface SubagentThreadTreeRow< + Thread extends SubagentThreadTreeInput = SubagentThreadTreeInput, +> { + readonly thread: Thread; + readonly depth: number; + readonly hasSubagentChildren: boolean; + readonly isSubagentBranchExpanded: boolean; +} + +export function isSubagentThread(thread: Pick): boolean { + return thread.lineage.relationshipToParent === "subagent"; +} + +export function subagentThreadKey( + thread: Pick, +): string { + return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); +} + +export function subagentParentThreadKey(thread: SubagentThreadTreeInput): string | null { + if (!isSubagentThread(thread)) return null; + const parentThreadId = thread.lineage.parentThreadId; + return parentThreadId === null + ? null + : scopedThreadKey(scopeThreadRef(thread.environmentId, parentThreadId)); +} + +function indexSubagentThreads(threads: readonly Thread[]) { + const threadKeys = new Set(threads.map(subagentThreadKey)); + const childrenByParentKey = new Map(); + for (const thread of threads) { + const parentKey = subagentParentThreadKey(thread); + if (parentKey === null || !threadKeys.has(parentKey)) continue; + const children = childrenByParentKey.get(parentKey); + if (children === undefined) childrenByParentKey.set(parentKey, [thread]); + else children.push(thread); + } + return { threadKeys, childrenByParentKey } as const; +} + +/** + * Returns every subagent ancestor needed to reveal `threadKey` in a collapsed + * tree. Scoped keys prevent same-named threads in different environments from + * being joined. Malformed cycles stop at the first repeated node. + */ +export function getSubagentThreadAncestorKeys( + threads: readonly Thread[], + threadKey: string | null, +): ReadonlySet { + if (threadKey === null) return new Set(); + + const threadByKey = new Map( + threads.map((thread) => [subagentThreadKey(thread), thread] as const), + ); + const ancestors = new Set(); + const visited = new Set([threadKey]); + let current = threadByKey.get(threadKey); + + while (current !== undefined) { + const parentKey = subagentParentThreadKey(current); + if (parentKey === null || visited.has(parentKey)) break; + ancestors.add(parentKey); + visited.add(parentKey); + current = threadByKey.get(parentKey); + } + + return ancestors; +} + +/** + * Selects roots for the nested subagent presentation. Forks remain top-level. + * Orphans are roots. If malformed lineage forms a rootless cycle, the first + * unplaced thread becomes a deterministic synthetic root so every thread stays + * visible exactly once. + */ +export function getSubagentThreadTreeRoots( + threads: readonly Thread[], +): readonly Thread[] { + const { threadKeys, childrenByParentKey } = indexSubagentThreads(threads); + + const roots = threads.filter((thread) => { + const parentKey = subagentParentThreadKey(thread); + return parentKey === null || !threadKeys.has(parentKey); + }); + const placedKeys = new Set(); + const markPlaced = (thread: Thread) => { + const key = subagentThreadKey(thread); + if (placedKeys.has(key)) return; + placedKeys.add(key); + for (const child of childrenByParentKey.get(key) ?? []) markPlaced(child); + }; + for (const root of roots) markPlaced(root); + for (const thread of threads) { + if (placedKeys.has(subagentThreadKey(thread))) continue; + roots.push(thread); + markPlaced(thread); + } + return roots; +} + +export function flattenSubagentThreadTree(input: { + readonly threads: readonly Thread[]; + readonly roots: readonly Thread[]; + readonly expandedThreadKeys: ReadonlySet; + readonly threadSortOrder: SidebarThreadSortOrder; +}): readonly SubagentThreadTreeRow[] { + const { childrenByParentKey } = indexSubagentThreads(input.threads); + + for (const [parentKey, children] of childrenByParentKey) { + childrenByParentKey.set(parentKey, sortThreads(children, input.threadSortOrder)); + } + + const rows: SubagentThreadTreeRow[] = []; + const visited = new Set(); + const visit = (thread: Thread, depth: number) => { + const key = subagentThreadKey(thread); + if (visited.has(key)) return; + visited.add(key); + const children = (childrenByParentKey.get(key) ?? []).filter( + (child) => !visited.has(subagentThreadKey(child)), + ); + const hasSubagentChildren = children.length > 0; + const isSubagentBranchExpanded = hasSubagentChildren && input.expandedThreadKeys.has(key); + rows.push({ thread, depth, hasSubagentChildren, isSubagentBranchExpanded }); + if (!isSubagentBranchExpanded) return; + for (const child of children) visit(child, depth + 1); + }; + + for (const root of input.roots) visit(root, 0); + return rows; +} + export type ThreadRelationshipKind = "parent" | "fork" | "subagent" | "transfer"; export interface ThreadRelationshipNode { From 0ce30a6763d7fc478d5979898c224b57305f9351 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Fri, 10 Jul 2026 12:00:25 -0700 Subject: [PATCH 03/10] fix(sidebar): preserve nested thread navigation --- apps/web/src/components/Sidebar.logic.test.ts | 65 ++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 15 ++++ apps/web/src/components/Sidebar.tsx | 77 ++++++++++--------- apps/web/src/uiStateStore.test.ts | 21 +++++ apps/web/src/uiStateStore.ts | 33 ++++++++ .../src/state/threadRelationships.test.ts | 25 ++++++ .../src/state/threadRelationships.ts | 6 +- 7 files changed, 204 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 9f7c94b05dc..611d067b61e 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -5,6 +5,7 @@ import { getSidebarThreadIdsToPrewarm, getSidebarSubagentAncestorKeys, getSidebarSubagentTreeRoots, + getSidebarThreadSelectionKeys, getVisibleSidebarThreadIds, resolveAdjacentThreadId, getFallbackThreadIdAfterDelete, @@ -19,6 +20,7 @@ import { resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, + resolveSidebarSubagentBranchExpanded, resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, @@ -265,6 +267,69 @@ describe("sidebar thread lineage helpers", () => { getSidebarSubagentAncestorKeys([root, child, grandchild], sidebarThreadKey(grandchild)), ).toEqual(new Set([sidebarThreadKey(child), sidebarThreadKey(root)])); }); + + it("uses rendered hierarchy order for range selection", () => { + const rootId = ThreadId.make("thread-root"); + const childId = ThreadId.make("thread-child"); + const grandchildId = ThreadId.make("thread-grandchild"); + const root = makeThreadFixture({ id: rootId }); + const child = makeThreadFixture({ + id: childId, + lineage: { + rootThreadId: rootId, + parentThreadId: rootId, + relationshipToParent: "subagent", + }, + }); + const grandchild = makeThreadFixture({ + id: grandchildId, + lineage: { + rootThreadId: rootId, + parentThreadId: childId, + relationshipToParent: "subagent", + }, + }); + const threads = [root, child, grandchild]; + const rows = flattenSidebarSubagentTree({ + threads, + roots: getSidebarSubagentTreeRoots(threads), + expandedThreadKeys: new Set([sidebarThreadKey(root)]), + threadSortOrder: "created_at", + }); + + expect(getSidebarThreadSelectionKeys(rows)).toEqual([ + sidebarThreadKey(root), + sidebarThreadKey(child), + ]); + expect(getSidebarThreadSelectionKeys(rows)).not.toContain(sidebarThreadKey(grandchild)); + }); + + it("keeps an active thread's ancestor expanded when toggled", () => { + const parentKey = "environment:parent"; + const expandedThreadKeys = new Set([parentKey]); + + expect( + resolveSidebarSubagentBranchExpanded({ + threadKey: parentKey, + expandedThreadKeys, + activeThreadAncestorKeys: new Set([parentKey]), + }), + ).toBe(true); + expect( + resolveSidebarSubagentBranchExpanded({ + threadKey: parentKey, + expandedThreadKeys, + activeThreadAncestorKeys: new Set(), + }), + ).toBe(false); + expect( + resolveSidebarSubagentBranchExpanded({ + threadKey: "environment:collapsed-parent", + expandedThreadKeys, + activeThreadAncestorKeys: new Set(), + }), + ).toBe(true); + }); }); function makeLatestRun(overrides?: { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 515d7740c86..f5a0156164f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -73,6 +73,21 @@ export function flattenSidebarSubagentTree(input: { return flattenSubagentThreadTree(input); } +export function getSidebarThreadSelectionKeys( + rows: readonly SidebarThreadTreeRow[], +): readonly string[] { + return rows.map((row) => sidebarThreadKey(row.thread)); +} + +export function resolveSidebarSubagentBranchExpanded(input: { + readonly threadKey: string; + readonly expandedThreadKeys: ReadonlySet; + readonly activeThreadAncestorKeys: ReadonlySet; +}): boolean { + if (input.activeThreadAncestorKeys.has(input.threadKey)) return true; + return !input.expandedThreadKeys.has(input.threadKey); +} + export function getSidebarForkParentThreadId( thread: Pick, ) { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 23a3dc7e286..7d91999cc44 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -190,6 +190,7 @@ import { getSidebarSubagentAncestorKeys, getSidebarSubagentTreeRoots, getSidebarThreadIdsToPrewarm, + getSidebarThreadSelectionKeys, flattenSidebarSubagentTree, isSidebarSubagentThread, resolveAdjacentThreadId, @@ -199,6 +200,7 @@ import { resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, resolveSidebarStageBadgeLabel, + resolveSidebarSubagentBranchExpanded, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, @@ -1369,12 +1371,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return counts; }, [memberProjectByScopedKey, project.memberProjects, projectThreads]); - const { - projectStatus, - sortedProjectThreads, - visibleRootProjectThreads, - orderedProjectThreadKeys, - } = useMemo(() => { + const { projectStatus, sortedProjectThreads, visibleRootProjectThreads } = useMemo(() => { const lastVisitedAtByThreadKey = new Map( projectThreads.map((thread, index) => [ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), @@ -1401,9 +1398,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec sortedProjectThreads.map((thread) => resolveProjectThreadStatus(thread)), ); return { - orderedProjectThreadKeys: sortedProjectThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), projectStatus, sortedProjectThreads, visibleRootProjectThreads, @@ -1504,6 +1498,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec threadSortOrder, visibleRootProjectThreads, ]); + const orderedProjectThreadKeys = useMemo( + () => getSidebarThreadSelectionKeys(renderedThreads), + [renderedThreads], + ); const handleProjectButtonClick = useCallback( (event: React.MouseEvent) => { @@ -3296,6 +3294,10 @@ export default function Sidebar() { const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const projectOrder = useUiStateStore((store) => store.projectOrder); const reorderProjects = useUiStateStore((store) => store.reorderProjects); + const persistedExpandedSubagentThreadKeys = useUiStateStore( + (store) => store.expandedSubagentThreadKeys, + ); + const setSubagentThreadExpanded = useUiStateStore((store) => store.setSubagentThreadExpanded); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); const isOnSettings = pathname.startsWith("/settings"); @@ -3324,8 +3326,9 @@ export default function Sidebar() { const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); - const [expandedSubagentThreadKeys, setExpandedSubagentThreadKeys] = useState>( - () => new Set(), + const expandedSubagentThreadKeys = useMemo>( + () => new Set(persistedExpandedSubagentThreadKeys), + [persistedExpandedSubagentThreadKeys], ); const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); const dragInProgressRef = useRef(false); @@ -3416,33 +3419,33 @@ export default function Sidebar() { ), [sidebarThreads], ); + const activeSubagentThreadAncestorKeys = useMemo>( + () => + sidebarShowSubagentThreads && routeThreadKey !== null + ? getSidebarSubagentAncestorKeys(sidebarThreads, routeThreadKey) + : new Set(), + [routeThreadKey, sidebarShowSubagentThreads, sidebarThreads], + ); useEffect(() => { - if (!sidebarShowSubagentThreads || routeThreadKey === null) { - return; - } - const ancestorKeys = getSidebarSubagentAncestorKeys(sidebarThreads, routeThreadKey); - if (ancestorKeys.size === 0) { - return; - } - setExpandedSubagentThreadKeys((current) => { - const next = new Set(current); - for (const key of ancestorKeys) { - next.add(key); - } - return next.size === current.size ? current : next; - }); - }, [routeThreadKey, sidebarShowSubagentThreads, sidebarThreads]); - const toggleSubagentBranch = useCallback((threadKey: string) => { - setExpandedSubagentThreadKeys((current) => { - const next = new Set(current); - if (next.has(threadKey)) { - next.delete(threadKey); - } else { - next.add(threadKey); - } - return next; - }); - }, []); + if (activeSubagentThreadAncestorKeys.size === 0) return; + setSubagentThreadExpanded([...activeSubagentThreadAncestorKeys], true); + }, [activeSubagentThreadAncestorKeys, setSubagentThreadExpanded]); + const toggleSubagentBranch = useCallback( + (threadKey: string) => { + const currentExpandedThreadKeys = new Set( + useUiStateStore.getState().expandedSubagentThreadKeys, + ); + setSubagentThreadExpanded( + threadKey, + resolveSidebarSubagentBranchExpanded({ + threadKey, + expandedThreadKeys: currentExpandedThreadKeys, + activeThreadAncestorKeys: activeSubagentThreadAncestorKeys, + }), + ); + }, + [activeSubagentThreadAncestorKeys, setSubagentThreadExpanded], + ); // Resolve the active route's project key to a logical key so it matches the // sidebar's grouped project entries. const activeRouteProjectKey = useMemo(() => { diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 1d3a8da791f..2567e603bdd 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -13,12 +13,14 @@ import { resolveProjectExpanded, setDefaultAdvertisedEndpointKey, setProjectExpanded, + setSubagentThreadExpanded, setThreadChangedFilesExpanded, type UiState, } from "./uiStateStore"; function makeUiState(overrides: Partial = {}): UiState { return { + expandedSubagentThreadKeys: [], projectExpandedById: {}, projectOrder: [], threadLastVisitedAtById: {}, @@ -140,6 +142,21 @@ describe("uiStateStore pure functions", () => { defaultAdvertisedEndpointKey: null, }); }); + + it("tracks expanded subagent branches without duplicate keys", () => { + const first = setSubagentThreadExpanded(makeUiState(), "environment:parent", true); + const second = setSubagentThreadExpanded( + first, + ["environment:parent", "environment:child"], + true, + ); + + expect(second.expandedSubagentThreadKeys).toEqual(["environment:parent", "environment:child"]); + expect(setSubagentThreadExpanded(second, "environment:parent", true)).toBe(second); + expect( + setSubagentThreadExpanded(second, "environment:parent", false).expandedSubagentThreadKeys, + ).toEqual(["environment:child"]); + }); }); describe("parsePersistedState", () => { @@ -150,6 +167,7 @@ describe("parsePersistedState", () => { invalid: "no" as unknown as boolean, }, projectOrder: ["physical-b", "", "physical-a", "physical-b"], + expandedSubagentThreadKeys: ["environment:parent", "", "environment:parent"], threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", invalid: "not-a-date", @@ -164,6 +182,7 @@ describe("parsePersistedState", () => { }); expect(parsed).toEqual({ + expandedSubagentThreadKeys: ["environment:parent"], projectExpandedById: { logical: false, }, @@ -248,6 +267,7 @@ describe("uiStateStore persistence", () => { it("persists raw UI preferences including thread visit markers", () => { const state = makeUiState({ + expandedSubagentThreadKeys: ["environment:parent"], projectExpandedById: { logical: false, }, @@ -270,6 +290,7 @@ describe("uiStateStore persistence", () => { localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", ) as PersistedUiState; expect(persisted).toEqual({ + expandedSubagentThreadKeys: ["environment:parent"], projectExpandedById: { logical: false, }, diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 4a97f0542b4..e1375eeb3ff 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -17,6 +17,7 @@ const LEGACY_PERSISTED_STATE_KEYS = [ ] as const; export interface PersistedUiState { + expandedSubagentThreadKeys?: string[]; projectExpandedById?: Record; projectOrder?: string[]; threadLastVisitedAtById?: Record; @@ -33,6 +34,7 @@ export interface UiProjectState { } export interface UiThreadState { + expandedSubagentThreadKeys: string[]; threadLastVisitedAtById: Record; threadChangedFilesExpandedById: Record>; } @@ -44,6 +46,7 @@ export interface UiEndpointState { export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {} const initialState: UiState = { + expandedSubagentThreadKeys: [], projectExpandedById: {}, projectOrder: [], threadLastVisitedAtById: {}, @@ -121,6 +124,7 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { : sanitizeStringArray(parsed.projectOrder); return { + expandedSubagentThreadKeys: sanitizeStringArray(parsed.expandedSubagentThreadKeys), projectExpandedById, projectOrder, threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById), @@ -206,6 +210,7 @@ export function persistState(state: UiState): void { window.localStorage.setItem( PERSISTED_STATE_KEY, JSON.stringify({ + expandedSubagentThreadKeys: state.expandedSubagentThreadKeys, projectExpandedById, projectOrder: state.projectOrder, threadLastVisitedAtById: state.threadLastVisitedAtById, @@ -334,6 +339,31 @@ export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | nu }; } +export function setSubagentThreadExpanded( + state: UiState, + threadKeys: string | readonly string[], + expanded: boolean, +): UiState { + const keys = typeof threadKeys === "string" ? [threadKeys] : threadKeys; + const expandedKeys = new Set(state.expandedSubagentThreadKeys); + let changed = false; + for (const key of keys) { + if (key.length === 0) continue; + if (expanded) { + if (expandedKeys.has(key)) continue; + expandedKeys.add(key); + changed = true; + continue; + } + changed = expandedKeys.delete(key) || changed; + } + if (!changed) return state; + return { + ...state, + expandedSubagentThreadKeys: [...expandedKeys], + }; +} + export function resolveProjectExpanded( projectExpandedById: Readonly>, preferenceKeys: readonly string[], @@ -416,6 +446,7 @@ interface UiStateStore extends UiState { markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; + setSubagentThreadExpanded: (threadKeys: string | readonly string[], expanded: boolean) => void; setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; reorderProjects: ( currentProjectOrder: readonly string[], @@ -434,6 +465,8 @@ export const useUiStateStore = create((set) => ({ set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), setDefaultAdvertisedEndpointKey: (key) => set((state) => setDefaultAdvertisedEndpointKey(state, key)), + setSubagentThreadExpanded: (threadKeys, expanded) => + set((state) => setSubagentThreadExpanded(state, threadKeys, expanded)), setProjectExpanded: (projectIds, expanded) => set((state) => setProjectExpanded(state, projectIds, expanded)), reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) => diff --git a/packages/client-runtime/src/state/threadRelationships.test.ts b/packages/client-runtime/src/state/threadRelationships.test.ts index 5c006265ca8..c23e35c01a1 100644 --- a/packages/client-runtime/src/state/threadRelationships.test.ts +++ b/packages/client-runtime/src/state/threadRelationships.test.ts @@ -106,6 +106,31 @@ describe("thread relationships", () => { expect(rows.at(-1)?.hasSubagentChildren).toBe(false); }); + it("only returns ancestors from the projected tree", () => { + const orphan = treeThread({ + id: "orphan", + parentId: "missing", + relationship: "subagent", + }); + const first = treeThread({ + id: "cycle-a", + parentId: "cycle-b", + relationship: "subagent", + }); + const second = treeThread({ + id: "cycle-b", + parentId: "cycle-a", + relationship: "subagent", + }); + const threads = [orphan, first, second]; + + expect(getSubagentThreadAncestorKeys(threads, subagentThreadKey(orphan))).toEqual(new Set()); + expect(getSubagentThreadAncestorKeys(threads, subagentThreadKey(first))).toEqual(new Set()); + expect(getSubagentThreadAncestorKeys(threads, subagentThreadKey(second))).toEqual( + new Set([subagentThreadKey(first)]), + ); + }); + it("keeps missing parents and cycles navigable without recursive traversal", () => { const root = ThreadId.make("thread-root"); const child = ThreadId.make("thread-child"); diff --git a/packages/client-runtime/src/state/threadRelationships.ts b/packages/client-runtime/src/state/threadRelationships.ts index d7a91dad04d..bc3533a4f4a 100644 --- a/packages/client-runtime/src/state/threadRelationships.ts +++ b/packages/client-runtime/src/state/threadRelationships.ts @@ -69,16 +69,20 @@ export function getSubagentThreadAncestorKeys [subagentThreadKey(thread), thread] as const), ); + const rootKeys = new Set(getSubagentThreadTreeRoots(threads).map(subagentThreadKey)); const ancestors = new Set(); const visited = new Set([threadKey]); let current = threadByKey.get(threadKey); while (current !== undefined) { + if (rootKeys.has(subagentThreadKey(current))) break; const parentKey = subagentParentThreadKey(current); if (parentKey === null || visited.has(parentKey)) break; + const parent = threadByKey.get(parentKey); + if (parent === undefined) break; ancestors.add(parentKey); visited.add(parentKey); - current = threadByKey.get(parentKey); + current = parent; } return ancestors; From 405bf0bd67f03cbf8eb245d7f8b2d668af4462cf Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Fri, 10 Jul 2026 12:26:40 -0700 Subject: [PATCH 04/10] fix(sidebar): expand active subagent ancestors on first render --- apps/web/src/components/Sidebar.logic.test.ts | 53 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 13 +++++ apps/web/src/components/Sidebar.tsx | 27 ++++++---- 3 files changed, 84 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 611d067b61e..a45ddbc0464 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -20,6 +20,7 @@ import { resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, + resolveExpandedSubagentThreadKeys, resolveSidebarSubagentBranchExpanded, resolveSidebarStageBadgeLabel, resolveThreadRowClassName, @@ -304,6 +305,58 @@ describe("sidebar thread lineage helpers", () => { expect(getSidebarThreadSelectionKeys(rows)).not.toContain(sidebarThreadKey(grandchild)); }); + it("reveals a routed nested child on cold render with empty persisted expansion", () => { + const rootId = ThreadId.make("thread-root"); + const childId = ThreadId.make("thread-child"); + const grandchildId = ThreadId.make("thread-grandchild"); + const root = makeThreadFixture({ id: rootId }); + const child = makeThreadFixture({ + id: childId, + lineage: { + rootThreadId: rootId, + parentThreadId: rootId, + relationshipToParent: "subagent", + }, + }); + const grandchild = makeThreadFixture({ + id: grandchildId, + lineage: { + rootThreadId: rootId, + parentThreadId: childId, + relationshipToParent: "subagent", + }, + }); + const threads = [root, child, grandchild]; + + // Cold load deep-linked to the grandchild: nothing persisted yet, so the + // render-time expanded set must already contain the routed thread's + // ancestors for the child row to be visible on first paint. + const expandedThreadKeys = resolveExpandedSubagentThreadKeys({ + persistedExpandedThreadKeys: [], + activeThreadAncestorKeys: getSidebarSubagentAncestorKeys( + threads, + sidebarThreadKey(grandchild), + ), + }); + const rows = flattenSidebarSubagentTree({ + threads, + roots: getSidebarSubagentTreeRoots(threads), + expandedThreadKeys, + threadSortOrder: "created_at", + }); + + expect(rows.map((row) => row.thread.id)).toEqual([rootId, childId, grandchildId]); + }); + + it("keeps persisted expansion when merging active thread ancestors", () => { + expect( + resolveExpandedSubagentThreadKeys({ + persistedExpandedThreadKeys: ["environment:persisted"], + activeThreadAncestorKeys: new Set(["environment:ancestor"]), + }), + ).toEqual(new Set(["environment:persisted", "environment:ancestor"])); + }); + it("keeps an active thread's ancestor expanded when toggled", () => { const parentKey = "environment:parent"; const expandedThreadKeys = new Set([parentKey]); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f5a0156164f..a61acc2eff7 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -79,6 +79,19 @@ export function getSidebarThreadSelectionKeys( return rows.map((row) => sidebarThreadKey(row.thread)); } +/** + * Derives the effective expanded set for rendering the subagent tree: the + * persisted expansion plus the ancestors of the currently routed thread. This + * runs at render time so a cold load deep-linked to a nested child reveals the + * child row on first paint instead of waiting for a post-render effect. + */ +export function resolveExpandedSubagentThreadKeys(input: { + readonly persistedExpandedThreadKeys: readonly string[]; + readonly activeThreadAncestorKeys: ReadonlySet; +}): ReadonlySet { + return new Set([...input.persistedExpandedThreadKeys, ...input.activeThreadAncestorKeys]); +} + export function resolveSidebarSubagentBranchExpanded(input: { readonly threadKey: string; readonly expandedThreadKeys: ReadonlySet; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 7d91999cc44..f5fc4d42ff9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -194,6 +194,7 @@ import { flattenSidebarSubagentTree, isSidebarSubagentThread, resolveAdjacentThreadId, + resolveExpandedSubagentThreadKeys, isContextMenuPointerDown, isTrailingDoubleClick, resolveProjectStatusIndicator, @@ -3326,9 +3327,24 @@ export default function Sidebar() { const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); + const activeSubagentThreadAncestorKeys = useMemo>( + () => + sidebarShowSubagentThreads && routeThreadKey !== null + ? getSidebarSubagentAncestorKeys(sidebarThreads, routeThreadKey) + : new Set(), + [routeThreadKey, sidebarShowSubagentThreads, sidebarThreads], + ); + // Merge the active route's ancestors into the expanded set at render time so + // a cold load deep-linked to a nested subagent reveals the child row on the + // first paint; the effect below only persists that expansion so the branch + // stays open after navigating away. const expandedSubagentThreadKeys = useMemo>( - () => new Set(persistedExpandedSubagentThreadKeys), - [persistedExpandedSubagentThreadKeys], + () => + resolveExpandedSubagentThreadKeys({ + persistedExpandedThreadKeys: persistedExpandedSubagentThreadKeys, + activeThreadAncestorKeys: activeSubagentThreadAncestorKeys, + }), + [activeSubagentThreadAncestorKeys, persistedExpandedSubagentThreadKeys], ); const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); const dragInProgressRef = useRef(false); @@ -3419,13 +3435,6 @@ export default function Sidebar() { ), [sidebarThreads], ); - const activeSubagentThreadAncestorKeys = useMemo>( - () => - sidebarShowSubagentThreads && routeThreadKey !== null - ? getSidebarSubagentAncestorKeys(sidebarThreads, routeThreadKey) - : new Set(), - [routeThreadKey, sidebarShowSubagentThreads, sidebarThreads], - ); useEffect(() => { if (activeSubagentThreadAncestorKeys.size === 0) return; setSubagentThreadExpanded([...activeSubagentThreadAncestorKeys], true); From 5e84932b8b279a6e1a1b0cf0bff5e9e6b37c50a8 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Fri, 10 Jul 2026 12:59:35 -0700 Subject: [PATCH 05/10] chore: retrigger checks From fa5151961afdba35c003f6eb1bd5cc4f2120e03f Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Fri, 10 Jul 2026 14:39:50 -0700 Subject: [PATCH 06/10] feat(sidebar): archive and delete owned subagent subtrees Web half of the owned-subtree lifecycle work: sidebar thread actions archive, unarchive, and delete a root together with its recursively owned subagent threads, using the shared subtree helpers and confirmation copy from client-runtime, with an app setting to skip the archive confirmation. The client-runtime helpers are byte-identical with the mobile-subagent-tree branch so the shared file merges cleanly in either order. --- apps/web/src/components/Sidebar.tsx | 250 +++++++++++++----- .../components/settings/SettingsPanels.tsx | 8 +- apps/web/src/hooks/useThreadActions.test.ts | 19 -- apps/web/src/hooks/useThreadActions.ts | 223 +++++++++++----- .../src/state/threadRelationships.test.ts | 73 +++++ .../src/state/threadRelationships.ts | 115 ++++++++ 6 files changed, 537 insertions(+), 151 deletions(-) delete mode 100644 apps/web/src/hooks/useThreadActions.test.ts diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f5fc4d42ff9..df089568f16 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -64,6 +64,12 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { + getOwnedSubagentDescendants, + hasUnavailableSubagentParent, + threadSubtreeActionCopy, +} from "@t3tools/client-runtime/state/thread-relationships"; +import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, @@ -113,7 +119,7 @@ import { useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useDesktopUpdateState } from "../state/desktopUpdate"; -import { useThreadActions } from "../hooks/useThreadActions"; +import { readThreadSubtree, useThreadActions } from "../hooks/useThreadActions"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { threadEnvironment, useEnvironmentThread } from "../state/threads"; @@ -341,6 +347,9 @@ interface SidebarThreadRowProps { isActive: boolean; jumpLabel: string | null; appSettingsConfirmThreadArchive: boolean; + subagentDescendantCount: number; + activeSubagentDescendantCount: number; + hasUnavailableSubagentParent: boolean; renamingThreadKey: string | null; renamingTitle: string; setRenamingTitle: (title: string) => void; @@ -368,7 +377,10 @@ interface SidebarThreadRowProps { originalTitle: string, ) => Promise; cancelRename: () => void; - attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; + attemptArchiveThread: ( + threadRef: ScopedThreadRef, + options?: { readonly confirmed?: boolean }, + ) => Promise; openPrLink: (event: React.MouseEvent, prUrl: string) => void; toggleSubagentBranch: (threadKey: string) => void; } @@ -383,6 +395,9 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr isActive, jumpLabel, appSettingsConfirmThreadArchive, + subagentDescendantCount, + activeSubagentDescendantCount, + hasUnavailableSubagentParent, renamingThreadKey, renamingTitle, setRenamingTitle, @@ -481,8 +496,8 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr }, [discoveredPorts, navigateToThread, openPreview, threadRef], ); - const isThreadRunning = - thread.runtime?.status === "running" && thread.runtime.activeRunId != null; + const activeThreadCount = + activeSubagentDescendantCount + (threadRuntimeIsActive(thread.runtime) ? 1 : 0); const threadStatus = resolveThreadStatusPill({ thread: { ...thread, @@ -492,12 +507,18 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr const pr = resolveThreadPr(thread.branch, gitStatus.data); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); - const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; + const isConfirmingArchive = confirmingArchiveThreadKey === threadKey; + const shouldConfirmArchive = + appSettingsConfirmThreadArchive || subagentDescendantCount > 0 || activeThreadCount > 0; + const archiveActionCopy = threadSubtreeActionCopy({ + action: "archive", + threadTitle: thread.title, + descendantCount: subagentDescendantCount, + activeThreadCount, + }); const threadMetaClassName = isConfirmingArchive ? "pointer-events-none opacity-0" - : !isThreadRunning - ? "pointer-events-none transition-opacity duration-150 max-sm:pr-6 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" - : "pointer-events-none"; + : "pointer-events-none transition-opacity duration-150 max-sm:pr-6 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0"; const clearConfirmingArchive = useCallback(() => { setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); }, [setConfirmingArchiveThreadKey, threadKey]); @@ -677,7 +698,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr event.preventDefault(); event.stopPropagation(); clearConfirmingArchive(); - void attemptArchiveThread(threadRef); + void attemptArchiveThread(threadRef, { confirmed: true }); }, [attemptArchiveThread, clearConfirmingArchive, threadRef], ); @@ -794,6 +815,22 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr Open parent thread ) : null} + {hasUnavailableSubagentParent ? ( + + + } + > + + + Parent thread unavailable + + ) : null} {prStatus && ( 0 + ? `Confirm archive ${thread.title} and ${subagentDescendantCount} subagent thread${subagentDescendantCount === 1 ? "" : "s"}` + : `Confirm archive ${thread.title}` + } + title={archiveActionCopy.message} className="absolute top-1/2 right-1 inline-flex h-5 -translate-y-1/2 cursor-pointer items-center rounded-md bg-destructive/12 px-2 text-[10px] font-medium text-destructive transition-colors hover:bg-destructive/18 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-destructive/40" onPointerDown={stopPropagationOnPointerDown} onClick={handleConfirmArchiveClick} > - Confirm + {subagentDescendantCount > 0 ? `Confirm +${subagentDescendantCount}` : "Confirm"} - ) : !isThreadRunning ? ( - appSettingsConfirmThreadArchive ? ( -
- -
- ) : ( - - - -
- } - /> - Archive - - ) - ) : null} + ) : shouldConfirmArchive ? ( +
+ +
+ ) : ( + + + +
+ } + /> + Archive + + )} {isRemoteThread && !isDesktopLocalThread && ( @@ -995,6 +1035,7 @@ interface SidebarProjectThreadListProps { hiddenThreadStatus: ThreadStatusPill | null; orderedProjectThreadKeys: readonly string[]; renderedThreads: readonly SidebarThreadTreeRow[]; + allProjectThreads: readonly SidebarThreadSummary[]; showEmptyThreadState: boolean; shouldShowThreadPanel: boolean; isThreadListExpanded: boolean; @@ -1030,7 +1071,10 @@ interface SidebarProjectThreadListProps { originalTitle: string, ) => Promise; cancelRename: () => void; - attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; + attemptArchiveThread: ( + threadRef: ScopedThreadRef, + options?: { readonly confirmed?: boolean }, + ) => Promise; openPrLink: (event: React.MouseEvent, prUrl: string) => void; expandThreadListForProject: (projectKey: string) => void; collapseThreadListForProject: (projectKey: string) => void; @@ -1048,6 +1092,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( hiddenThreadStatus, orderedProjectThreadKeys, renderedThreads, + allProjectThreads, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, @@ -1101,6 +1146,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( renderedThreads.map((row) => { const { thread } = row; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const subagentDescendants = getOwnedSubagentDescendants(allProjectThreads, thread); return ( + threadRuntimeIsActive(descendant.runtime), + ).length + } + hasUnavailableSubagentParent={hasUnavailableSubagentParent(allProjectThreads, thread)} renamingThreadKey={renamingThreadKey} renamingTitle={renamingTitle} setRenamingTitle={setRenamingTitle} @@ -1180,7 +1233,7 @@ interface SidebarProjectItemProps { activeRouteThreadKey: string | null; newThreadShortcutLabel: string | null; handleNewThread: ReturnType; - archiveThread: ReturnType["archiveThread"]; + archiveThread: ReturnType["confirmAndArchiveThread"]; deleteThread: ReturnType["deleteThread"]; threadJumpLabelByKey: ReadonlyMap; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; @@ -1943,20 +1996,72 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (clicked !== "delete") return; - if (appSettingsConfirmThreadDelete) { + const selectedThreads = threadKeys.flatMap((threadKey) => { + const thread = sidebarThreadByKeyRef.current.get(threadKey); + return thread === undefined ? [] : [thread]; + }); + const rootThreads: typeof selectedThreads = []; + for (const selectedThread of selectedThreads) { + const selectedRef = scopeThreadRef(selectedThread.environmentId, selectedThread.id); + if ( + rootThreads.some((rootThread) => + readThreadSubtree(scopeThreadRef(rootThread.environmentId, rootThread.id)).some( + (entry) => + entry.environmentId === selectedThread.environmentId && + entry.id === selectedThread.id, + ), + ) + ) { + continue; + } + const selectedSubtree = readThreadSubtree(selectedRef); + for (let index = rootThreads.length - 1; index >= 0; index -= 1) { + const rootThread = rootThreads[index]; + if ( + rootThread && + selectedSubtree.some( + (entry) => + entry.environmentId === rootThread.environmentId && entry.id === rootThread.id, + ) + ) { + rootThreads.splice(index, 1); + } + } + rootThreads.push(selectedThread); + } + const deletedThreadKeys = new Set( + rootThreads.flatMap((rootThread) => + readThreadSubtree(scopeThreadRef(rootThread.environmentId, rootThread.id)).map((entry) => + scopedThreadKey(scopeThreadRef(entry.environmentId, entry.id)), + ), + ), + ); + const cascadedCount = Math.max(0, deletedThreadKeys.size - selectedThreads.length); + const activeThreadCount = rootThreads + .flatMap((rootThread) => + readThreadSubtree(scopeThreadRef(rootThread.environmentId, rootThread.id)), + ) + .filter((entry) => threadRuntimeIsActive(entry.runtime)).length; + const deleteMessage = + cascadedCount > 0 + ? `This permanently deletes the selected threads and ${cascadedCount} owned subagent thread${cascadedCount === 1 ? "" : "s"}, including their conversation and terminal history.` + : "This permanently deletes the selected threads, including their conversation and terminal history."; + const activeWarning = + activeThreadCount > 0 + ? ` Active work in ${activeThreadCount} thread${activeThreadCount === 1 ? "" : "s"} will be cancelled.` + : ""; + + if (appSettingsConfirmThreadDelete || cascadedCount > 0 || activeThreadCount > 0) { const confirmed = await api.dialogs.confirm( [ - `Delete ${count} thread${count === 1 ? "" : "s"}?`, - "This permanently clears conversation history for these threads.", + `Delete ${deletedThreadKeys.size} thread${deletedThreadKeys.size === 1 ? "" : "s"}?`, + `${deleteMessage}${activeWarning}`, ].join("\n"), ); if (!confirmed) return; } - const deletedThreadKeys = new Set(threadKeys); - for (const threadKey of threadKeys) { - const thread = sidebarThreadByKeyRef.current.get(threadKey); - if (!thread) continue; + for (const thread of rootThreads) { const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { deletedThreadKeys, }); @@ -2113,8 +2218,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); const attemptArchiveThread = useCallback( - async (threadRef: ScopedThreadRef) => { - const result = await archiveThread(threadRef); + async (threadRef: ScopedThreadRef, options?: { readonly confirmed?: boolean }) => { + const result = await archiveThread(threadRef, options); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( @@ -2313,13 +2418,19 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } if (clicked !== "delete") return; - if (appSettingsConfirmThreadDelete) { - const confirmed = await api.dialogs.confirm( - [ - `Delete thread "${thread.title}"?`, - "This permanently clears conversation history for this thread.", - ].join("\n"), - ); + const subtree = readThreadSubtree(threadRef); + const descendantCount = Math.max(0, subtree.length - 1); + const activeThreadCount = subtree.filter((entry) => + threadRuntimeIsActive(entry.runtime), + ).length; + if (appSettingsConfirmThreadDelete || descendantCount > 0 || activeThreadCount > 0) { + const copy = threadSubtreeActionCopy({ + action: "delete", + threadTitle: thread.title, + descendantCount, + activeThreadCount, + }); + const confirmed = await api.dialogs.confirm([copy.title, copy.message].join("\n")); if (!confirmed) { return; } @@ -2463,6 +2574,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec hiddenThreadStatus={hiddenThreadStatus} orderedProjectThreadKeys={orderedProjectThreadKeys} renderedThreads={renderedThreads} + allProjectThreads={sidebarThreads} showEmptyThreadState={showEmptyThreadState} shouldShowThreadPanel={shouldShowThreadPanel} isThreadListExpanded={isThreadListExpanded} @@ -3020,7 +3132,7 @@ interface SidebarProjectsContentProps { handleProjectDragEnd: (event: DragEndEvent) => void; handleProjectDragCancel: (event: DragCancelEvent) => void; handleNewThread: ReturnType; - archiveThread: ReturnType["archiveThread"]; + archiveThread: ReturnType["confirmAndArchiveThread"]; deleteThread: ReturnType["deleteThread"]; sortedProjects: readonly SidebarProjectSnapshot[]; expandedThreadListsByProject: ReadonlySet; @@ -3310,7 +3422,7 @@ export default function Sidebar() { const sidebarShowSubagentThreads = useClientSettings((s) => s.sidebarShowSubagentThreads); const updateSettings = useUpdateClientSettings(); const handleNewThread = useNewThreadHandler(); - const { archiveThread, deleteThread } = useThreadActions(); + const { confirmAndArchiveThread: archiveThread, deleteThread } = useThreadActions(); const { isMobile, setOpenMobile } = useSidebar(); const routeThreadRef = useParams({ strict: false, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 685baeba0d7..5551738a8e5 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1436,7 +1436,7 @@ export function ProviderSettingsPanel() { export function ArchivedThreadsPanel() { const projects = useProjects(); - const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); + const { confirmAndUnarchiveThread, confirmAndDeleteThread } = useThreadActions(); const environmentIds = useMemo( () => [...new Set(projects.map((project) => project.environmentId))], [projects], @@ -1508,7 +1508,7 @@ export function ArchivedThreadsPanel() { ); if (clicked === "unarchive") { - const result = await unarchiveThread(threadRef); + const result = await confirmAndUnarchiveThread(threadRef); if (result._tag === "Success") { refreshArchivedThreads(); } else if (!isAtomCommandInterrupted(result)) { @@ -1540,7 +1540,7 @@ export function ArchivedThreadsPanel() { } } }, - [confirmAndDeleteThread, refreshArchivedThreads, unarchiveThread], + [confirmAndDeleteThread, confirmAndUnarchiveThread, refreshArchivedThreads], ); return ( @@ -1620,7 +1620,7 @@ export function ArchivedThreadsPanel() { className="h-7 shrink-0 cursor-pointer gap-1.5 px-2.5" onClick={() => { void (async () => { - const result = await unarchiveThread( + const result = await confirmAndUnarchiveThread( scopeThreadRef(thread.environmentId, thread.id), ); if (result._tag === "Success") { diff --git a/apps/web/src/hooks/useThreadActions.test.ts b/apps/web/src/hooks/useThreadActions.test.ts deleted file mode 100644 index c5385211591..00000000000 --- a/apps/web/src/hooks/useThreadActions.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { ThreadArchiveBlockedError } from "./useThreadActions"; - -describe("ThreadArchiveBlockedError", () => { - it("keeps the blocked thread context with the fixed message", () => { - const error = new ThreadArchiveBlockedError({ - environmentId: EnvironmentId.make("environment-1"), - threadId: ThreadId.make("thread-1"), - }); - - expect(error).toMatchObject({ - environmentId: "environment-1", - threadId: "thread-1", - }); - expect(error.message).toBe("Cannot archive a running thread."); - }); -}); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index cfaa842b3f5..b7fe58a2e25 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -4,9 +4,12 @@ import { scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + getOwnedSubagentSubtree, + threadSubtreeActionCopy, +} from "@t3tools/client-runtime/state/thread-relationships"; +import { presentThreadShell, threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/unstable/reactivity"; import { useRouter } from "@tanstack/react-router"; import { useCallback, useMemo, useRef } from "react"; @@ -19,7 +22,9 @@ import { vcsEnvironment } from "../state/vcs"; import { useNewThreadHandler } from "./useHandleNewThread"; import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; import { readLocalApi } from "../localApi"; +import { appAtomRegistry } from "../rpc/atomRegistry"; import { readEnvironmentThreadRefs, readProject, readThreadShell } from "../state/entities"; +import { environmentSnapshotAtom } from "../state/shell"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; @@ -27,16 +32,31 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useClientSettings } from "./useSettings"; import { useAtomCommand } from "../state/use-atom-command"; -export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass()( - "ThreadArchiveBlockedError", - { - environmentId: EnvironmentId, - threadId: ThreadId, - }, -) { - override get message(): string { - return "Cannot archive a running thread."; +function readEnvironmentThreads(environmentId: EnvironmentId) { + const snapshot = appAtomRegistry.get(environmentSnapshotAtom(environmentId)); + if (snapshot !== null) { + return [...snapshot.threads, ...snapshot.archivedThreads].map((thread) => + presentThreadShell(environmentId, thread), + ); } + return readEnvironmentThreadRefs(environmentId).flatMap((ref) => { + const thread = readThreadShell(ref); + return thread === null ? [] : [thread]; + }); +} + +/** Reads the complete active + archived owned-subagent subtree from the shell cache. */ +export function readThreadSubtree( + target: ScopedThreadRef, + fallbackRoot?: ReturnType, +) { + const threads = readEnvironmentThreads(target.environmentId); + const root = threads.find((candidate) => candidate.id === target.threadId) ?? fallbackRoot; + if (root === undefined) return []; + return getOwnedSubagentSubtree( + threads.some((candidate) => candidate.id === root.id) ? threads : [...threads, root], + root, + ); } export function useThreadActions() { @@ -59,6 +79,7 @@ export function useThreadActions() { }); const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder); const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); + const confirmThreadArchive = useClientSettings((settings) => settings.confirmThreadArchive); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const clearProjectDraftThreadById = useComposerDraftStore( (store) => store.clearProjectDraftThreadById, @@ -74,7 +95,11 @@ export function useThreadActions() { handleNewThreadRef.current = handleNewThread; const resolveThreadTarget = useCallback((target: ScopedThreadRef) => { - const thread = readThreadShell(target); + const thread = + readThreadShell(target) ?? + readEnvironmentThreads(target.environmentId).find( + (candidate) => candidate.id === target.threadId, + ); if (!thread) { return null; } @@ -83,6 +108,11 @@ export function useThreadActions() { threadRef: target, }; }, []); + const getThreadSubtree = useCallback( + (target: ScopedThreadRef, fallbackRoot?: ReturnType) => + readThreadSubtree(target, fallbackRoot), + [], + ); const getCurrentRouteThreadRef = useCallback(() => { const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; return resolveThreadRouteRef(currentRouteParams); @@ -93,21 +123,12 @@ export function useThreadActions() { const resolved = resolveThreadTarget(target); if (!resolved) return AsyncResult.success(undefined); const { thread, threadRef } = resolved; - if (thread.runtime?.status === "running" && thread.runtime.activeRunId != null) { - return AsyncResult.failure( - Cause.fail( - new ThreadArchiveBlockedError({ - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - }), - ), - ); - } const currentRouteThreadRef = getCurrentRouteThreadRef(); + const subtree = getThreadSubtree(threadRef, thread); const shouldNavigateToDraft = - currentRouteThreadRef?.threadId === threadRef.threadId && - currentRouteThreadRef.environmentId === threadRef.environmentId; + currentRouteThreadRef?.environmentId === threadRef.environmentId && + subtree.some((entry) => entry.id === currentRouteThreadRef.threadId); const archiveResult = await archiveThreadMutation({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId }, @@ -130,7 +151,38 @@ export function useThreadActions() { refreshArchivedThreadsForEnvironment(threadRef.environmentId); return archiveResult; }, - [archiveThreadMutation, getCurrentRouteThreadRef, resolveThreadTarget], + [archiveThreadMutation, getCurrentRouteThreadRef, getThreadSubtree, resolveThreadTarget], + ); + + const confirmAndArchiveThread = useCallback( + async (target: ScopedThreadRef, options: { readonly confirmed?: boolean } = {}) => { + const localApi = readLocalApi(); + const resolved = resolveThreadTarget(target); + const subtree = getThreadSubtree(target, resolved?.thread); + const descendants = subtree.slice(1).filter((entry) => entry.archivedAt === null); + const activeThreadCount = [resolved?.thread, ...descendants].filter( + (entry) => entry !== undefined && threadRuntimeIsActive(entry.runtime), + ).length; + if ( + options.confirmed !== true && + (confirmThreadArchive || descendants.length > 0 || activeThreadCount > 0) && + localApi + ) { + const copy = threadSubtreeActionCopy({ + action: "archive", + threadTitle: resolved?.thread.title ?? "this thread", + descendantCount: descendants.length, + activeThreadCount, + }); + const confirmationResult = await settlePromise(() => + localApi.dialogs.confirm([copy.title, copy.message].join("\n")), + ); + if (confirmationResult._tag === "Failure") return confirmationResult; + if (!confirmationResult.value) return AsyncResult.success(undefined); + } + return archiveThread(target); + }, + [archiveThread, confirmThreadArchive, getThreadSubtree, resolveThreadTarget], ); const unarchiveThread = useCallback( @@ -162,25 +214,32 @@ export function useThreadActions() { return result; } const { thread, threadRef } = resolved; - const threads = readEnvironmentThreadRefs(threadRef.environmentId).flatMap((ref) => { - const shell = readThreadShell(ref); - return shell === null ? [] : [shell]; - }); + const threads = readEnvironmentThreads(threadRef.environmentId); + const subtree = getOwnedSubagentSubtree(threads, thread); const threadProject = readProject({ environmentId: threadRef.environmentId, projectId: thread.projectId, }); - const deletedIds = - opts.deletedThreadKeys && opts.deletedThreadKeys.size > 0 - ? new Set( - [...opts.deletedThreadKeys].flatMap((threadKey) => { - const ref = parseScopedThreadKey(threadKey); - return ref && ref.environmentId === threadRef.environmentId ? [ref.threadId] : []; - }), - ) - : undefined; + const explicitlyDeletedIds = new Set( + [...(opts.deletedThreadKeys ?? [])].flatMap((threadKey) => { + const ref = parseScopedThreadKey(threadKey); + return ref && ref.environmentId === threadRef.environmentId ? [ref.threadId] : []; + }), + ); + explicitlyDeletedIds.add(threadRef.threadId); + const deletedIds = new Set(); + for (const rootId of explicitlyDeletedIds) { + const root = threads.find((entry) => entry.id === rootId); + if (root === undefined) { + deletedIds.add(rootId); + continue; + } + for (const entry of getOwnedSubagentSubtree(threads, root)) { + deletedIds.add(entry.id); + } + } const survivingThreads = - deletedIds && deletedIds.size > 0 + deletedIds.size > 0 ? threads.filter((entry) => entry.id === threadRef.threadId || !deletedIds.has(entry.id)) : threads; const orphanedWorktreePath = getOrphanedWorktreePathForThread( @@ -222,13 +281,13 @@ export function useThreadActions() { input: { threadId: threadRef.threadId, deleteHistory: true }, }); - const deletedThreadIds = deletedIds ?? new Set(); + const deletedThreadIds = deletedIds; const currentRouteThreadRef = getCurrentRouteThreadRef(); const shouldNavigateToFallback = - currentRouteThreadRef?.threadId === threadRef.threadId && - currentRouteThreadRef.environmentId === threadRef.environmentId; + currentRouteThreadRef?.environmentId === threadRef.environmentId && + deletedThreadIds.has(currentRouteThreadRef.threadId); const fallbackThreadId = getFallbackThreadIdAfterDelete({ - threads, + threads: threads.filter((entry) => entry.archivedAt === null), deletedThreadId: threadRef.threadId, deletedThreadIds, sortOrder: sidebarThreadSortOrder, @@ -241,12 +300,15 @@ export function useThreadActions() { return deleteResult; } refreshArchivedThreadsForEnvironment(threadRef.environmentId); - clearComposerDraftForThread(threadRef); - clearProjectDraftThreadById( - scopeProjectRef(threadRef.environmentId, thread.projectId), - threadRef, - ); - clearTerminalUiState(threadRef); + for (const deletedThread of subtree) { + const deletedThreadRef = scopeThreadRef(deletedThread.environmentId, deletedThread.id); + clearComposerDraftForThread(deletedThreadRef); + clearProjectDraftThreadById( + scopeProjectRef(deletedThread.environmentId, deletedThread.projectId), + deletedThreadRef, + ); + clearTerminalUiState(deletedThreadRef); + } if (shouldNavigateToFallback) { if (fallbackThreadId) { @@ -349,16 +411,21 @@ export function useThreadActions() { async (target: ScopedThreadRef) => { const localApi = readLocalApi(); const resolved = resolveThreadTarget(target); + const subtree = getThreadSubtree(target, resolved?.thread); + const descendantCount = Math.max(0, subtree.length - 1); + const activeThreadCount = subtree.filter((entry) => + threadRuntimeIsActive(entry.runtime), + ).length; - if (confirmThreadDelete && localApi) { - const title = resolved?.thread.title ?? "this thread"; + if ((confirmThreadDelete || descendantCount > 0 || activeThreadCount > 0) && localApi) { + const copy = threadSubtreeActionCopy({ + action: "delete", + threadTitle: resolved?.thread.title ?? "this thread", + descendantCount, + activeThreadCount, + }); const confirmationResult = await settlePromise(() => - localApi.dialogs.confirm( - [ - `Delete thread "${title}"?`, - "This permanently clears conversation history for this thread.", - ].join("\n"), - ), + localApi.dialogs.confirm([copy.title, copy.message].join("\n")), ); if (confirmationResult._tag === "Failure") { return confirmationResult; @@ -370,16 +437,54 @@ export function useThreadActions() { return deleteThread(target); }, - [confirmThreadDelete, deleteThread, resolveThreadTarget], + [confirmThreadDelete, deleteThread, getThreadSubtree, resolveThreadTarget], + ); + + const confirmAndUnarchiveThread = useCallback( + async (target: ScopedThreadRef) => { + const localApi = readLocalApi(); + const resolved = resolveThreadTarget(target); + const subtree = getThreadSubtree(target, resolved?.thread); + const descendantCount = + resolved === null + ? 0 + : subtree.slice(1).filter((entry) => entry.archivedAt === resolved.thread.archivedAt) + .length; + if (descendantCount > 0 && localApi) { + const copy = threadSubtreeActionCopy({ + action: "unarchive", + threadTitle: resolved?.thread.title ?? "this thread", + descendantCount, + }); + const confirmationResult = await settlePromise(() => + localApi.dialogs.confirm([copy.title, copy.message].join("\n")), + ); + if (confirmationResult._tag === "Failure") return confirmationResult; + if (!confirmationResult.value) return AsyncResult.success(undefined); + } + return unarchiveThread(target); + }, + [getThreadSubtree, resolveThreadTarget, unarchiveThread], ); return useMemo( () => ({ archiveThread, + confirmAndArchiveThread, unarchiveThread, + confirmAndUnarchiveThread, deleteThread, confirmAndDeleteThread, + getThreadSubtree, }), - [archiveThread, confirmAndDeleteThread, deleteThread, unarchiveThread], + [ + archiveThread, + confirmAndArchiveThread, + confirmAndDeleteThread, + confirmAndUnarchiveThread, + deleteThread, + getThreadSubtree, + unarchiveThread, + ], ); } diff --git a/packages/client-runtime/src/state/threadRelationships.test.ts b/packages/client-runtime/src/state/threadRelationships.test.ts index c23e35c01a1..0e653bf55f2 100644 --- a/packages/client-runtime/src/state/threadRelationships.test.ts +++ b/packages/client-runtime/src/state/threadRelationships.test.ts @@ -4,13 +4,17 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { deriveThreadRelationshipGraph, flattenSubagentThreadTree, + getOwnedSubagentDescendants, + getOwnedSubagentSubtree, getSubagentThreadAncestorKeys, getSubagentThreadTreeRoots, + hasUnavailableSubagentParent, immediateThreadRelationships, isSubagentThread, relatedThreadIds, resolveMergeBackTargetThreadId, subagentThreadKey, + threadSubtreeActionCopy, type SubagentThreadTreeInput, walkThreadRelationships, } from "./threadRelationships.ts"; @@ -83,6 +87,73 @@ describe("thread relationships", () => { ); }); + it("walks only recursively owned subagents and stays scoped to the environment", () => { + const root = treeThread({ id: "root" }); + const child = treeThread({ id: "child", parentId: "root", relationship: "subagent" }); + const grandchild = treeThread({ + id: "grandchild", + parentId: "child", + relationship: "subagent", + }); + const fork = treeThread({ id: "fork", parentId: "root", relationship: "fork" }); + const otherEnvironmentChild = { + ...treeThread({ id: "other-child", parentId: "root", relationship: "subagent" }), + environmentId: EnvironmentId.make("environment-2"), + }; + const threads = [root, child, grandchild, fork, otherEnvironmentChild]; + + expect(getOwnedSubagentDescendants(threads, root).map((thread) => thread.id)).toEqual([ + child.id, + grandchild.id, + ]); + expect(getOwnedSubagentSubtree(threads, root).map((thread) => thread.id)).toEqual([ + root.id, + child.id, + grandchild.id, + ]); + }); + + it("bounds malformed ownership cycles", () => { + const root = treeThread({ id: "root", parentId: "child", relationship: "subagent" }); + const child = treeThread({ id: "child", parentId: "root", relationship: "subagent" }); + + expect(getOwnedSubagentDescendants([root, child], root).map((thread) => thread.id)).toEqual([ + child.id, + ]); + }); + + it("uses consistent subtree action copy", () => { + expect( + threadSubtreeActionCopy({ + action: "delete", + threadTitle: "Parent", + descendantCount: 2, + }), + ).toEqual({ + title: "Delete thread and 2 subagent threads?", + message: + "“Parent” and its 2 subagent threads will be permanently deleted, including their conversation and terminal history.", + confirmText: "Delete", + }); + expect( + threadSubtreeActionCopy({ + action: "unarchive", + threadTitle: "Parent", + descendantCount: 1, + }).message, + ).toBe("“Parent” and its 1 subagent thread archived with it will be restored."); + expect( + threadSubtreeActionCopy({ + action: "archive", + threadTitle: "Parent", + descendantCount: 1, + activeThreadCount: 2, + }).message, + ).toBe( + "“Parent” and its 1 subagent thread will be moved to the archive. Active work in 2 threads will be cancelled.", + ); + }); + it("keeps orphans and rootless cycles visible exactly once", () => { const orphan = treeThread({ id: "orphan", @@ -104,6 +175,8 @@ describe("thread relationships", () => { expect(rows.map((row) => row.thread.id)).toEqual([orphan.id, first.id, second.id]); expect(new Set(rows.map((row) => subagentThreadKey(row.thread))).size).toBe(3); expect(rows.at(-1)?.hasSubagentChildren).toBe(false); + expect(hasUnavailableSubagentParent(threads, orphan)).toBe(true); + expect(hasUnavailableSubagentParent(threads, second)).toBe(false); }); it("only returns ancestors from the projected tree", () => { diff --git a/packages/client-runtime/src/state/threadRelationships.ts b/packages/client-runtime/src/state/threadRelationships.ts index bc3533a4f4a..80f88eddb41 100644 --- a/packages/client-runtime/src/state/threadRelationships.ts +++ b/packages/client-runtime/src/state/threadRelationships.ts @@ -42,6 +42,121 @@ export function subagentParentThreadKey(thread: SubagentThreadTreeInput): string : scopedThreadKey(scopeThreadRef(thread.environmentId, parentThreadId)); } +/** + * Identifies recovery roots whose declared subagent parent is not present in + * the supplied shell view (for example because it is deleted or archived). + */ +export function hasUnavailableSubagentParent( + threads: readonly Thread[], + thread: Thread, +): boolean { + const parentKey = subagentParentThreadKey(thread); + return ( + parentKey !== null && !threads.some((candidate) => subagentThreadKey(candidate) === parentKey) + ); +} + +/** + * Returns the transitive set of threads owned by `root` through subagent + * lineage. Forks and context-transfer relationships are intentionally not + * ownership edges and are therefore never included. Malformed cycles are + * bounded by the visited set. + */ +export function getOwnedSubagentDescendants( + threads: readonly Thread[], + root: Pick, +): readonly Thread[] { + const childrenByParentKey = new Map(); + for (const thread of threads) { + const parentKey = subagentParentThreadKey(thread); + if (parentKey === null) continue; + const children = childrenByParentKey.get(parentKey); + if (children === undefined) childrenByParentKey.set(parentKey, [thread]); + else children.push(thread); + } + + const rootKey = subagentThreadKey(root); + const visited = new Set([rootKey]); + const descendants: Thread[] = []; + const pending = [...(childrenByParentKey.get(rootKey) ?? [])]; + while (pending.length > 0) { + const thread = pending.shift(); + if (thread === undefined) continue; + const key = subagentThreadKey(thread); + if (visited.has(key)) continue; + visited.add(key); + descendants.push(thread); + pending.push(...(childrenByParentKey.get(key) ?? [])); + } + return descendants; +} + +/** Returns `root` followed by every recursively owned subagent descendant. */ +export function getOwnedSubagentSubtree( + threads: readonly Thread[], + root: Thread, +): readonly Thread[] { + return [root, ...getOwnedSubagentDescendants(threads, root)]; +} + +export type ThreadSubtreeAction = "archive" | "unarchive" | "delete"; + +export interface ThreadSubtreeActionCopy { + readonly title: string; + readonly message: string; + readonly confirmText: "Archive" | "Unarchive" | "Delete"; +} + +/** + * Shared action copy keeps web and mobile explicit about recursive ownership + * semantics. `descendantCount` excludes the selected root thread. + */ +export function threadSubtreeActionCopy(input: { + readonly action: ThreadSubtreeAction; + readonly threadTitle: string; + readonly descendantCount: number; + readonly activeThreadCount?: number; +}): ThreadSubtreeActionCopy { + const count = Math.max(0, input.descendantCount); + const activeCount = Math.max(0, input.activeThreadCount ?? 0); + const childLabel = `${count} subagent thread${count === 1 ? "" : "s"}`; + const quotedTitle = `“${input.threadTitle}”`; + const activeWarning = + activeCount === 0 || input.action === "unarchive" + ? "" + : ` Active work in ${activeCount} thread${activeCount === 1 ? "" : "s"} will be cancelled.`; + + switch (input.action) { + case "archive": + return { + title: count === 0 ? "Archive thread?" : `Archive thread and ${childLabel}?`, + message: + count === 0 + ? `${quotedTitle} will be moved to the archive.${activeWarning}` + : `${quotedTitle} and its ${childLabel} will be moved to the archive.${activeWarning}`, + confirmText: "Archive", + }; + case "unarchive": + return { + title: count === 0 ? "Unarchive thread?" : `Unarchive thread and ${childLabel}?`, + message: + count === 0 + ? `${quotedTitle} will be restored.` + : `${quotedTitle} and its ${childLabel} archived with it will be restored.`, + confirmText: "Unarchive", + }; + case "delete": + return { + title: count === 0 ? "Delete thread?" : `Delete thread and ${childLabel}?`, + message: + count === 0 + ? `${quotedTitle} will be permanently deleted, including its conversation and terminal history.${activeWarning}` + : `${quotedTitle} and its ${childLabel} will be permanently deleted, including their conversation and terminal history.${activeWarning}`, + confirmText: "Delete", + }; + } +} + function indexSubagentThreads(threads: readonly Thread[]) { const threadKeys = new Set(threads.map(subagentThreadKey)); const childrenByParentKey = new Map(); From 83f95f5de689b8cfe030783401b5381f221ddf36 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Fri, 10 Jul 2026 14:59:22 -0700 Subject: [PATCH 07/10] fix(web): make subtree actions consistent with what the sidebar shows Review follow-ups: deleting a root now stops sessions and closes terminals for every thread in the owned subtree rather than only the root; the environment-thread fallback read merges loaded archived shells so subtree confirmations are not undercounted before the snapshot arrives; and sidebar descendant counts and recovery-root badges are computed over unarchived threads only, matching the archive confirmation flow. --- apps/web/src/components/Sidebar.tsx | 13 +++++++-- apps/web/src/hooks/useThreadActions.ts | 36 +++++++++++++++++------- apps/web/src/lib/archivedThreadsState.ts | 12 +++++++- 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index df089568f16..714eb5f2c40 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1126,6 +1126,12 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( } = props; const showMoreButtonRender = useMemo(() =>