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/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 100b9beadba..043497f2381 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -75,7 +75,7 @@ export class GitWorkflowService extends Context.Service< readonly fallbackRemoteName: string; }) => Effect.Effect< { readonly commitSha: string; readonly remoteRefName: string }, - GitCommandError + GitCommandError | GitVcsDriver.RemoteTrackingRefNotFoundError >; readonly removeWorktree: ( input: VcsRemoveWorktreeInput, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 347c2920792..4f33810c7a8 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6280,6 +6280,183 @@ 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 GitVcsDriver.RemoteTrackingRefNotFoundError({ + cwd: "/tmp/project", + remoteRefName: "origin/t3code/local-only", + }); + + 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("propagates unexpected remote base resolution failures", () => + Effect.gen(function* () { + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-unexpected-failure", + path: "/tmp/bootstrap-unexpected-failure", + }, + }), + ); + const unexpectedFailure = new GitCommandError({ + operation: "GitVcsDriver.resolveRemoteTrackingCommit", + command: "git show-ref --verify --quiet refs/remotes/origin/main", + cwd: "/tmp/project", + detail: "Git remote tracking ref lookup failed.", + exitCode: 128, + }); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { + fetchRemote: () => Effect.void, + resolveRemoteTrackingCommit: () => Effect.fail(unexpectedFailure), + createWorktree, + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const failed = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-unexpected-failure"), + threadId: ThreadId.make("thread-bootstrap-unexpected-failure"), + message: { + messageId: MessageId.make("msg-bootstrap-unexpected-failure"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Unexpected Failure", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-unexpected-failure", + startFromOrigin: true, + }, + }, + createdAt: "2026-01-01T00:00:00.000Z", + }), + ), + ).pipe(Effect.match({ onFailure: () => true, onSuccess: () => false })); + + assertTrue(failed); + assert.equal(createWorktree.mock.calls.length, 0); + }).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/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 55aa8f38835..6130104e552 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -7,6 +7,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/unstable/process"; import { @@ -177,6 +178,20 @@ export interface GitResolveRemoteTrackingCommitResult { remoteRefName: string; } +export class RemoteTrackingRefNotFoundError extends Schema.TaggedErrorClass()( + "RemoteTrackingRefNotFoundError", + { + cwd: Schema.String, + remoteRefName: Schema.String, + }, +) { + override get message(): string { + return `Remote tracking ref '${this.remoteRefName}' does not exist (${this.cwd}).`; + } +} + +export const isRemoteTrackingRefNotFound = Schema.is(RemoteTrackingRefNotFoundError); + export interface GitSetBranchUpstreamInput { cwd: string; branch: string; @@ -240,7 +255,10 @@ export class GitVcsDriver extends Context.Service< readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect; readonly resolveRemoteTrackingCommit: ( input: GitResolveRemoteTrackingCommitInput, - ) => Effect.Effect; + ) => Effect.Effect< + GitResolveRemoteTrackingCommitResult, + GitCommandError | RemoteTrackingRefNotFoundError + >; readonly fetchRemoteBranch: ( input: GitFetchRemoteBranchInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index dc58fc2543c..d7861e47865 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -528,6 +528,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const cwd = yield* makeTmpDir(); yield* initRepoWithCommit(cwd); const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; yield* driver.createRef({ cwd, refName: "feature/original" }); const switchRef = yield* driver.switchRef({ cwd, refName: "feature/original" }); @@ -542,6 +543,10 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(yield* git(cwd, ["branch", "--show-current"]), "feature/renamed"); const refs = yield* driver.listRefs({ cwd }); + assert.equal( + refs.mainCheckoutPath ? yield* fileSystem.realPath(refs.mainCheckoutPath) : null, + yield* fileSystem.realPath(cwd), + ); assert.equal( refs.refs.find((refName) => refName.name === "feature/renamed")?.current, true, @@ -666,6 +671,18 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.deepEqual(explicitlyResolvedBase, resolvedBase); assert.equal(yield* git(cwd, ["rev-parse", initialBranch]), beforeFetch); + const missingRemoteRef = yield* driver + .resolveRemoteTrackingCommit({ + cwd, + refName: "missing-remote-ref", + fallbackRemoteName: "origin", + }) + .pipe(Effect.flip); + assert.equal(GitVcsDriver.isRemoteTrackingRefNotFound(missingRemoteRef), true); + if (GitVcsDriver.isRemoteTrackingRefNotFound(missingRemoteRef)) { + assert.equal(missingRemoteRef.remoteRefName, "origin/missing-remote-ref"); + } + const pathService = yield* Path.Path; const worktreePath = pathService.join( yield* makeTmpDir("git-fetched-worktrees-"), diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a406cbce549..dce8af784ee 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2143,8 +2143,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : null; const worktreeMap = new Map(); + let mainCheckoutPath: string | null = null; if (worktreeList.exitCode === 0) { let currentPath: string | null = null; + let sawMainCheckout = false; for (const line of worktreeList.stdout.split("\n")) { if (line.startsWith("worktree ")) { const candidatePath = line.slice("worktree ".length); @@ -2153,6 +2155,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.orElseSucceed(() => false), ); currentPath = exists ? candidatePath : null; + if (!sawMainCheckout) { + mainCheckoutPath = currentPath; + sawMainCheckout = true; + } } else if (line.startsWith("branch refs/heads/") && currentPath) { worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); } else if (line === "") { @@ -2163,6 +2169,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const localBranches = Arr.filterMap(localBranchResult.stdout.split("\n"), (line) => { const refName = parseBranchLine(line); + const worktreePath = refName === null ? null : (worktreeMap.get(refName.name) ?? null); return refName === null ? Result.failVoid : Result.succeed({ @@ -2170,7 +2177,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* current: refName.current, isRemote: false, isDefault: refName.name === defaultBranch, - worktreePath: worktreeMap.get(refName.name) ?? null, + worktreePath, }); }).toSorted((a, b) => { const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; @@ -2236,6 +2243,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* refs: [...refs.refs], isRepo: true, hasPrimaryRemote: remoteNames.includes("origin"), + mainCheckoutPath, nextCursor: refs.nextCursor, totalCount: refs.totalCount, }; @@ -2321,10 +2329,38 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); const remoteRefName = parsedRemoteRef?.remoteRef ?? `${input.fallbackRemoteName}/${input.refName}`; + const remoteRef = `refs/remotes/${remoteRefName}`; + const args = ["show-ref", "--verify", "--quiet", remoteRef]; + const result = yield* executeGit( + "GitVcsDriver.resolveRemoteTrackingCommit", + input.cwd, + args, + { allowNonZeroExit: true }, + ); + if (result.exitCode !== 0) { + if (result.exitCode === 1) { + return yield* new GitVcsDriver.RemoteTrackingRefNotFoundError({ + cwd: input.cwd, + remoteRefName, + }); + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.resolveRemoteTrackingCommit", + cwd: input.cwd, + args, + }), + detail: "Git remote tracking ref lookup failed.", + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }); + } const commitSha = yield* runGitStdout("GitVcsDriver.resolveRemoteTrackingCommit", input.cwd, [ - "rev-parse", + "show-ref", "--verify", - `refs/remotes/${remoteRefName}^{commit}`, + "--hash=40", + remoteRef, ]).pipe(Effect.map((stdout) => stdout.trim())); return { commitSha, remoteRefName }; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fafdbfa6814..980e25b8782 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -836,24 +836,41 @@ 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.catchTags({ + RemoteTrackingRefNotFoundError: (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)), + }), + ); + 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..5517daf1ad0 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -3,20 +3,305 @@ import { describe, expect, it } from "vite-plus/test"; import { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, + deriveWorkspaceOptions, resolveEnvironmentOptionLabel, resolveBranchSelectionTarget, - resolveCurrentWorkspaceLabel, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, resolveEnvModeLabel, + resolveMainCheckoutTarget, + resolveWorkspaceSelection, resolveBranchToolbarValue, - resolveLockedWorkspaceLabel, shouldIncludeBranchPickerItem, + withActiveWorkspaceFallback, } 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", "/repo")).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("uses checkout metadata when the default branch is not checked out", () => { + const refs: VcsRef[] = [ + { + name: "feature/current", + current: true, + isDefault: false, + worktreePath: "/repo/.t3/worktrees/current", + }, + { + name: "feature/main-checkout", + current: false, + isDefault: false, + worktreePath: "/repo", + }, + { name: "main", current: false, isDefault: true, worktreePath: null }, + ]; + + expect(deriveWorkspaceOptions(refs, "/repo/.t3/worktrees/current", "/repo")).toMatchObject({ + mainCheckout: { + branch: "feature/main-checkout", + path: "/repo", + }, + }); + }); + + 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", + }); + }); + + it("normalizes workspace paths when resolving the active selection", () => { + expect( + resolveWorkspaceSelection({ + effectiveEnvMode: "local", + activeWorktreePath: "C:\\repo\\worktrees\\current\\", + mainCheckout, + existingWorktrees: [{ ...projectCheckout, path: "C:/repo/worktrees/current" }], + }), + ).toMatchObject({ + label: "t3code/current", + value: "existing:C:/repo/worktrees/current", + }); + }); + + it("treats Windows drive-letter paths as case-insensitive", () => { + expect( + resolveWorkspaceSelection({ + effectiveEnvMode: "local", + activeWorktreePath: "C:/Repo/worktrees/current", + mainCheckout: { ...mainCheckout, path: "c:/repo" }, + existingWorktrees: [{ ...projectCheckout, path: "c:/repo/worktrees/current" }], + }), + ).toMatchObject({ + label: "t3code/current", + value: "existing:c:/repo/worktrees/current", + }); + }); +}); + +describe("withActiveWorkspaceFallback", () => { + it("keeps an active worktree visible while refs are unavailable", () => { + expect( + withActiveWorkspaceFallback( + { mainCheckout: null, existingWorktrees: [] }, + { + activeWorktreePath: "/repo/.t3/worktrees/current", + activeBranch: "feature/current", + projectWorkspaceRoot: "/repo/.t3/worktrees/current", + }, + ), + ).toEqual({ + mainCheckout: null, + existingWorktrees: [ + { + branch: "feature/current", + label: "feature/current", + path: "/repo/.t3/worktrees/current", + isProjectCheckout: true, + }, + ], + }); + }); +}); + +describe("resolveMainCheckoutTarget", () => { + it("uses the branch currently checked out in the main project checkout", () => { + const refs: VcsRef[] = [ + { + name: "feature/current", + current: true, + isDefault: false, + worktreePath: "/repo", + }, + { name: "main", current: false, isDefault: true, worktreePath: null }, + ]; + + expect(resolveMainCheckoutTarget(refs, "/repo", "/repo")).toEqual({ + branch: "feature/current", + path: null, + }); + }); + + it("returns the external main checkout for a registered linked worktree", () => { + const refs: VcsRef[] = [ + { + name: "feature/linked", + current: true, + isDefault: false, + worktreePath: "/repo/worktrees/linked", + }, + { + name: "feature/main-checkout", + current: false, + isDefault: false, + worktreePath: "/repo", + }, + ]; + + expect(resolveMainCheckoutTarget(refs, "/repo/worktrees/linked", "/repo")).toEqual({ + branch: "feature/main-checkout", + path: "/repo", + }); + }); +}); + describe("resolveDraftEnvModeAfterBranchChange", () => { it("switches to local mode when returning from an existing worktree to the main worktree", () => { expect( @@ -143,31 +428,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..aa0f0ab7280 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -15,6 +15,181 @@ 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 normalizeWorkspacePath(path: string): string { + return path.trim().replaceAll("\\", "/").replace(/\/+$/, ""); +} + +function looksLikeWindowsPath(path: string): boolean { + return /^[a-zA-Z]:\//.test(path); +} + +function workspacePathKey(path: string): string { + const normalized = normalizeWorkspacePath(path); + // Paths may come from a remote Windows environment even when the UI runs on + // another OS, so detect Windows paths by shape rather than process.platform. + return looksLikeWindowsPath(normalized) ? normalized.toLowerCase() : normalized; +} + +export function workspacePathsEqual(left: string, right: string): boolean { + return workspacePathKey(left) === workspacePathKey(right); +} + +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 !== null && workspacePathsEqual(mainCheckout.path, activeWorktreePath) + : effectiveEnvMode === "local" && mainCheckout === null; + const selectedExistingWorktree = activeWorktreePath + ? existingWorktrees.find((worktree) => workspacePathsEqual(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, + mainCheckoutPath?: string | null, +): WorkspaceOptions { + const worktreeOptions = refs.flatMap((ref): ExistingWorktreeOption[] => { + const worktreePath = ref.worktreePath?.trim(); + if (!worktreePath) return []; + return [ + { + branch: ref.name, + path: worktreePath, + label: ref.name, + isProjectCheckout: workspacePathsEqual(worktreePath, projectWorkspaceRoot), + }, + ]; + }); + const explicitMainCheckoutPath = mainCheckoutPath?.trim() || null; + const mainCheckoutRef = explicitMainCheckoutPath + ? refs.find( + (ref) => + ref.worktreePath !== null && + workspacePathsEqual(ref.worktreePath, explicitMainCheckoutPath), + ) + : refs.find( + (ref) => + ref.isDefault && + ref.worktreePath !== null && + !workspacePathsEqual(ref.worktreePath, projectWorkspaceRoot), + ); + const resolvedMainCheckoutPath = + explicitMainCheckoutPath ?? mainCheckoutRef?.worktreePath ?? null; + const mainCheckout = + resolvedMainCheckoutPath !== null && + !workspacePathsEqual(resolvedMainCheckoutPath, projectWorkspaceRoot) + ? (worktreeOptions.find((option) => + workspacePathsEqual(option.path, resolvedMainCheckoutPath), + ) ?? { + branch: mainCheckoutRef?.name ?? "HEAD", + label: mainCheckoutRef?.name ?? "Main checkout", + path: resolvedMainCheckoutPath, + isProjectCheckout: false, + }) + : null; + const seenPaths = new Set(mainCheckout ? [workspacePathKey(mainCheckout.path)] : []); + const existingWorktrees = worktreeOptions.filter((option) => { + if (mainCheckout === null && option.isProjectCheckout) return false; + const pathKey = workspacePathKey(option.path); + if (seenPaths.has(pathKey)) return false; + seenPaths.add(pathKey); + return true; + }); + return { mainCheckout, existingWorktrees }; +} + +export function resolveMainCheckoutTarget( + refs: readonly VcsRef[], + projectWorkspaceRoot: string, + mainCheckoutPath?: string | null, +): { readonly branch: string; readonly path: string | null } | null { + const options = deriveWorkspaceOptions(refs, projectWorkspaceRoot, mainCheckoutPath); + if (options.mainCheckout) { + return { branch: options.mainCheckout.branch, path: options.mainCheckout.path }; + } + + const projectCheckoutRef = refs.find( + (ref) => + !ref.isRemote && + ref.worktreePath !== null && + workspacePathsEqual(ref.worktreePath, projectWorkspaceRoot), + ); + const currentRef = refs.find((ref) => !ref.isRemote && ref.current); + const defaultRef = refs.find((ref) => !ref.isRemote && ref.isDefault); + const branch = projectCheckoutRef?.name ?? currentRef?.name ?? defaultRef?.name; + return branch ? { branch, path: null } : null; +} + +export function withActiveWorkspaceFallback( + options: WorkspaceOptions, + input: { + readonly activeWorktreePath: string | null; + readonly activeBranch: string | null; + readonly projectWorkspaceRoot: string; + }, +): WorkspaceOptions { + const activePath = input.activeWorktreePath?.trim(); + if ( + !activePath || + (options.mainCheckout !== null && workspacePathsEqual(options.mainCheckout.path, activePath)) || + options.existingWorktrees.some((worktree) => workspacePathsEqual(worktree.path, activePath)) + ) { + return options; + } + + const fallback: ExistingWorktreeOption = { + branch: input.activeBranch?.trim() || "Current worktree", + path: activePath, + label: + input.activeBranch?.trim() || + normalizeWorkspacePath(activePath).split("/").at(-1) || + "Current worktree", + isProjectCheckout: workspacePathsEqual(activePath, input.projectWorkspaceRoot), + }; + return { ...options, existingWorktrees: [...options.existingWorktrees, fallback] }; +} + const GENERIC_LOCAL_ENVIRONMENT_LABELS = new Set(["local", "local environment"]); function normalizeDisplayLabel(value: string | null | undefined): string | null { @@ -43,15 +218,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..d36507cda01 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -16,10 +16,12 @@ import { useIsMobile } from "../hooks/useMediaQuery"; import { type EnvMode, type EnvironmentOption, - resolveCurrentWorkspaceLabel, + type ExistingWorktreeOption, + deriveWorkspaceOptions, resolveEnvModeLabel, resolveEffectiveEnvMode, - resolveLockedWorkspaceLabel, + resolveWorkspaceSelection, + withActiveWorkspaceFallback, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; @@ -36,13 +38,16 @@ import { MenuTrigger, } from "./ui/menu"; import { Separator } from "./ui/separator"; +import { useAllBranches } from "../state/queries"; interface BranchToolbarProps { environmentId: EnvironmentId; threadId: ThreadId; draftId?: DraftId; onEnvModeChange: (mode: EnvMode) => void; + onExistingWorktreeChange: (worktree: ExistingWorktreeOption) => void; effectiveEnvModeOverride?: EnvMode; + activeWorktreePathOverride?: string | null; activeThreadBranchOverride?: string | null; onActiveThreadBranchOverrideChange?: (branch: string | null) => void; startFromOrigin: boolean; @@ -64,6 +69,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 +84,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 +178,33 @@ 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) { + onExistingWorktreeChange(existingWorktree); + return; + } + onEnvModeChange(value as EnvMode); + }} > - + {activeWorktreePath ? ( ) : ( )} - - {resolveCurrentWorkspaceLabel(activeWorktreePath)} - + Main checkout @@ -183,6 +213,18 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ {resolveEnvModeLabel("worktree")} + {existingWorktrees.map((worktree) => ( + + + + {worktree.label} + + + ))} @@ -195,7 +237,9 @@ export const BranchToolbar = memo(function BranchToolbar({ threadId, draftId, onEnvModeChange, + onExistingWorktreeChange, effectiveEnvModeOverride, + activeWorktreePathOverride, activeThreadBranchOverride, onActiveThreadBranchOverrideChange, startFromOrigin, @@ -221,7 +265,9 @@ export const BranchToolbar = memo(function BranchToolbar({ : null; const activeProject = useProject(activeProjectRef); const hasActiveThread = serverThread !== null || draftThread !== null; - const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const persistedWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const activeWorktreePath = + activeWorktreePathOverride !== undefined ? activeWorktreePathOverride : persistedWorktreePath; const effectiveEnvMode = effectiveEnvModeOverride ?? resolveEffectiveEnvMode({ @@ -229,7 +275,28 @@ export const BranchToolbar = memo(function BranchToolbar({ hasServerThread: serverThread !== null, draftThreadEnvMode: draftThread?.envMode, }); - const envModeLocked = envLocked || (serverThread !== null && activeWorktreePath !== null); + const envModeLocked = envLocked || (serverThread !== null && persistedWorktreePath !== null); + const branchState = useAllBranches({ + environmentId, + cwd: activeProject?.workspaceRoot ?? null, + }); + const activeBranch = + activeThreadBranchOverride ?? serverThread?.branch ?? draftThread?.branch ?? null; + const workspaceOptions = useMemo(() => { + if (!activeProject) return { mainCheckout: null, existingWorktrees: [] }; + const options = deriveWorkspaceOptions( + branchState.data?.refs ?? [], + activeProject.workspaceRoot, + branchState.data?.mainCheckoutPath, + ); + // Always apply the fallback so a partial first page of refs that omits the + // active worktree still keeps that checkout selectable in the picker. + return withActiveWorkspaceFallback(options, { + activeWorktreePath, + activeBranch, + projectWorkspaceRoot: activeProject.workspaceRoot, + }); + }, [activeBranch, activeProject, activeWorktreePath, branchState.data]); const showEnvironmentPicker = Boolean( availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, @@ -251,6 +318,9 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} onEnvModeChange={onEnvModeChange} + existingWorktrees={workspaceOptions.existingWorktrees} + mainCheckout={workspaceOptions.mainCheckout} + onExistingWorktreeChange={onExistingWorktreeChange} /> ) : (
@@ -270,6 +340,9 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} onEnvModeChange={onEnvModeChange} + existingWorktrees={workspaceOptions.existingWorktrees} + mainCheckout={workspaceOptions.mainCheckout} + onExistingWorktreeChange={onExistingWorktreeChange} />
)} @@ -281,6 +354,7 @@ export const BranchToolbar = memo(function BranchToolbar({ {...(draftId ? { draftId } : {})} envLocked={envLocked} {...(effectiveEnvModeOverride ? { effectiveEnvModeOverride } : {})} + {...(activeWorktreePathOverride !== undefined ? { activeWorktreePathOverride } : {})} {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})} {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} startFromOrigin={startFromOrigin} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index e2ee24c3608..7ad343f864c 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -65,6 +65,7 @@ interface BranchToolbarBranchSelectorProps { draftId?: DraftId; envLocked: boolean; effectiveEnvModeOverride?: "local" | "worktree"; + activeWorktreePathOverride?: string | null; activeThreadBranchOverride?: string | null; onActiveThreadBranchOverrideChange?: (refName: string | null) => void; startFromOrigin: boolean; @@ -99,6 +100,7 @@ export function BranchToolbarBranchSelector({ draftId, envLocked, effectiveEnvModeOverride, + activeWorktreePathOverride, activeThreadBranchOverride, onActiveThreadBranchOverrideChange, startFromOrigin, @@ -144,7 +146,10 @@ export function BranchToolbarBranchSelector({ activeThreadBranchOverride !== undefined ? activeThreadBranchOverride : (serverThread?.branch ?? draftThread?.branch ?? null); - const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const activeWorktreePath = + activeWorktreePathOverride !== undefined + ? activeWorktreePathOverride + : (serverThread?.worktreePath ?? draftThread?.worktreePath ?? null); const activeProjectCwd = activeProject?.workspaceRoot ?? null; const branchCwd = activeWorktreePath ?? activeProjectCwd; const hasServerThread = serverThread !== null; diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 6d06882662f..21b73ce395f 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,46 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe return ( ); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 0a0103df183..1292aa5ef4c 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -13,6 +13,7 @@ import { hasServerAcknowledgedLocalDispatch, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, + resolveEffectiveServerThreadWorktreePath, resolveSendEnvMode, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; @@ -254,6 +255,28 @@ describe("resolveSendEnvMode", () => { }); }); +describe("resolveEffectiveServerThreadWorktreePath", () => { + it("uses a pending existing-worktree selection before metadata round-trips", () => { + expect( + resolveEffectiveServerThreadWorktreePath({ + canOverride: true, + persistedWorktreePath: null, + pendingWorktreePath: "/repo/worktrees/selected", + }), + ).toBe("/repo/worktrees/selected"); + }); + + it("uses persisted metadata once overrides are locked", () => { + expect( + resolveEffectiveServerThreadWorktreePath({ + canOverride: false, + persistedWorktreePath: "/repo/worktrees/persisted", + pendingWorktreePath: "/repo/worktrees/stale", + }), + ).toBe("/repo/worktrees/persisted"); + }); +}); + describe("reconcileMountedTerminalThreadIds", () => { it("keeps open threads and makes the active thread most recent", () => { expect( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 705793ec77e..458da07e0de 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -193,6 +193,16 @@ export function resolveSendEnvMode(input: { return input.isGitRepo ? input.requestedEnvMode : "local"; } +export function resolveEffectiveServerThreadWorktreePath(input: { + readonly canOverride: boolean; + readonly persistedWorktreePath: string | null; + readonly pendingWorktreePath: string | null | undefined; +}): string | null { + return input.canOverride && input.pendingWorktreePath !== undefined + ? input.pendingWorktreePath + : input.persistedWorktreePath; +} + export function cloneComposerImageForRetry( image: ComposerImageAttachment, ): ComposerImageAttachment { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f5ea5bb1eba..10233d9a8d0 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"; @@ -230,6 +230,7 @@ import { deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, + resolveEffectiveServerThreadWorktreePath, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, @@ -1139,6 +1140,9 @@ function ChatViewContent(props: ChatViewProps) { const [pendingServerThreadEnvMode, setPendingServerThreadEnvMode] = useState(null); const [pendingServerThreadBranch, setPendingServerThreadBranch] = useState(); + const [pendingServerThreadWorktreePath, setPendingServerThreadWorktreePath] = useState< + string | null + >(); const [ pendingServerThreadStartFromOriginByThreadId, setPendingServerThreadStartFromOriginByThreadId, @@ -3562,12 +3566,7 @@ function ChatViewContent(props: ChatViewProps) { setExpandedImage(null); }, []); - const activeWorktreePath = activeThread?.worktreePath ?? null; - const derivedEnvMode: DraftThreadEnvMode = resolveEffectiveEnvMode({ - activeWorktreePath, - hasServerThread: isServerThread, - draftThreadEnvMode: isLocalDraftThread ? draftThread?.envMode : undefined, - }); + const persistedWorktreePath = activeThread?.worktreePath ?? null; const canOverrideServerThreadEnvMode = Boolean( isServerThread && activeThread && @@ -3575,6 +3574,16 @@ function ChatViewContent(props: ChatViewProps) { activeThread.worktreePath === null && !envLocked, ); + const activeWorktreePath = resolveEffectiveServerThreadWorktreePath({ + canOverride: canOverrideServerThreadEnvMode, + persistedWorktreePath, + pendingWorktreePath: pendingServerThreadWorktreePath, + }); + const derivedEnvMode: DraftThreadEnvMode = resolveEffectiveEnvMode({ + activeWorktreePath, + hasServerThread: isServerThread, + draftThreadEnvMode: isLocalDraftThread ? draftThread?.envMode : undefined, + }); const envMode: DraftThreadEnvMode = canOverrideServerThreadEnvMode ? (pendingServerThreadEnvMode ?? draftThread?.envMode ?? derivedEnvMode) : derivedEnvMode; @@ -3596,6 +3605,7 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { setPendingServerThreadEnvMode(null); setPendingServerThreadBranch(undefined); + setPendingServerThreadWorktreePath(undefined); }, [activeThread?.id]); useEffect(() => { @@ -3604,6 +3614,7 @@ function ChatViewContent(props: ChatViewProps) { } setPendingServerThreadEnvMode(null); setPendingServerThreadBranch(undefined); + setPendingServerThreadWorktreePath(undefined); }, [canOverrideServerThreadEnvMode]); useEffect(() => { @@ -3967,14 +3978,14 @@ function ChatViewContent(props: ChatViewProps) { const threadIdForSend = activeThread.id; const isFirstMessage = !isServerThread || activeThread.messages.length === 0; const baseBranchForWorktree = - isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath + isFirstMessage && sendEnvMode === "worktree" && !activeWorktreePath ? activeThreadBranch : null; // In worktree mode, require an explicit base branch so we don't silently // fall back to local execution when branch selection is missing. const shouldCreateWorktree = - isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath; + isFirstMessage && sendEnvMode === "worktree" && !activeWorktreePath; if (shouldCreateWorktree && !activeThreadBranch) { setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); return; @@ -4144,7 +4155,7 @@ function ChatViewContent(props: ChatViewProps) { runtimeMode, interactionMode, branch: activeThreadBranch, - worktreePath: activeThread.worktreePath, + worktreePath: activeWorktreePath, createdAt: activeThread.createdAt, }, } @@ -4840,6 +4851,7 @@ function ChatViewContent(props: ChatViewProps) { (mode: DraftThreadEnvMode) => { if (canOverrideServerThreadEnvMode) { setPendingServerThreadEnvMode(mode); + setPendingServerThreadWorktreePath(null); scheduleComposerFocus(); return; } @@ -4850,7 +4862,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 +4883,44 @@ function ChatViewContent(props: ChatViewProps) { ], ); + const onExistingWorktreeChange = useCallback( + (worktree: ExistingWorktreeOption) => { + if (canOverrideServerThreadEnvMode && activeThread) { + setPendingServerThreadEnvMode("worktree"); + setPendingServerThreadBranch(worktree.branch); + setPendingServerThreadWorktreePath(worktree.path); + 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,13 +5284,13 @@ function ChatViewContent(props: ChatViewProps) { threadId={activeThread.id} {...(routeKind === "draft" && draftId ? { draftId } : {})} onEnvModeChange={onEnvModeChange} + onExistingWorktreeChange={onExistingWorktreeChange} startFromOrigin={startFromOrigin} onStartFromOriginChange={onStartFromOriginChange} - {...(canOverrideServerThreadEnvMode - ? { effectiveEnvModeOverride: envMode } - : {})} {...(canOverrideServerThreadEnvMode ? { + effectiveEnvModeOverride: envMode, + activeWorktreePathOverride: activeWorktreePath, activeThreadBranchOverride: activeThreadBranch, onActiveThreadBranchOverrideChange: setPendingServerThreadBranch, } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8dccf984457..7b32b5deb5a 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,16 @@ 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, + resolveNewThreadDefaults, + } = useHandleNewThread(); const projects = useProjects(); const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -624,7 +631,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 +698,27 @@ function OpenCommandPaletteDialog(props: { activeDraftThread, activeThread: activeThread ?? undefined, defaultProjectRef, + defaultThreadEnvMode, + defaultNewWorktreesStartFromOrigin, handleNewThread, + resolveDefaultMainCheckout, + resolveNewThreadDefaults, }, scopeProjectRef(project.environmentId, project.id), ); }, }), - [activeDraftThread, activeThread, defaultProjectRef, handleNewThread, projects], + [ + activeDraftThread, + activeThread, + defaultNewWorktreesStartFromOrigin, + defaultProjectRef, + defaultThreadEnvMode, + handleNewThread, + projects, + resolveDefaultMainCheckout, + resolveNewThreadDefaults, + ], ); const allThreadItems = useMemo( @@ -875,7 +901,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 +1016,11 @@ function OpenCommandPaletteDialog(props: { activeDraftThread, activeThread: activeThread ?? undefined, defaultProjectRef, + defaultThreadEnvMode, + defaultNewWorktreesStartFromOrigin, handleNewThread, + resolveDefaultMainCheckout, + resolveNewThreadDefaults, }); }, }); @@ -1614,7 +1650,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 +1864,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..e7340713c25 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,18 +1,32 @@ +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, + orderSidebarThreadsByWorktree, + resolveAdjacentThreadId, resolveProjectStatusIndicator, - resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, resolveSidebarStageBadgeLabel, resolveThreadRowClassName, @@ -21,22 +35,207 @@ 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], + }, + ]); + }); + + it("reconciles each logical-project member with its own checkout", () => { + const memberOneImplicit = { + environmentId: "local", + projectId: "one", + branch: "feature/one", + worktreePath: null, + }; + const memberTwoImplicit = { + environmentId: "local", + projectId: "two", + branch: "feature/two", + worktreePath: null, + }; + const memberTwoExplicit = { + environmentId: "local", + projectId: "two", + branch: "feature/two", + worktreePath: "/repo-two", + }; + + const groups = groupSidebarThreadsByWorktree( + [memberOneImplicit, memberTwoImplicit, memberTwoExplicit], + [ + { + environmentId: "local", + projectId: "one", + projectCheckoutPath: "/repo-one", + projectCheckoutLabel: "feature/one", + mainCheckoutPath: "/repo-one", + }, + { + environmentId: "local", + projectId: "two", + projectCheckoutPath: "/repo-two", + projectCheckoutLabel: "feature/two", + mainCheckoutPath: null, + }, + ], + ); + + expect(groups).toEqual([ + { + key: "local:worktree:/repo-one", + label: "Main checkout", + threads: [memberOneImplicit], + }, + { + key: "local:worktree:/repo-two", + label: "feature/two", + threads: [memberTwoImplicit, memberTwoExplicit], + }, + ]); + }); + + it("groups Windows workspace paths case-insensitively", () => { + const implicitThread = { + environmentId: "windows", + projectId: "project", + branch: "main", + worktreePath: null, + }; + const explicitThread = { + ...implicitThread, + worktreePath: "c:\\repo\\worktree", + }; + + expect( + groupSidebarThreadsByWorktree( + [implicitThread, explicitThread], + [ + { + environmentId: "windows", + projectId: "project", + projectCheckoutPath: "C:\\Repo\\Worktree", + projectCheckoutLabel: "main", + mainCheckoutPath: "C:\\REPO\\WORKTREE", + }, + ], + ), + ).toEqual([ + { + key: "windows:worktree:c:/repo/worktree", + label: "Main checkout", + threads: [implicitThread, explicitThread], + }, + ]); + }); +}); + +describe("orderSidebarThreadsByWorktree", () => { + it("matches the visual grouped-row order for interleaved worktrees", () => { + const first = { + environmentId: "local", + projectId: "project", + branch: "feature/one", + worktreePath: "/repo/one", + }; + const second = { + environmentId: "local", + projectId: "project", + branch: "feature/two", + worktreePath: "/repo/two", + }; + const third = { ...first, branch: "feature/one-again" }; + + expect(orderSidebarThreadsByWorktree([first, second, third])).toEqual([first, third, second]); + }); +}); + describe("resolveSidebarStageBadgeLabel", () => { it("returns Nightly for nightly primary server versions", () => { expect( @@ -260,93 +459,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 +781,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 +792,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 +803,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..0d6af62ea21 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,100 @@ 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 | null; +} + +function normalizeWorkspacePath(path: string): string { + const normalized = path.trim().replaceAll("\\", "/").replace(/\/+$/, ""); + return /^[A-Za-z]:\//.test(normalized) || normalized.startsWith("//") + ? normalized.toLowerCase() + : normalized; +} + +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[], + workspaceIdentities: readonly SidebarWorkspaceIdentity[] = [], +): SidebarWorktreeThreadGroup[] { + const groups = new Map(); + + for (const thread of threads) { + const workspaceIdentity = workspaceIdentities.find( + (identity) => + identity.environmentId === thread.environmentId && identity.projectId === thread.projectId, + ); + const matchesWorkspaceIdentity = workspaceIdentity !== undefined; + const worktreePath = thread.worktreePath?.trim() || null; + const effectivePath = + worktreePath ?? (matchesWorkspaceIdentity ? workspaceIdentity.projectCheckoutPath : null); + const normalizedEffectivePath = effectivePath ? normalizeWorkspacePath(effectivePath) : null; + const isMainCheckout = + matchesWorkspaceIdentity && + workspaceIdentity.mainCheckoutPath !== null && + 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 })); +} + +export function orderSidebarThreadsByWorktree< + TThread extends { + readonly environmentId: string; + readonly projectId: string; + readonly branch: string | null; + readonly worktreePath: string | null; + }, +>( + threads: readonly TThread[], + workspaceIdentities: readonly SidebarWorkspaceIdentity[] = [], +): TThread[] { + return groupSidebarThreadsByWorktree(threads, workspaceIdentities).flatMap( + (group) => group.threads, + ); +} type SidebarProject = { id: string; title: string; @@ -184,55 +278,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..cf22579238e 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,38 @@ import { type SidebarThreadPreviewCount, type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; +import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +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 +85,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 +142,39 @@ import { shouldShowArm64IntelBuildWarning, shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; +import { ProjectFavicon } from "./ProjectFavicon"; +import { openDiscoveredPort } from "./preview/openDiscoveredPort"; +import { + getSidebarThreadIdsToPrewarm, + groupSidebarThreadsByWorktree, + isContextMenuPointerDown, + isTrailingDoubleClick, + orderItemsByPreferredIds, + orderSidebarThreadsByWorktree, + 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 +185,7 @@ import { DialogTitle, } from "./ui/dialog"; import { Input } from "./ui/input"; +import { Kbd } from "./ui/kbd"; import { Menu, MenuGroup, @@ -165,7 +203,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 +218,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 +240,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 +444,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,10 +900,12 @@ interface SidebarProjectThreadListProps { hiddenThreadStatus: ThreadStatusPill | null; orderedProjectThreadKeys: readonly string[]; renderedThreads: readonly SidebarThreadSummary[]; + threadGroupingMode: SidebarThreadGroupingMode; showEmptyThreadState: boolean; shouldShowThreadPanel: boolean; isThreadListExpanded: boolean; projectCwd: string; + projectMembers: readonly SidebarProjectGroupMember[]; activeRouteThreadKey: string | null; threadJumpLabelByKey: ReadonlyMap; appSettingsConfirmThreadArchive: boolean; @@ -943,10 +953,12 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( hiddenThreadStatus, orderedProjectThreadKeys, renderedThreads, + threadGroupingMode, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, projectCwd, + projectMembers, activeRouteThreadKey, threadJumpLabelByKey, appSettingsConfirmThreadArchive, @@ -974,6 +986,92 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( } = props; const showMoreButtonRender = useMemo(() =>