Skip to content
Open
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
19 changes: 19 additions & 0 deletions apps/web/src/components/BranchToolbar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,19 @@ describe("resolveCurrentWorkspaceLabel", () => {
it("describes the active checkout as a worktree when one is attached", () => {
expect(resolveCurrentWorkspaceLabel("/repo/.t3/worktrees/feature-a")).toBe("Current worktree");
});

it("prefers a custom worktree label when one is set", () => {
expect(resolveCurrentWorkspaceLabel("/repo/.t3/worktrees/feature-a", "Feature A")).toBe(
"Feature A",
);
});

it("ignores a blank custom label and falls back to the default", () => {
expect(resolveCurrentWorkspaceLabel("/repo/.t3/worktrees/feature-a", " ")).toBe(
"Current worktree",
);
expect(resolveCurrentWorkspaceLabel(null, "Feature A")).toBe("Current checkout");
});
});

describe("resolveLockedWorkspaceLabel", () => {
Expand All @@ -166,6 +179,12 @@ describe("resolveLockedWorkspaceLabel", () => {
it("uses a shorter label for an attached worktree", () => {
expect(resolveLockedWorkspaceLabel("/repo/.t3/worktrees/feature-a")).toBe("Worktree");
});

it("prefers a custom worktree label when one is set", () => {
expect(resolveLockedWorkspaceLabel("/repo/.t3/worktrees/feature-a", "Feature A")).toBe(
"Feature A",
);
});
});

describe("deriveLocalBranchNameFromRemoteRef", () => {
Expand Down
20 changes: 16 additions & 4 deletions apps/web/src/components/BranchToolbar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,24 @@ 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 resolveCurrentWorkspaceLabel(
activeWorktreePath: string | null,
worktreeLabel?: string | null,
): string {
if (!activeWorktreePath) {
return resolveEnvModeLabel("local");
}
return normalizeDisplayLabel(worktreeLabel) ?? "Current worktree";
}

export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): string {
return activeWorktreePath ? "Worktree" : "Local checkout";
export function resolveLockedWorkspaceLabel(
activeWorktreePath: string | null,
worktreeLabel?: string | null,
): string {
if (!activeWorktreePath) {
return "Local checkout";
}
return normalizeDisplayLabel(worktreeLabel) ?? "Worktree";
}

export function resolveEffectiveEnvMode(input: {
Expand Down
20 changes: 16 additions & 4 deletions apps/web/src/components/BranchToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import { memo, useMemo } from "react";

import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
import { useIsMobile } from "../hooks/useMediaQuery";
import { useWorktreeRenameTrigger } from "../hooks/useWorktreeRenameTrigger";
import { useStore } from "../store";
import { useWorktreeLabel } from "../uiStateStore";
import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";
import {
type EnvMode,
Expand Down Expand Up @@ -80,17 +82,21 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
() => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null,
[availableEnvironments, environmentId],
);
const worktreeLabel = useWorktreeLabel(activeWorktreePath);
// Double-click or right-click the workspace label to rename the active
// worktree (cosmetic label only). No-op when the thread isn't on a worktree.
const renameTrigger = useWorktreeRenameTrigger(activeWorktreePath);
const WorkspaceIcon =
effectiveEnvMode === "worktree"
? FolderGit2Icon
: activeWorktreePath
? FolderGitIcon
: FolderIcon;
const workspaceLabel = envModeLocked
? resolveLockedWorkspaceLabel(activeWorktreePath)
? resolveLockedWorkspaceLabel(activeWorktreePath, worktreeLabel)
: effectiveEnvMode === "worktree"
? resolveEnvModeLabel("worktree")
: resolveCurrentWorkspaceLabel(activeWorktreePath);
: resolveCurrentWorkspaceLabel(activeWorktreePath, worktreeLabel);
const isLocked = envLocked || envModeLocked;
const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon;
const icon = showEnvironmentPicker ? (
Expand All @@ -114,7 +120,11 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({

if (isLocked) {
return (
<span className="inline-flex min-w-0 max-w-[48%] flex-1 items-center justify-start gap-1 rounded-md border border-transparent px-[calc(--spacing(2)-1px)] text-sm font-medium text-muted-foreground/70 md:hidden">
<span
className="inline-flex min-w-0 max-w-[48%] flex-1 items-center justify-start gap-1 rounded-md border border-transparent px-[calc(--spacing(2)-1px)] text-sm font-medium text-muted-foreground/70 md:hidden"
onDoubleClick={renameTrigger.onDoubleClick}
onContextMenu={renameTrigger.onContextMenu}
>
{triggerContent}
</span>
);
Expand All @@ -125,6 +135,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
<MenuTrigger
render={<Button variant="ghost" size="xs" />}
className="min-w-0 max-w-[48%] flex-1 justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden"
onDoubleClick={renameTrigger.onDoubleClick}
onContextMenu={renameTrigger.onContextMenu}
>
{triggerContent}
<ChevronDownIcon className="size-3 shrink-0 opacity-50" />
Expand Down Expand Up @@ -172,7 +184,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
<FolderIcon className="size-3" />
)}
<span className="min-w-0 truncate">
{resolveCurrentWorkspaceLabel(activeWorktreePath)}
{resolveCurrentWorkspaceLabel(activeWorktreePath, worktreeLabel)}
</span>
</span>
</MenuRadioItem>
Expand Down
33 changes: 26 additions & 7 deletions apps/web/src/components/BranchToolbarEnvModeSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react";
import { memo, useMemo } from "react";

import { useWorktreeRenameTrigger } from "../hooks/useWorktreeRenameTrigger";
import { useWorktreeLabel } from "../uiStateStore";
import {
resolveCurrentWorkspaceLabel,
resolveEnvModeLabel,
Expand Down Expand Up @@ -30,26 +32,35 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
activeWorktreePath,
onEnvModeChange,
}: BranchToolbarEnvModeSelectorProps) {
const worktreeLabel = useWorktreeLabel(activeWorktreePath);
// Double-click or right-click the workspace label to rename the active
// worktree (cosmetic label only). No-op when the thread isn't on a worktree.
const renameTrigger = useWorktreeRenameTrigger(activeWorktreePath);
const envModeItems = useMemo(
() => [
{ value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath) },
{ value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath, worktreeLabel) },
{ value: "worktree", label: resolveEnvModeLabel("worktree") },
],
[activeWorktreePath],
[activeWorktreePath, worktreeLabel],
);

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">
<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"
onDoubleClick={renameTrigger.onDoubleClick}
onContextMenu={renameTrigger.onContextMenu}
title={activeWorktreePath ? "Double-click or right-click to rename worktree" : undefined}
>
{activeWorktreePath ? (
<>
<FolderGitIcon className="size-3" />
{resolveLockedWorkspaceLabel(activeWorktreePath)}
{resolveLockedWorkspaceLabel(activeWorktreePath, worktreeLabel)}
</>
) : (
<>
<FolderIcon className="size-3" />
{resolveLockedWorkspaceLabel(activeWorktreePath)}
{resolveLockedWorkspaceLabel(activeWorktreePath, worktreeLabel)}
</>
)}
</span>
Expand All @@ -63,7 +74,15 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
onValueChange={(value) => onEnvModeChange(value as EnvMode)}
items={envModeItems}
>
<SelectTrigger variant="ghost" size="xs" className="font-medium" aria-label="Workspace">
<SelectTrigger
variant="ghost"
size="xs"
className="font-medium"
aria-label="Workspace"
onDoubleClick={renameTrigger.onDoubleClick}
onContextMenu={renameTrigger.onContextMenu}
title={activeWorktreePath ? "Double-click or right-click to rename worktree" : undefined}
>
{effectiveEnvMode === "worktree" ? (
<FolderGit2Icon className="size-3" />
) : activeWorktreePath ? (
Expand All @@ -83,7 +102,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
) : (
<FolderIcon className="size-3" />
)}
{resolveCurrentWorkspaceLabel(activeWorktreePath)}
{resolveCurrentWorkspaceLabel(activeWorktreePath, worktreeLabel)}
</span>
</SelectItem>
<SelectItem value="worktree">
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import {
import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
import { useThreadRunningTerminalIds } from "../terminalSessionState";
import { useUiStateStore } from "../uiStateStore";
import { useWorktreeRenameStore } from "../worktreeRenameStore";
import {
resolveShortcutCommand,
shortcutLabelForCommand,
Expand Down Expand Up @@ -1929,9 +1930,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
);
const threadWorkspacePath = thread.worktreePath ?? threadProject?.cwd ?? project.cwd ?? null;
const worktreePath = thread.worktreePath ?? null;
const clicked = await api.contextMenu.show(
[
{ id: "rename", label: "Rename thread" },
...(worktreePath ? [{ id: "rename-worktree", label: "Rename worktree" } as const] : []),
{ id: "mark-unread", label: "Mark unread" },
{ id: "copy-path", label: "Copy Path" },
{ id: "copy-thread-id", label: "Copy Thread ID" },
Expand All @@ -1947,6 +1950,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
return;
}

if (clicked === "rename-worktree") {
if (!worktreePath) {
return;
}
useWorktreeRenameStore.getState().openWorktreeRename(worktreePath);
return;
}

if (clicked === "mark-unread") {
markThreadUnread(threadKey, thread.latestTurn?.completedAt);
return;
Expand Down
96 changes: 96 additions & 0 deletions apps/web/src/components/WorktreeRenameDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useEffect, useState } from "react";

import { useUiStateStore } from "../uiStateStore";
import { formatWorktreePathForDisplay } from "../worktreeCleanup";
import { useWorktreeRenameStore } from "../worktreeRenameStore";
import { Button } from "./ui/button";
import {
Dialog,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "./ui/dialog";
import { Input } from "./ui/input";

/**
* Global "Rename worktree" dialog. Mounted once in the app shell and driven by
* useWorktreeRenameStore so any surface (sidebar context menu, bottom-bar
* workspace label) can open it. Writes a cosmetic label keyed by worktree PATH
* — shared by every thread on the same worktree, no disk move.
*/
export function WorktreeRenameDialog() {
const targetPath = useWorktreeRenameStore((state) => state.targetPath);
const closeWorktreeRename = useWorktreeRenameStore((state) => state.closeWorktreeRename);
const setWorktreeLabel = useUiStateStore((state) => state.setWorktreeLabel);
const [title, setTitle] = useState("");

// Seed with the existing custom label only (blank when none) so the
// placeholder can show the default name and a no-op Save keeps it.
useEffect(() => {
if (targetPath) {
setTitle(useUiStateStore.getState().worktreeLabelByPath[targetPath] ?? "");
}
}, [targetPath]);

const submit = () => {
if (!targetPath) {
return;
}
// An empty label clears the custom name, falling back to the path-derived
// default — so we intentionally allow blank input here.
setWorktreeLabel(targetPath, title);
closeWorktreeRename();
};

return (
<Dialog
open={targetPath !== null}
onOpenChange={(open) => {
if (!open) {
closeWorktreeRename();
}
}}
>
<DialogPopup className="max-w-lg">
<DialogHeader>
<DialogTitle>Rename worktree</DialogTitle>
<DialogDescription>
{targetPath
? `Set a display name for ${targetPath}. This is a label only and does not move the worktree on disk.`
: "Set a display name for this worktree."}
</DialogDescription>
</DialogHeader>
<DialogPanel className="space-y-4">
<div className="grid gap-1.5">
<span className="text-xs font-medium text-foreground">Worktree name</span>
<Input
aria-label="Worktree name"
placeholder={targetPath ? formatWorktreePathForDisplay(targetPath) : undefined}
value={title}
onChange={(event) => setTitle(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
submit();
}
}}
/>
<p className="text-xs text-muted-foreground">
Leave blank to reset to the default name. The label is shared by every thread on this
worktree.
</p>
</div>
</DialogPanel>
<DialogFooter>
<Button variant="outline" onClick={closeWorktreeRename}>
Cancel
</Button>
<Button onClick={submit}>Save</Button>
</DialogFooter>
</DialogPopup>
</Dialog>
);
}
12 changes: 10 additions & 2 deletions apps/web/src/hooks/useThreadActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import {
} from "../store";
import { useTerminalUiStateStore } from "../terminalUiStateStore";
import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
import { getOrphanedWorktreePathForThread, worktreeDisplayName } from "../worktreeCleanup";
import { useUiStateStore } from "../uiStateStore";
import { stackedThreadToast, toastManager } from "../components/ui/toast";
import { useSettings } from "./useSettings";

Expand Down Expand Up @@ -138,7 +139,10 @@ export function useThreadActions() {
threadRef.threadId,
);
const displayWorktreePath = orphanedWorktreePath
? formatWorktreePathForDisplay(orphanedWorktreePath)
? worktreeDisplayName(
orphanedWorktreePath,
useUiStateStore.getState().worktreeLabelByPath,
)
: null;
const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined;
const localApi = readLocalApi();
Expand Down Expand Up @@ -227,6 +231,10 @@ export function useThreadActions() {
path: orphanedWorktreePath,
force: true,
});
// Drop any custom display label for the now-deleted worktree so it
// doesn't linger in persisted state or get inherited by a future
// worktree reusing the same path.
useUiStateStore.getState().setWorktreeLabel(orphanedWorktreePath, "");
await invalidateSourceControlState({
environmentId: threadRef.environmentId,
});
Expand Down
Loading
Loading