authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-09 12:51:17-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-09 12:51:17-07:00
log7b3cdc6a8c69224aa2314e4969579c3e82b6b0ba
tree910f43d10169c6a825e02519324b61beaafefc1e
parent386eb18a587b0a791991342b0c6bc49b3e73d3d5
signature Signed by PGP key B5690EEEBB952194

Clarify environment and workspace picker labels (#1854)


7 files changed, 241 insertions(+), 60 deletions(-)

apps/web/src/components/BranchToolbar.logic.test.ts+82-1
......@@ -1,14 +1,21 @@
1import type { GitBranch } from "@t3tools/contracts";
1import { EnvironmentId, type GitBranch } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33import {
44 dedupeRemoteBranchesWithLocalMatches,
55 deriveLocalBranchNameFromRemoteRef,
6 resolveEnvironmentOptionLabel,
67 resolveBranchSelectionTarget,
8 resolveCurrentWorkspaceLabel,
79 resolveDraftEnvModeAfterBranchChange,
10 resolveEffectiveEnvMode,
11 resolveEnvModeLabel,
812 resolveBranchToolbarValue,
913 shouldIncludeBranchPickerItem,
1014} from "./BranchToolbar.logic";
1115
16const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local");
17const remoteEnvironmentId = EnvironmentId.makeUnsafe("environment-remote");
18
1219describe("resolveDraftEnvModeAfterBranchChange", () => {
1320 it("switches to local mode when returning from an existing worktree to the main worktree", () => {
1421 expect(
......@@ -76,6 +83,80 @@ describe("resolveBranchToolbarValue", () => {
7683 });
7784});
7885
86describe("resolveEnvironmentOptionLabel", () => {
87 it("prefers the primary environment's machine label", () => {
88 expect(
89 resolveEnvironmentOptionLabel({
90 isPrimary: true,
91 environmentId: localEnvironmentId,
92 runtimeLabel: "Julius's Mac mini",
93 savedLabel: "Local environment",
94 }),
95 ).toBe("Julius's Mac mini");
96 });
97
98 it("falls back to 'This device' for generic primary labels", () => {
99 expect(
100 resolveEnvironmentOptionLabel({
101 isPrimary: true,
102 environmentId: localEnvironmentId,
103 runtimeLabel: "Local environment",
104 savedLabel: "Local",
105 }),
106 ).toBe("This device");
107 });
108
109 it("keeps configured labels for non-primary environments", () => {
110 expect(
111 resolveEnvironmentOptionLabel({
112 isPrimary: false,
113 environmentId: remoteEnvironmentId,
114 runtimeLabel: null,
115 savedLabel: "Build box",
116 }),
117 ).toBe("Build box");
118 });
119});
120
121describe("resolveEffectiveEnvMode", () => {
122 it("treats draft threads already attached to a worktree as current-checkout mode", () => {
123 expect(
124 resolveEffectiveEnvMode({
125 activeWorktreePath: "/repo/.t3/worktrees/feature-a",
126 hasServerThread: false,
127 draftThreadEnvMode: "worktree",
128 }),
129 ).toBe("local");
130 });
131
132 it("keeps explicit new-worktree mode for draft threads without a worktree path", () => {
133 expect(
134 resolveEffectiveEnvMode({
135 activeWorktreePath: null,
136 hasServerThread: false,
137 draftThreadEnvMode: "worktree",
138 }),
139 ).toBe("worktree");
140 });
141});
142
143describe("resolveEnvModeLabel", () => {
144 it("uses explicit workspace labels", () => {
145 expect(resolveEnvModeLabel("local")).toBe("Current checkout");
146 expect(resolveEnvModeLabel("worktree")).toBe("New worktree");
147 });
148});
149
150describe("resolveCurrentWorkspaceLabel", () => {
151 it("describes the main repo checkout when no worktree path is active", () => {
152 expect(resolveCurrentWorkspaceLabel(null)).toBe("Current checkout");
153 });
154
155 it("describes the active checkout as a worktree when one is attached", () => {
156 expect(resolveCurrentWorkspaceLabel("/repo/.t3/worktrees/feature-a")).toBe("Current worktree");
157 });
158});
159
79160describe("deriveLocalBranchNameFromRemoteRef", () => {
80161 it("strips the remote prefix from a remote ref", () => {
81162 expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo");
apps/web/src/components/BranchToolbar.logic.ts+42-3
......@@ -15,15 +15,54 @@ export interface EnvironmentOption {
1515export const EnvMode = Schema.Literals(["local", "worktree"]);
1616export type EnvMode = typeof EnvMode.Type;
1717
18const GENERIC_LOCAL_ENVIRONMENT_LABELS = new Set(["local", "local environment"]);
19
20function normalizeDisplayLabel(value: string | null | undefined): string | null {
21 const trimmed = value?.trim();
22 return trimmed && trimmed.length > 0 ? trimmed : null;
23}
24
25export function resolveEnvironmentOptionLabel(input: {
26 isPrimary: boolean;
27 environmentId: EnvironmentId;
28 runtimeLabel?: string | null;
29 savedLabel?: string | null;
30}): string {
31 const runtimeLabel = normalizeDisplayLabel(input.runtimeLabel);
32 const savedLabel = normalizeDisplayLabel(input.savedLabel);
33
34 if (input.isPrimary) {
35 const preferredLocalLabel = [runtimeLabel, savedLabel].find((label) => {
36 if (!label) return false;
37 return !GENERIC_LOCAL_ENVIRONMENT_LABELS.has(label.toLowerCase());
38 });
39 return preferredLocalLabel ?? "This device";
40 }
41
42 return runtimeLabel ?? savedLabel ?? input.environmentId;
43}
44
45export function resolveEnvModeLabel(mode: EnvMode): string {
46 return mode === "worktree" ? "New worktree" : "Current checkout";
47}
48
49export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): string {
50 return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local");
51}
52
1853export function resolveEffectiveEnvMode(input: {
1954 activeWorktreePath: string | null;
2055 hasServerThread: boolean;
2156 draftThreadEnvMode: EnvMode | undefined;
2257}): EnvMode {
2358 const { activeWorktreePath, hasServerThread, draftThreadEnvMode } = input;
24 return activeWorktreePath || (!hasServerThread && draftThreadEnvMode === "worktree")
25 ? "worktree"
26 : "local";
59 if (!hasServerThread) {
60 if (activeWorktreePath) {
61 return "local";
62 }
63 return draftThreadEnvMode === "worktree" ? "worktree" : "local";
64 }
65 return activeWorktreePath ? "worktree" : "local";
2766}
2867
2968export function resolveDraftEnvModeAfterBranchChange(input: {
apps/web/src/components/BranchToolbar.tsx+2-1
......@@ -62,6 +62,7 @@ export function BranchToolbar({
6262 hasServerThread: serverThread !== undefined,
6363 draftThreadEnvMode: draftThread?.envMode,
6464 });
65 const envModeLocked = envLocked || (serverThread !== undefined && activeWorktreePath !== null);
6566
6667 const showEnvironmentPicker =
6768 availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange;
......@@ -83,7 +84,7 @@ export function BranchToolbar({
8384 </>
8485 )}
8586 <BranchToolbarEnvModeSelector
86 envLocked={envLocked}
87 envLocked={envModeLocked}
8788 effectiveEnvMode={effectiveEnvMode}
8889 activeWorktreePath={activeWorktreePath}
8990 onEnvModeChange={onEnvModeChange}
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+51-27
......@@ -1,13 +1,20 @@
1import { FolderIcon, GitForkIcon } from "lucide-react";
2import { memo } from "react";
1import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react";
2import { memo, useMemo } from "react";
33
4import type { EnvMode } from "./BranchToolbar.logic";
5import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
6
7const envModeItems = [
8 { value: "local", label: "Local" },
9 { value: "worktree", label: "New worktree" },
10] as const;
4import {
5 resolveCurrentWorkspaceLabel,
6 resolveEnvModeLabel,
7 type EnvMode,
8} from "./BranchToolbar.logic";
9import {
10 Select,
11 SelectGroup,
12 SelectGroupLabel,
13 SelectItem,
14 SelectPopup,
15 SelectTrigger,
16 SelectValue,
17} from "./ui/select";
1118
1219interface BranchToolbarEnvModeSelectorProps {
1320 envLocked: boolean;
......@@ -22,18 +29,26 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
2229 activeWorktreePath,
2330 onEnvModeChange,
2431}: BranchToolbarEnvModeSelectorProps) {
25 if (envLocked || activeWorktreePath) {
32 const envModeItems = useMemo(
33 () => [
34 { value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath) },
35 { value: "worktree", label: resolveEnvModeLabel("worktree") },
36 ],
37 [activeWorktreePath],
38 );
39
40 if (envLocked) {
2641 return (
2742 <span className="inline-flex items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:text-xs">
2843 {activeWorktreePath ? (
2944 <>
30 <GitForkIcon className="size-3" />
31 Worktree
45 <FolderGitIcon className="size-3" />
46 {resolveCurrentWorkspaceLabel(activeWorktreePath)}
3247 </>
3348 ) : (
3449 <>
3550 <FolderIcon className="size-3" />
36 Local
51 {resolveCurrentWorkspaceLabel(activeWorktreePath)}
3752 </>
3853 )}
3954 </span>
......@@ -46,27 +61,36 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
4661 onValueChange={(value) => onEnvModeChange(value as EnvMode)}
4762 items={envModeItems}
4863 >
49 <SelectTrigger variant="ghost" size="xs" className="font-medium">
64 <SelectTrigger variant="ghost" size="xs" className="font-medium" aria-label="Workspace">
5065 {effectiveEnvMode === "worktree" ? (
51 <GitForkIcon className="size-3" />
66 <FolderGit2Icon className="size-3" />
67 ) : activeWorktreePath ? (
68 <FolderGitIcon className="size-3" />
5269 ) : (
5370 <FolderIcon className="size-3" />
5471 )}
5572 <SelectValue />
5673 </SelectTrigger>
5774 <SelectPopup>
58 <SelectItem value="local">
59 <span className="inline-flex items-center gap-1.5">
60 <FolderIcon className="size-3" />
61 Local
62 </span>
63 </SelectItem>
64 <SelectItem value="worktree">
65 <span className="inline-flex items-center gap-1.5">
66 <GitForkIcon className="size-3" />
67 New worktree
68 </span>
69 </SelectItem>
75 <SelectGroup>
76 <SelectGroupLabel>Workspace</SelectGroupLabel>
77 <SelectItem value="local">
78 <span className="inline-flex items-center gap-1.5">
79 {activeWorktreePath ? (
80 <FolderGitIcon className="size-3" />
81 ) : (
82 <FolderIcon className="size-3" />
83 )}
84 {resolveCurrentWorkspaceLabel(activeWorktreePath)}
85 </span>
86 </SelectItem>
87 <SelectItem value="worktree">
88 <span className="inline-flex items-center gap-1.5">
89 <FolderGit2Icon className="size-3" />
90 {resolveEnvModeLabel("worktree")}
91 </span>
92 </SelectItem>
93 </SelectGroup>
7094 </SelectPopup>
7195 </Select>
7296 );
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+39-16
......@@ -1,9 +1,17 @@
11import type { EnvironmentId } from "@t3tools/contracts";
2import { CloudIcon, FolderIcon, ServerIcon } from "lucide-react";
2import { CloudIcon, MonitorIcon } from "lucide-react";
33import { memo, useMemo } from "react";
44
55import type { EnvironmentOption } from "./BranchToolbar.logic";
6import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
6import {
7 Select,
8 SelectGroup,
9 SelectGroupLabel,
10 SelectItem,
11 SelectPopup,
12 SelectTrigger,
13 SelectValue,
14} from "./ui/select";
715
816interface BranchToolbarEnvironmentSelectorProps {
917 envLocked: boolean;
......@@ -18,8 +26,8 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
1826 availableEnvironments,
1927 onEnvironmentChange,
2028}: BranchToolbarEnvironmentSelectorProps) {
21 const activeEnvironmentLabel = useMemo(() => {
22 return availableEnvironments.find((env) => env.environmentId === environmentId)?.label ?? null;
29 const activeEnvironment = useMemo(() => {
30 return availableEnvironments.find((env) => env.environmentId === environmentId) ?? null;
2331 }, [availableEnvironments, environmentId]);
2432
2533 const environmentItems = useMemo(
......@@ -34,8 +42,12 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
3442 if (envLocked) {
3543 return (
3644 <span className="inline-flex items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:text-xs">
37 <ServerIcon className="size-3" />
38 {activeEnvironmentLabel ?? "Environment"}
45 {activeEnvironment?.isPrimary ? (
46 <MonitorIcon className="size-3" />
47 ) : (
48 <CloudIcon className="size-3" />
49 )}
50 {activeEnvironment?.label ?? "Run on"}
3951 </span>
4052 );
4153 }
......@@ -46,19 +58,30 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
4658 onValueChange={(value) => onEnvironmentChange(value as EnvironmentId)}
4759 items={environmentItems}
4860 >
49 <SelectTrigger variant="ghost" size="xs" className="font-medium">
50 <ServerIcon className="size-3" />
61 <SelectTrigger variant="ghost" size="xs" className="font-medium" aria-label="Run on">
62 {activeEnvironment?.isPrimary ? (
63 <MonitorIcon className="size-3" />
64 ) : (
65 <CloudIcon className="size-3" />
66 )}
5167 <SelectValue />
5268 </SelectTrigger>
5369 <SelectPopup>
54 {availableEnvironments.map((env) => (
55 <SelectItem key={env.environmentId} value={env.environmentId}>
56 <span className="inline-flex items-center gap-1.5">
57 {env.isPrimary ? <FolderIcon className="size-3" /> : <CloudIcon className="size-3" />}
58 {env.label}
59 </span>
60 </SelectItem>
61 ))}
70 <SelectGroup>
71 <SelectGroupLabel>Run on</SelectGroupLabel>
72 {availableEnvironments.map((env) => (
73 <SelectItem key={env.environmentId} value={env.environmentId}>
74 <span className="inline-flex items-center gap-1.5">
75 {env.isPrimary ? (
76 <MonitorIcon className="size-3" />
77 ) : (
78 <CloudIcon className="size-3" />
79 )}
80 {env.label}
81 </span>
82 </SelectItem>
83 ))}
84 </SelectGroup>
6285 </SelectPopup>
6386 </Select>
6487 );
apps/web/src/components/ChatView.browser.tsx+1-1
......@@ -1993,7 +1993,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
19931993 });
19941994
19951995 const mounted = await mountChatView({
1996 viewport: DEFAULT_VIEWPORT,
1996 viewport: WIDE_FOOTER_VIEWPORT,
19971997 snapshot: withProjectScripts(createDraftOnlySnapshot(), [
19981998 {
19991999 id: "setup",
apps/web/src/components/ChatView.tsx+24-11
......@@ -187,6 +187,7 @@ import { ComposerPrimaryActions } from "./chat/ComposerPrimaryActions";
187187import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel";
188188import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel";
189189import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner";
190import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
190191import {
191192 getComposerProviderState,
192193 renderProviderTraitsMenuContent,
......@@ -1065,9 +1066,12 @@ export default function ChatView(props: ChatViewProps) {
10651066 const isPrimary = p.environmentId === primaryEnvironmentId;
10661067 const savedRecord = savedEnvironmentRegistry[p.environmentId];
10671068 const runtimeState = savedEnvironmentRuntimeById[p.environmentId];
1068 const label = isPrimary
1069 ? "Local"
1070 : (runtimeState?.descriptor?.label ?? savedRecord?.label ?? p.environmentId);
1069 const label = resolveEnvironmentOptionLabel({
1070 isPrimary,
1071 environmentId: p.environmentId,
1072 runtimeLabel: runtimeState?.descriptor?.label ?? null,
1073 savedLabel: savedRecord?.label ?? null,
1074 });
10711075 envs.push({
10721076 environmentId: p.environmentId,
10731077 projectId: p.id,
......@@ -2890,12 +2894,12 @@ export default function ChatView(props: ChatViewProps) {
28902894 return () => window.removeEventListener("keydown", onKeyDown);
28912895 }, [closeExpandedImage, expandedImage, navigateExpandedImage]);
28922896
2893 const activeWorktreePath = activeThread?.worktreePath;
2894 const envMode: DraftThreadEnvMode = activeWorktreePath
2895 ? "worktree"
2896 : isLocalDraftThread
2897 ? (draftThread?.envMode ?? "local")
2898 : "local";
2897 const activeWorktreePath = activeThread?.worktreePath ?? null;
2898 const envMode: DraftThreadEnvMode = resolveEffectiveEnvMode({
2899 activeWorktreePath,
2900 hasServerThread: isServerThread,
2901 draftThreadEnvMode: isLocalDraftThread ? draftThread?.envMode : undefined,
2902 });
28992903
29002904 useEffect(() => {
29012905 if (!activeThreadId) {
......@@ -4031,11 +4035,20 @@ export default function ChatView(props: ChatViewProps) {
40314035 const onEnvModeChange = useCallback(
40324036 (mode: DraftThreadEnvMode) => {
40334037 if (isLocalDraftThread) {
4034 setDraftThreadContext(composerDraftTarget, { envMode: mode });
4038 setDraftThreadContext(composerDraftTarget, {
4039 envMode: mode,
4040 ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}),
4041 });
40354042 }
40364043 scheduleComposerFocus();
40374044 },
4038 [composerDraftTarget, isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext],
4045 [
4046 composerDraftTarget,
4047 draftThread?.worktreePath,
4048 isLocalDraftThread,
4049 scheduleComposerFocus,
4050 setDraftThreadContext,
4051 ],
40394052 );
40404053
40414054 const applyPromptReplacement = useCallback(