From 9b9656985642912155b312c463e0c6e48a158dde Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 11 Jul 2026 17:25:35 -0400 Subject: [PATCH 01/33] Group threads by worktree --- .../settings/DesktopClientSettings.test.ts | 1 + apps/server/src/server.test.ts | 106 ++++ apps/server/src/ws.ts | 42 +- .../components/BranchToolbar.logic.test.ts | 190 ++++++- .../web/src/components/BranchToolbar.logic.ts | 100 +++- apps/web/src/components/BranchToolbar.tsx | 100 +++- .../BranchToolbarEnvModeSelector.tsx | 85 ++- apps/web/src/components/ChatView.tsx | 46 +- apps/web/src/components/CommandPalette.tsx | 102 ++-- apps/web/src/components/Sidebar.logic.test.ts | 221 ++++---- apps/web/src/components/Sidebar.logic.ts | 129 +++-- apps/web/src/components/Sidebar.tsx | 495 ++++++++++-------- .../components/settings/SettingsPanels.tsx | 4 +- apps/web/src/hooks/useHandleNewThread.ts | 91 +++- apps/web/src/keybindings.test.ts | 16 + apps/web/src/lib/chatThreadActions.test.ts | 128 ++++- apps/web/src/lib/chatThreadActions.ts | 86 ++- apps/web/src/routes/_chat.tsx | 48 +- docs/user/keybindings.md | 6 +- packages/contracts/src/keybindings.test.ts | 6 + packages/contracts/src/keybindings.ts | 1 + packages/contracts/src/settings.test.ts | 12 + packages/contracts/src/settings.ts | 8 + packages/shared/src/keybindings.ts | 1 + 24 files changed, 1506 insertions(+), 518 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ea7ec6e1512..bdbcfbee065 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -26,6 +26,7 @@ const clientSettings: ClientSettings = { }, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", + sidebarThreadGroupingMode: "separate", sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", wordWrap: true, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 347c2920792..e7e941483a8 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6280,6 +6280,112 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("falls back to a local base when the branch is not available on origin", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-local-base", + path: "/tmp/bootstrap-local-base", + }, + }), + ); + const missingRemoteBranch = new GitCommandError({ + operation: "GitVcsDriver.resolveRemoteTrackingCommit", + command: "git rev-parse --verify refs/remotes/origin/t3code/local-only", + cwd: "/tmp/project", + detail: "remote branch does not exist", + }); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { + fetchRemote: () => Effect.void, + resolveRemoteTrackingCommit: () => Effect.fail(missingRemoteBranch), + createWorktree, + }, + vcsStatusBroadcaster: { + refreshStatus: () => + Effect.succeed({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "t3code/bootstrap-local-base", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-local-base"), + threadId: ThreadId.make("thread-bootstrap-local-base"), + message: { + messageId: MessageId.make("msg-bootstrap-local-base"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Local Base", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "t3code/local-only", + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "t3code/local-only", + branch: "t3code/bootstrap-local-base", + startFromOrigin: true, + }, + }, + createdAt: "2026-01-01T00:00:00.000Z", + }), + ), + ); + + assert.equal(response.sequence, 3); + assert.deepEqual(createWorktree.mock.calls[0]?.[0], { + cwd: "/tmp/project", + refName: "t3code/local-only", + newRefName: "t3code/bootstrap-local-base", + baseRefName: "t3code/local-only", + path: null, + }); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.meta.update", "thread.turn.start"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("records setup-script failures without aborting bootstrap turn start", () => Effect.gen(function* () { const dispatchedCommands: Array = []; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fafdbfa6814..bb0b55b7974 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -836,24 +836,42 @@ const makeWsRpcLayer = ( } if (bootstrap?.prepareWorktree) { - let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch; - if (bootstrap.prepareWorktree.startFromOrigin) { + const prepareWorktree = bootstrap.prepareWorktree; + let worktreeBaseRef = prepareWorktree.baseBranch; + if (prepareWorktree.startFromOrigin) { yield* gitWorkflow.fetchRemote({ - cwd: bootstrap.prepareWorktree.projectCwd, + cwd: prepareWorktree.projectCwd, remoteName: "origin", }); - const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({ - cwd: bootstrap.prepareWorktree.projectCwd, - refName: bootstrap.prepareWorktree.baseBranch, - fallbackRemoteName: "origin", - }); - worktreeBaseRef = resolvedRemoteBase.commitSha; + const resolvedRemoteBase = yield* gitWorkflow + .resolveRemoteTrackingCommit({ + cwd: prepareWorktree.projectCwd, + refName: prepareWorktree.baseBranch, + fallbackRemoteName: "origin", + }) + .pipe( + Effect.matchEffect({ + onFailure: (error) => + Effect.logWarning( + "Remote tracking branch was unavailable; using the local worktree base", + { + cwd: prepareWorktree.projectCwd, + baseBranch: prepareWorktree.baseBranch, + detail: error.message, + }, + ).pipe(Effect.as(null)), + onSuccess: (resolved) => Effect.succeed(resolved), + }), + ); + if (resolvedRemoteBase) { + worktreeBaseRef = resolvedRemoteBase.commitSha; + } } const worktree = yield* gitWorkflow.createWorktree({ - cwd: bootstrap.prepareWorktree.projectCwd, + cwd: prepareWorktree.projectCwd, refName: worktreeBaseRef, - newRefName: bootstrap.prepareWorktree.branch, - baseRefName: bootstrap.prepareWorktree.baseBranch, + newRefName: prepareWorktree.branch, + baseRefName: prepareWorktree.baseBranch, path: null, }); targetWorktreePath = worktree.worktree.path; diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 94a3909a961..482b9cef2bd 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -3,20 +3,184 @@ import { describe, expect, it } from "vite-plus/test"; import { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, + deriveWorkspaceOptions, resolveEnvironmentOptionLabel, resolveBranchSelectionTarget, - resolveCurrentWorkspaceLabel, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, resolveEnvModeLabel, + resolveWorkspaceSelection, resolveBranchToolbarValue, - resolveLockedWorkspaceLabel, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; const localEnvironmentId = EnvironmentId.make("environment-local"); const remoteEnvironmentId = EnvironmentId.make("environment-remote"); +describe("deriveWorkspaceOptions", () => { + it("combines the default branch with the local main checkout", () => { + const refs: VcsRef[] = [ + { + name: "main", + current: true, + isDefault: true, + worktreePath: "/repo", + }, + { + name: "feature/one", + current: false, + isDefault: false, + worktreePath: "/repo/.t3/worktrees/one", + }, + { + name: "feature/one-alias", + current: false, + isDefault: false, + worktreePath: "/repo/.t3/worktrees/one", + }, + ]; + + expect(deriveWorkspaceOptions(refs, "/repo")).toEqual({ + mainCheckout: null, + existingWorktrees: [ + { + branch: "feature/one", + label: "feature/one", + path: "/repo/.t3/worktrees/one", + isProjectCheckout: false, + }, + ], + }); + }); + + it("promotes a separate default-branch worktree to the main checkout option", () => { + const refs: VcsRef[] = [ + { + name: "feature/current", + current: true, + isDefault: false, + worktreePath: "/repo/.t3/worktrees/current", + }, + { + name: "main", + current: false, + isDefault: true, + worktreePath: "/repo", + }, + { + name: "feature/other", + current: false, + isDefault: false, + worktreePath: "/repo/.t3/worktrees/other", + }, + ]; + + expect(deriveWorkspaceOptions(refs, "/repo/.t3/worktrees/current")).toEqual({ + mainCheckout: { + branch: "main", + label: "main", + path: "/repo", + isProjectCheckout: false, + }, + existingWorktrees: [ + { + branch: "feature/current", + label: "feature/current", + path: "/repo/.t3/worktrees/current", + isProjectCheckout: true, + }, + { + branch: "feature/other", + label: "feature/other", + path: "/repo/.t3/worktrees/other", + isProjectCheckout: false, + }, + ], + }); + }); + + it("does not duplicate the project checkout when its current branch is not the default", () => { + const refs: VcsRef[] = [ + { + name: "feature/current", + current: true, + isDefault: false, + worktreePath: "/repo", + }, + { + name: "main", + current: false, + isDefault: true, + worktreePath: null, + }, + { + name: "feature/other", + current: false, + isDefault: false, + worktreePath: "/repo/.t3/worktrees/other", + }, + ]; + + expect(deriveWorkspaceOptions(refs, "/repo")).toEqual({ + mainCheckout: null, + existingWorktrees: [ + { + branch: "feature/other", + label: "feature/other", + path: "/repo/.t3/worktrees/other", + isProjectCheckout: false, + }, + ], + }); + }); +}); + +describe("resolveWorkspaceSelection", () => { + const projectCheckout = { + branch: "t3code/current", + label: "t3code/current", + path: "/repo/worktrees/current", + isProjectCheckout: true, + }; + const mainCheckout = { + branch: "main", + label: "main", + path: "/repo", + isProjectCheckout: false, + }; + + it("keeps New worktree selected when the registered project is a linked worktree", () => { + expect( + resolveWorkspaceSelection({ + effectiveEnvMode: "worktree", + activeWorktreePath: null, + mainCheckout, + existingWorktrees: [projectCheckout], + }), + ).toMatchObject({ + isMainCheckout: false, + selectedExistingWorktree: undefined, + value: "worktree", + label: "New worktree", + }); + }); + + it("selects the registered project checkout only in local mode", () => { + expect( + resolveWorkspaceSelection({ + effectiveEnvMode: "local", + activeWorktreePath: null, + mainCheckout, + existingWorktrees: [projectCheckout], + }), + ).toMatchObject({ + selectedExistingWorktree: projectCheckout, + value: `existing:${projectCheckout.path}`, + label: "t3code/current", + }); + }); +}); + describe("resolveDraftEnvModeAfterBranchChange", () => { it("switches to local mode when returning from an existing worktree to the main worktree", () => { expect( @@ -143,31 +307,11 @@ describe("resolveEffectiveEnvMode", () => { describe("resolveEnvModeLabel", () => { it("uses explicit workspace labels", () => { - expect(resolveEnvModeLabel("local")).toBe("Current checkout"); + expect(resolveEnvModeLabel("local")).toBe("Main checkout"); expect(resolveEnvModeLabel("worktree")).toBe("New worktree"); }); }); -describe("resolveCurrentWorkspaceLabel", () => { - it("describes the main repo checkout when no worktree path is active", () => { - expect(resolveCurrentWorkspaceLabel(null)).toBe("Current checkout"); - }); - - it("describes the active checkout as a worktree when one is attached", () => { - expect(resolveCurrentWorkspaceLabel("/repo/.t3/worktrees/feature-a")).toBe("Current worktree"); - }); -}); - -describe("resolveLockedWorkspaceLabel", () => { - it("uses a shorter label for the main repo checkout", () => { - expect(resolveLockedWorkspaceLabel(null)).toBe("Local checkout"); - }); - - it("uses a shorter label for an attached worktree", () => { - expect(resolveLockedWorkspaceLabel("/repo/.t3/worktrees/feature-a")).toBe("Worktree"); - }); -}); - describe("deriveLocalBranchNameFromRemoteRef", () => { it("strips the remote prefix from a remote ref", () => { expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo"); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 65388962c08..d108f711ad2 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -15,6 +15,96 @@ export interface EnvironmentOption { export const EnvMode = Schema.Literals(["local", "worktree"]); export type EnvMode = typeof EnvMode.Type; +export interface ExistingWorktreeOption { + readonly branch: string; + readonly path: string; + readonly label: string; + readonly isProjectCheckout: boolean; +} + +export interface WorkspaceOptions { + readonly mainCheckout: ExistingWorktreeOption | null; + readonly existingWorktrees: readonly ExistingWorktreeOption[]; +} + +export function resolveWorkspaceSelection(input: { + readonly effectiveEnvMode: EnvMode; + readonly activeWorktreePath: string | null; + readonly mainCheckout: ExistingWorktreeOption | null; + readonly existingWorktrees: readonly ExistingWorktreeOption[]; +}): { + readonly isMainCheckout: boolean; + readonly selectedExistingWorktree: ExistingWorktreeOption | undefined; + readonly value: string; + readonly label: string; +} { + const { effectiveEnvMode, activeWorktreePath, mainCheckout, existingWorktrees } = input; + const isMainCheckout = activeWorktreePath + ? mainCheckout?.path === activeWorktreePath + : effectiveEnvMode === "local" && mainCheckout === null; + const selectedExistingWorktree = activeWorktreePath + ? existingWorktrees.find((worktree) => worktree.path === activeWorktreePath) + : effectiveEnvMode === "local" + ? existingWorktrees.find((worktree) => worktree.isProjectCheckout) + : undefined; + const value = isMainCheckout + ? mainCheckout + ? `main:${mainCheckout.path}` + : "local" + : selectedExistingWorktree + ? `existing:${selectedExistingWorktree.path}` + : effectiveEnvMode; + const label = isMainCheckout + ? "Main checkout" + : selectedExistingWorktree + ? selectedExistingWorktree.label + : effectiveEnvMode === "worktree" + ? resolveEnvModeLabel("worktree") + : "Main checkout"; + + return { isMainCheckout, selectedExistingWorktree, value, label }; +} + +export function deriveWorkspaceOptions( + refs: readonly VcsRef[], + projectWorkspaceRoot: string, +): WorkspaceOptions { + const normalizedProjectRoot = projectWorkspaceRoot.replaceAll("\\", "/").replace(/\/+$/, ""); + const worktreeOptions = refs.flatMap((ref): ExistingWorktreeOption[] => { + const worktreePath = ref.worktreePath?.trim(); + if (!worktreePath) return []; + const normalizedPath = worktreePath.replaceAll("\\", "/").replace(/\/+$/, ""); + return [ + { + branch: ref.name, + path: worktreePath, + label: ref.name, + isProjectCheckout: normalizedPath === normalizedProjectRoot, + }, + ]; + }); + const externalDefaultRef = refs.find( + (ref) => + ref.isDefault && + ref.worktreePath !== null && + ref.worktreePath.replaceAll("\\", "/").replace(/\/+$/, "") !== normalizedProjectRoot, + ); + const mainCheckout = externalDefaultRef + ? (worktreeOptions.find((option) => option.path === externalDefaultRef.worktreePath) ?? null) + : null; + const seenPaths = new Set( + mainCheckout ? [mainCheckout.path.replaceAll("\\", "/").replace(/\/+$/, "")] : [], + ); + const existingWorktrees = worktreeOptions.filter((option) => { + if (mainCheckout === null && option.isProjectCheckout) return false; + const normalizedPath = option.path.replaceAll("\\", "/").replace(/\/+$/, ""); + if (seenPaths.has(normalizedPath)) return false; + seenPaths.add(normalizedPath); + return true; + }); + return { mainCheckout, existingWorktrees }; +} + const GENERIC_LOCAL_ENVIRONMENT_LABELS = new Set(["local", "local environment"]); function normalizeDisplayLabel(value: string | null | undefined): string | null { @@ -43,15 +133,7 @@ export function resolveEnvironmentOptionLabel(input: { } export function resolveEnvModeLabel(mode: EnvMode): string { - return mode === "worktree" ? "New worktree" : "Current checkout"; -} - -export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): string { - return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local"); -} - -export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): string { - return activeWorktreePath ? "Worktree" : "Local checkout"; + return mode === "worktree" ? "New worktree" : "Main checkout"; } export function resolveEffectiveEnvMode(input: { diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 958cb3743c7..bdb1bed4e28 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -16,10 +16,11 @@ import { useIsMobile } from "../hooks/useMediaQuery"; import { type EnvMode, type EnvironmentOption, - resolveCurrentWorkspaceLabel, + type ExistingWorktreeOption, + deriveWorkspaceOptions, resolveEnvModeLabel, resolveEffectiveEnvMode, - resolveLockedWorkspaceLabel, + resolveWorkspaceSelection, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; @@ -36,12 +37,14 @@ import { MenuTrigger, } from "./ui/menu"; import { Separator } from "./ui/separator"; +import { useBranches } from "../state/queries"; interface BranchToolbarProps { environmentId: EnvironmentId; threadId: ThreadId; draftId?: DraftId; onEnvModeChange: (mode: EnvMode) => void; + onExistingWorktreeChange: (worktree: ExistingWorktreeOption) => void; effectiveEnvModeOverride?: EnvMode; activeThreadBranchOverride?: string | null; onActiveThreadBranchOverrideChange?: (branch: string | null) => void; @@ -64,6 +67,9 @@ interface MobileRunContextSelectorProps { effectiveEnvMode: EnvMode; activeWorktreePath: string | null; onEnvModeChange: (mode: EnvMode) => void; + existingWorktrees: readonly ExistingWorktreeOption[]; + mainCheckout: ExistingWorktreeOption | null; + onExistingWorktreeChange: (worktree: ExistingWorktreeOption) => void; } const MobileRunContextSelector = memo(function MobileRunContextSelector({ @@ -76,22 +82,30 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ effectiveEnvMode, activeWorktreePath, onEnvModeChange, + existingWorktrees, + mainCheckout, + onExistingWorktreeChange, }: MobileRunContextSelectorProps) { const activeEnvironment = useMemo( () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, [availableEnvironments, environmentId], ); - const WorkspaceIcon = - effectiveEnvMode === "worktree" - ? FolderGit2Icon - : activeWorktreePath - ? FolderGitIcon - : FolderIcon; - const workspaceLabel = envModeLocked - ? resolveLockedWorkspaceLabel(activeWorktreePath) - : effectiveEnvMode === "worktree" - ? resolveEnvModeLabel("worktree") - : resolveCurrentWorkspaceLabel(activeWorktreePath); + const { + isMainCheckout, + selectedExistingWorktree, + value: workspaceValue, + label: workspaceLabel, + } = resolveWorkspaceSelection({ + effectiveEnvMode, + activeWorktreePath, + mainCheckout, + existingWorktrees, + }); + const WorkspaceIcon = isMainCheckout + ? FolderIcon + : selectedExistingWorktree + ? FolderGitIcon + : FolderGit2Icon; const isLocked = envLocked || envModeLocked; const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon; const icon = showEnvironmentPicker ? ( @@ -162,19 +176,37 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ Workspace onEnvModeChange(value as EnvMode)} + value={workspaceValue} + onValueChange={(value) => { + if (mainCheckout && value === `main:${mainCheckout.path}`) { + onExistingWorktreeChange(mainCheckout); + return; + } + const existingWorktree = existingWorktrees.find( + (item) => `existing:${item.path}` === value, + ); + if (existingWorktree) { + if (existingWorktree.isProjectCheckout) { + onEnvModeChange("local"); + } else { + onExistingWorktreeChange(existingWorktree); + } + return; + } + onEnvModeChange(value as EnvMode); + }} > - + {activeWorktreePath ? ( ) : ( )} - - {resolveCurrentWorkspaceLabel(activeWorktreePath)} - + Main checkout @@ -183,6 +215,18 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ {resolveEnvModeLabel("worktree")} + {existingWorktrees.map((worktree) => ( + + + + {worktree.label} + + + ))} @@ -195,6 +239,7 @@ export const BranchToolbar = memo(function BranchToolbar({ threadId, draftId, onEnvModeChange, + onExistingWorktreeChange, effectiveEnvModeOverride, activeThreadBranchOverride, onActiveThreadBranchOverrideChange, @@ -230,6 +275,17 @@ export const BranchToolbar = memo(function BranchToolbar({ draftThreadEnvMode: draftThread?.envMode, }); const envModeLocked = envLocked || (serverThread !== null && activeWorktreePath !== null); + const branchState = useBranches({ + environmentId, + cwd: activeProject?.workspaceRoot ?? null, + }); + const workspaceOptions = useMemo( + () => + activeProject + ? deriveWorkspaceOptions(branchState.data?.refs ?? [], activeProject.workspaceRoot) + : { mainCheckout: null, existingWorktrees: [] }, + [activeProject, branchState.data?.refs], + ); const showEnvironmentPicker = Boolean( availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, @@ -251,6 +307,9 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} onEnvModeChange={onEnvModeChange} + existingWorktrees={workspaceOptions.existingWorktrees} + mainCheckout={workspaceOptions.mainCheckout} + onExistingWorktreeChange={onExistingWorktreeChange} /> ) : (
@@ -270,6 +329,9 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} onEnvModeChange={onEnvModeChange} + existingWorktrees={workspaceOptions.existingWorktrees} + mainCheckout={workspaceOptions.mainCheckout} + onExistingWorktreeChange={onExistingWorktreeChange} />
)} diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 6d06882662f..3b8e6ac9a6d 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -2,9 +2,9 @@ import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react"; import { memo, useMemo } from "react"; import { - resolveCurrentWorkspaceLabel, resolveEnvModeLabel, - resolveLockedWorkspaceLabel, + resolveWorkspaceSelection, + type ExistingWorktreeOption, type EnvMode, } from "./BranchToolbar.logic"; import { @@ -14,7 +14,6 @@ import { SelectItem, SelectPopup, SelectTrigger, - SelectValue, } from "./ui/select"; interface BranchToolbarEnvModeSelectorProps { @@ -22,6 +21,9 @@ interface BranchToolbarEnvModeSelectorProps { effectiveEnvMode: EnvMode; activeWorktreePath: string | null; onEnvModeChange: (mode: EnvMode) => void; + existingWorktrees: readonly ExistingWorktreeOption[]; + mainCheckout: ExistingWorktreeOption | null; + onExistingWorktreeChange: (worktree: ExistingWorktreeOption) => void; } export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ @@ -29,27 +31,48 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe effectiveEnvMode, activeWorktreePath, onEnvModeChange, + existingWorktrees, + mainCheckout, + onExistingWorktreeChange, }: BranchToolbarEnvModeSelectorProps) { + const { + isMainCheckout, + selectedExistingWorktree, + value: selectValue, + label: selectedWorkspaceLabel, + } = resolveWorkspaceSelection({ + effectiveEnvMode, + activeWorktreePath, + mainCheckout, + existingWorktrees, + }); const envModeItems = useMemo( () => [ - { value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath) }, + { + value: mainCheckout ? `main:${mainCheckout.path}` : "local", + label: "Main checkout", + }, { value: "worktree", label: resolveEnvModeLabel("worktree") }, + ...existingWorktrees.map((worktree) => ({ + value: `existing:${worktree.path}`, + label: worktree.label, + })), ], - [activeWorktreePath], + [existingWorktrees, mainCheckout], ); if (envLocked) { return ( - {activeWorktreePath ? ( + {selectedExistingWorktree ? ( <> - {resolveLockedWorkspaceLabel(activeWorktreePath)} + {selectedExistingWorktree.label} ) : ( <> - {resolveLockedWorkspaceLabel(activeWorktreePath)} + Main checkout )} @@ -59,31 +82,50 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe return ( ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f5ea5bb1eba..8f02c6a7199 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -208,7 +208,7 @@ import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; -import { resolveEffectiveEnvMode } from "./BranchToolbar.logic"; +import { resolveEffectiveEnvMode, type ExistingWorktreeOption } from "./BranchToolbar.logic"; import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; @@ -4850,7 +4850,11 @@ function ChatViewContent(props: ChatViewProps) { envMode: mode, newWorktreesStartFromOrigin: settings.newWorktreesStartFromOrigin, }), - ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), + ...(draftThread?.worktreePath + ? mode === "local" + ? { branch: null, worktreePath: null } + : { worktreePath: null } + : {}), }); } scheduleComposerFocus(); @@ -4867,6 +4871,43 @@ function ChatViewContent(props: ChatViewProps) { ], ); + const onExistingWorktreeChange = useCallback( + (worktree: ExistingWorktreeOption) => { + if (canOverrideServerThreadEnvMode && activeThread) { + setPendingServerThreadEnvMode("worktree"); + setPendingServerThreadBranch(worktree.branch); + void updateThreadMetadata({ + environmentId: activeThread.environmentId, + input: { + threadId: activeThread.id, + branch: worktree.branch, + worktreePath: worktree.path, + }, + }); + scheduleComposerFocus(); + return; + } + if (isLocalDraftThread) { + setDraftThreadContext(composerDraftTarget, { + branch: worktree.branch, + worktreePath: worktree.path, + envMode: "worktree", + startFromOrigin: false, + }); + } + scheduleComposerFocus(); + }, + [ + activeThread, + canOverrideServerThreadEnvMode, + composerDraftTarget, + isLocalDraftThread, + scheduleComposerFocus, + setDraftThreadContext, + updateThreadMetadata, + ], + ); + const onStartFromOriginChange = (nextStartFromOrigin: boolean) => { if (canOverrideServerThreadEnvMode && activeThread) { setPendingServerThreadStartFromOriginByThreadId((current) => @@ -5230,6 +5271,7 @@ function ChatViewContent(props: ChatViewProps) { threadId={activeThread.id} {...(routeKind === "draft" && draftId ? { draftId } : {})} onEnvModeChange={onEnvModeChange} + onExistingWorktreeChange={onExistingWorktreeChange} startFromOrigin={startFromOrigin} onStartFromOriginChange={onStartFromOriginChange} {...(canOverrideServerThreadEnvMode diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8dccf984457..a28798a2d88 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,5 +1,6 @@ "use client"; +import { useAtomValue } from "@effect/atom-react"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { isAtomCommandInterrupted, @@ -11,12 +12,12 @@ import { type DesktopWslState, type EnvironmentId, type FilesystemBrowseResult, + PRIMARY_LOCAL_ENVIRONMENT_ID, type ProjectId, ProviderInstanceId, type SourceControlDiscoveryResult, type SourceControlProviderKind, type SourceControlRepositoryInfo, - PRIMARY_LOCAL_ENVIRONMENT_ID, } from "@t3tools/contracts"; import { useNavigate, useParams } from "@tanstack/react-router"; import * as Option from "effect/Option"; @@ -33,6 +34,8 @@ import { SquarePenIcon, } from "lucide-react"; import { + type KeyboardEvent, + type ReactNode, useCallback, useDeferredValue, useEffect, @@ -41,28 +44,17 @@ import { useReducer, useRef, useState, - type KeyboardEvent, - type ReactNode, } from "react"; -import { useAtomValue } from "@effect/atom-react"; import { OpenAddProjectCommandPaletteProvider } from "../commandPaletteContext"; -import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; +import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; +import { desktopLocalBackendId, isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useClientSettings } from "../hooks/useSettings"; -import { readLocalApi } from "../localApi"; -import { desktopLocalBackendId } from "../connection/desktopLocal"; -import { filesystemEnvironment } from "../state/filesystem"; -import { projectEnvironment } from "../state/projects"; -import { useEnvironmentQuery } from "../state/query"; -import { sourceControlEnvironment } from "../state/sourceControl"; -import { useAtomCommand } from "../state/use-atom-command"; -import { useAtomQueryRunner } from "../state/use-atom-query-runner"; -import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; -import { useProjects, useThreadShells } from "../state/entities"; +import { resolveShortcutCommand } from "../keybindings"; import { - startNewThreadInProjectFromContext, startNewThreadFromContext, + startNewThreadInProjectFromContext, } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, @@ -82,6 +74,16 @@ import { import { isTerminalFocused } from "../lib/terminalFocus"; import { getLatestThreadForProject } from "../lib/threadSort"; import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; +import { readLocalApi } from "../localApi"; +import { useProjects, useThreadShells } from "../state/entities"; +import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; +import { filesystemEnvironment } from "../state/filesystem"; +import { projectEnvironment } from "../state/projects"; +import { useEnvironmentQuery } from "../state/query"; +import { primaryServerKeybindingsAtom } from "../state/server"; +import { sourceControlEnvironment } from "../state/sourceControl"; +import { useAtomCommand } from "../state/use-atom-command"; +import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; import { @@ -90,6 +92,7 @@ import { resolveProjectPickerTarget, resolveWslProjectSelection, } from "../wslPaths"; +import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { ADDON_ICON_CLASS, buildBrowseGroups, @@ -106,13 +109,12 @@ import { ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, } from "./CommandPalette.logic"; -import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteResults } from "./CommandPaletteResults"; +import type { ChatComposerHandle } from "./chat/ChatComposer"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; import { ProjectFavicon } from "./ProjectFavicon"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; -import { primaryServerKeybindingsAtom } from "../state/server"; -import { resolveShortcutCommand } from "../keybindings"; +import { Button } from "./ui/button"; import { Command, CommandDialog, @@ -121,12 +123,9 @@ import { CommandInput, CommandPanel, } from "./ui/command"; -import { Button } from "./ui/button"; import { Kbd, KbdGroup } from "./ui/kbd"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; -import type { ChatComposerHandle } from "./chat/ChatComposer"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; @@ -471,8 +470,15 @@ function OpenCommandPaletteDialog(props: { const { environments } = useEnvironments(); const desktopLocalBootstraps = useDesktopLocalBootstraps(); const primaryEnvironment = usePrimaryEnvironment(); - const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = - useHandleNewThread(); + const { + activeDraftThread, + activeThread, + defaultProjectRef, + defaultNewWorktreesStartFromOrigin, + defaultThreadEnvMode, + handleNewThread, + resolveDefaultMainCheckout, + } = useHandleNewThread(); const projects = useProjects(); const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -624,7 +630,12 @@ function OpenCommandPaletteDialog(props: { const isBrowsePending = browseQuery.isPending; const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; const { filteredEntries: filteredBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( - () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }), + () => + filterBrowseEntries({ + browseEntries, + browseFilterQuery, + highlightedItemValue, + }), [browseEntries, browseFilterQuery, highlightedItemValue], ); @@ -686,13 +697,25 @@ function OpenCommandPaletteDialog(props: { activeDraftThread, activeThread: activeThread ?? undefined, defaultProjectRef, + defaultThreadEnvMode, + defaultNewWorktreesStartFromOrigin, handleNewThread, + resolveDefaultMainCheckout, }, scopeProjectRef(project.environmentId, project.id), ); }, }), - [activeDraftThread, activeThread, defaultProjectRef, handleNewThread, projects], + [ + activeDraftThread, + activeThread, + defaultNewWorktreesStartFromOrigin, + defaultProjectRef, + defaultThreadEnvMode, + handleNewThread, + projects, + resolveDefaultMainCheckout, + ], ); const allThreadItems = useMemo( @@ -875,7 +898,13 @@ function OpenCommandPaletteDialog(props: { }); } - return [{ value: `sources:${environmentId}`, label: "Sources", items: sourceItems }]; + return [ + { + value: `sources:${environmentId}`, + label: "Sources", + items: sourceItems, + }, + ]; }, [openSourceControlSettings, startAddProjectBrowse, startAddProjectClone], ); @@ -984,7 +1013,10 @@ function OpenCommandPaletteDialog(props: { activeDraftThread, activeThread: activeThread ?? undefined, defaultProjectRef, + defaultThreadEnvMode, + defaultNewWorktreesStartFromOrigin, handleNewThread, + resolveDefaultMainCheckout, }); }, }); @@ -1614,7 +1646,13 @@ function OpenCommandPaletteDialog(props: { (candidate) => candidate.httpBaseUrl === environment.displayUrl, ); const runningDistro = bootstrap?.runningDistro ?? null; - return [{ environmentId: environment.environmentId, backendId, runningDistro }]; + return [ + { + environmentId: environment.environmentId, + backendId, + runningDistro, + }, + ]; }), primaryEnvironmentId, desktopWslState ?? null, @@ -1822,9 +1860,13 @@ function OpenCommandPaletteDialog(props: { : "Enter a repository path and press Enter to look it up.", } : addProjectCloneFlow?.step === "confirm" - ? { emptyStateMessage: "Choose a destination path and press Enter to clone." } + ? { + emptyStateMessage: "Choose a destination path and press Enter to clone.", + } : relativePathNeedsActiveProject - ? { emptyStateMessage: "Relative paths require an active project." } + ? { + emptyStateMessage: "Relative paths require an active project.", + } : willCreateProjectPath ? { emptyStateMessage: diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 574e33d4dab..99741946090 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,18 +1,31 @@ +import { + EnvironmentId, + type OrchestrationLatestTurn, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { + DEFAULT_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + type Project, + type Thread, +} from "../types"; import { createThreadJumpHintVisibilityController, + getFallbackThreadIdAfterDelete, + getProjectSortTimestamp, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, - resolveAdjacentThreadId, - getFallbackThreadIdAfterDelete, getVisibleThreadsForProject, - getProjectSortTimestamp, + groupSidebarThreadsByWorktree, hasUnseenCompletion, isContextMenuPointerDown, isTrailingDoubleClick, orderItemsByPreferredIds, + resolveAdjacentThreadId, resolveProjectStatusIndicator, - resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, resolveSidebarStageBadgeLabel, resolveThreadRowClassName, @@ -21,22 +34,94 @@ import { sortProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; -import { - EnvironmentId, - OrchestrationLatestTurn, - ProjectId, - ProviderInstanceId, - ThreadId, -} from "@t3tools/contracts"; -import { - DEFAULT_INTERACTION_MODE, - DEFAULT_RUNTIME_MODE, - type Project, - type Thread, -} from "../types"; const localEnvironmentId = EnvironmentId.make("environment-local"); +describe("groupSidebarThreadsByWorktree", () => { + it("groups threads sharing a worktree and labels the group with its branch", () => { + const threads = [ + { + environmentId: "local", + projectId: "project", + branch: "feature/shared", + worktreePath: "/repo/.t3/worktrees/shared", + id: "one", + }, + { + environmentId: "local", + projectId: "project", + branch: "feature/shared", + worktreePath: "/repo/.t3/worktrees/shared", + id: "two", + }, + ]; + + expect(groupSidebarThreadsByWorktree(threads)).toEqual([ + { + key: "local:worktree:/repo/.t3/worktrees/shared", + label: "feature/shared", + threads, + }, + ]); + }); + + it("keeps main checkouts from different projects in separate groups", () => { + const groups = groupSidebarThreadsByWorktree([ + { + environmentId: "local", + projectId: "one", + branch: "main", + worktreePath: null, + }, + { + environmentId: "local", + projectId: "two", + branch: "main", + worktreePath: null, + }, + ]); + + expect(groups).toHaveLength(2); + expect(groups.map((group) => group.label)).toEqual(["main", "main"]); + }); + + it("distinguishes a registered linked worktree from the actual main checkout", () => { + const linkedCheckoutThread = { + environmentId: "local", + projectId: "project", + branch: "t3code/group-threads-worktrees", + worktreePath: null, + }; + const mainCheckoutThread = { + environmentId: "local", + projectId: "project", + branch: "main", + worktreePath: "/repo", + }; + + expect( + groupSidebarThreadsByWorktree([linkedCheckoutThread, mainCheckoutThread], { + environmentId: "local", + projectId: "project", + projectCheckoutPath: "/repo/.t3/worktrees/group-threads-worktrees", + projectCheckoutLabel: "t3code/group-threads-worktrees", + mainCheckoutPath: "/repo", + }), + ).toEqual([ + { + key: "local:worktree:/repo/.t3/worktrees/group-threads-worktrees", + label: "t3code/group-threads-worktrees", + threads: [linkedCheckoutThread], + }, + { + key: "local:worktree:/repo", + label: "Main checkout", + threads: [mainCheckoutThread], + }, + ]); + }); +}); + describe("resolveSidebarStageBadgeLabel", () => { it("returns Nightly for nightly primary server versions", () => { expect( @@ -260,93 +345,6 @@ describe("resolveSidebarNewThreadEnvMode", () => { }); }); -describe("resolveSidebarNewThreadSeedContext", () => { - it("prefers the default worktree mode over active thread context", () => { - expect( - resolveSidebarNewThreadSeedContext({ - projectId: "project-1", - defaultEnvMode: "worktree", - activeThread: { - projectId: "project-1", - branch: "feature/existing", - worktreePath: "/repo/.t3/worktrees/existing", - }, - activeDraftThread: { - projectId: "project-1", - branch: "feature/draft", - worktreePath: "/repo/.t3/worktrees/draft", - envMode: "worktree", - startFromOrigin: true, - }, - }), - ).toEqual({ - envMode: "worktree", - }); - }); - - it("inherits the active server thread context when creating a new thread in the same project", () => { - expect( - resolveSidebarNewThreadSeedContext({ - projectId: "project-1", - defaultEnvMode: "local", - activeThread: { - projectId: "project-1", - branch: "effect-atom", - worktreePath: null, - }, - activeDraftThread: null, - }), - ).toEqual({ - branch: "effect-atom", - worktreePath: null, - envMode: "local", - }); - }); - - it("prefers the active draft thread context when it matches the target project", () => { - expect( - resolveSidebarNewThreadSeedContext({ - projectId: "project-1", - defaultEnvMode: "local", - activeThread: { - projectId: "project-1", - branch: "effect-atom", - worktreePath: null, - }, - activeDraftThread: { - projectId: "project-1", - branch: "feature/new-draft", - worktreePath: "/repo/worktree", - envMode: "worktree", - startFromOrigin: true, - }, - }), - ).toEqual({ - branch: "feature/new-draft", - worktreePath: "/repo/worktree", - envMode: "worktree", - startFromOrigin: true, - }); - }); - - it("falls back to the default env mode when there is no matching active thread context", () => { - expect( - resolveSidebarNewThreadSeedContext({ - projectId: "project-2", - defaultEnvMode: "worktree", - activeThread: { - projectId: "project-1", - branch: "effect-atom", - worktreePath: null, - }, - activeDraftThread: null, - }), - ).toEqual({ - envMode: "worktree", - }); - }); -}); - describe("orderItemsByPreferredIds", () => { it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => { const ordered = orderItemsByPreferredIds({ @@ -669,7 +667,10 @@ describe("resolveThreadStatusPill", () => { describe("resolveThreadRowClassName", () => { it("uses the darker selected palette when a thread is both selected and active", () => { - const className = resolveThreadRowClassName({ isActive: true, isSelected: true }); + const className = resolveThreadRowClassName({ + isActive: true, + isSelected: true, + }); expect(className).toContain("bg-primary/22"); expect(className).toContain("hover:bg-primary/26"); expect(className).toContain("dark:bg-primary/30"); @@ -677,7 +678,10 @@ describe("resolveThreadRowClassName", () => { }); it("uses selected hover colors for selected threads", () => { - const className = resolveThreadRowClassName({ isActive: false, isSelected: true }); + const className = resolveThreadRowClassName({ + isActive: false, + isSelected: true, + }); expect(className).toContain("bg-primary/15"); expect(className).toContain("hover:bg-primary/19"); expect(className).toContain("dark:bg-primary/22"); @@ -685,7 +689,10 @@ describe("resolveThreadRowClassName", () => { }); it("keeps the accent palette for active-only threads", () => { - const className = resolveThreadRowClassName({ isActive: true, isSelected: false }); + const className = resolveThreadRowClassName({ + isActive: true, + isSelected: false, + }); expect(className).toContain("bg-accent/85"); expect(className).toContain("hover:bg-accent"); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4e7614ed551..0aa50b76968 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,15 +1,15 @@ -import * as React from "react"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import * as React from "react"; +import { resolveServerBackedAppStageLabel } from "../branding.logic"; import { getThreadSortTimestamp, sortThreads, - toSortableTimestamp, type ThreadSortInput, + toSortableTimestamp, } from "../lib/threadSort"; -import type { SidebarThreadSummary, Thread } from "../types"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; -import { resolveServerBackedAppStageLabel } from "../branding.logic"; +import type { SidebarThreadSummary, Thread } from "../types"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; @@ -17,6 +17,78 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; // nearby thread usually reuses an already-hot subscription. export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; export type SidebarNewThreadEnvMode = "local" | "worktree"; + +export interface SidebarWorktreeThreadGroup { + readonly key: string; + readonly label: string; + readonly threads: readonly TThread[]; +} + +export interface SidebarWorkspaceIdentity { + readonly environmentId: string; + readonly projectId: string; + readonly projectCheckoutPath: string; + readonly projectCheckoutLabel: string | null; + readonly mainCheckoutPath: string; +} + +function normalizeWorkspacePath(path: string): string { + return path.trim().replaceAll("\\", "/").replace(/\/+$/, ""); +} + +function worktreeDisplayName(path: string): string { + const normalized = path.trim().replaceAll("\\", "/").replace(/\/+$/, ""); + return normalized.split("/").at(-1) || path; +} + +export function groupSidebarThreadsByWorktree< + TThread extends { + readonly environmentId: string; + readonly projectId: string; + readonly branch: string | null; + readonly worktreePath: string | null; + }, +>( + threads: readonly TThread[], + workspaceIdentity?: SidebarWorkspaceIdentity | null, +): SidebarWorktreeThreadGroup[] { + const groups = new Map(); + + for (const thread of threads) { + const matchesWorkspaceIdentity = + workspaceIdentity?.environmentId === thread.environmentId && + workspaceIdentity.projectId === thread.projectId; + const worktreePath = thread.worktreePath?.trim() || null; + const effectivePath = + worktreePath ?? (matchesWorkspaceIdentity ? workspaceIdentity.projectCheckoutPath : null); + const normalizedEffectivePath = effectivePath ? normalizeWorkspacePath(effectivePath) : null; + const isMainCheckout = + matchesWorkspaceIdentity && + normalizedEffectivePath === normalizeWorkspacePath(workspaceIdentity.mainCheckoutPath); + const key = normalizedEffectivePath + ? `${thread.environmentId}:worktree:${normalizedEffectivePath}` + : `${thread.environmentId}:project:${thread.projectId}:checkout`; + const existing = groups.get(key); + if (existing) { + existing.threads.push(thread); + continue; + } + groups.set(key, { + label: isMainCheckout + ? "Main checkout" + : worktreePath + ? thread.branch?.trim() || worktreeDisplayName(worktreePath) + : matchesWorkspaceIdentity + ? (workspaceIdentity.projectCheckoutLabel ?? + thread.branch?.trim() ?? + "Project checkout") + : thread.branch?.trim() || "Project checkout", + threads: [thread], + }); + } + + return [...groups].map(([key, group]) => ({ key, ...group })); +} type SidebarProject = { id: string; title: string; @@ -184,55 +256,6 @@ export function resolveSidebarNewThreadEnvMode(input: { return input.requestedEnvMode ?? input.defaultEnvMode; } -export function resolveSidebarNewThreadSeedContext(input: { - projectId: string; - defaultEnvMode: SidebarNewThreadEnvMode; - activeThread?: { - projectId: string; - branch: string | null; - worktreePath: string | null; - } | null; - activeDraftThread?: { - projectId: string; - branch: string | null; - worktreePath: string | null; - envMode: SidebarNewThreadEnvMode; - startFromOrigin: boolean; - } | null; -}): { - branch?: string | null; - worktreePath?: string | null; - envMode: SidebarNewThreadEnvMode; - startFromOrigin?: boolean; -} { - if (input.defaultEnvMode === "worktree") { - return { - envMode: "worktree", - }; - } - - if (input.activeDraftThread?.projectId === input.projectId) { - return { - branch: input.activeDraftThread.branch, - worktreePath: input.activeDraftThread.worktreePath, - envMode: input.activeDraftThread.envMode, - startFromOrigin: input.activeDraftThread.startFromOrigin, - }; - } - - if (input.activeThread?.projectId === input.projectId) { - return { - branch: input.activeThread.branch, - worktreePath: input.activeThread.worktreePath, - envMode: input.activeThread.worktreePath ? "worktree" : "local", - }; - } - - return { - envMode: input.defaultEnvMode, - }; -} - export function orderItemsByPreferredIds(input: { items: readonly TItem[]; preferredIds: readonly TId[]; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 21525b56b77..03f2e181eaa 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,55 +1,20 @@ import { - ArchiveIcon, - ArrowUpDownIcon, - ChevronRightIcon, - CloudIcon, - ContainerIcon, - FolderPlusIcon, - Globe2Icon, - LoaderIcon, - SearchIcon, - SettingsIcon, - SquarePenIcon, - TerminalIcon, - TriangleAlertIcon, -} from "lucide-react"; -import { - ChangeRequestStatusIcon, - prStatusIndicator, - resolveThreadPr, - terminalStatusFromRunningIds, - ThreadStatusLabel, - ThreadWorktreeIndicator, -} from "./ThreadStatusIndicators"; -import { ProjectFavicon } from "./ProjectFavicon"; -import { useAtomValue } from "@effect/atom-react"; -import { autoAnimate } from "@formkit/auto-animate"; -import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; -import { useShallow } from "zustand/react/shallow"; -import { + type CollisionDetection, + closestCorners, DndContext, type DragCancelEvent, - type CollisionDetection, - PointerSensor, + type DragEndEvent, type DragStartEvent, - closestCorners, + PointerSensor, pointerWithin, useSensor, useSensors, - type DragEndEvent, } from "@dnd-kit/core"; -import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { - type ContextMenuItem, - DEFAULT_SERVER_SETTINGS, - ProjectId, - type ScopedThreadRef, - type ResolvedKeybindingsConfig, - type SidebarProjectGroupingMode, - ThreadId, -} from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { autoAnimate } from "@formkit/auto-animate"; import { parseScopedThreadKey, scopedProjectKey, @@ -63,7 +28,16 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; +import type { + ContextMenuItem, + ProjectId, + ResolvedKeybindingsConfig, + ScopedProjectRef, + ScopedThreadRef, + SidebarProjectGroupingMode, + SidebarThreadGroupingMode, + ThreadId, +} from "@t3tools/contracts"; import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, MIN_SIDEBAR_THREAD_PREVIEW_COUNT, @@ -71,32 +45,36 @@ import { type SidebarThreadPreviewCount, type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; +import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; +import { + ArchiveIcon, + ArrowUpDownIcon, + ChevronRightIcon, + CloudIcon, + ContainerIcon, + FolderPlusIcon, + Globe2Icon, + LoaderIcon, + SearchIcon, + SettingsIcon, + SquarePenIcon, + TerminalIcon, + TriangleAlertIcon, +} from "lucide-react"; +import type React from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { useIsMobile } from "~/hooks/useMediaQuery"; +import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; +import { APP_STAGE_LABEL } from "../branding"; +import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; +import { useComposerDraftStore } from "../composerDraftStore"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { isElectron } from "../env"; -import { APP_STAGE_LABEL } from "../branding"; -import { useOpenPrLink } from "../lib/openPullRequestLink"; -import { isTerminalFocused } from "../lib/terminalFocus"; -import { isMacPlatform } from "../lib/utils"; -import { - readThreadShell, - useProject, - useProjects, - useServerConfigs, - useThreadShells, - useThreadShellsForProjectRefs, -} from "../state/entities"; -import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { useThreadRunningTerminalIds } from "../state/terminalSessions"; -import { useThreadDiscoveredPorts } from "../portDiscoveryState"; -import { openDiscoveredPort } from "./preview/openDiscoveredPort"; -import { useAtomCommand } from "../state/use-atom-command"; -import { previewEnvironment } from "../state/preview"; -import { - legacyProjectCwdPreferenceKey, - resolveProjectExpanded, - useUiStateStore, -} from "../uiStateStore"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useThreadActions } from "../hooks/useThreadActions"; import { resolveShortcutCommand, shortcutLabelForCommand, @@ -105,28 +83,54 @@ import { threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; +import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { startNewThreadInProjectFromContext } from "../lib/chatThreadActions"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { sortThreads } from "../lib/threadSort"; +import { isMacPlatform } from "../lib/utils"; +import { readLocalApi } from "../localApi"; +import { + derivePhysicalProjectKey, + deriveProjectGroupingOverrideKey, + getProjectOrderKey, + selectProjectGroupingSettings, +} from "../logicalProject"; import { isModelPickerOpen } from "../modelPickerVisibility"; +import { useThreadDiscoveredPorts } from "../portDiscoveryState"; import { useShortcutModifierState } from "../shortcutModifierState"; -import { readLocalApi } from "../localApi"; -import { useComposerDraftStore } from "../composerDraftStore"; -import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { + buildPhysicalToLogicalProjectKeyMap, + buildSidebarProjectSnapshots, + type SidebarProjectGroupMember, + type SidebarProjectSnapshot, +} from "../sidebarProjectGrouping"; import { useDesktopUpdateState } from "../state/desktopUpdate"; - -import { useThreadActions } from "../hooks/useThreadActions"; +import { + useProject, + useProjects, + useThreadShells, + useThreadShellsForProjectRefs, +} from "../state/entities"; +import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { previewEnvironment } from "../state/preview"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; +import { primaryServerConfigAtom, primaryServerKeybindingsAtom } from "../state/server"; +import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { threadEnvironment, useEnvironmentThread } from "../state/threads"; +import { useAtomCommand } from "../state/use-atom-command"; import { vcsEnvironment } from "../state/vcs"; -import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { - buildThreadRouteParams, - resolveThreadRouteRef, - resolveThreadRouteTarget, -} from "../threadRoutes"; -import { stackedThreadToast, toastManager } from "./ui/toast"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; +import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatRelativeTimeLabel } from "../timestampFormat"; -import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; -import { Kbd } from "./ui/kbd"; +import type { SidebarThreadSummary } from "../types"; +import { + legacyProjectCwdPreferenceKey, + resolveProjectExpanded, + useUiStateStore, +} from "../uiStateStore"; +import { deriveWorkspaceOptions } from "./BranchToolbar.logic"; import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, @@ -136,8 +140,38 @@ import { shouldShowArm64IntelBuildWarning, shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; +import { ProjectFavicon } from "./ProjectFavicon"; +import { openDiscoveredPort } from "./preview/openDiscoveredPort"; +import { + getSidebarThreadIdsToPrewarm, + groupSidebarThreadsByWorktree, + isContextMenuPointerDown, + isTrailingDoubleClick, + orderItemsByPreferredIds, + resolveAdjacentThreadId, + resolveProjectStatusIndicator, + resolveSidebarStageBadgeLabel, + resolveThreadRowClassName, + resolveThreadStatusPill, + shouldClearThreadSelectionOnMouseDown, + sortProjectsForSidebar, + type ThreadStatusPill, + useThreadJumpHintVisibility, +} from "./Sidebar.logic"; +import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; +import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; +import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; +import { + ChangeRequestStatusIcon, + prStatusIndicator, + resolveThreadPr, + ThreadStatusLabel, + ThreadWorktreeIndicator, + terminalStatusFromRunningIds, +} from "./ThreadStatusIndicators"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; +import { CommandDialogTrigger } from "./ui/command"; import { Dialog, DialogDescription, @@ -148,6 +182,7 @@ import { DialogTitle, } from "./ui/dialog"; import { Input } from "./ui/input"; +import { Kbd } from "./ui/kbd"; import { Menu, MenuGroup, @@ -165,7 +200,6 @@ import { NumberFieldInput, } from "./ui/number-field"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, SidebarFooter, @@ -181,46 +215,9 @@ import { SidebarTrigger, useSidebar, } from "./ui/sidebar"; -import { useThreadSelectionStore } from "../threadSelectionStore"; -import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; -import { - getSidebarThreadIdsToPrewarm, - resolveAdjacentThreadId, - isContextMenuPointerDown, - isTrailingDoubleClick, - resolveProjectStatusIndicator, - resolveSidebarNewThreadSeedContext, - resolveSidebarNewThreadEnvMode, - resolveSidebarStageBadgeLabel, - resolveThreadRowClassName, - resolveThreadStatusPill, - orderItemsByPreferredIds, - shouldClearThreadSelectionOnMouseDown, - sortProjectsForSidebar, - useThreadJumpHintVisibility, - ThreadStatusPill, -} from "./Sidebar.logic"; -import { sortThreads } from "../lib/threadSort"; -import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; -import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { useIsMobile } from "~/hooks/useMediaQuery"; -import { CommandDialogTrigger } from "./ui/command"; -import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; -import { primaryServerConfigAtom, primaryServerKeybindingsAtom } from "../state/server"; -import { - derivePhysicalProjectKey, - deriveProjectGroupingOverrideKey, - getProjectOrderKey, - selectProjectGroupingSettings, -} from "../logicalProject"; -import type { SidebarThreadSummary } from "../types"; -import { - buildPhysicalToLogicalProjectKeyMap, - buildSidebarProjectSnapshots, - type SidebarProjectGroupMember, - type SidebarProjectSnapshot, -} from "../sidebarProjectGrouping"; -import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; +import { stackedThreadToast, toastManager } from "./ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -240,6 +237,10 @@ const PROJECT_GROUPING_MODE_LABELS: Record = repository_path: "Group by repository path", separate: "Keep separate", }; +const THREAD_GROUPING_MODE_LABELS: Record = { + worktree: "Group by worktree", + separate: "Keep separate", +}; const SIDEBAR_ICON_ACTION_BUTTON_CLASS = "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; @@ -440,7 +441,11 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr event.stopPropagation(); navigateToThread(threadRef); void (async () => { - const result = await openDiscoveredPort({ threadRef, port, openPreview }); + const result = await openDiscoveredPort({ + threadRef, + port, + openPreview, + }); if (result._tag === "Success" || isAtomCommandInterrupted(result)) { return; } @@ -892,6 +897,7 @@ interface SidebarProjectThreadListProps { hiddenThreadStatus: ThreadStatusPill | null; orderedProjectThreadKeys: readonly string[]; renderedThreads: readonly SidebarThreadSummary[]; + threadGroupingMode: SidebarThreadGroupingMode; showEmptyThreadState: boolean; shouldShowThreadPanel: boolean; isThreadListExpanded: boolean; @@ -943,6 +949,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( hiddenThreadStatus, orderedProjectThreadKeys, renderedThreads, + threadGroupingMode, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, @@ -974,6 +981,68 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( } = props; const showMoreButtonRender = useMemo(() =>