Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 82 additions & 1 deletion apps/web/src/components/BranchToolbar.logic.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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");
Expand Down
45 changes: 42 additions & 3 deletions apps/web/src/components/BranchToolbar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/BranchToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -83,7 +84,7 @@ export function BranchToolbar({
</>
)}
<BranchToolbarEnvModeSelector
envLocked={envLocked}
envLocked={envModeLocked}
effectiveEnvMode={effectiveEnvMode}
activeWorktreePath={activeWorktreePath}
onEnvModeChange={onEnvModeChange}
Expand Down
78 changes: 51 additions & 27 deletions apps/web/src/components/BranchToolbarEnvModeSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { FolderIcon, GitForkIcon } from "lucide-react";
import { memo } from "react";
import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react";
import { memo, useMemo } from "react";

import type { EnvMode } from "./BranchToolbar.logic";
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";

const envModeItems = [
{ value: "local", label: "Local" },
{ value: "worktree", label: "New worktree" },
] as const;
import {
resolveCurrentWorkspaceLabel,
resolveEnvModeLabel,
type EnvMode,
} from "./BranchToolbar.logic";
import {
Select,
SelectGroup,
SelectGroupLabel,
SelectItem,
SelectPopup,
SelectTrigger,
SelectValue,
} from "./ui/select";

interface BranchToolbarEnvModeSelectorProps {
envLocked: boolean;
Expand All @@ -22,18 +29,26 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
activeWorktreePath,
onEnvModeChange,
}: BranchToolbarEnvModeSelectorProps) {
if (envLocked || activeWorktreePath) {
const envModeItems = useMemo(
() => [
{ value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath) },
{ value: "worktree", label: resolveEnvModeLabel("worktree") },
],
[activeWorktreePath],
);

if (envLocked) {
return (
<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">
{activeWorktreePath ? (
<>
<GitForkIcon className="size-3" />
Worktree
<FolderGitIcon className="size-3" />
{resolveCurrentWorkspaceLabel(activeWorktreePath)}
</>
) : (
<>
<FolderIcon className="size-3" />
Local
{resolveCurrentWorkspaceLabel(activeWorktreePath)}
</>
)}
</span>
Expand All @@ -46,27 +61,36 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
onValueChange={(value) => onEnvModeChange(value as EnvMode)}
items={envModeItems}
>
<SelectTrigger variant="ghost" size="xs" className="font-medium">
<SelectTrigger variant="ghost" size="xs" className="font-medium" aria-label="Workspace">
{effectiveEnvMode === "worktree" ? (
<GitForkIcon className="size-3" />
<FolderGit2Icon className="size-3" />
) : activeWorktreePath ? (
<FolderGitIcon className="size-3" />
) : (
<FolderIcon className="size-3" />
)}
<SelectValue />
</SelectTrigger>
<SelectPopup>
<SelectItem value="local">
<span className="inline-flex items-center gap-1.5">
<FolderIcon className="size-3" />
Local
</span>
</SelectItem>
<SelectItem value="worktree">
<span className="inline-flex items-center gap-1.5">
<GitForkIcon className="size-3" />
New worktree
</span>
</SelectItem>
<SelectGroup>
<SelectGroupLabel>Workspace</SelectGroupLabel>
<SelectItem value="local">
<span className="inline-flex items-center gap-1.5">
{activeWorktreePath ? (
<FolderGitIcon className="size-3" />
) : (
<FolderIcon className="size-3" />
)}
{resolveCurrentWorkspaceLabel(activeWorktreePath)}
</span>
</SelectItem>
<SelectItem value="worktree">
<span className="inline-flex items-center gap-1.5">
<FolderGit2Icon className="size-3" />
{resolveEnvModeLabel("worktree")}
</span>
</SelectItem>
</SelectGroup>
</SelectPopup>
</Select>
);
Expand Down
55 changes: 39 additions & 16 deletions apps/web/src/components/BranchToolbarEnvironmentSelector.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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(
Expand All @@ -34,8 +42,12 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
if (envLocked) {
return (
<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">
<ServerIcon className="size-3" />
{activeEnvironmentLabel ?? "Environment"}
{activeEnvironment?.isPrimary ? (
<MonitorIcon className="size-3" />
) : (
<CloudIcon className="size-3" />
)}
{activeEnvironment?.label ?? "Run on"}
</span>
);
}
Expand All @@ -46,19 +58,30 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
onValueChange={(value) => onEnvironmentChange(value as EnvironmentId)}
items={environmentItems}
>
<SelectTrigger variant="ghost" size="xs" className="font-medium">
<ServerIcon className="size-3" />
<SelectTrigger variant="ghost" size="xs" className="font-medium" aria-label="Run on">
{activeEnvironment?.isPrimary ? (
<MonitorIcon className="size-3" />
) : (
<CloudIcon className="size-3" />
)}
<SelectValue />
</SelectTrigger>
<SelectPopup>
{availableEnvironments.map((env) => (
<SelectItem key={env.environmentId} value={env.environmentId}>
<span className="inline-flex items-center gap-1.5">
{env.isPrimary ? <FolderIcon className="size-3" /> : <CloudIcon className="size-3" />}
{env.label}
</span>
</SelectItem>
))}
<SelectGroup>
<SelectGroupLabel>Run on</SelectGroupLabel>
{availableEnvironments.map((env) => (
<SelectItem key={env.environmentId} value={env.environmentId}>
<span className="inline-flex items-center gap-1.5">
{env.isPrimary ? (
<MonitorIcon className="size-3" />
) : (
<CloudIcon className="size-3" />
)}
{env.label}
</span>
</SelectItem>
))}
</SelectGroup>
</SelectPopup>
</Select>
);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading