| author | |
| committer | |
| log | 7b3cdc6a8c69224aa2314e4969579c3e82b6b0ba |
| tree | 910f43d10169c6a825e02519324b61beaafefc1e |
| parent | 386eb18a587b0a791991342b0c6bc49b3e73d3d5 |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 241 insertions(+), 60 deletions(-)
apps/web/src/components/BranchToolbar.logic.test.ts+82-1| ... | @@ -1,14 +1,21 @@ | ... | @@ -1,14 +1,21 @@ |
| 1 | import type { GitBranch } from "@t3tools/contracts"; | 1 | import { EnvironmentId, type GitBranch } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vitest"; | 2 | import { describe, expect, it } from "vitest"; |
| 3 | import { | 3 | import { |
| 4 | dedupeRemoteBranchesWithLocalMatches, | 4 | dedupeRemoteBranchesWithLocalMatches, |
| 5 | deriveLocalBranchNameFromRemoteRef, | 5 | deriveLocalBranchNameFromRemoteRef, |
| 6 | resolveEnvironmentOptionLabel, | ||
| 6 | resolveBranchSelectionTarget, | 7 | resolveBranchSelectionTarget, |
| 8 | resolveCurrentWorkspaceLabel, | ||
| 7 | resolveDraftEnvModeAfterBranchChange, | 9 | resolveDraftEnvModeAfterBranchChange, |
| 10 | resolveEffectiveEnvMode, | ||
| 11 | resolveEnvModeLabel, | ||
| 8 | resolveBranchToolbarValue, | 12 | resolveBranchToolbarValue, |
| 9 | shouldIncludeBranchPickerItem, | 13 | shouldIncludeBranchPickerItem, |
| 10 | } from "./BranchToolbar.logic"; | 14 | } from "./BranchToolbar.logic"; |
| 11 | 15 | ||
| 16 | const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); | ||
| 17 | const remoteEnvironmentId = EnvironmentId.makeUnsafe("environment-remote"); | ||
| 18 | |||
| 12 | describe("resolveDraftEnvModeAfterBranchChange", () => { | 19 | describe("resolveDraftEnvModeAfterBranchChange", () => { |
| 13 | it("switches to local mode when returning from an existing worktree to the main worktree", () => { | 20 | it("switches to local mode when returning from an existing worktree to the main worktree", () => { |
| 14 | expect( | 21 | expect( |
| ... | @@ -76,6 +83,80 @@ describe("resolveBranchToolbarValue", () => { | ... | @@ -76,6 +83,80 @@ describe("resolveBranchToolbarValue", () => { |
| 76 | }); | 83 | }); |
| 77 | }); | 84 | }); |
| 78 | 85 | ||
| 86 | describe("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 | |||
| 121 | describe("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 | |||
| 143 | describe("resolveEnvModeLabel", () => { | ||
| 144 | it("uses explicit workspace labels", () => { | ||
| 145 | expect(resolveEnvModeLabel("local")).toBe("Current checkout"); | ||
| 146 | expect(resolveEnvModeLabel("worktree")).toBe("New worktree"); | ||
| 147 | }); | ||
| 148 | }); | ||
| 149 | |||
| 150 | describe("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 | |||
| 79 | describe("deriveLocalBranchNameFromRemoteRef", () => { | 160 | describe("deriveLocalBranchNameFromRemoteRef", () => { |
| 80 | it("strips the remote prefix from a remote ref", () => { | 161 | it("strips the remote prefix from a remote ref", () => { |
| 81 | expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo"); | 162 | expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo"); |
apps/web/src/components/BranchToolbar.logic.ts+42-3| ... | @@ -15,15 +15,54 @@ export interface EnvironmentOption { | ... | @@ -15,15 +15,54 @@ export interface EnvironmentOption { |
| 15 | export const EnvMode = Schema.Literals(["local", "worktree"]); | 15 | export const EnvMode = Schema.Literals(["local", "worktree"]); |
| 16 | export type EnvMode = typeof EnvMode.Type; | 16 | export type EnvMode = typeof EnvMode.Type; |
| 17 | 17 | ||
| 18 | const GENERIC_LOCAL_ENVIRONMENT_LABELS = new Set(["local", "local environment"]); | ||
| 19 | |||
| 20 | function normalizeDisplayLabel(value: string | null | undefined): string | null { | ||
| 21 | const trimmed = value?.trim(); | ||
| 22 | return trimmed && trimmed.length > 0 ? trimmed : null; | ||
| 23 | } | ||
| 24 | |||
| 25 | export 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 | |||
| 45 | export function resolveEnvModeLabel(mode: EnvMode): string { | ||
| 46 | return mode === "worktree" ? "New worktree" : "Current checkout"; | ||
| 47 | } | ||
| 48 | |||
| 49 | export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): string { | ||
| 50 | return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local"); | ||
| 51 | } | ||
| 52 | |||
| 18 | export function resolveEffectiveEnvMode(input: { | 53 | export function resolveEffectiveEnvMode(input: { |
| 19 | activeWorktreePath: string | null; | 54 | activeWorktreePath: string | null; |
| 20 | hasServerThread: boolean; | 55 | hasServerThread: boolean; |
| 21 | draftThreadEnvMode: EnvMode | undefined; | 56 | draftThreadEnvMode: EnvMode | undefined; |
| 22 | }): EnvMode { | 57 | }): EnvMode { |
| 23 | const { activeWorktreePath, hasServerThread, draftThreadEnvMode } = input; | 58 | const { activeWorktreePath, hasServerThread, draftThreadEnvMode } = input; |
| 24 | return activeWorktreePath || (!hasServerThread && draftThreadEnvMode === "worktree") | 59 | if (!hasServerThread) { |
| 25 | ? "worktree" | 60 | if (activeWorktreePath) { |
| 26 | : "local"; | 61 | return "local"; |
| 62 | } | ||
| 63 | return draftThreadEnvMode === "worktree" ? "worktree" : "local"; | ||
| 64 | } | ||
| 65 | return activeWorktreePath ? "worktree" : "local"; | ||
| 27 | } | 66 | } |
| 28 | 67 | ||
| 29 | export function resolveDraftEnvModeAfterBranchChange(input: { | 68 | export function resolveDraftEnvModeAfterBranchChange(input: { |
apps/web/src/components/BranchToolbar.tsx+2-1| ... | @@ -62,6 +62,7 @@ export function BranchToolbar({ | ... | @@ -62,6 +62,7 @@ export function BranchToolbar({ |
| 62 | hasServerThread: serverThread !== undefined, | 62 | hasServerThread: serverThread !== undefined, |
| 63 | draftThreadEnvMode: draftThread?.envMode, | 63 | draftThreadEnvMode: draftThread?.envMode, |
| 64 | }); | 64 | }); |
| 65 | const envModeLocked = envLocked || (serverThread !== undefined && activeWorktreePath !== null); | ||
| 65 | 66 | ||
| 66 | const showEnvironmentPicker = | 67 | const showEnvironmentPicker = |
| 67 | availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange; | 68 | availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange; |
| ... | @@ -83,7 +84,7 @@ export function BranchToolbar({ | ... | @@ -83,7 +84,7 @@ export function BranchToolbar({ |
| 83 | </> | 84 | </> |
| 84 | )} | 85 | )} |
| 85 | <BranchToolbarEnvModeSelector | 86 | <BranchToolbarEnvModeSelector |
| 86 | envLocked={envLocked} | 87 | envLocked={envModeLocked} |
| 87 | effectiveEnvMode={effectiveEnvMode} | 88 | effectiveEnvMode={effectiveEnvMode} |
| 88 | activeWorktreePath={activeWorktreePath} | 89 | activeWorktreePath={activeWorktreePath} |
| 89 | onEnvModeChange={onEnvModeChange} | 90 | onEnvModeChange={onEnvModeChange} |
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+51-27| ... | @@ -1,13 +1,20 @@ | ... | @@ -1,13 +1,20 @@ |
| 1 | import { FolderIcon, GitForkIcon } from "lucide-react"; | 1 | import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react"; |
| 2 | import { memo } from "react"; | 2 | import { memo, useMemo } from "react"; |
| 3 | 3 | ||
| 4 | import type { EnvMode } from "./BranchToolbar.logic"; | 4 | import { |
| 5 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; | 5 | resolveCurrentWorkspaceLabel, |
| 6 | 6 | resolveEnvModeLabel, | |
| 7 | const envModeItems = [ | 7 | type EnvMode, |
| 8 | { value: "local", label: "Local" }, | 8 | } from "./BranchToolbar.logic"; |
| 9 | { value: "worktree", label: "New worktree" }, | 9 | import { |
| 10 | ] as const; | 10 | Select, |
| 11 | SelectGroup, | ||
| 12 | SelectGroupLabel, | ||
| 13 | SelectItem, | ||
| 14 | SelectPopup, | ||
| 15 | SelectTrigger, | ||
| 16 | SelectValue, | ||
| 17 | } from "./ui/select"; | ||
| 11 | 18 | ||
| 12 | interface BranchToolbarEnvModeSelectorProps { | 19 | interface BranchToolbarEnvModeSelectorProps { |
| 13 | envLocked: boolean; | 20 | envLocked: boolean; |
| ... | @@ -22,18 +29,26 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe | ... | @@ -22,18 +29,26 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 22 | activeWorktreePath, | 29 | activeWorktreePath, |
| 23 | onEnvModeChange, | 30 | onEnvModeChange, |
| 24 | }: BranchToolbarEnvModeSelectorProps) { | 31 | }: 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) { | ||
| 26 | return ( | 41 | return ( |
| 27 | <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"> | 42 | <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"> |
| 28 | {activeWorktreePath ? ( | 43 | {activeWorktreePath ? ( |
| 29 | <> | 44 | <> |
| 30 | <GitForkIcon className="size-3" /> | 45 | <FolderGitIcon className="size-3" /> |
| 31 | Worktree | 46 | {resolveCurrentWorkspaceLabel(activeWorktreePath)} |
| 32 | </> | 47 | </> |
| 33 | ) : ( | 48 | ) : ( |
| 34 | <> | 49 | <> |
| 35 | <FolderIcon className="size-3" /> | 50 | <FolderIcon className="size-3" /> |
| 36 | Local | 51 | {resolveCurrentWorkspaceLabel(activeWorktreePath)} |
| 37 | </> | 52 | </> |
| 38 | )} | 53 | )} |
| 39 | </span> | 54 | </span> |
| ... | @@ -46,27 +61,36 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe | ... | @@ -46,27 +61,36 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 46 | onValueChange={(value) => onEnvModeChange(value as EnvMode)} | 61 | onValueChange={(value) => onEnvModeChange(value as EnvMode)} |
| 47 | items={envModeItems} | 62 | items={envModeItems} |
| 48 | > | 63 | > |
| 49 | <SelectTrigger variant="ghost" size="xs" className="font-medium"> | 64 | <SelectTrigger variant="ghost" size="xs" className="font-medium" aria-label="Workspace"> |
| 50 | {effectiveEnvMode === "worktree" ? ( | 65 | {effectiveEnvMode === "worktree" ? ( |
| 51 | <GitForkIcon className="size-3" /> | 66 | <FolderGit2Icon className="size-3" /> |
| 67 | ) : activeWorktreePath ? ( | ||
| 68 | <FolderGitIcon className="size-3" /> | ||
| 52 | ) : ( | 69 | ) : ( |
| 53 | <FolderIcon className="size-3" /> | 70 | <FolderIcon className="size-3" /> |
| 54 | )} | 71 | )} |
| 55 | <SelectValue /> | 72 | <SelectValue /> |
| 56 | </SelectTrigger> | 73 | </SelectTrigger> |
| 57 | <SelectPopup> | 74 | <SelectPopup> |
| 58 | <SelectItem value="local"> | 75 | <SelectGroup> |
| 59 | <span className="inline-flex items-center gap-1.5"> | 76 | <SelectGroupLabel>Workspace</SelectGroupLabel> |
| 60 | <FolderIcon className="size-3" /> | 77 | <SelectItem value="local"> |
| 61 | Local | 78 | <span className="inline-flex items-center gap-1.5"> |
| 62 | </span> | 79 | {activeWorktreePath ? ( |
| 63 | </SelectItem> | 80 | <FolderGitIcon className="size-3" /> |
| 64 | <SelectItem value="worktree"> | 81 | ) : ( |
| 65 | <span className="inline-flex items-center gap-1.5"> | 82 | <FolderIcon className="size-3" /> |
| 66 | <GitForkIcon className="size-3" /> | 83 | )} |
| 67 | New worktree | 84 | {resolveCurrentWorkspaceLabel(activeWorktreePath)} |
| 68 | </span> | 85 | </span> |
| 69 | </SelectItem> | 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> | ||
| 70 | </SelectPopup> | 94 | </SelectPopup> |
| 71 | </Select> | 95 | </Select> |
| 72 | ); | 96 | ); |
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+39-16| ... | @@ -1,9 +1,17 @@ | ... | @@ -1,9 +1,17 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | 1 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { CloudIcon, FolderIcon, ServerIcon } from "lucide-react"; | 2 | import { CloudIcon, MonitorIcon } from "lucide-react"; |
| 3 | import { memo, useMemo } from "react"; | 3 | import { memo, useMemo } from "react"; |
| 4 | 4 | ||
| 5 | import type { EnvironmentOption } from "./BranchToolbar.logic"; | 5 | import type { EnvironmentOption } from "./BranchToolbar.logic"; |
| 6 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; | 6 | import { |
| 7 | Select, | ||
| 8 | SelectGroup, | ||
| 9 | SelectGroupLabel, | ||
| 10 | SelectItem, | ||
| 11 | SelectPopup, | ||
| 12 | SelectTrigger, | ||
| 13 | SelectValue, | ||
| 14 | } from "./ui/select"; | ||
| 7 | 15 | ||
| 8 | interface BranchToolbarEnvironmentSelectorProps { | 16 | interface BranchToolbarEnvironmentSelectorProps { |
| 9 | envLocked: boolean; | 17 | envLocked: boolean; |
| ... | @@ -18,8 +26,8 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir | ... | @@ -18,8 +26,8 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir |
| 18 | availableEnvironments, | 26 | availableEnvironments, |
| 19 | onEnvironmentChange, | 27 | onEnvironmentChange, |
| 20 | }: BranchToolbarEnvironmentSelectorProps) { | 28 | }: BranchToolbarEnvironmentSelectorProps) { |
| 21 | const activeEnvironmentLabel = useMemo(() => { | 29 | const activeEnvironment = useMemo(() => { |
| 22 | return availableEnvironments.find((env) => env.environmentId === environmentId)?.label ?? null; | 30 | return availableEnvironments.find((env) => env.environmentId === environmentId) ?? null; |
| 23 | }, [availableEnvironments, environmentId]); | 31 | }, [availableEnvironments, environmentId]); |
| 24 | 32 | ||
| 25 | const environmentItems = useMemo( | 33 | const environmentItems = useMemo( |
| ... | @@ -34,8 +42,12 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir | ... | @@ -34,8 +42,12 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir |
| 34 | if (envLocked) { | 42 | if (envLocked) { |
| 35 | return ( | 43 | return ( |
| 36 | <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"> | 44 | <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" /> | 45 | {activeEnvironment?.isPrimary ? ( |
| 38 | {activeEnvironmentLabel ?? "Environment"} | 46 | <MonitorIcon className="size-3" /> |
| 47 | ) : ( | ||
| 48 | <CloudIcon className="size-3" /> | ||
| 49 | )} | ||
| 50 | {activeEnvironment?.label ?? "Run on"} | ||
| 39 | </span> | 51 | </span> |
| 40 | ); | 52 | ); |
| 41 | } | 53 | } |
| ... | @@ -46,19 +58,30 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir | ... | @@ -46,19 +58,30 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir |
| 46 | onValueChange={(value) => onEnvironmentChange(value as EnvironmentId)} | 58 | onValueChange={(value) => onEnvironmentChange(value as EnvironmentId)} |
| 47 | items={environmentItems} | 59 | items={environmentItems} |
| 48 | > | 60 | > |
| 49 | <SelectTrigger variant="ghost" size="xs" className="font-medium"> | 61 | <SelectTrigger variant="ghost" size="xs" className="font-medium" aria-label="Run on"> |
| 50 | <ServerIcon className="size-3" /> | 62 | {activeEnvironment?.isPrimary ? ( |
| 63 | <MonitorIcon className="size-3" /> | ||
| 64 | ) : ( | ||
| 65 | <CloudIcon className="size-3" /> | ||
| 66 | )} | ||
| 51 | <SelectValue /> | 67 | <SelectValue /> |
| 52 | </SelectTrigger> | 68 | </SelectTrigger> |
| 53 | <SelectPopup> | 69 | <SelectPopup> |
| 54 | {availableEnvironments.map((env) => ( | 70 | <SelectGroup> |
| 55 | <SelectItem key={env.environmentId} value={env.environmentId}> | 71 | <SelectGroupLabel>Run on</SelectGroupLabel> |
| 56 | <span className="inline-flex items-center gap-1.5"> | 72 | {availableEnvironments.map((env) => ( |
| 57 | {env.isPrimary ? <FolderIcon className="size-3" /> : <CloudIcon className="size-3" />} | 73 | <SelectItem key={env.environmentId} value={env.environmentId}> |
| 58 | {env.label} | 74 | <span className="inline-flex items-center gap-1.5"> |
| 59 | </span> | 75 | {env.isPrimary ? ( |
| 60 | </SelectItem> | 76 | <MonitorIcon className="size-3" /> |
| 61 | ))} | 77 | ) : ( |
| 78 | <CloudIcon className="size-3" /> | ||
| 79 | )} | ||
| 80 | {env.label} | ||
| 81 | </span> | ||
| 82 | </SelectItem> | ||
| 83 | ))} | ||
| 84 | </SelectGroup> | ||
| 62 | </SelectPopup> | 85 | </SelectPopup> |
| 63 | </Select> | 86 | </Select> |
| 64 | ); | 87 | ); |
apps/web/src/components/ChatView.browser.tsx+1-1| ... | @@ -1993,7 +1993,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1993,7 +1993,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1993 | }); | 1993 | }); |
| 1994 | 1994 | ||
| 1995 | const mounted = await mountChatView({ | 1995 | const mounted = await mountChatView({ |
| 1996 | viewport: DEFAULT_VIEWPORT, | 1996 | viewport: WIDE_FOOTER_VIEWPORT, |
| 1997 | snapshot: withProjectScripts(createDraftOnlySnapshot(), [ | 1997 | snapshot: withProjectScripts(createDraftOnlySnapshot(), [ |
| 1998 | { | 1998 | { |
| 1999 | id: "setup", | 1999 | id: "setup", |
apps/web/src/components/ChatView.tsx+24-11| ... | @@ -187,6 +187,7 @@ import { ComposerPrimaryActions } from "./chat/ComposerPrimaryActions"; | ... | @@ -187,6 +187,7 @@ import { ComposerPrimaryActions } from "./chat/ComposerPrimaryActions"; |
| 187 | import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; | 187 | import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; |
| 188 | import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; | 188 | import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; |
| 189 | import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; | 189 | import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; |
| 190 | import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; | ||
| 190 | import { | 191 | import { |
| 191 | getComposerProviderState, | 192 | getComposerProviderState, |
| 192 | renderProviderTraitsMenuContent, | 193 | renderProviderTraitsMenuContent, |
| ... | @@ -1065,9 +1066,12 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1065,9 +1066,12 @@ export default function ChatView(props: ChatViewProps) { |
| 1065 | const isPrimary = p.environmentId === primaryEnvironmentId; | 1066 | const isPrimary = p.environmentId === primaryEnvironmentId; |
| 1066 | const savedRecord = savedEnvironmentRegistry[p.environmentId]; | 1067 | const savedRecord = savedEnvironmentRegistry[p.environmentId]; |
| 1067 | const runtimeState = savedEnvironmentRuntimeById[p.environmentId]; | 1068 | const runtimeState = savedEnvironmentRuntimeById[p.environmentId]; |
| 1068 | const label = isPrimary | 1069 | const label = resolveEnvironmentOptionLabel({ |
| 1069 | ? "Local" | 1070 | isPrimary, |
| 1070 | : (runtimeState?.descriptor?.label ?? savedRecord?.label ?? p.environmentId); | 1071 | environmentId: p.environmentId, |
| 1072 | runtimeLabel: runtimeState?.descriptor?.label ?? null, | ||
| 1073 | savedLabel: savedRecord?.label ?? null, | ||
| 1074 | }); | ||
| 1071 | envs.push({ | 1075 | envs.push({ |
| 1072 | environmentId: p.environmentId, | 1076 | environmentId: p.environmentId, |
| 1073 | projectId: p.id, | 1077 | projectId: p.id, |
| ... | @@ -2890,12 +2894,12 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2890,12 +2894,12 @@ export default function ChatView(props: ChatViewProps) { |
| 2890 | return () => window.removeEventListener("keydown", onKeyDown); | 2894 | return () => window.removeEventListener("keydown", onKeyDown); |
| 2891 | }, [closeExpandedImage, expandedImage, navigateExpandedImage]); | 2895 | }, [closeExpandedImage, expandedImage, navigateExpandedImage]); |
| 2892 | 2896 | ||
| 2893 | const activeWorktreePath = activeThread?.worktreePath; | 2897 | const activeWorktreePath = activeThread?.worktreePath ?? null; |
| 2894 | const envMode: DraftThreadEnvMode = activeWorktreePath | 2898 | const envMode: DraftThreadEnvMode = resolveEffectiveEnvMode({ |
| 2895 | ? "worktree" | 2899 | activeWorktreePath, |
| 2896 | : isLocalDraftThread | 2900 | hasServerThread: isServerThread, |
| 2897 | ? (draftThread?.envMode ?? "local") | 2901 | draftThreadEnvMode: isLocalDraftThread ? draftThread?.envMode : undefined, |
| 2898 | : "local"; | 2902 | }); |
| 2899 | 2903 | ||
| 2900 | useEffect(() => { | 2904 | useEffect(() => { |
| 2901 | if (!activeThreadId) { | 2905 | if (!activeThreadId) { |
| ... | @@ -4031,11 +4035,20 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -4031,11 +4035,20 @@ export default function ChatView(props: ChatViewProps) { |
| 4031 | const onEnvModeChange = useCallback( | 4035 | const onEnvModeChange = useCallback( |
| 4032 | (mode: DraftThreadEnvMode) => { | 4036 | (mode: DraftThreadEnvMode) => { |
| 4033 | if (isLocalDraftThread) { | 4037 | if (isLocalDraftThread) { |
| 4034 | setDraftThreadContext(composerDraftTarget, { envMode: mode }); | 4038 | setDraftThreadContext(composerDraftTarget, { |
| 4039 | envMode: mode, | ||
| 4040 | ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), | ||
| 4041 | }); | ||
| 4035 | } | 4042 | } |
| 4036 | scheduleComposerFocus(); | 4043 | scheduleComposerFocus(); |
| 4037 | }, | 4044 | }, |
| 4038 | [composerDraftTarget, isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext], | 4045 | [ |
| 4046 | composerDraftTarget, | ||
| 4047 | draftThread?.worktreePath, | ||
| 4048 | isLocalDraftThread, | ||
| 4049 | scheduleComposerFocus, | ||
| 4050 | setDraftThreadContext, | ||
| 4051 | ], | ||
| 4039 | ); | 4052 | ); |
| 4040 | 4053 | ||
| 4041 | const applyPromptReplacement = useCallback( | 4054 | const applyPromptReplacement = useCallback( |