diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index c4ae7b96694..e03352f1840 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -1,6 +1,8 @@ -import { EnvironmentId, type VcsRef } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, type VcsRef } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { + buildCheckoutWorkspaceChoices, + dedupeCheckoutOptions, dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, resolveEnvironmentOptionLabel, @@ -106,6 +108,90 @@ describe("resolvePreviousWorktreeLabel", () => { }); }); +describe("buildCheckoutWorkspaceChoices", () => { + it("keeps every same-repository clone selectable as a checkout or worktree base", () => { + const firstProjectId = ProjectId.make("project-first-clone"); + const secondProjectId = ProjectId.make("project-second-clone"); + + expect( + buildCheckoutWorkspaceChoices([ + { + projectId: firstProjectId, + title: "t3code", + workspaceRoot: "/src/t3code", + }, + { + projectId: secondProjectId, + title: "t3code-3", + workspaceRoot: "/src/t3code-3", + }, + ]).map(({ projectId, mode }) => ({ projectId, mode })), + ).toEqual([ + { projectId: firstProjectId, mode: "local" }, + { projectId: firstProjectId, mode: "worktree" }, + { projectId: secondProjectId, mode: "local" }, + { projectId: secondProjectId, mode: "worktree" }, + ]); + }); +}); + +describe("dedupeCheckoutOptions", () => { + it("keeps the active project when duplicate normalized roots exist", () => { + const staleProjectId = ProjectId.make("project-stale"); + const activeProjectId = ProjectId.make("project-active"); + + expect( + dedupeCheckoutOptions( + [ + { + projectId: staleProjectId, + title: "Stale checkout", + workspaceRoot: "C:\\src\\t3code", + }, + { + projectId: activeProjectId, + title: "Active checkout", + workspaceRoot: "C:/src/t3code", + }, + ], + activeProjectId, + ), + ).toEqual([ + { + projectId: activeProjectId, + title: "Active checkout", + workspaceRoot: "C:/src/t3code", + }, + ]); + }); + + it("preserves checkouts that differ only by path casing", () => { + const lowerProjectId = ProjectId.make("project-lower"); + const upperProjectId = ProjectId.make("project-upper"); + const checkouts = [ + { projectId: lowerProjectId, title: "Lowercase", workspaceRoot: "/src/t3code" }, + { projectId: upperProjectId, title: "Uppercase", workspaceRoot: "/src/T3Code" }, + ]; + + expect(dedupeCheckoutOptions(checkouts, lowerProjectId)).toEqual(checkouts); + }); + + it("deduplicates paths that differ only by trailing separators", () => { + const staleProjectId = ProjectId.make("project-trailing-slash"); + const activeProjectId = ProjectId.make("project-no-trailing-slash"); + + expect( + dedupeCheckoutOptions( + [ + { projectId: staleProjectId, title: "Stale", workspaceRoot: "/src/t3code/" }, + { projectId: activeProjectId, title: "Active", workspaceRoot: "/src/t3code" }, + ], + activeProjectId, + ).map((checkout) => checkout.projectId), + ).toEqual([activeProjectId]); + }); +}); + describe("resolveDraftEnvModeAfterBranchChange", () => { it("switches to local mode when returning from an existing worktree to the main worktree", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 8fe35fa464a..76307b8e99e 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,4 +1,5 @@ import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; import * as Schema from "effect/Schema"; import { toSortableTimestamp } from "../lib/threadSort"; export { @@ -13,9 +14,50 @@ export interface EnvironmentOption { isPrimary: boolean; } +export interface CheckoutOption { + projectId: ProjectId; + title: string; + workspaceRoot: string; +} + +export interface CheckoutWorkspaceChoice { + projectId: ProjectId; + mode: EnvMode; + value: string; +} + +export function dedupeCheckoutOptions( + checkouts: readonly CheckoutOption[], + activeProjectId: ProjectId, +): CheckoutOption[] { + const activeCheckout = checkouts.find((checkout) => checkout.projectId === activeProjectId); + const orderedCheckouts = activeCheckout + ? [activeCheckout, ...checkouts.filter((checkout) => checkout.projectId !== activeProjectId)] + : checkouts; + const seenWorkspaceRoots = new Set(); + return orderedCheckouts.filter((checkout) => { + const workspaceRoot = normalizeProjectPathForComparison(checkout.workspaceRoot); + if (seenWorkspaceRoots.has(workspaceRoot)) return false; + seenWorkspaceRoots.add(workspaceRoot); + return true; + }); +} + export const EnvMode = Schema.Literals(["local", "worktree"]); export type EnvMode = typeof EnvMode.Type; +export function buildCheckoutWorkspaceChoices( + checkouts: readonly CheckoutOption[], +): CheckoutWorkspaceChoice[] { + return checkouts.flatMap((checkout) => + (["local", "worktree"] as const).map((mode) => ({ + projectId: checkout.projectId, + mode, + value: JSON.stringify([checkout.projectId, mode]), + })), + ); +} + const GENERIC_LOCAL_ENVIRONMENT_LABELS = new Set(["local", "local environment"]); function normalizeDisplayLabel(value: string | null | undefined): string | null { diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index e703427d4b6..96bc44aae72 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -15,6 +15,7 @@ import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; import { useIsMobile } from "../hooks/useMediaQuery"; import { + type CheckoutOption, type EnvMode, type EnvironmentOption, resolveCurrentWorkspaceLabel, @@ -56,6 +57,8 @@ interface BranchToolbarProps { onComposerFocusRequest?: () => void; availableEnvironments?: readonly EnvironmentOption[]; onEnvironmentChange?: (environmentId: EnvironmentId) => void; + availableCheckouts?: readonly CheckoutOption[]; + onWorkspaceChange?: (projectId: CheckoutOption["projectId"], mode: EnvMode) => void; } interface MobileRunContextSelectorProps { @@ -229,6 +232,8 @@ export const BranchToolbar = memo(function BranchToolbar({ onComposerFocusRequest, availableEnvironments, onEnvironmentChange, + availableCheckouts, + onWorkspaceChange, }: BranchToolbarProps) { const threadRef = useMemo( () => scopeThreadRef(environmentId, threadId), @@ -340,6 +345,9 @@ export const BranchToolbar = memo(function BranchToolbar({ onEnvModeChange={onEnvModeChange} previousWorktreeLabel={previousWorktreeLabel} onUsePreviousWorktree={onUsePreviousWorktree} + activeProjectId={activeProject.id} + availableCheckouts={availableCheckouts ?? []} + {...(onWorkspaceChange ? { onWorkspaceChange } : {})} /> )} diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index e915c27312c..e91d3017576 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -2,6 +2,8 @@ import { FolderGit2Icon, FolderGitIcon, FolderIcon, HistoryIcon } from "lucide-r import { memo, useMemo } from "react"; import { + buildCheckoutWorkspaceChoices, + type CheckoutOption, resolveCurrentWorkspaceLabel, resolveEnvModeLabel, resolveLockedWorkspaceLabel, @@ -24,28 +26,78 @@ interface BranchToolbarEnvModeSelectorProps { effectiveEnvMode: EnvMode; activeWorktreePath: string | null; onEnvModeChange: (mode: EnvMode) => void; + activeProjectId: CheckoutOption["projectId"]; + availableCheckouts: readonly CheckoutOption[]; + onWorkspaceChange?: (projectId: CheckoutOption["projectId"], mode: EnvMode) => void; previousWorktreeLabel?: string | null; onUsePreviousWorktree?: () => void; } +function formatCheckoutPath(path: string): string { + const normalized = path.replaceAll("\\", "/"); + const homePrefix = "/Users/"; + if (normalized.startsWith(homePrefix)) { + const segments = normalized.slice(homePrefix.length).split("/"); + if (segments.length > 1) { + return `~/${segments.slice(1).join("/")}`; + } + } + return normalized; +} + export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ envLocked, effectiveEnvMode, activeWorktreePath, onEnvModeChange, + activeProjectId, + availableCheckouts, + onWorkspaceChange, previousWorktreeLabel, onUsePreviousWorktree, }: BranchToolbarEnvModeSelectorProps) { const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree); + const hasMultipleCheckouts = availableCheckouts.length > 1 && onWorkspaceChange !== undefined; + const activeCheckout = + availableCheckouts.find((checkout) => checkout.projectId === activeProjectId) ?? null; + const checkoutChoices = useMemo( + () => buildCheckoutWorkspaceChoices(availableCheckouts), + [availableCheckouts], + ); + const choiceValue = (projectId: CheckoutOption["projectId"], mode: EnvMode) => + checkoutChoices.find((choice) => choice.projectId === projectId && choice.mode === mode) + ?.value ?? ""; const envModeItems = useMemo( - () => [ - { value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath) }, - { value: "worktree", label: resolveEnvModeLabel("worktree") }, - ...(showPreviousWorktree && previousWorktreeLabel - ? [{ value: PREVIOUS_WORKTREE_SELECT_VALUE, label: previousWorktreeLabel }] - : []), + () => + hasMultipleCheckouts + ? checkoutChoices.map((choice) => { + const checkout = availableCheckouts.find( + (candidate) => candidate.projectId === choice.projectId, + )!; + return { + value: choice.value, + label: `${checkout.title} — ${ + choice.mode === "local" + ? resolveCurrentWorkspaceLabel(activeWorktreePath) + : resolveEnvModeLabel("worktree") + }`, + }; + }) + : [ + { value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath) }, + { value: "worktree", label: resolveEnvModeLabel("worktree") }, + ...(showPreviousWorktree && previousWorktreeLabel + ? [{ value: PREVIOUS_WORKTREE_SELECT_VALUE, label: previousWorktreeLabel }] + : []), + ], + [ + activeWorktreePath, + availableCheckouts, + checkoutChoices, + hasMultipleCheckouts, + previousWorktreeLabel, + showPreviousWorktree, ], - [activeWorktreePath, previousWorktreeLabel, showPreviousWorktree], ); if (envLocked) { @@ -69,13 +121,22 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe return ( ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ab1256cddb3..f441339a4c2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -223,7 +223,11 @@ import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; -import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; +import { + dedupeCheckoutOptions, + resolveEffectiveEnvMode, + resolveLocalCheckoutBranchMismatch, +} from "./BranchToolbar.logic"; import { getProviderStatusBannerKey, ProviderStatusBanner, @@ -1669,6 +1673,22 @@ function ChatViewContent(props: ChatViewProps) { return envs; }, [activeProject, allProjects, projectGroupingSettings, primaryEnvironmentId, environmentById]); const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; + const logicalProjectCheckouts = useMemo(() => { + if (!activeProject) return []; + const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); + const checkouts = allProjects + .filter( + (project) => + project.environmentId === activeProject.environmentId && + deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) === logicalKey, + ) + .map((project) => ({ + projectId: project.id, + title: project.title, + workspaceRoot: project.workspaceRoot, + })); + return dedupeCheckoutOptions(checkouts, activeProject.id); + }, [activeProject, allProjects, projectGroupingSettings]); const openPullRequestDialog = useCallback( (reference?: string) => { @@ -2442,6 +2462,32 @@ function ChatViewContent(props: ChatViewProps) { [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], ); + const onWorkspaceChange = useCallback( + (projectId: ProjectId, mode: DraftThreadEnvMode) => { + if (envLocked || !draftId || !activeProject) return; + const target = logicalProjectCheckouts.find((checkout) => checkout.projectId === projectId); + if (!target) return; + setDraftThreadContext(draftId, { + projectRef: scopeProjectRef(activeProject.environmentId, target.projectId), + envMode: mode, + startFromOrigin: resolveNewDraftStartFromOrigin({ + envMode: mode, + newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, + }), + ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), + }); + }, + [ + activeProject, + draftId, + draftThread?.worktreePath, + envLocked, + logicalProjectCheckouts, + primaryServerSettings.newWorktreesStartFromOrigin, + setDraftThreadContext, + ], + ); + const activeTerminalGroup = terminalUiState.terminalGroups.find( (group) => group.id === terminalUiState.activeTerminalGroupId, @@ -5892,6 +5938,10 @@ function ChatViewContent(props: ChatViewProps) { : {})} {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})} availableEnvironments={logicalProjectEnvironments} + availableCheckouts={logicalProjectCheckouts} + {...(routeKind === "draft" && !isServerThread + ? { onWorkspaceChange } + : {})} /> )}