diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 2f66e063ad..6137b77c52 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -1,14 +1,21 @@ -import type { GitBranch } from "@t3tools/contracts"; +import { EnvironmentId, type GitBranch } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, + resolveEnvironmentOptionLabel, resolveBranchSelectionTarget, + resolveCurrentWorkspaceLabel, resolveDraftEnvModeAfterBranchChange, + resolveEffectiveEnvMode, + resolveEnvModeLabel, resolveBranchToolbarValue, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; +const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); +const remoteEnvironmentId = EnvironmentId.makeUnsafe("environment-remote"); + describe("resolveDraftEnvModeAfterBranchChange", () => { it("switches to local mode when returning from an existing worktree to the main worktree", () => { expect( @@ -76,6 +83,80 @@ describe("resolveBranchToolbarValue", () => { }); }); +describe("resolveEnvironmentOptionLabel", () => { + it("prefers the primary environment's machine label", () => { + expect( + resolveEnvironmentOptionLabel({ + isPrimary: true, + environmentId: localEnvironmentId, + runtimeLabel: "Julius's Mac mini", + savedLabel: "Local environment", + }), + ).toBe("Julius's Mac mini"); + }); + + it("falls back to 'This device' for generic primary labels", () => { + expect( + resolveEnvironmentOptionLabel({ + isPrimary: true, + environmentId: localEnvironmentId, + runtimeLabel: "Local environment", + savedLabel: "Local", + }), + ).toBe("This device"); + }); + + it("keeps configured labels for non-primary environments", () => { + expect( + resolveEnvironmentOptionLabel({ + isPrimary: false, + environmentId: remoteEnvironmentId, + runtimeLabel: null, + savedLabel: "Build box", + }), + ).toBe("Build box"); + }); +}); + +describe("resolveEffectiveEnvMode", () => { + it("treats draft threads already attached to a worktree as current-checkout mode", () => { + expect( + resolveEffectiveEnvMode({ + activeWorktreePath: "/repo/.t3/worktrees/feature-a", + hasServerThread: false, + draftThreadEnvMode: "worktree", + }), + ).toBe("local"); + }); + + it("keeps explicit new-worktree mode for draft threads without a worktree path", () => { + expect( + resolveEffectiveEnvMode({ + activeWorktreePath: null, + hasServerThread: false, + draftThreadEnvMode: "worktree", + }), + ).toBe("worktree"); + }); +}); + +describe("resolveEnvModeLabel", () => { + it("uses explicit workspace labels", () => { + expect(resolveEnvModeLabel("local")).toBe("Current 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("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 adc6495953..54ec4370f4 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -15,15 +15,54 @@ export interface EnvironmentOption { export const EnvMode = Schema.Literals(["local", "worktree"]); export type EnvMode = typeof EnvMode.Type; +const GENERIC_LOCAL_ENVIRONMENT_LABELS = new Set(["local", "local environment"]); + +function normalizeDisplayLabel(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +} + +export function resolveEnvironmentOptionLabel(input: { + isPrimary: boolean; + environmentId: EnvironmentId; + runtimeLabel?: string | null; + savedLabel?: string | null; +}): string { + const runtimeLabel = normalizeDisplayLabel(input.runtimeLabel); + const savedLabel = normalizeDisplayLabel(input.savedLabel); + + if (input.isPrimary) { + const preferredLocalLabel = [runtimeLabel, savedLabel].find((label) => { + if (!label) return false; + return !GENERIC_LOCAL_ENVIRONMENT_LABELS.has(label.toLowerCase()); + }); + return preferredLocalLabel ?? "This device"; + } + + return runtimeLabel ?? savedLabel ?? input.environmentId; +} + +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 resolveEffectiveEnvMode(input: { activeWorktreePath: string | null; hasServerThread: boolean; draftThreadEnvMode: EnvMode | undefined; }): EnvMode { const { activeWorktreePath, hasServerThread, draftThreadEnvMode } = input; - return activeWorktreePath || (!hasServerThread && draftThreadEnvMode === "worktree") - ? "worktree" - : "local"; + if (!hasServerThread) { + if (activeWorktreePath) { + return "local"; + } + return draftThreadEnvMode === "worktree" ? "worktree" : "local"; + } + return activeWorktreePath ? "worktree" : "local"; } export function resolveDraftEnvModeAfterBranchChange(input: { diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 94d11ae7bd..10e6023a41 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -62,6 +62,7 @@ export function BranchToolbar({ hasServerThread: serverThread !== undefined, draftThreadEnvMode: draftThread?.envMode, }); + const envModeLocked = envLocked || (serverThread !== undefined && activeWorktreePath !== null); const showEnvironmentPicker = availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange; @@ -83,7 +84,7 @@ export function BranchToolbar({ )} [ + { value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath) }, + { value: "worktree", label: resolveEnvModeLabel("worktree") }, + ], + [activeWorktreePath], + ); + + if (envLocked) { return ( {activeWorktreePath ? ( <> - - Worktree + + {resolveCurrentWorkspaceLabel(activeWorktreePath)} ) : ( <> - Local + {resolveCurrentWorkspaceLabel(activeWorktreePath)} )} @@ -46,27 +61,36 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe onValueChange={(value) => onEnvModeChange(value as EnvMode)} items={envModeItems} > - + {effectiveEnvMode === "worktree" ? ( - + + ) : activeWorktreePath ? ( + ) : ( )} - - - - Local - - - - - - New worktree - - + + Workspace + + + {activeWorktreePath ? ( + + ) : ( + + )} + {resolveCurrentWorkspaceLabel(activeWorktreePath)} + + + + + + {resolveEnvModeLabel("worktree")} + + + ); diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 1725e8e909..abfa21365e 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -1,9 +1,17 @@ import type { EnvironmentId } from "@t3tools/contracts"; -import { CloudIcon, FolderIcon, ServerIcon } from "lucide-react"; +import { CloudIcon, MonitorIcon } from "lucide-react"; import { memo, useMemo } from "react"; import type { EnvironmentOption } from "./BranchToolbar.logic"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; +import { + Select, + SelectGroup, + SelectGroupLabel, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "./ui/select"; interface BranchToolbarEnvironmentSelectorProps { envLocked: boolean; @@ -18,8 +26,8 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir availableEnvironments, onEnvironmentChange, }: BranchToolbarEnvironmentSelectorProps) { - const activeEnvironmentLabel = useMemo(() => { - return availableEnvironments.find((env) => env.environmentId === environmentId)?.label ?? null; + const activeEnvironment = useMemo(() => { + return availableEnvironments.find((env) => env.environmentId === environmentId) ?? null; }, [availableEnvironments, environmentId]); const environmentItems = useMemo( @@ -34,8 +42,12 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir if (envLocked) { return ( - - {activeEnvironmentLabel ?? "Environment"} + {activeEnvironment?.isPrimary ? ( + + ) : ( + + )} + {activeEnvironment?.label ?? "Run on"} ); } @@ -46,19 +58,30 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir onValueChange={(value) => onEnvironmentChange(value as EnvironmentId)} items={environmentItems} > - - + + {activeEnvironment?.isPrimary ? ( + + ) : ( + + )} - {availableEnvironments.map((env) => ( - - - {env.isPrimary ? : } - {env.label} - - - ))} + + Run on + {availableEnvironments.map((env) => ( + + + {env.isPrimary ? ( + + ) : ( + + )} + {env.label} + + + ))} + ); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index ae2397d316..98a0e0f7bb 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1993,7 +1993,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, + viewport: WIDE_FOOTER_VIEWPORT, snapshot: withProjectScripts(createDraftOnlySnapshot(), [ { id: "setup", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6c408ea7b0..578c679798 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -187,6 +187,7 @@ import { ComposerPrimaryActions } from "./chat/ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; +import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { getComposerProviderState, renderProviderTraitsMenuContent, @@ -1065,9 +1066,12 @@ export default function ChatView(props: ChatViewProps) { const isPrimary = p.environmentId === primaryEnvironmentId; const savedRecord = savedEnvironmentRegistry[p.environmentId]; const runtimeState = savedEnvironmentRuntimeById[p.environmentId]; - const label = isPrimary - ? "Local" - : (runtimeState?.descriptor?.label ?? savedRecord?.label ?? p.environmentId); + const label = resolveEnvironmentOptionLabel({ + isPrimary, + environmentId: p.environmentId, + runtimeLabel: runtimeState?.descriptor?.label ?? null, + savedLabel: savedRecord?.label ?? null, + }); envs.push({ environmentId: p.environmentId, projectId: p.id, @@ -2890,12 +2894,12 @@ export default function ChatView(props: ChatViewProps) { return () => window.removeEventListener("keydown", onKeyDown); }, [closeExpandedImage, expandedImage, navigateExpandedImage]); - const activeWorktreePath = activeThread?.worktreePath; - const envMode: DraftThreadEnvMode = activeWorktreePath - ? "worktree" - : isLocalDraftThread - ? (draftThread?.envMode ?? "local") - : "local"; + const activeWorktreePath = activeThread?.worktreePath ?? null; + const envMode: DraftThreadEnvMode = resolveEffectiveEnvMode({ + activeWorktreePath, + hasServerThread: isServerThread, + draftThreadEnvMode: isLocalDraftThread ? draftThread?.envMode : undefined, + }); useEffect(() => { if (!activeThreadId) { @@ -4031,11 +4035,20 @@ export default function ChatView(props: ChatViewProps) { const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { if (isLocalDraftThread) { - setDraftThreadContext(composerDraftTarget, { envMode: mode }); + setDraftThreadContext(composerDraftTarget, { + envMode: mode, + ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), + }); } scheduleComposerFocus(); }, - [composerDraftTarget, isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext], + [ + composerDraftTarget, + draftThread?.worktreePath, + isLocalDraftThread, + scheduleComposerFocus, + setDraftThreadContext, + ], ); const applyPromptReplacement = useCallback(