Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
77 changes: 77 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"
import {
archiveSelectedThreadEntries,
buildMultiSelectThreadContextMenuItems,
collapseWorktreeSiblings,
createThreadJumpHintVisibilityController,
getSidebarThreadIdsToPrewarm,
getVisibleSidebarThreadIds,
Expand Down Expand Up @@ -1041,6 +1042,82 @@ describe("getVisibleThreadsForProject", () => {
});
});

describe("collapseWorktreeSiblings", () => {
type CollapsibleThread = {
id: string;
environmentId: string;
worktreePath: string | null;
createdAt: string;
};
const keyOf = (thread: CollapsibleThread) => `${thread.environmentId}:${thread.id}`;
const make = (
id: string,
worktreePath: string | null,
createdAt: string,
environmentId = "env-1",
): CollapsibleThread => ({ id, environmentId, worktreePath, createdAt });

it("keeps only the earliest-created chat per worktree, preserving input order", () => {
const threads = [
make("newer", "/wt/a", "2026-03-09T12:00:00.000Z"),
make("standalone", null, "2026-03-09T11:00:00.000Z"),
make("older", "/wt/a", "2026-03-09T10:00:00.000Z"),
];

const { threads: collapsed } = collapseWorktreeSiblings(threads, keyOf);

// "older" survives (earliest in its worktree) and stays where it sat.
expect(collapsed.map((thread) => thread.id)).toEqual(["standalone", "older"]);
});

it("never collapses threads without a worktree", () => {
const threads = [
make("a", null, "2026-03-09T10:00:00.000Z"),
make("b", null, "2026-03-09T10:00:00.000Z"),
];

const { threads: collapsed } = collapseWorktreeSiblings(threads, keyOf);

expect(collapsed.map((thread) => thread.id)).toEqual(["a", "b"]);
});

it("does not merge same-path worktrees across environments", () => {
const threads = [
make("a", "/wt/shared", "2026-03-09T10:00:00.000Z", "env-1"),
make("b", "/wt/shared", "2026-03-09T11:00:00.000Z", "env-2"),
];

const { threads: collapsed } = collapseWorktreeSiblings(threads, keyOf);

expect(collapsed.map((thread) => thread.id)).toEqual(["a", "b"]);
});

it("maps every sibling and the representative to the representative's key", () => {
const threads = [
make("older", "/wt/a", "2026-03-09T10:00:00.000Z"),
make("newer", "/wt/a", "2026-03-09T12:00:00.000Z"),
make("solo", null, "2026-03-09T11:00:00.000Z"),
];

const { representativeKeyByThreadKey } = collapseWorktreeSiblings(threads, keyOf);

expect(representativeKeyByThreadKey.get("env-1:newer")).toBe("env-1:older");
expect(representativeKeyByThreadKey.get("env-1:older")).toBe("env-1:older");
expect(representativeKeyByThreadKey.get("env-1:solo")).toBe("env-1:solo");
});

it("breaks createdAt ties deterministically by id", () => {
const threads = [
make("beta", "/wt/a", "2026-03-09T10:00:00.000Z"),
make("alpha", "/wt/a", "2026-03-09T10:00:00.000Z"),
];

const { threads: collapsed } = collapseWorktreeSiblings(threads, keyOf);

expect(collapsed.map((thread) => thread.id)).toEqual(["alpha"]);
});
});

