From d827e43c2558ba235492d8557968995db1e538fa Mon Sep 17 00:00:00 2001 From: RJWadley Date: Thu, 14 May 2026 22:58:18 -0600 Subject: [PATCH 1/4] Group sidebar threads by worktree --- .../settings/DesktopClientSettings.test.ts | 2 + apps/web/src/components/Sidebar.logic.test.ts | 487 +++++++++++++ apps/web/src/components/Sidebar.logic.ts | 320 ++++++++- apps/web/src/components/Sidebar.tsx | 678 +++++++++++++----- .../components/settings/SettingsPanels.tsx | 11 + apps/web/src/localApi.test.ts | 4 + apps/web/src/uiStateStore.test.ts | 26 + apps/web/src/uiStateStore.ts | 34 + packages/contracts/src/settings.ts | 22 + 9 files changed, 1389 insertions(+), 195 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index f666e692860..62c42bdffe8 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -25,8 +25,10 @@ const clientSettings: ClientSettings = { "environment-1:/tmp/project-a": "separate", }, sidebarProjectSortOrder: "manual", + sidebarThreadGroupingMode: "worktree", sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, + sidebarWorktreePreviewCount: 4, timestampFormat: "24-hour", }; diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 926c117c1c0..9f7cf9bb38e 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -3,6 +3,10 @@ import { ProviderDriverKind } from "@t3tools/contracts"; import { createThreadJumpHintVisibilityController, + buildSidebarProjectThreadRenderState, + buildSidebarThreadRenderModel, + buildSidebarWorktreeThreadGroups, + getSidebarWorktreeGroupUiKey, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, resolveAdjacentThreadId, @@ -19,6 +23,8 @@ import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, + SIDEBAR_CURRENT_CHECKOUT_WORKTREE_KEY, + SIDEBAR_FLAT_THREADS_GROUP_KEY, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; import { @@ -36,6 +42,11 @@ import { } from "../types"; const localEnvironmentId = EnvironmentId.make("environment-local"); +const currentCheckoutGroupUiKey = getSidebarWorktreeGroupUiKey( + "", + SIDEBAR_CURRENT_CHECKOUT_WORKTREE_KEY, +); +const flatThreadsGroupUiKey = getSidebarWorktreeGroupUiKey("", SIDEBAR_FLAT_THREADS_GROUP_KEY); function makeLatestTurn(overrides?: { completedAt?: string | null; @@ -143,6 +154,482 @@ describe("getSidebarThreadIdsToPrewarm", () => { }); }); +describe("buildSidebarWorktreeThreadGroups", () => { + it("groups local checkout threads under the current checkout", () => { + const threads = [ + { branch: "main", worktreePath: null, title: "One" }, + { branch: "main", worktreePath: null, title: "Two" }, + ]; + + expect(buildSidebarWorktreeThreadGroups(threads)).toEqual([ + { + expanded: true, + key: SIDEBAR_CURRENT_CHECKOUT_WORKTREE_KEY, + uiKey: currentCheckoutGroupUiKey, + label: "Current checkout", + threadsExpanded: false, + totalThreadCount: 2, + hiddenThreadCount: 0, + overflowThreadCount: 0, + threads, + }, + ]); + }); + + it("groups contiguous worktree threads by worktree path", () => { + const currentThread = { branch: "main", worktreePath: null, title: "Current" }; + const firstWorktreeThread = { + branch: "feature/workspaces", + worktreePath: "/repo/.t3/worktrees/workspaces", + title: "First", + }; + const secondWorktreeThread = { + branch: "feature/workspaces", + worktreePath: "/repo/.t3/worktrees/workspaces", + title: "Second", + }; + + expect( + buildSidebarWorktreeThreadGroups([currentThread, firstWorktreeThread, secondWorktreeThread]), + ).toEqual([ + { + expanded: true, + key: SIDEBAR_CURRENT_CHECKOUT_WORKTREE_KEY, + uiKey: currentCheckoutGroupUiKey, + label: "Current checkout", + threadsExpanded: false, + totalThreadCount: 1, + hiddenThreadCount: 0, + overflowThreadCount: 0, + threads: [currentThread], + }, + { + expanded: true, + key: "/repo/.t3/worktrees/workspaces", + uiKey: "::/repo/.t3/worktrees/workspaces", + label: "feature/workspaces", + threadsExpanded: false, + totalThreadCount: 2, + hiddenThreadCount: 0, + overflowThreadCount: 0, + threads: [firstWorktreeThread, secondWorktreeThread], + }, + ]); + }); + + it("groups interleaved worktree threads in first-seen worktree order", () => { + const firstWorktreeThread = { + branch: "feature/a", + worktreePath: "/repo/.t3/worktrees/a", + title: "First", + }; + const currentThread = { branch: "main", worktreePath: null, title: "Current" }; + const secondWorktreeThread = { + branch: "feature/a", + worktreePath: "/repo/.t3/worktrees/a", + title: "Second", + }; + + expect( + buildSidebarWorktreeThreadGroups([firstWorktreeThread, currentThread, secondWorktreeThread]), + ).toEqual([ + { + expanded: true, + key: "/repo/.t3/worktrees/a", + uiKey: "::/repo/.t3/worktrees/a", + label: "feature/a", + threadsExpanded: false, + totalThreadCount: 2, + hiddenThreadCount: 0, + overflowThreadCount: 0, + threads: [firstWorktreeThread, secondWorktreeThread], + }, + { + expanded: true, + key: SIDEBAR_CURRENT_CHECKOUT_WORKTREE_KEY, + uiKey: currentCheckoutGroupUiKey, + label: "Current checkout", + threadsExpanded: false, + totalThreadCount: 1, + hiddenThreadCount: 0, + overflowThreadCount: 0, + threads: [currentThread], + }, + ]); + }); + + it("falls back to the worktree directory name when branch is unavailable", () => { + const thread = { + branch: null, + worktreePath: "/repo/.t3/worktrees/generated", + title: "Generated", + }; + + expect(buildSidebarWorktreeThreadGroups([thread])[0]?.label).toBe("generated"); + }); + + it("uses a generic worktree label when the worktree path has no basename", () => { + const thread = { + branch: null, + worktreePath: "/", + title: "Generated", + }; + + expect(buildSidebarWorktreeThreadGroups([thread])[0]?.label).toBe("Worktree"); + }); +}); + +describe("buildSidebarThreadRenderModel", () => { + it("keeps separate mode flat and preserves input order", () => { + const threads = [ + { id: "1", branch: "feature/a", worktreePath: "/repo/a" }, + { id: "2", branch: "main", worktreePath: null }, + { id: "3", branch: "feature/a", worktreePath: "/repo/a" }, + ]; + + const model = buildSidebarThreadRenderModel<(typeof threads)[number]>({ + threads, + groupingMode: "separate", + expanded: false, + threadPreviewCount: 2, + worktreePreviewCount: 2, + }); + + expect(model.visibleThreads.map((thread) => thread.id)).toEqual(["1", "2"]); + expect(model.hiddenThreads.map((thread) => thread.id)).toEqual(["3"]); + expect(model.groups).toEqual([ + { + expanded: true, + key: SIDEBAR_FLAT_THREADS_GROUP_KEY, + uiKey: flatThreadsGroupUiKey, + label: "", + threadsExpanded: true, + totalThreadCount: 3, + hiddenThreadCount: 1, + overflowThreadCount: 1, + threads: [threads[0], threads[1]], + }, + ]); + expect(model.hiddenGroupCount).toBe(0); + }); + + it("groups worktree mode before applying worktree and per-worktree limits", () => { + const threads = [ + { id: "1", branch: "feature/a", worktreePath: "/repo/a" }, + { id: "2", branch: "main", worktreePath: null }, + { id: "3", branch: "feature/a", worktreePath: "/repo/a" }, + { id: "4", branch: "feature/b", worktreePath: "/repo/b" }, + ]; + + const model = buildSidebarThreadRenderModel({ + threads, + groupingMode: "worktree", + expanded: false, + threadPreviewCount: 1, + worktreePreviewCount: 2, + }); + + expect(model.visibleThreads.map((thread) => thread.id)).toEqual(["1", "2"]); + expect(model.hiddenThreads.map((thread) => thread.id)).toEqual(["3", "4"]); + expect(model.hiddenGroupCount).toBe(1); + expect(model.groups.map((group) => group.key)).toEqual([ + "/repo/a", + SIDEBAR_CURRENT_CHECKOUT_WORKTREE_KEY, + ]); + expect(model.groups[0]?.hiddenThreadCount).toBe(1); + }); + + it("keeps overflow true after grouped worktree rows are expanded", () => { + const threads = [ + { id: "1", branch: "feature/a", worktreePath: "/repo/a" }, + { id: "2", branch: "main", worktreePath: null }, + { id: "3", branch: "feature/a", worktreePath: "/repo/a" }, + { id: "4", branch: "feature/b", worktreePath: "/repo/b" }, + ]; + + const model = buildSidebarThreadRenderModel({ + threads, + groupingMode: "worktree", + expanded: true, + threadPreviewCount: 10, + worktreePreviewCount: 2, + }); + + expect(model.hiddenThreads).toEqual([]); + expect(model.hasOverflowingGroups).toBe(true); + expect(model.hasOverflowingThreads).toBe(true); + }); + + it("uses top-level expansion for single current-checkout worktree groups", () => { + const threads = [ + { id: "1", branch: "main", worktreePath: null }, + { id: "2", branch: "main", worktreePath: null }, + { id: "3", branch: "main", worktreePath: null }, + ]; + + const collapsedModel = buildSidebarThreadRenderModel({ + threads, + groupingMode: "worktree", + expanded: false, + threadPreviewCount: 2, + worktreePreviewCount: 2, + }); + const expandedModel = buildSidebarThreadRenderModel({ + threads, + groupingMode: "worktree", + expanded: true, + threadPreviewCount: 2, + worktreePreviewCount: 2, + }); + + expect(collapsedModel.groups[0]?.threads.map((thread) => thread.id)).toEqual(["1", "2"]); + expect(collapsedModel.hiddenThreads.map((thread) => thread.id)).toEqual(["3"]); + expect(collapsedModel.hasOverflowingGroups).toBe(false); + expect(collapsedModel.hasOverflowingThreads).toBe(true); + expect(expandedModel.groups[0]?.threads.map((thread) => thread.id)).toEqual(["1", "2", "3"]); + expect(expandedModel.hiddenThreads).toEqual([]); + expect(expandedModel.hasOverflowingGroups).toBe(false); + expect(expandedModel.hasOverflowingThreads).toBe(true); + }); + + it("keeps per-worktree overflow available after grouped thread rows are expanded", () => { + const threads = [ + { id: "1", branch: "feature/a", worktreePath: "/repo/a" }, + { id: "2", branch: "feature/a", worktreePath: "/repo/a" }, + { id: "3", branch: "feature/a", worktreePath: "/repo/a" }, + ]; + + const model = buildSidebarThreadRenderModel({ + threads, + groupingMode: "worktree", + expanded: true, + expandedWorktreeThreadKeys: new Set(["::/repo/a"]), + threadPreviewCount: 2, + worktreePreviewCount: 2, + }); + + expect(model.groups[0]?.threads.map((thread) => thread.id)).toEqual(["1", "2", "3"]); + expect(model.groups[0]?.hiddenThreadCount).toBe(0); + expect(model.groups[0]?.overflowThreadCount).toBe(1); + }); + + it("keeps the active thread visible when it is past the per-worktree preview", () => { + const threads = Array.from({ length: 7 }, (_, index) => ({ + id: String(index + 1), + branch: "feature/a", + worktreePath: "/repo/a", + })); + + const model = buildSidebarThreadRenderModel({ + threads, + groupingMode: "worktree", + expanded: false, + threadPreviewCount: 4, + worktreePreviewCount: 2, + pinnedThread: threads[6]!, + }); + + expect(model.groups[0]?.threads.map((thread) => thread.id)).toEqual(["1", "2", "3", "4", "7"]); + expect(model.hiddenThreads.map((thread) => thread.id)).toEqual(["5", "6"]); + expect(model.visibleThreads.map((thread) => thread.id)).toEqual(["1", "2", "3", "4", "7"]); + }); + + it("keeps the active worktree visible when it is past the worktree preview", () => { + const threads = [ + { id: "1", branch: "feature/a", worktreePath: "/repo/a" }, + { id: "2", branch: "main", worktreePath: null }, + { id: "3", branch: "feature/b", worktreePath: "/repo/b" }, + { id: "4", branch: "feature/b", worktreePath: "/repo/b" }, + ]; + + const model = buildSidebarThreadRenderModel({ + threads, + groupingMode: "worktree", + expanded: false, + threadPreviewCount: 1, + worktreePreviewCount: 1, + pinnedThread: threads[3]!, + }); + + expect(model.groups.map((group) => group.key)).toEqual(["/repo/a", "/repo/b"]); + expect(model.groups.map((group) => group.threads.map((thread) => thread.id))).toEqual([ + ["1"], + ["3", "4"], + ]); + expect(model.hiddenGroupCount).toBe(1); + expect(model.hiddenThreads.map((thread) => thread.id)).toEqual(["2"]); + expect(model.visibleThreads.map((thread) => thread.id)).toEqual(["1", "3", "4"]); + }); + + it("keeps the active thread visible when its worktree is collapsed past the worktree preview", () => { + const threads = [ + { id: "1", branch: "feature/a", worktreePath: "/repo/a" }, + { id: "2", branch: "main", worktreePath: null }, + { id: "3", branch: "feature/b", worktreePath: "/repo/b" }, + { id: "4", branch: "feature/b", worktreePath: "/repo/b" }, + ]; + + const model = buildSidebarThreadRenderModel({ + threads, + groupingMode: "worktree", + expanded: false, + threadPreviewCount: 1, + worktreePreviewCount: 1, + projectKey: "repo", + collapsedWorktreeKeys: new Set([getSidebarWorktreeGroupUiKey("repo", "/repo/b")]), + pinnedThread: threads[3]!, + }); + + expect(model.groups.map((group) => group.key)).toEqual(["/repo/a", "/repo/b"]); + expect(model.groups.map((group) => group.threads.map((thread) => thread.id))).toEqual([ + ["1"], + ["4"], + ]); + expect(model.hiddenGroupCount).toBe(1); + expect(model.hiddenThreads.map((thread) => thread.id)).toEqual(["3", "2"]); + expect(model.visibleThreads.map((thread) => thread.id)).toEqual(["1", "4"]); + }); + + it("keeps a pinned-only thread visible inside a collapsed worktree", () => { + const thread = { + id: "1", + branch: "feature/a", + worktreePath: "/repo/a", + }; + + const model = buildSidebarThreadRenderModel({ + threads: [thread], + groupingMode: "worktree", + expanded: true, + threadPreviewCount: 1, + worktreePreviewCount: 1, + projectKey: "repo", + collapsedWorktreeKeys: new Set([getSidebarWorktreeGroupUiKey("repo", "/repo/a")]), + pinnedThread: thread, + }); + + expect(model.groups[0]).toMatchObject({ + key: "/repo/a", + expanded: false, + threads: [thread], + }); + expect(model.visibleThreads).toEqual([thread]); + }); + + it("returns grouped flattened order for shortcuts, prewarming, and range selection", () => { + const threads = [ + { id: "1", branch: "feature/a", worktreePath: "/repo/a" }, + { id: "2", branch: "main", worktreePath: null }, + { id: "3", branch: "feature/a", worktreePath: "/repo/a" }, + { id: "4", branch: "feature/b", worktreePath: "/repo/b" }, + ]; + + const model = buildSidebarThreadRenderModel({ + threads, + groupingMode: "worktree", + expanded: true, + expandedWorktreeThreadKeys: new Set(["::/repo/a"]), + threadPreviewCount: 1, + worktreePreviewCount: 1, + }); + + expect(model.groups.map((group) => group.threads.map((thread) => thread.id))).toEqual([ + ["1", "3"], + ["2"], + ["4"], + ]); + expect(model.visibleThreads.map((thread) => thread.id)).toEqual(["1", "3", "2", "4"]); + }); + + it("excludes collapsed worktree rows from visible order", () => { + const threads = [ + { id: "1", branch: "feature/a", worktreePath: "/repo/a" }, + { id: "2", branch: "main", worktreePath: null }, + { id: "3", branch: "feature/a", worktreePath: "/repo/a" }, + { id: "4", branch: "feature/b", worktreePath: "/repo/b" }, + ]; + + const model = buildSidebarThreadRenderModel({ + threads, + groupingMode: "worktree", + expanded: true, + threadPreviewCount: 1, + worktreePreviewCount: 1, + projectKey: "repo", + collapsedWorktreeKeys: new Set([getSidebarWorktreeGroupUiKey("repo", "/repo/a")]), + }); + + expect(model.groups[0]).toMatchObject({ + key: "/repo/a", + uiKey: "repo::/repo/a", + expanded: false, + totalThreadCount: 2, + threads: [], + hiddenThreadCount: 0, + }); + expect(model.visibleThreads.map((thread) => thread.id)).toEqual(["2", "4"]); + }); + + it("keeps the active thread visible inside a collapsed worktree", () => { + const threads = [ + { id: "1", branch: "feature/a", worktreePath: "/repo/a" }, + { id: "2", branch: "main", worktreePath: null }, + { id: "3", branch: "feature/a", worktreePath: "/repo/a" }, + { id: "4", branch: "feature/b", worktreePath: "/repo/b" }, + ]; + + const model = buildSidebarThreadRenderModel({ + threads, + groupingMode: "worktree", + expanded: true, + threadPreviewCount: 1, + worktreePreviewCount: 1, + projectKey: "repo", + collapsedWorktreeKeys: new Set([getSidebarWorktreeGroupUiKey("repo", "/repo/a")]), + pinnedThread: threads[2]!, + }); + + expect(model.groups[0]).toMatchObject({ + key: "/repo/a", + expanded: false, + threads: [threads[2]], + hiddenThreadCount: 0, + }); + expect(model.visibleThreads.map((thread) => thread.id)).toEqual(["3", "2", "4"]); + }); +}); + +describe("buildSidebarProjectThreadRenderState", () => { + it("pins the active thread when its project is collapsed", () => { + const threads = Array.from({ length: 7 }, (_, index) => ({ + id: String(index + 1), + branch: "feature/a", + worktreePath: "/repo/a", + })); + + const renderState = buildSidebarProjectThreadRenderState({ + activeThreadKey: "7", + collapsedWorktreeKeys: new Set(), + expandedWorktreeThreadKeys: new Set(), + getThreadKey: (thread) => thread.id, + isThreadListExpanded: false, + projectExpanded: false, + projectKey: "repo", + threadGroupingMode: "worktree", + threadPreviewCount: 4, + threads, + worktreePreviewCount: 1, + }); + + expect(renderState.shouldShowThreadPanel).toBe(true); + expect(renderState.pinnedCollapsedThread).toBe(threads[6]); + expect(renderState.hasOverflowingThreads).toBe(false); + expect(renderState.hasOverflowingWorktrees).toBe(false); + expect(renderState.orderedThreadKeys).toEqual(["7"]); + expect(renderState.renderModel.visibleThreads).toEqual([threads[6]]); + }); +}); + describe("shouldClearThreadSelectionOnMouseDown", () => { it("preserves selection for thread items", () => { const child = { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 39b759ac313..49b8d48bf54 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,5 +1,9 @@ import * as React from "react"; -import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import type { + SidebarProjectSortOrder, + SidebarThreadGroupingMode, + SidebarThreadSortOrder, +} from "@t3tools/contracts/settings"; import { getThreadSortTimestamp, sortThreads, @@ -12,10 +16,41 @@ import { isLatestTurnSettled } from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; +export const SIDEBAR_CURRENT_CHECKOUT_WORKTREE_KEY = "__current_checkout__"; +export const SIDEBAR_FLAT_THREADS_GROUP_KEY = "__flat_threads__"; // Visible sidebar rows are prewarmed into the thread-detail cache so opening a // nearby thread usually reuses an already-hot subscription. export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; export type SidebarNewThreadEnvMode = "local" | "worktree"; +export interface SidebarWorktreeThreadGroup { + key: string; + uiKey: string; + label: string; + expanded: boolean; + threadsExpanded: boolean; + totalThreadCount: number; + threads: TThread[]; + hiddenThreadCount: number; + overflowThreadCount: number; +} +export interface SidebarThreadRenderModel { + groups: SidebarWorktreeThreadGroup[]; + hiddenGroupCount: number; + hiddenThreads: TThread[]; + hasOverflowingGroups: boolean; + hasOverflowingThreads: boolean; + visibleThreads: TThread[]; +} +export type SidebarProjectThreadRenderState = { + hasOverflowingThreads: boolean; + hasOverflowingWorktrees: boolean; + hiddenWorktreeCount: number; + orderedThreadKeys: string[]; + pinnedCollapsedThread: TThread | null; + renderModel: SidebarThreadRenderModel; + shouldShowThreadPanel: boolean; + showEmptyThreadState: boolean; +}; type SidebarProject = { id: string; name: string; @@ -259,6 +294,289 @@ export function getSidebarThreadIdsToPrewarm( return visibleThreadIds.slice(0, Math.max(0, limit)); } +function basenameOfPath(path: string): string { + const normalized = path.replace(/[\\/]+$/, ""); + const separatorIndex = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\")); + return separatorIndex >= 0 ? normalized.slice(separatorIndex + 1) : normalized; +} + +export function getSidebarWorktreeGroupUiKey(projectKey: string, worktreeKey: string): string { + return `${projectKey}::${worktreeKey}`; +} + +export function getSidebarThreadWorktreeKey(thread: { worktreePath: string | null }): string { + return thread.worktreePath ?? SIDEBAR_CURRENT_CHECKOUT_WORKTREE_KEY; +} + +function includePinnedThread( + threads: readonly TThread[], + visibleThreads: readonly TThread[], + pinnedThread: TThread | null | undefined, +): TThread[] { + if (!pinnedThread || visibleThreads.includes(pinnedThread) || !threads.includes(pinnedThread)) { + return [...visibleThreads]; + } + + const visibleThreadSet = new Set([...visibleThreads, pinnedThread]); + return threads.filter((thread) => visibleThreadSet.has(thread)); +} + +export function buildSidebarWorktreeThreadGroups< + TThread extends { + branch: string | null; + worktreePath: string | null; + }, +>( + threads: readonly TThread[], + options?: { + projectKey?: string; + collapsedWorktreeKeys?: ReadonlySet; + }, +): SidebarWorktreeThreadGroup[] { + const groups: SidebarWorktreeThreadGroup[] = []; + const groupByKey = new Map>(); + + for (const thread of threads) { + const key = getSidebarThreadWorktreeKey(thread); + let group = groupByKey.get(key); + if (!group) { + const uiKey = getSidebarWorktreeGroupUiKey(options?.projectKey ?? "", key); + group = { + key, + uiKey, + label: thread.worktreePath + ? (thread.branch ?? (basenameOfPath(thread.worktreePath) || "Worktree")) + : "Current checkout", + expanded: !options?.collapsedWorktreeKeys?.has(uiKey), + threadsExpanded: false, + totalThreadCount: 0, + hiddenThreadCount: 0, + overflowThreadCount: 0, + threads: [], + }; + groups.push(group); + groupByKey.set(key, group); + } + group.totalThreadCount += 1; + group.threads.push(thread); + } + + return groups; +} + +export function buildSidebarThreadRenderModel< + TThread extends { + branch: string | null; + worktreePath: string | null; + }, +>(input: { + threads: readonly TThread[]; + groupingMode: SidebarThreadGroupingMode; + expanded: boolean; + expandedWorktreeThreadKeys?: ReadonlySet; + threadPreviewCount: number; + worktreePreviewCount: number; + projectKey?: string; + collapsedWorktreeKeys?: ReadonlySet; + pinnedThread?: TThread | null; +}): SidebarThreadRenderModel { + if (input.groupingMode === "separate") { + const hasOverflowingThreads = input.threads.length > input.threadPreviewCount; + const visibleThreads = + input.expanded || !hasOverflowingThreads + ? [...input.threads] + : includePinnedThread( + input.threads, + input.threads.slice(0, input.threadPreviewCount), + input.pinnedThread, + ); + const visibleThreadSet = new Set(visibleThreads); + return { + groups: [ + { + key: SIDEBAR_FLAT_THREADS_GROUP_KEY, + uiKey: getSidebarWorktreeGroupUiKey( + input.projectKey ?? "", + SIDEBAR_FLAT_THREADS_GROUP_KEY, + ), + label: "", + expanded: true, + threadsExpanded: true, + totalThreadCount: input.threads.length, + threads: visibleThreads, + hiddenThreadCount: input.threads.length - visibleThreads.length, + overflowThreadCount: Math.max(input.threads.length - input.threadPreviewCount, 0), + }, + ], + hiddenGroupCount: 0, + hiddenThreads: input.threads.filter((thread) => !visibleThreadSet.has(thread)), + hasOverflowingGroups: false, + hasOverflowingThreads, + visibleThreads, + }; + } + + const allGroups = buildSidebarWorktreeThreadGroups(input.threads, { + ...(input.projectKey !== undefined ? { projectKey: input.projectKey } : {}), + ...(input.collapsedWorktreeKeys !== undefined + ? { collapsedWorktreeKeys: input.collapsedWorktreeKeys } + : {}), + }); + if (allGroups.length === 1 && allGroups[0]?.key === SIDEBAR_CURRENT_CHECKOUT_WORKTREE_KEY) { + const group = allGroups[0]; + const hasOverflowingThreads = group.threads.length > input.threadPreviewCount; + const visibleThreads = + input.expanded || !hasOverflowingThreads + ? group.threads + : includePinnedThread( + group.threads, + group.threads.slice(0, input.threadPreviewCount), + input.pinnedThread, + ); + const visibleThreadSet = new Set(visibleThreads); + return { + groups: [ + { + ...group, + expanded: true, + threadsExpanded: true, + totalThreadCount: group.threads.length, + threads: visibleThreads, + hiddenThreadCount: group.threads.length - visibleThreads.length, + overflowThreadCount: Math.max(group.threads.length - input.threadPreviewCount, 0), + }, + ], + hiddenGroupCount: 0, + hiddenThreads: group.threads.filter((thread) => !visibleThreadSet.has(thread)), + hasOverflowingGroups: false, + hasOverflowingThreads, + visibleThreads, + }; + } + const visibleGroupCount = input.expanded + ? allGroups.length + : Math.min(allGroups.length, input.worktreePreviewCount); + const visibleGroupIndexes = new Set( + Array.from({ length: visibleGroupCount }, (_, index) => index), + ); + if (input.pinnedThread) { + const pinnedGroupKey = getSidebarThreadWorktreeKey(input.pinnedThread); + const pinnedGroupIndex = allGroups.findIndex((group) => group.key === pinnedGroupKey); + if (pinnedGroupIndex >= 0) { + visibleGroupIndexes.add(pinnedGroupIndex); + } + } + const visibleGroups = allGroups.flatMap((group, index) => { + if (!visibleGroupIndexes.has(index)) { + return []; + } + const previewThreads = group.threads.slice(0, input.threadPreviewCount); + const threadsExpanded = input.expandedWorktreeThreadKeys?.has(group.uiKey) ?? false; + const pinnedThread = + input.pinnedThread && getSidebarThreadWorktreeKey(input.pinnedThread) === group.key + ? input.pinnedThread + : null; + const visibleThreads = threadsExpanded + ? group.threads + : includePinnedThread(group.threads, previewThreads, pinnedThread); + const renderedThreads = group.expanded ? visibleThreads : pinnedThread ? [pinnedThread] : []; + const baselineHiddenThreadCount = group.threads.length - previewThreads.length; + const renderedThreadSet = new Set(renderedThreads); + return [ + { + key: group.key, + uiKey: group.uiKey, + label: group.label, + expanded: group.expanded, + threadsExpanded, + totalThreadCount: group.threads.length, + threads: renderedThreads, + hiddenThreadCount: group.expanded + ? group.threads.filter((thread) => !renderedThreadSet.has(thread)).length + : 0, + overflowThreadCount: Math.max(baselineHiddenThreadCount, 0), + }, + ]; + }); + const hiddenGroups = allGroups.filter((_, index) => !visibleGroupIndexes.has(index)); + const renderedThreadSet = new Set(visibleGroups.flatMap((group) => group.threads)); + const hiddenThreads = [ + ...allGroups + .filter((_, index) => visibleGroupIndexes.has(index)) + .flatMap((group) => group.threads.filter((thread) => !renderedThreadSet.has(thread))), + ...hiddenGroups.flatMap((group) => group.threads), + ]; + const hasOverflowingGroups = allGroups.length > input.worktreePreviewCount; + const hasOverflowingThreads = + hasOverflowingGroups || + allGroups.some((group) => group.threads.length > input.threadPreviewCount); + return { + groups: visibleGroups, + hiddenGroupCount: hiddenGroups.length, + hiddenThreads, + hasOverflowingGroups, + hasOverflowingThreads, + visibleThreads: visibleGroups.flatMap((group) => group.threads), + }; +} + +export function buildSidebarProjectThreadRenderState< + TThread extends { + branch: string | null; + worktreePath: string | null; + }, +>(input: { + activeThreadKey: string | null; + collapsedWorktreeKeys: ReadonlySet; + expandedWorktreeThreadKeys: ReadonlySet; + getThreadKey: (thread: TThread) => string; + isThreadListExpanded: boolean; + projectExpanded: boolean; + projectKey: string; + threadGroupingMode: SidebarThreadGroupingMode; + threadPreviewCount: number; + threads: readonly TThread[]; + worktreePreviewCount: number; +}): SidebarProjectThreadRenderState { + const activeThread = input.activeThreadKey + ? (input.threads.find((thread) => input.getThreadKey(thread) === input.activeThreadKey) ?? null) + : null; + const pinnedCollapsedThread = input.projectExpanded ? null : activeThread; + const shouldShowThreadPanel = input.projectExpanded || pinnedCollapsedThread !== null; + const renderThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : input.threads; + const renderModel = shouldShowThreadPanel + ? buildSidebarThreadRenderModel({ + threads: renderThreads, + groupingMode: input.threadGroupingMode, + expanded: input.isThreadListExpanded || pinnedCollapsedThread !== null, + expandedWorktreeThreadKeys: input.expandedWorktreeThreadKeys, + threadPreviewCount: input.threadPreviewCount, + worktreePreviewCount: input.worktreePreviewCount, + projectKey: input.projectKey, + collapsedWorktreeKeys: input.collapsedWorktreeKeys, + pinnedThread: pinnedCollapsedThread ?? activeThread, + }) + : { + groups: [], + hiddenGroupCount: 0, + hiddenThreads: [], + hasOverflowingGroups: false, + hasOverflowingThreads: false, + visibleThreads: [], + }; + + return { + hasOverflowingThreads: pinnedCollapsedThread ? false : renderModel.hasOverflowingThreads, + hasOverflowingWorktrees: pinnedCollapsedThread ? false : renderModel.hasOverflowingGroups, + hiddenWorktreeCount: pinnedCollapsedThread ? 0 : renderModel.hiddenGroupCount, + orderedThreadKeys: renderModel.visibleThreads.map(input.getThreadKey), + pinnedCollapsedThread, + renderModel, + shouldShowThreadPanel, + showEmptyThreadState: input.projectExpanded && input.threads.length === 0, + }; +} + export function resolveAdjacentThreadId(input: { threadIds: readonly T[]; currentThreadId: T | null; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e8c5bbe0b11..8ec35f8f6f0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -55,10 +55,14 @@ import { import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, + MAX_SIDEBAR_WORKTREE_PREVIEW_COUNT, MIN_SIDEBAR_THREAD_PREVIEW_COUNT, + MIN_SIDEBAR_WORKTREE_PREVIEW_COUNT, type SidebarProjectSortOrder, + type SidebarThreadGroupingMode, type SidebarThreadPreviewCount, type SidebarThreadSortOrder, + type SidebarWorktreePreviewCount, } from "@t3tools/contracts/settings"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { isElectron } from "../env"; @@ -158,19 +162,23 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { + buildSidebarProjectThreadRenderState, getSidebarThreadIdsToPrewarm, - resolveAdjacentThreadId, + getSidebarThreadWorktreeKey, isContextMenuPointerDown, + orderItemsByPreferredIds, + resolveAdjacentThreadId, resolveProjectStatusIndicator, - resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, + resolveSidebarNewThreadSeedContext, resolveThreadRowClassName, resolveThreadStatusPill, - orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, + SIDEBAR_CURRENT_CHECKOUT_WORKTREE_KEY, sortProjectsForSidebar, - useThreadJumpHintVisibility, + type SidebarThreadRenderModel, ThreadStatusPill, + useThreadJumpHintVisibility, } from "./Sidebar.logic"; import { sortThreads } from "../lib/threadSort"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; @@ -206,6 +214,10 @@ const SIDEBAR_THREAD_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", }; +const SIDEBAR_THREAD_GROUPING_LABELS: Record = { + worktree: "Group by worktree", + separate: "Keep separate", +}; const SIDEBAR_LIST_ANIMATION_OPTIONS = { duration: 180, easing: "ease-out", @@ -216,6 +228,10 @@ const PROJECT_GROUPING_MODE_LABELS: Record = repository_path: "Group by repository path", separate: "Keep separate", }; +type RenderedSidebarWorktreeThreadGroup = + SidebarThreadRenderModel["groups"][number] & { + status: ThreadStatusPill | null; + }; function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount { return Math.min( @@ -224,6 +240,36 @@ function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCoun ) as SidebarThreadPreviewCount; } +function clampSidebarWorktreePreviewCount(value: number): SidebarWorktreePreviewCount { + return Math.min( + MAX_SIDEBAR_WORKTREE_PREVIEW_COUNT, + Math.max(MIN_SIDEBAR_WORKTREE_PREVIEW_COUNT, value), + ) as SidebarWorktreePreviewCount; +} + +function getSidebarThreadKey(thread: Pick): string { + return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); +} + +function createSidebarThreadStatusResolver( + threads: readonly SidebarThreadSummary[], + lastVisitedAts: readonly (string | null | undefined)[], +): (thread: SidebarThreadSummary) => ThreadStatusPill | null { + const lastVisitedAtByThreadKey = new Map( + threads.map((thread, index) => [getSidebarThreadKey(thread), lastVisitedAts[index] ?? null]), + ); + + return (thread) => { + const lastVisitedAt = lastVisitedAtByThreadKey.get(getSidebarThreadKey(thread)); + return resolveThreadStatusPill({ + thread: { + ...thread, + ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}), + }, + }); + }; +} + function formatProjectMemberActionLabel( member: SidebarProjectGroupMember, groupedProjectCount: number, @@ -721,10 +767,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP interface SidebarProjectThreadListProps { projectKey: string; projectExpanded: boolean; + threadGroupingMode: SidebarThreadGroupingMode; hasOverflowingThreads: boolean; + hasOverflowingWorktrees: boolean; + hiddenWorktreeCount: number; hiddenThreadStatus: ThreadStatusPill | null; orderedProjectThreadKeys: readonly string[]; - renderedThreads: readonly SidebarThreadSummary[]; + renderedThreadGroups: readonly RenderedSidebarWorktreeThreadGroup[]; showEmptyThreadState: boolean; shouldShowThreadPanel: boolean; isThreadListExpanded: boolean; @@ -763,6 +812,9 @@ interface SidebarProjectThreadListProps { openPrLink: (event: React.MouseEvent, prUrl: string) => void; expandThreadListForProject: (projectKey: string) => void; collapseThreadListForProject: (projectKey: string) => void; + expandWorktreeThreads: (worktreeKey: string) => void; + collapseWorktreeThreads: (worktreeKey: string) => void; + toggleWorktree: (worktreeKey: string) => void; } const SidebarProjectThreadList = memo(function SidebarProjectThreadList( @@ -771,10 +823,13 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( const { projectKey, projectExpanded, + threadGroupingMode, hasOverflowingThreads, + hasOverflowingWorktrees, + hiddenWorktreeCount, hiddenThreadStatus, orderedProjectThreadKeys, - renderedThreads, + renderedThreadGroups, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, @@ -802,9 +857,20 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( openPrLink, expandThreadListForProject, collapseThreadListForProject, + expandWorktreeThreads, + collapseWorktreeThreads, + toggleWorktree, } = props; const showMoreButtonRender = useMemo(() =>