Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const clientSettings: ClientSettings = {
sidebarProjectSortOrder: "manual",
sidebarThreadSortOrder: "created_at",
sidebarThreadPreviewCount: 6,
sidebarShowSubagentThreads: false,
timestampFormat: "24-hour",
wordWrap: true,
};
Expand Down
273 changes: 273 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import {
createThreadJumpHintVisibilityController,
flattenSidebarSubagentTree,
getSidebarThreadIdsToPrewarm,
getSidebarSubagentAncestorKeys,
getSidebarSubagentTreeRoots,
getSidebarThreadSelectionKeys,
getVisibleSidebarThreadIds,
resolveAdjacentThreadId,
getFallbackThreadIdAfterDelete,
Expand All @@ -16,9 +20,12 @@ import {
resolveProjectStatusIndicator,
resolveSidebarNewThreadSeedContext,
resolveSidebarNewThreadEnvMode,
resolveExpandedSubagentThreadKeys,
resolveSidebarSubagentBranchExpanded,
resolveSidebarStageBadgeLabel,
resolveThreadRowClassName,
resolveThreadStatusPill,
sidebarThreadKey,
shouldClearThreadSelectionOnMouseDown,
sortProjectsForSidebar,
THREAD_JUMP_HINT_SHOW_DELAY_MS,
Expand Down Expand Up @@ -110,6 +117,272 @@ 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)]));
});

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("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]);

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?: {
Expand Down
73 changes: 72 additions & 1 deletion apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import * as React from "react";
import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
import {
flattenSubagentThreadTree,
getSubagentThreadAncestorKeys,
getSubagentThreadTreeRoots,
isSubagentThread,
subagentParentThreadKey,
subagentThreadKey,
type SubagentThreadTreeRow,
} from "@t3tools/client-runtime/state/thread-relationships";
import {
getThreadSortTimestamp,
sortThreads,
Expand Down Expand Up @@ -27,7 +36,69 @@ type SidebarProject = {
export type ThreadTraversalDirection = "previous" | "next";

export function isSidebarSubagentThread(thread: Pick<SidebarThreadSummary, "lineage">): boolean {
return thread.lineage.relationshipToParent === "subagent";
return isSubagentThread(thread);
}

export type SidebarThreadTreeRow = SubagentThreadTreeRow<SidebarThreadSummary>;

export function sidebarThreadKey(
thread: Pick<SidebarThreadSummary, "environmentId" | "id">,
): string {
return subagentThreadKey(thread);
}

export function sidebarSubagentParentKey(thread: SidebarThreadSummary): string | null {
return subagentParentThreadKey(thread);
}

export function getSidebarSubagentAncestorKeys(
threads: readonly SidebarThreadSummary[],
threadKey: string | null,
): ReadonlySet<string> {
return getSubagentThreadAncestorKeys(threads, threadKey);
}

export function getSidebarSubagentTreeRoots(
threads: readonly SidebarThreadSummary[],
): readonly SidebarThreadSummary[] {
return getSubagentThreadTreeRoots(threads);
}

export function flattenSidebarSubagentTree(input: {
readonly threads: readonly SidebarThreadSummary[];
readonly roots: readonly SidebarThreadSummary[];
readonly expandedThreadKeys: ReadonlySet<string>;
readonly threadSortOrder: SidebarThreadSortOrder;
}): readonly SidebarThreadTreeRow[] {
return flattenSubagentThreadTree(input);
}

export function getSidebarThreadSelectionKeys(
rows: readonly SidebarThreadTreeRow[],
): readonly string[] {
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<string>;
}): ReadonlySet<string> {
return new Set([...input.persistedExpandedThreadKeys, ...input.activeThreadAncestorKeys]);
}

export function resolveSidebarSubagentBranchExpanded(input: {
readonly threadKey: string;
readonly expandedThreadKeys: ReadonlySet<string>;
readonly activeThreadAncestorKeys: ReadonlySet<string>;
}): boolean {
if (input.activeThreadAncestorKeys.has(input.threadKey)) return true;
return !input.expandedThreadKeys.has(input.threadKey);
}

export function getSidebarForkParentThreadId(
Expand Down
Loading
Loading