function makeProject(overrides: Partial<Project> = {}): Project {
const { defaultModelSelection, ...rest } = overrides;
return {
Expand Down
66 changes: 66 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,72 @@ export function getVisibleThreadsForProject<T extends Pick<Thread, "id">>(input:
};
}

type WorktreeCollapsibleThread = {
readonly id: string;
readonly environmentId: string;
readonly worktreePath: string | null;
readonly createdAt: string;
};

function isEarlierCreatedThread<T extends WorktreeCollapsibleThread>(
candidate: T,
incumbent: T,
): boolean {
const byCreatedAt = candidate.createdAt.localeCompare(incumbent.createdAt);
return byCreatedAt !== 0 ? byCreatedAt < 0 : candidate.id.localeCompare(incumbent.id) < 0;
}

/**
* Collapse chats that share one on-disk git worktree into a single sidebar
* row. Several chats can run in the same worktree — the in-chat worktree tab
* strip spawns siblings that reuse it — and listing each as its own row
* duplicates the worktree down the sidebar. Only the earliest-created chat in
* a group survives as the representative row; its siblings stay reachable
* through the tab strip. Threads with no worktree (worktreePath === null)
* never collapse — each keeps its own row.
*
* Survivors keep their input order (each stays at its own position), so the
* caller's sort is preserved. `representativeKeyByThreadKey` maps every input
* thread's key to its representative's key so the caller can highlight the
* representative row when the active route is a collapsed sibling.
*/
export function collapseWorktreeSiblings<T extends WorktreeCollapsibleThread>(
threads: readonly T[],
keyOf: (thread: T) => string,
): { threads: T[]; representativeKeyByThreadKey: Map<string, string> } {
const representativeByGroupKey = new Map<string, T>();
for (const thread of threads) {
if (thread.worktreePath === null) continue;
const groupKey = `${thread.environmentId}\0${thread.worktreePath}`;
const incumbent = representativeByGroupKey.get(groupKey);
if (incumbent === undefined || isEarlierCreatedThread(thread, incumbent)) {
representativeByGroupKey.set(groupKey, thread);
}
}

const representativeKeyByGroupKey = new Map<string, string>();
for (const [groupKey, thread] of representativeByGroupKey) {
representativeKeyByGroupKey.set(groupKey, keyOf(thread));
}

const representativeKeyByThreadKey = new Map<string, string>();
const survivors: T[] = [];
for (const thread of threads) {
const key = keyOf(thread);
if (thread.worktreePath === null) {
representativeKeyByThreadKey.set(key, key);
survivors.push(thread);
continue;
}
const groupKey = `${thread.environmentId}\0${thread.worktreePath}`;
const representativeKey = representativeKeyByGroupKey.get(groupKey) ?? key;
representativeKeyByThreadKey.set(key, representativeKey);
if (representativeKey === key) survivors.push(thread);
}

return { threads: survivors, representativeKeyByThreadKey };
}

export function getFallbackThreadIdAfterDelete<
T extends Pick<Thread, "id" | "projectId" | "createdAt" | "updatedAt"> & ThreadSortInput,
>(input: {
Expand Down
65 changes: 53 additions & 12 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ import { openCommandPalette } from "../commandPaletteBus";
import {
archiveSelectedThreadEntries,
buildMultiSelectThreadContextMenuItems,
collapseWorktreeSiblings,
getSidebarThreadIdsToPrewarm,
resolveAdjacentThreadId,
isContextMenuPointerDown,
Expand Down Expand Up @@ -1239,7 +1240,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
return counts;
}, [memberProjectByScopedKey, project.memberProjects, projectThreads]);

const { projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = useMemo(() => {
const {
projectStatus,
visibleProjectThreads,
orderedProjectThreadKeys,
representativeKeyByThreadKey,
} = useMemo(() => {
const lastVisitedAtByThreadKey = new Map(
projectThreads.map((thread, index) => [
scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
Expand All @@ -1257,23 +1263,38 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
},
});
};
const visibleProjectThreads = sortThreads(
const sortedThreads = sortThreads(
projectThreads.filter((thread) => thread.archivedAt === null),
threadSortOrder,
);
// Chats spawned into the same worktree (via the in-chat worktree tab
// strip) collapse to a single row; the siblings stay reachable only as
// tabs. The project status dot still reads from every non-archived
// thread so a busy sibling never goes unreported behind its row.
const { threads: visibleProjectThreads, representativeKeyByThreadKey } =
collapseWorktreeSiblings(sortedThreads, (thread) =>
scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
);
const projectStatus = resolveProjectStatusIndicator(
visibleProjectThreads.map((thread) => resolveProjectThreadStatus(thread)),
sortedThreads.map((thread) => resolveProjectThreadStatus(thread)),
);
return {
orderedProjectThreadKeys: visibleProjectThreads.map((thread) =>
scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
),
projectStatus,
representativeKeyByThreadKey,
visibleProjectThreads,
};
}, [projectThreads, threadLastVisitedAts, threadSortOrder]);
// When the active route is a collapsed worktree sibling, highlight and pin
// the representative row that stands in for it.
const effectiveActiveRouteThreadKey =
activeRouteThreadKey === null
? null
: (representativeKeyByThreadKey.get(activeRouteThreadKey) ?? activeRouteThreadKey);
const pinnedCollapsedThread = useMemo(() => {
const activeThreadKey = activeRouteThreadKey ?? undefined;
const activeThreadKey = effectiveActiveRouteThreadKey ?? undefined;
if (!activeThreadKey || projectExpanded) {
return null;
}
Expand All @@ -1283,7 +1304,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeThreadKey,
) ?? null
);
}, [activeRouteThreadKey, projectExpanded, visibleProjectThreads]);
}, [effectiveActiveRouteThreadKey, projectExpanded, visibleProjectThreads]);

const {
hasOverflowingThreads,
Expand Down Expand Up @@ -2375,7 +2396,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
shouldShowThreadPanel={shouldShowThreadPanel}
isThreadListExpanded={isThreadListExpanded}
projectCwd={project.workspaceRoot}
activeRouteThreadKey={activeRouteThreadKey}
activeRouteThreadKey={effectiveActiveRouteThreadKey}
threadJumpLabelByKey={threadJumpLabelByKey}
appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive}
renamingThreadKey={renamingThreadKey}
Expand Down Expand Up @@ -3327,6 +3348,16 @@ export default function Sidebar() {
() => sidebarThreads.filter((thread) => thread.archivedAt === null),
[sidebarThreads],
);
// The active route may be a collapsed worktree sibling that has no row of
// its own; keyboard traversal and jump ordering run against the earliest
// chat that represents it.
const routeRepresentativeThreadKey = useMemo(() => {
if (routeThreadKey === null) return null;
const { representativeKeyByThreadKey } = collapseWorktreeSiblings(visibleThreads, (thread) =>
scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
);
return representativeKeyByThreadKey.get(routeThreadKey) ?? routeThreadKey;
}, [routeThreadKey, visibleThreads]);
const sortedProjects = useMemo(() => {
const sortableProjects = sidebarProjects.map((project) => ({
...project,
Expand Down Expand Up @@ -3362,17 +3393,26 @@ export default function Sidebar() {
const visibleSidebarThreadKeys = useMemo(
() =>
sortedProjects.flatMap((project) => {
const projectThreads = sortThreads(
(threadsByProjectKey.get(project.projectKey) ?? []).filter(
(thread) => thread.archivedAt === null,
// Mirror the row-level collapse so jump labels and prewarming line up
// with the rendered rows: worktree siblings fold into their earliest
// representative and never claim their own visible slot.
const { threads: projectThreads, representativeKeyByThreadKey } = collapseWorktreeSiblings(
sortThreads(
(threadsByProjectKey.get(project.projectKey) ?? []).filter(
(thread) => thread.archivedAt === null,
),
sidebarThreadSortOrder,
),
sidebarThreadSortOrder,
(thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
);
const projectExpanded = resolveProjectExpanded(
projectExpandedById,
projectExpansionPreferenceKeys(project),
);
const activeThreadKey = routeThreadKey ?? undefined;
const activeThreadKey =
routeThreadKey === null
? undefined
: (representativeKeyByThreadKey.get(routeThreadKey) ?? routeThreadKey);
const pinnedCollapsedThread =
!projectExpanded && activeThreadKey
? (projectThreads.find(
Expand Down Expand Up @@ -3482,7 +3522,7 @@ export default function Sidebar() {
if (traversalDirection !== null) {
const targetThreadKey = resolveAdjacentThreadId({
threadIds: orderedSidebarThreadKeys,
currentThreadId: routeThreadKey,
currentThreadId: routeRepresentativeThreadKey,
direction: traversalDirection,
});
if (!targetThreadKey) {
Expand Down Expand Up @@ -3529,6 +3569,7 @@ export default function Sidebar() {
navigateToThread,
orderedSidebarThreadKeys,
platform,
routeRepresentativeThreadKey,
routeThreadKey,
sidebarThreadByKey,
threadJumpThreadKeys,
Expand Down
Loading