From 6070a6c875af3254722931e4ee72de4a9157297f Mon Sep 17 00:00:00 2001 From: TheIcarusWings <10465470+TheIcarusWings@users.noreply.github.com> Date: Sat, 13 Jun 2026 14:23:29 +0100 Subject: [PATCH 1/4] feat(web): custom display labels for worktrees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let a worktree carry a readable display name instead of the auto-generated hash folder name (e.g. "t3code-f07cb2c5"). The label is cosmetic only — no `git worktree move`, no disk changes. Keyed by worktree PATH (not per-thread) so threads sharing a worktree always show the same name. Stored in a persisted `worktreeLabelByPath` map in useUiStateStore. A single `worktreeDisplayName(path, labelMap)` resolver is used everywhere a worktree name renders. Rename entry points (all open one shared, globally-mounted dialog): - Sidebar thread context menu → "Rename worktree" (worktree-backed threads). - Bottom-bar workspace label: double-click, or right-click → "Rename worktree". Labels surface in the bottom-bar workspace label and the orphan-worktree delete confirmation. Co-Authored-By: Claude Opus 4.8 Co-authored-by: codex --- .../components/BranchToolbar.logic.test.ts | 19 ++++ .../web/src/components/BranchToolbar.logic.ts | 20 +++- apps/web/src/components/BranchToolbar.tsx | 20 +++- .../BranchToolbarEnvModeSelector.tsx | 33 +++++-- apps/web/src/components/Sidebar.tsx | 11 +++ .../src/components/WorktreeRenameDialog.tsx | 96 +++++++++++++++++++ apps/web/src/hooks/useThreadActions.ts | 8 +- .../web/src/hooks/useWorktreeRenameTrigger.ts | 62 ++++++++++++ apps/web/src/routes/__root.tsx | 2 + apps/web/src/uiStateStore.test.ts | 39 ++++++++ apps/web/src/uiStateStore.ts | 70 +++++++++++++- apps/web/src/worktreeCleanup.test.ts | 26 ++++- apps/web/src/worktreeCleanup.ts | 19 ++++ apps/web/src/worktreeRenameStore.ts | 25 +++++ 14 files changed, 430 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/components/WorktreeRenameDialog.tsx create mode 100644 apps/web/src/hooks/useWorktreeRenameTrigger.ts create mode 100644 apps/web/src/worktreeRenameStore.ts diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 94a3909a961..dc38daa1912 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -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", () => { @@ -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", () => { diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 65388962c08..c888dcfa4c3 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -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: { diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 03f24dac8e9..4b826bdb4ac 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -11,8 +11,10 @@ import { import { memo, useMemo } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; -import { useProject, useThread } from "../state/entities"; import { useIsMobile } from "../hooks/useMediaQuery"; +import { useWorktreeRenameTrigger } from "../hooks/useWorktreeRenameTrigger"; +import { useProject, useThread } from "../state/entities"; +import { useWorktreeLabel } from "../uiStateStore"; import { type EnvMode, type EnvironmentOption, @@ -81,6 +83,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, [availableEnvironments, environmentId], ); + const worktreeLabel = useWorktreeLabel(activeWorktreePath); + const renameTrigger = useWorktreeRenameTrigger(activeWorktreePath); const WorkspaceIcon = effectiveEnvMode === "worktree" ? FolderGit2Icon @@ -88,10 +92,10 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ ? 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 ? ( @@ -115,7 +119,11 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ if (isLocked) { return ( - + {triggerContent} ); @@ -126,6 +134,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ } 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} @@ -173,7 +183,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ )} - {resolveCurrentWorkspaceLabel(activeWorktreePath)} + {resolveCurrentWorkspaceLabel(activeWorktreePath, worktreeLabel)} diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 6d06882662f..cdc0e1981f0 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -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, @@ -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 ( - + {activeWorktreePath ? ( <> - {resolveLockedWorkspaceLabel(activeWorktreePath)} + {resolveLockedWorkspaceLabel(activeWorktreePath, worktreeLabel)} ) : ( <> - {resolveLockedWorkspaceLabel(activeWorktreePath)} + {resolveLockedWorkspaceLabel(activeWorktreePath, worktreeLabel)} )} @@ -63,7 +74,15 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe onValueChange={(value) => onEnvModeChange(value as EnvMode)} items={envModeItems} > - + {effectiveEnvMode === "worktree" ? ( ) : activeWorktreePath ? ( @@ -83,7 +102,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe ) : ( )} - {resolveCurrentWorkspaceLabel(activeWorktreePath)} + {resolveCurrentWorkspaceLabel(activeWorktreePath, worktreeLabel)} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1b46b0f1d04..391063d915e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -89,6 +89,7 @@ import { resolveProjectExpanded, useUiStateStore, } from "../uiStateStore"; +import { useWorktreeRenameStore } from "../worktreeRenameStore"; import { resolveShortcutCommand, shortcutLabelForCommand, @@ -2126,9 +2127,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); const threadWorkspacePath = thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? 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" }, @@ -2142,6 +2145,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; diff --git a/apps/web/src/components/WorktreeRenameDialog.tsx b/apps/web/src/components/WorktreeRenameDialog.tsx new file mode 100644 index 00000000000..c0487852350 --- /dev/null +++ b/apps/web/src/components/WorktreeRenameDialog.tsx @@ -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 ( + { + if (!open) { + closeWorktreeRename(); + } + }} + > + + + Rename worktree + + {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."} + + + +
+ Worktree name + setTitle(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + submit(); + } + }} + /> +

+ Leave blank to reset to the default name. The label is shared by every thread on this + worktree. +

+
+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index f174ed8e6c6..549d064a215 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -22,7 +22,8 @@ import { readLocalApi } from "../localApi"; import { readEnvironmentThreadRefs, readProject, readThreadShell } from "../state/entities"; 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"; import { useAtomCommand } from "../state/use-atom-command"; @@ -179,7 +180,10 @@ export function useThreadActions() { threadRef.threadId, ); const displayWorktreePath = orphanedWorktreePath - ? formatWorktreePathForDisplay(orphanedWorktreePath) + ? worktreeDisplayName( + orphanedWorktreePath, + useUiStateStore.getState().worktreeLabelByPath, + ) : null; const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== null; const localApi = readLocalApi(); diff --git a/apps/web/src/hooks/useWorktreeRenameTrigger.ts b/apps/web/src/hooks/useWorktreeRenameTrigger.ts new file mode 100644 index 00000000000..99a0a997c9c --- /dev/null +++ b/apps/web/src/hooks/useWorktreeRenameTrigger.ts @@ -0,0 +1,62 @@ +import { useCallback, type MouseEvent } from "react"; + +import { readLocalApi } from "../localApi"; +import { useWorktreeRenameStore } from "../worktreeRenameStore"; + +export interface WorktreeRenameTriggerHandlers { + onDoubleClick: (event: MouseEvent) => void; + onContextMenu: (event: MouseEvent) => void; +} + +/** + * Shared interaction handlers for renaming the active worktree from a label + * surface (e.g. the bottom-bar workspace label). Double-click opens the rename + * dialog directly; right-click shows a native "Rename worktree" context menu. + * Both are no-ops when the thread isn't on a worktree, so they're safe to wire + * unconditionally. The rename is a cosmetic label only — no disk move. + */ +export function useWorktreeRenameTrigger( + activeWorktreePath: string | null, +): WorktreeRenameTriggerHandlers { + const openWorktreeRename = useWorktreeRenameStore((state) => state.openWorktreeRename); + + const onDoubleClick = useCallback( + (event: MouseEvent) => { + if (!activeWorktreePath) { + return; + } + event.preventDefault(); + event.stopPropagation(); + openWorktreeRename(activeWorktreePath); + }, + [activeWorktreePath, openWorktreeRename], + ); + + const onContextMenu = useCallback( + (event: MouseEvent) => { + if (!activeWorktreePath) { + return; + } + event.preventDefault(); + event.stopPropagation(); + // Capture coordinates before the await — the event may be reused. + const position = { x: event.clientX, y: event.clientY }; + const api = readLocalApi(); + if (!api) { + // No native context menu available; open the dialog directly. + openWorktreeRename(activeWorktreePath); + return; + } + void api.contextMenu + .show([{ id: "rename-worktree", label: "Rename worktree" }], position) + .then((clicked) => { + if (clicked === "rename-worktree") { + openWorktreeRename(activeWorktreePath); + } + }); + }, + [activeWorktreePath, openWorktreeRename], + ); + + return { onDoubleClick, onContextMenu }; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index d01518a3858..b41ce116629 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -13,6 +13,7 @@ import { useEffect, useEffectEvent, useRef, useState } from "react"; import { APP_DISPLAY_NAME } from "../branding"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; +import { WorktreeRenameDialog } from "../components/WorktreeRenameDialog"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; @@ -108,6 +109,7 @@ function RootRouteView() { + ); diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 0fbbd79ec27..855a0d2a794 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -14,6 +14,7 @@ import { setDefaultAdvertisedEndpointKey, setProjectExpanded, setThreadChangedFilesExpanded, + setWorktreeLabel, type UiState, } from "./uiStateStore"; @@ -24,6 +25,7 @@ function makeUiState(overrides: Partial = {}): UiState { threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, + worktreeLabelByPath: {}, ...overrides, }; } @@ -140,6 +142,30 @@ describe("uiStateStore pure functions", () => { defaultAdvertisedEndpointKey: null, }); }); + + it("stores, trims, and clears labels keyed by worktree path", () => { + const initialState = makeUiState(); + const path = "/repo/.t3/worktrees/feature-a"; + + const labeled = setWorktreeLabel(initialState, path, " Feature A "); + expect(labeled.worktreeLabelByPath).toEqual({ [path]: "Feature A" }); + expect(setWorktreeLabel(labeled, path, "Feature A")).toBe(labeled); + + const cleared = setWorktreeLabel(labeled, path, " "); + expect(cleared.worktreeLabelByPath).toEqual({}); + expect(setWorktreeLabel(initialState, path, "")).toBe(initialState); + }); + + it("keeps sibling worktree labels independent", () => { + const pathA = "/repo/.t3/worktrees/feature-a"; + const pathB = "/repo/.t3/worktrees/feature-b"; + const state = setWorktreeLabel(makeUiState(), pathA, "Alpha"); + + expect(setWorktreeLabel(state, pathB, "Beta").worktreeLabelByPath).toEqual({ + [pathA]: "Alpha", + [pathB]: "Beta", + }); + }); }); describe("parsePersistedState", () => { @@ -161,6 +187,10 @@ describe("parsePersistedState", () => { "turn-2": true, }, }, + worktreeLabelByPath: { + "/repo/.t3/worktrees/feature-a": " Feature A ", + "/repo/.t3/worktrees/blank": " ", + }, }); expect(parsed).toEqual({ @@ -177,6 +207,9 @@ describe("parsePersistedState", () => { "turn-1": false, }, }, + worktreeLabelByPath: { + "/repo/.t3/worktrees/feature-a": "Feature A", + }, }); }); @@ -262,6 +295,9 @@ describe("uiStateStore persistence", () => { }, }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", + worktreeLabelByPath: { + "/repo/.t3/worktrees/feature-a": "Feature A", + }, }); persistState(state); @@ -283,6 +319,9 @@ describe("uiStateStore persistence", () => { "turn-1": false, }, }, + worktreeLabelByPath: { + "/repo/.t3/worktrees/feature-a": "Feature A", + }, }); expect(parsePersistedState(persisted)).toEqual({ ...state, diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 4a97f0542b4..a5176e67bb7 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -25,6 +25,7 @@ export interface PersistedUiState { projectOrderCwds?: string[]; defaultAdvertisedEndpointKey?: string | null; threadChangedFilesExpandedById?: Record>; + worktreeLabelByPath?: Record; } export interface UiProjectState { @@ -41,7 +42,15 @@ export interface UiEndpointState { defaultAdvertisedEndpointKey: string | null; } -export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {} +export interface UiWorktreeState { + worktreeLabelByPath: Record; +} + +export interface UiState + extends UiProjectState, + UiThreadState, + UiEndpointState, + UiWorktreeState {} const initialState: UiState = { projectExpandedById: {}, @@ -49,6 +58,7 @@ const initialState: UiState = { threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, + worktreeLabelByPath: {}, }; const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:"; @@ -132,6 +142,7 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { parsed.defaultAdvertisedEndpointKey.length > 0 ? parsed.defaultAdvertisedEndpointKey : null, + worktreeLabelByPath: sanitizePersistedWorktreeLabels(parsed.worktreeLabelByPath), }; } @@ -157,6 +168,25 @@ function readPersistedState(): UiState { } } +function sanitizePersistedWorktreeLabels( + value: PersistedUiState["worktreeLabelByPath"], +): Record { + if (!value || typeof value !== "object") { + return {}; + } + const labels: Record = {}; + for (const [path, label] of Object.entries(value)) { + if (!path || typeof label !== "string") { + continue; + } + const trimmedLabel = label.trim(); + if (trimmedLabel.length > 0) { + labels[path] = trimmedLabel; + } + } + return labels; +} + function sanitizePersistedThreadChangedFilesExpanded( value: PersistedUiState["threadChangedFilesExpandedById"], ): Record> { @@ -211,6 +241,7 @@ export function persistState(state: UiState): void { threadLastVisitedAtById: state.threadLastVisitedAtById, defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, threadChangedFilesExpandedById, + worktreeLabelByPath: state.worktreeLabelByPath, } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { @@ -334,6 +365,34 @@ export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | nu }; } +export function setWorktreeLabel(state: UiState, worktreePath: string, label: string): UiState { + if (!worktreePath.trim()) { + return state; + } + const trimmedLabel = label.trim(); + const currentLabel = state.worktreeLabelByPath[worktreePath]; + + if (trimmedLabel.length === 0) { + if (currentLabel === undefined) { + return state; + } + const worktreeLabelByPath = { ...state.worktreeLabelByPath }; + delete worktreeLabelByPath[worktreePath]; + return { ...state, worktreeLabelByPath }; + } + + if (currentLabel === trimmedLabel) { + return state; + } + return { + ...state, + worktreeLabelByPath: { + ...state.worktreeLabelByPath, + [worktreePath]: trimmedLabel, + }, + }; +} + export function resolveProjectExpanded( projectExpandedById: Readonly>, preferenceKeys: readonly string[], @@ -416,6 +475,7 @@ interface UiStateStore extends UiState { markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; + setWorktreeLabel: (worktreePath: string, label: string) => void; setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; reorderProjects: ( currentProjectOrder: readonly string[], @@ -434,6 +494,8 @@ export const useUiStateStore = create((set) => ({ set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), setDefaultAdvertisedEndpointKey: (key) => set((state) => setDefaultAdvertisedEndpointKey(state, key)), + setWorktreeLabel: (worktreePath, label) => + set((state) => setWorktreeLabel(state, worktreePath, label)), setProjectExpanded: (projectIds, expanded) => set((state) => setProjectExpanded(state, projectIds, expanded)), reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) => @@ -442,6 +504,12 @@ export const useUiStateStore = create((set) => ({ ), })); +export function useWorktreeLabel(worktreePath: string | null | undefined): string | null { + return useUiStateStore((state) => + worktreePath ? (state.worktreeLabelByPath[worktreePath] ?? null) : null, + ); +} + useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); if (typeof window !== "undefined" && typeof window.addEventListener === "function") { diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 13ff2f0f73e..ffc45a3c3d4 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -2,7 +2,11 @@ import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; -import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup"; +import { + formatWorktreePathForDisplay, + getOrphanedWorktreePathForThread, + worktreeDisplayName, +} from "./worktreeCleanup"; const localEnvironmentId = EnvironmentId.make("environment-local"); @@ -108,3 +112,23 @@ describe("formatWorktreePathForDisplay", () => { expect(result).toBe("my-worktree"); }); }); + +describe("worktreeDisplayName", () => { + const path = "/Users/julius/.t3/worktrees/t3code-mvp/t3code-4e609bb8"; + + it("returns the custom label when one is set for the path", () => { + expect(worktreeDisplayName(path, { [path]: "Checkout UI" })).toBe("Checkout UI"); + }); + + it("falls back to the path-derived name when no label is set", () => { + expect(worktreeDisplayName(path, {})).toBe("t3code-4e609bb8"); + }); + + it("falls back to the path-derived name when the label is blank", () => { + expect(worktreeDisplayName(path, { [path]: " " })).toBe("t3code-4e609bb8"); + }); + + it("trims surrounding whitespace from the custom label", () => { + expect(worktreeDisplayName(path, { [path]: " Checkout UI " })).toBe("Checkout UI"); + }); +}); diff --git a/apps/web/src/worktreeCleanup.ts b/apps/web/src/worktreeCleanup.ts index 109f71ccd9a..25267a0c4fa 100644 --- a/apps/web/src/worktreeCleanup.ts +++ b/apps/web/src/worktreeCleanup.ts @@ -43,3 +43,22 @@ export function formatWorktreePathForDisplay(worktreePath: string): string { const lastPart = parts[parts.length - 1]?.trim() ?? ""; return lastPart.length > 0 ? lastPart : trimmed; } + +/** + * Resolve the name to show for a worktree: the user-assigned label if one exists + * for this path, otherwise the path-derived display name. Labels are keyed by + * worktree PATH (not thread) so threads sharing a worktree show the same name. + * + * Use this everywhere a worktree name renders so the UI stays consistent. + */ +export function worktreeDisplayName( + worktreePath: string, + labelByPath: Readonly>, +): string { + const label = labelByPath[worktreePath.trim()] ?? labelByPath[worktreePath]; + const trimmedLabel = label?.trim(); + if (trimmedLabel && trimmedLabel.length > 0) { + return trimmedLabel; + } + return formatWorktreePathForDisplay(worktreePath); +} diff --git a/apps/web/src/worktreeRenameStore.ts b/apps/web/src/worktreeRenameStore.ts new file mode 100644 index 00000000000..39df9b346e3 --- /dev/null +++ b/apps/web/src/worktreeRenameStore.ts @@ -0,0 +1,25 @@ +import { create } from "zustand"; + +/** + * Ephemeral UI state for the "Rename worktree" dialog. The dialog is mounted + * once globally (see WorktreeRenameDialog) so any surface — the sidebar thread + * context menu, the bottom-bar workspace label — can open it for a given + * worktree path. Not persisted; the labels themselves live in useUiStateStore. + */ +interface WorktreeRenameStore { + /** Worktree path currently being renamed, or null when the dialog is closed. */ + targetPath: string | null; + openWorktreeRename: (worktreePath: string) => void; + closeWorktreeRename: () => void; +} + +export const useWorktreeRenameStore = create((set) => ({ + targetPath: null, + openWorktreeRename: (worktreePath) => { + const trimmed = worktreePath.trim(); + if (trimmed.length > 0) { + set({ targetPath: trimmed }); + } + }, + closeWorktreeRename: () => set({ targetPath: null }), +})); From 7bc93c2a43691d85777264ecc720d41eccd2f5d9 Mon Sep 17 00:00:00 2001 From: TheIcarusWings <10465470+TheIcarusWings@users.noreply.github.com> Date: Sun, 14 Jun 2026 11:25:01 +0100 Subject: [PATCH 2/4] fix(web): worktree label cleanup + consistent keying Address self-review findings on the worktree display-label feature: - Clear a worktree's custom label when the worktree is deleted (orphan-delete in useThreadActions), so it can't linger in persisted state or be inherited by a future worktree reusing the same path. - Key labels by the verbatim worktree path everywhere. Previously writers trimmed the path key while useWorktreeLabel read it raw, so a path with surrounding whitespace would store and read under different keys. - Remove unused useWorktreeDisplayName hook (dead code). Co-Authored-By: Claude Opus 4.8 Co-authored-by: codex --- apps/web/src/hooks/useThreadActions.ts | 3 +++ apps/web/src/uiStateStore.ts | 1 + apps/web/src/worktreeCleanup.ts | 3 +-- apps/web/src/worktreeRenameStore.ts | 7 ++++--- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 549d064a215..699644cb6fc 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -291,6 +291,9 @@ export function useThreadActions() { force: true, }, }); + if (removeResult._tag === "Success") { + useUiStateStore.getState().setWorktreeLabel(orphanedWorktreePath, ""); + } const refreshResult = removeResult._tag === "Success" ? await refreshVcsStatus({ diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index a5176e67bb7..9daf6ededf7 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -366,6 +366,7 @@ export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | nu } export function setWorktreeLabel(state: UiState, worktreePath: string, label: string): UiState { + // Paths are exact identifiers, so writers and readers use the verbatim value. if (!worktreePath.trim()) { return state; } diff --git a/apps/web/src/worktreeCleanup.ts b/apps/web/src/worktreeCleanup.ts index 25267a0c4fa..77d1c100e44 100644 --- a/apps/web/src/worktreeCleanup.ts +++ b/apps/web/src/worktreeCleanup.ts @@ -55,8 +55,7 @@ export function worktreeDisplayName( worktreePath: string, labelByPath: Readonly>, ): string { - const label = labelByPath[worktreePath.trim()] ?? labelByPath[worktreePath]; - const trimmedLabel = label?.trim(); + const trimmedLabel = labelByPath[worktreePath]?.trim(); if (trimmedLabel && trimmedLabel.length > 0) { return trimmedLabel; } diff --git a/apps/web/src/worktreeRenameStore.ts b/apps/web/src/worktreeRenameStore.ts index 39df9b346e3..9f6f0489ec5 100644 --- a/apps/web/src/worktreeRenameStore.ts +++ b/apps/web/src/worktreeRenameStore.ts @@ -16,9 +16,10 @@ interface WorktreeRenameStore { export const useWorktreeRenameStore = create((set) => ({ targetPath: null, openWorktreeRename: (worktreePath) => { - const trimmed = worktreePath.trim(); - if (trimmed.length > 0) { - set({ targetPath: trimmed }); + // Store the path verbatim — it's the exact key labels are written/read + // under (see setWorktreeLabel). Ignore a blank path. + if (worktreePath.trim().length > 0) { + set({ targetPath: worktreePath }); } }, closeWorktreeRename: () => set({ targetPath: null }), From 29febae04b464cacfcf7cb023f922ca8262e1005 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 18 Jun 2026 23:00:11 -0700 Subject: [PATCH 3/4] fix(web): show worktree path before deletion Co-authored-by: codex --- apps/web/src/hooks/useThreadActions.ts | 21 ++++++++++----------- apps/web/src/uiStateStore.ts | 6 +----- apps/web/src/worktreeCleanup.test.ts | 17 +++++++++++++++++ apps/web/src/worktreeCleanup.ts | 13 +++++++++++++ 4 files changed, 41 insertions(+), 16 deletions(-) diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 699644cb6fc..ed606be7a19 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -22,7 +22,11 @@ import { readLocalApi } from "../localApi"; import { readEnvironmentThreadRefs, readProject, readThreadShell } from "../state/entities"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; -import { getOrphanedWorktreePathForThread, worktreeDisplayName } from "../worktreeCleanup"; +import { + formatWorktreeDeleteConfirmation, + getOrphanedWorktreePathForThread, + worktreeDisplayName, +} from "../worktreeCleanup"; import { useUiStateStore } from "../uiStateStore"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useSettings } from "./useSettings"; @@ -180,10 +184,7 @@ export function useThreadActions() { threadRef.threadId, ); const displayWorktreePath = orphanedWorktreePath - ? worktreeDisplayName( - orphanedWorktreePath, - useUiStateStore.getState().worktreeLabelByPath, - ) + ? worktreeDisplayName(orphanedWorktreePath, useUiStateStore.getState().worktreeLabelByPath) : null; const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== null; const localApi = readLocalApi(); @@ -191,12 +192,10 @@ export function useThreadActions() { if (canDeleteWorktree && localApi) { const confirmationResult = await settlePromise(() => localApi.dialogs.confirm( - [ - "This thread is the only one linked to this worktree:", - displayWorktreePath ?? orphanedWorktreePath, - "", - "Delete the worktree too?", - ].join("\n"), + formatWorktreeDeleteConfirmation( + orphanedWorktreePath, + useUiStateStore.getState().worktreeLabelByPath, + ), ), ); if (confirmationResult._tag === "Failure") { diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 9daf6ededf7..3a3466fb315 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -46,11 +46,7 @@ export interface UiWorktreeState { worktreeLabelByPath: Record; } -export interface UiState - extends UiProjectState, - UiThreadState, - UiEndpointState, - UiWorktreeState {} +export interface UiState extends UiProjectState, UiThreadState, UiEndpointState, UiWorktreeState {} const initialState: UiState = { projectExpandedById: {}, diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index ffc45a3c3d4..2833da5979c 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; import { + formatWorktreeDeleteConfirmation, formatWorktreePathForDisplay, getOrphanedWorktreePathForThread, worktreeDisplayName, @@ -132,3 +133,19 @@ describe("worktreeDisplayName", () => { expect(worktreeDisplayName(path, { [path]: " Checkout UI " })).toBe("Checkout UI"); }); }); + +describe("formatWorktreeDeleteConfirmation", () => { + it("shows both a custom label and the full path for the destructive target", () => { + const path = "/Users/julius/.t3/worktrees/t3code-mvp/t3code-4e609bb8"; + + expect(formatWorktreeDeleteConfirmation(path, { [path]: "Checkout UI" })).toBe( + [ + "This thread is the only one linked to this worktree:", + "Name: Checkout UI", + `Path: ${path}`, + "", + "Delete the worktree too?", + ].join("\n"), + ); + }); +}); diff --git a/apps/web/src/worktreeCleanup.ts b/apps/web/src/worktreeCleanup.ts index 77d1c100e44..ffbccd59f6c 100644 --- a/apps/web/src/worktreeCleanup.ts +++ b/apps/web/src/worktreeCleanup.ts @@ -61,3 +61,16 @@ export function worktreeDisplayName( } return formatWorktreePathForDisplay(worktreePath); } + +export function formatWorktreeDeleteConfirmation( + worktreePath: string, + labelByPath: Readonly>, +): string { + return [ + "This thread is the only one linked to this worktree:", + `Name: ${worktreeDisplayName(worktreePath, labelByPath)}`, + `Path: ${worktreePath}`, + "", + "Delete the worktree too?", + ].join("\n"); +} From 64f86a90536ea20616f0f71b899d0b65274a41b5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 11:53:28 -0700 Subject: [PATCH 4/4] fix(web): scope worktree labels by environment Prevent label collisions across environments that reuse the same worktree path, and fall back to the rename dialog when the native context-menu bridge fails. Co-authored-by: codex --- apps/web/src/components/BranchToolbar.tsx | 5 +- .../BranchToolbarEnvModeSelector.tsx | 7 +- apps/web/src/components/Sidebar.tsx | 2 +- .../src/components/WorktreeRenameDialog.tsx | 32 ++++--- apps/web/src/hooks/useThreadActions.ts | 20 ++-- .../hooks/useWorktreeRenameTrigger.test.ts | 38 ++++++++ .../web/src/hooks/useWorktreeRenameTrigger.ts | 36 ++++--- apps/web/src/uiStateStore.test.ts | 75 ++++++++++----- apps/web/src/uiStateStore.ts | 96 +++++++++++++------ apps/web/src/worktreeCleanup.test.ts | 10 +- apps/web/src/worktreeCleanup.ts | 12 +-- apps/web/src/worktreeRenameStore.ts | 19 ++-- 12 files changed, 243 insertions(+), 109 deletions(-) create mode 100644 apps/web/src/hooks/useWorktreeRenameTrigger.test.ts diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 4b826bdb4ac..8c77004646a 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -83,8 +83,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, [availableEnvironments, environmentId], ); - const worktreeLabel = useWorktreeLabel(activeWorktreePath); - const renameTrigger = useWorktreeRenameTrigger(activeWorktreePath); + const worktreeLabel = useWorktreeLabel(environmentId, activeWorktreePath); + const renameTrigger = useWorktreeRenameTrigger(environmentId, activeWorktreePath); const WorkspaceIcon = effectiveEnvMode === "worktree" ? FolderGit2Icon @@ -277,6 +277,7 @@ export const BranchToolbar = memo(function BranchToolbar({ )} void; @@ -28,14 +30,15 @@ interface BranchToolbarEnvModeSelectorProps { export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ envLocked, + environmentId, effectiveEnvMode, activeWorktreePath, onEnvModeChange, }: BranchToolbarEnvModeSelectorProps) { - const worktreeLabel = useWorktreeLabel(activeWorktreePath); + const worktreeLabel = useWorktreeLabel(environmentId, 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 renameTrigger = useWorktreeRenameTrigger(environmentId, activeWorktreePath); const envModeItems = useMemo( () => [ { value: "local", label: resolveCurrentWorkspaceLabel(activeWorktreePath, worktreeLabel) }, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 391063d915e..250e3bc124b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2149,7 +2149,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (!worktreePath) { return; } - useWorktreeRenameStore.getState().openWorktreeRename(worktreePath); + useWorktreeRenameStore.getState().openWorktreeRename(thread.environmentId, worktreePath); return; } diff --git a/apps/web/src/components/WorktreeRenameDialog.tsx b/apps/web/src/components/WorktreeRenameDialog.tsx index c0487852350..5cc5db294de 100644 --- a/apps/web/src/components/WorktreeRenameDialog.tsx +++ b/apps/web/src/components/WorktreeRenameDialog.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { useUiStateStore } from "../uiStateStore"; +import { resolveWorktreeLabel, useUiStateStore } from "../uiStateStore"; import { formatWorktreePathForDisplay } from "../worktreeCleanup"; import { useWorktreeRenameStore } from "../worktreeRenameStore"; import { Button } from "./ui/button"; @@ -18,11 +18,11 @@ 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. + * workspace label) can open it. Writes a cosmetic label keyed by environment + * and worktree path — shared by every thread on that worktree, no disk move. */ export function WorktreeRenameDialog() { - const targetPath = useWorktreeRenameStore((state) => state.targetPath); + const target = useWorktreeRenameStore((state) => state.target); const closeWorktreeRename = useWorktreeRenameStore((state) => state.closeWorktreeRename); const setWorktreeLabel = useUiStateStore((state) => state.setWorktreeLabel); const [title, setTitle] = useState(""); @@ -30,24 +30,30 @@ export function WorktreeRenameDialog() { // 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] ?? ""); + if (target) { + setTitle( + resolveWorktreeLabel( + useUiStateStore.getState(), + target.environmentId, + target.worktreePath, + ) ?? "", + ); } - }, [targetPath]); + }, [target]); const submit = () => { - if (!targetPath) { + if (!target) { 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); + setWorktreeLabel(target.environmentId, target.worktreePath, title); closeWorktreeRename(); }; return ( { if (!open) { closeWorktreeRename(); @@ -58,8 +64,8 @@ export function WorktreeRenameDialog() { Rename worktree - {targetPath - ? `Set a display name for ${targetPath}. This is a label only and does not move the worktree on disk.` + {target + ? `Set a display name for ${target.worktreePath}. This is a label only and does not move the worktree on disk.` : "Set a display name for this worktree."} @@ -68,7 +74,7 @@ export function WorktreeRenameDialog() { Worktree name setTitle(event.target.value)} onKeyDown={(event) => { diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index ed606be7a19..f939b004278 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -27,7 +27,7 @@ import { getOrphanedWorktreePathForThread, worktreeDisplayName, } from "../worktreeCleanup"; -import { useUiStateStore } from "../uiStateStore"; +import { resolveWorktreeLabel, useUiStateStore } from "../uiStateStore"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useSettings } from "./useSettings"; import { useAtomCommand } from "../state/use-atom-command"; @@ -183,8 +183,15 @@ export function useThreadActions() { survivingThreads, threadRef.threadId, ); + const worktreeLabel = orphanedWorktreePath + ? resolveWorktreeLabel( + useUiStateStore.getState(), + threadRef.environmentId, + orphanedWorktreePath, + ) + : null; const displayWorktreePath = orphanedWorktreePath - ? worktreeDisplayName(orphanedWorktreePath, useUiStateStore.getState().worktreeLabelByPath) + ? worktreeDisplayName(orphanedWorktreePath, worktreeLabel) : null; const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== null; const localApi = readLocalApi(); @@ -192,10 +199,7 @@ export function useThreadActions() { if (canDeleteWorktree && localApi) { const confirmationResult = await settlePromise(() => localApi.dialogs.confirm( - formatWorktreeDeleteConfirmation( - orphanedWorktreePath, - useUiStateStore.getState().worktreeLabelByPath, - ), + formatWorktreeDeleteConfirmation(orphanedWorktreePath, worktreeLabel), ), ); if (confirmationResult._tag === "Failure") { @@ -291,7 +295,9 @@ export function useThreadActions() { }, }); if (removeResult._tag === "Success") { - useUiStateStore.getState().setWorktreeLabel(orphanedWorktreePath, ""); + useUiStateStore + .getState() + .setWorktreeLabel(threadRef.environmentId, orphanedWorktreePath, ""); } const refreshResult = removeResult._tag === "Success" diff --git a/apps/web/src/hooks/useWorktreeRenameTrigger.test.ts b/apps/web/src/hooks/useWorktreeRenameTrigger.test.ts new file mode 100644 index 00000000000..df373792426 --- /dev/null +++ b/apps/web/src/hooks/useWorktreeRenameTrigger.test.ts @@ -0,0 +1,38 @@ +import type { LocalApi } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { shouldOpenWorktreeRenameFromContextMenu } from "./useWorktreeRenameTrigger"; + +function makeContextMenu(result: Promise<"rename-worktree" | null>): LocalApi["contextMenu"] { + return { + show: vi.fn(() => result), + } as LocalApi["contextMenu"]; +} + +describe("shouldOpenWorktreeRenameFromContextMenu", () => { + const position = { x: 10, y: 20 }; + + it("opens when the rename item is selected", async () => { + await expect( + shouldOpenWorktreeRenameFromContextMenu( + makeContextMenu(Promise.resolve("rename-worktree")), + position, + ), + ).resolves.toBe(true); + }); + + it("does not open when the menu is dismissed", async () => { + await expect( + shouldOpenWorktreeRenameFromContextMenu(makeContextMenu(Promise.resolve(null)), position), + ).resolves.toBe(false); + }); + + it("falls back to the dialog when the context-menu bridge rejects", async () => { + await expect( + shouldOpenWorktreeRenameFromContextMenu( + makeContextMenu(Promise.reject(new Error("IPC unavailable"))), + position, + ), + ).resolves.toBe(true); + }); +}); diff --git a/apps/web/src/hooks/useWorktreeRenameTrigger.ts b/apps/web/src/hooks/useWorktreeRenameTrigger.ts index 99a0a997c9c..8e1db5863de 100644 --- a/apps/web/src/hooks/useWorktreeRenameTrigger.ts +++ b/apps/web/src/hooks/useWorktreeRenameTrigger.ts @@ -1,3 +1,4 @@ +import type { EnvironmentId, LocalApi } from "@t3tools/contracts"; import { useCallback, type MouseEvent } from "react"; import { readLocalApi } from "../localApi"; @@ -8,6 +9,20 @@ export interface WorktreeRenameTriggerHandlers { onContextMenu: (event: MouseEvent) => void; } +export async function shouldOpenWorktreeRenameFromContextMenu( + contextMenu: LocalApi["contextMenu"], + position: { x: number; y: number }, +): Promise { + try { + return ( + (await contextMenu.show([{ id: "rename-worktree", label: "Rename worktree" }], position)) === + "rename-worktree" + ); + } catch { + return true; + } +} + /** * Shared interaction handlers for renaming the active worktree from a label * surface (e.g. the bottom-bar workspace label). Double-click opens the rename @@ -16,6 +31,7 @@ export interface WorktreeRenameTriggerHandlers { * unconditionally. The rename is a cosmetic label only — no disk move. */ export function useWorktreeRenameTrigger( + environmentId: EnvironmentId, activeWorktreePath: string | null, ): WorktreeRenameTriggerHandlers { const openWorktreeRename = useWorktreeRenameStore((state) => state.openWorktreeRename); @@ -27,9 +43,9 @@ export function useWorktreeRenameTrigger( } event.preventDefault(); event.stopPropagation(); - openWorktreeRename(activeWorktreePath); + openWorktreeRename(environmentId, activeWorktreePath); }, - [activeWorktreePath, openWorktreeRename], + [activeWorktreePath, environmentId, openWorktreeRename], ); const onContextMenu = useCallback( @@ -44,18 +60,16 @@ export function useWorktreeRenameTrigger( const api = readLocalApi(); if (!api) { // No native context menu available; open the dialog directly. - openWorktreeRename(activeWorktreePath); + openWorktreeRename(environmentId, activeWorktreePath); return; } - void api.contextMenu - .show([{ id: "rename-worktree", label: "Rename worktree" }], position) - .then((clicked) => { - if (clicked === "rename-worktree") { - openWorktreeRename(activeWorktreePath); - } - }); + void shouldOpenWorktreeRenameFromContextMenu(api.contextMenu, position).then((shouldOpen) => { + if (shouldOpen) { + openWorktreeRename(environmentId, activeWorktreePath); + } + }); }, - [activeWorktreePath, openWorktreeRename], + [activeWorktreePath, environmentId, openWorktreeRename], ); return { onDoubleClick, onContextMenu }; diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 855a0d2a794..f41f7c60703 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -1,4 +1,4 @@ -import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { @@ -11,6 +11,7 @@ import { persistState, reorderProjects, resolveProjectExpanded, + resolveWorktreeLabel, setDefaultAdvertisedEndpointKey, setProjectExpanded, setThreadChangedFilesExpanded, @@ -25,12 +26,15 @@ function makeUiState(overrides: Partial = {}): UiState { threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, - worktreeLabelByPath: {}, + worktreeLabelByEnvironment: {}, ...overrides, }; } describe("uiStateStore pure functions", () => { + const localEnvironmentId = EnvironmentId.make("environment-local"); + const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + it("stores server timestamps without moving visit state backwards", () => { const threadId = ThreadId.make("thread-1"); const initialState = makeUiState(); @@ -143,29 +147,48 @@ describe("uiStateStore pure functions", () => { }); }); - it("stores, trims, and clears labels keyed by worktree path", () => { + it("stores, trims, and clears labels keyed by environment and worktree path", () => { const initialState = makeUiState(); const path = "/repo/.t3/worktrees/feature-a"; - const labeled = setWorktreeLabel(initialState, path, " Feature A "); - expect(labeled.worktreeLabelByPath).toEqual({ [path]: "Feature A" }); - expect(setWorktreeLabel(labeled, path, "Feature A")).toBe(labeled); + const labeled = setWorktreeLabel(initialState, localEnvironmentId, path, " Feature A "); + expect(labeled.worktreeLabelByEnvironment).toEqual({ + [localEnvironmentId]: { [path]: "Feature A" }, + }); + expect(setWorktreeLabel(labeled, localEnvironmentId, path, "Feature A")).toBe(labeled); - const cleared = setWorktreeLabel(labeled, path, " "); - expect(cleared.worktreeLabelByPath).toEqual({}); - expect(setWorktreeLabel(initialState, path, "")).toBe(initialState); + const cleared = setWorktreeLabel(labeled, localEnvironmentId, path, " "); + expect(cleared.worktreeLabelByEnvironment).toEqual({}); + expect(setWorktreeLabel(initialState, localEnvironmentId, path, "")).toBe(initialState); }); it("keeps sibling worktree labels independent", () => { const pathA = "/repo/.t3/worktrees/feature-a"; const pathB = "/repo/.t3/worktrees/feature-b"; - const state = setWorktreeLabel(makeUiState(), pathA, "Alpha"); + const state = setWorktreeLabel(makeUiState(), localEnvironmentId, pathA, "Alpha"); - expect(setWorktreeLabel(state, pathB, "Beta").worktreeLabelByPath).toEqual({ - [pathA]: "Alpha", - [pathB]: "Beta", + expect( + setWorktreeLabel(state, localEnvironmentId, pathB, "Beta").worktreeLabelByEnvironment, + ).toEqual({ + [localEnvironmentId]: { + [pathA]: "Alpha", + [pathB]: "Beta", + }, }); }); + + it("keeps identical worktree paths independent across environments", () => { + const path = "/repo/.t3/worktrees/feature-a"; + const localState = setWorktreeLabel(makeUiState(), localEnvironmentId, path, "Local"); + const state = setWorktreeLabel(localState, remoteEnvironmentId, path, "Remote"); + + expect(resolveWorktreeLabel(state, localEnvironmentId, path)).toBe("Local"); + expect(resolveWorktreeLabel(state, remoteEnvironmentId, path)).toBe("Remote"); + + const clearedLocal = setWorktreeLabel(state, localEnvironmentId, path, ""); + expect(resolveWorktreeLabel(clearedLocal, localEnvironmentId, path)).toBeNull(); + expect(resolveWorktreeLabel(clearedLocal, remoteEnvironmentId, path)).toBe("Remote"); + }); }); describe("parsePersistedState", () => { @@ -187,9 +210,11 @@ describe("parsePersistedState", () => { "turn-2": true, }, }, - worktreeLabelByPath: { - "/repo/.t3/worktrees/feature-a": " Feature A ", - "/repo/.t3/worktrees/blank": " ", + worktreeLabelByEnvironment: { + "environment-local": { + "/repo/.t3/worktrees/feature-a": " Feature A ", + "/repo/.t3/worktrees/blank": " ", + }, }, }); @@ -207,8 +232,10 @@ describe("parsePersistedState", () => { "turn-1": false, }, }, - worktreeLabelByPath: { - "/repo/.t3/worktrees/feature-a": "Feature A", + worktreeLabelByEnvironment: { + "environment-local": { + "/repo/.t3/worktrees/feature-a": "Feature A", + }, }, }); }); @@ -295,8 +322,10 @@ describe("uiStateStore persistence", () => { }, }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", - worktreeLabelByPath: { - "/repo/.t3/worktrees/feature-a": "Feature A", + worktreeLabelByEnvironment: { + "environment-local": { + "/repo/.t3/worktrees/feature-a": "Feature A", + }, }, }); @@ -319,8 +348,10 @@ describe("uiStateStore persistence", () => { "turn-1": false, }, }, - worktreeLabelByPath: { - "/repo/.t3/worktrees/feature-a": "Feature A", + worktreeLabelByEnvironment: { + "environment-local": { + "/repo/.t3/worktrees/feature-a": "Feature A", + }, }, }); expect(parsePersistedState(persisted)).toEqual({ diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 3a3466fb315..7d1e0403d24 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -1,4 +1,5 @@ import { Debouncer } from "@tanstack/react-pacer"; +import type { EnvironmentId } from "@t3tools/contracts"; import { create } from "zustand"; import { normalizeProjectPathForComparison } from "./lib/projectPaths"; @@ -25,7 +26,7 @@ export interface PersistedUiState { projectOrderCwds?: string[]; defaultAdvertisedEndpointKey?: string | null; threadChangedFilesExpandedById?: Record>; - worktreeLabelByPath?: Record; + worktreeLabelByEnvironment?: Record>; } export interface UiProjectState { @@ -43,7 +44,7 @@ export interface UiEndpointState { } export interface UiWorktreeState { - worktreeLabelByPath: Record; + worktreeLabelByEnvironment: Record>; } export interface UiState extends UiProjectState, UiThreadState, UiEndpointState, UiWorktreeState {} @@ -54,7 +55,7 @@ const initialState: UiState = { threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, - worktreeLabelByPath: {}, + worktreeLabelByEnvironment: {}, }; const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:"; @@ -138,7 +139,7 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { parsed.defaultAdvertisedEndpointKey.length > 0 ? parsed.defaultAdvertisedEndpointKey : null, - worktreeLabelByPath: sanitizePersistedWorktreeLabels(parsed.worktreeLabelByPath), + worktreeLabelByEnvironment: sanitizePersistedWorktreeLabels(parsed.worktreeLabelByEnvironment), }; } @@ -165,22 +166,32 @@ function readPersistedState(): UiState { } function sanitizePersistedWorktreeLabels( - value: PersistedUiState["worktreeLabelByPath"], -): Record { + value: PersistedUiState["worktreeLabelByEnvironment"], +): Record> { if (!value || typeof value !== "object") { return {}; } - const labels: Record = {}; - for (const [path, label] of Object.entries(value)) { - if (!path || typeof label !== "string") { + const labelsByEnvironment: Record> = {}; + for (const [environmentId, valueByPath] of Object.entries(value)) { + if (!environmentId || !valueByPath || typeof valueByPath !== "object") { continue; } - const trimmedLabel = label.trim(); - if (trimmedLabel.length > 0) { - labels[path] = trimmedLabel; + + const labelsByPath: Record = {}; + for (const [path, label] of Object.entries(valueByPath)) { + if (!path || typeof label !== "string") { + continue; + } + const trimmedLabel = label.trim(); + if (trimmedLabel.length > 0) { + labelsByPath[path] = trimmedLabel; + } + } + if (Object.keys(labelsByPath).length > 0) { + labelsByEnvironment[environmentId] = labelsByPath; } } - return labels; + return labelsByEnvironment; } function sanitizePersistedThreadChangedFilesExpanded( @@ -237,7 +248,7 @@ export function persistState(state: UiState): void { threadLastVisitedAtById: state.threadLastVisitedAtById, defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, threadChangedFilesExpandedById, - worktreeLabelByPath: state.worktreeLabelByPath, + worktreeLabelByEnvironment: state.worktreeLabelByEnvironment, } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { @@ -361,21 +372,32 @@ export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | nu }; } -export function setWorktreeLabel(state: UiState, worktreePath: string, label: string): UiState { - // Paths are exact identifiers, so writers and readers use the verbatim value. +export function setWorktreeLabel( + state: UiState, + environmentId: EnvironmentId, + worktreePath: string, + label: string, +): UiState { if (!worktreePath.trim()) { return state; } const trimmedLabel = label.trim(); - const currentLabel = state.worktreeLabelByPath[worktreePath]; + const labelsByPath = state.worktreeLabelByEnvironment[environmentId] ?? {}; + const currentLabel = labelsByPath[worktreePath]; if (trimmedLabel.length === 0) { if (currentLabel === undefined) { return state; } - const worktreeLabelByPath = { ...state.worktreeLabelByPath }; - delete worktreeLabelByPath[worktreePath]; - return { ...state, worktreeLabelByPath }; + const nextLabelsByPath = { ...labelsByPath }; + delete nextLabelsByPath[worktreePath]; + const worktreeLabelByEnvironment = { ...state.worktreeLabelByEnvironment }; + if (Object.keys(nextLabelsByPath).length === 0) { + delete worktreeLabelByEnvironment[environmentId]; + } else { + worktreeLabelByEnvironment[environmentId] = nextLabelsByPath; + } + return { ...state, worktreeLabelByEnvironment }; } if (currentLabel === trimmedLabel) { @@ -383,13 +405,26 @@ export function setWorktreeLabel(state: UiState, worktreePath: string, label: st } return { ...state, - worktreeLabelByPath: { - ...state.worktreeLabelByPath, - [worktreePath]: trimmedLabel, + worktreeLabelByEnvironment: { + ...state.worktreeLabelByEnvironment, + [environmentId]: { + ...labelsByPath, + [worktreePath]: trimmedLabel, + }, }, }; } +export function resolveWorktreeLabel( + state: UiWorktreeState, + environmentId: EnvironmentId, + worktreePath: string | null | undefined, +): string | null { + return worktreePath + ? (state.worktreeLabelByEnvironment[environmentId]?.[worktreePath] ?? null) + : null; +} + export function resolveProjectExpanded( projectExpandedById: Readonly>, preferenceKeys: readonly string[], @@ -472,7 +507,7 @@ interface UiStateStore extends UiState { markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; - setWorktreeLabel: (worktreePath: string, label: string) => void; + setWorktreeLabel: (environmentId: EnvironmentId, worktreePath: string, label: string) => void; setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; reorderProjects: ( currentProjectOrder: readonly string[], @@ -491,8 +526,8 @@ export const useUiStateStore = create((set) => ({ set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), setDefaultAdvertisedEndpointKey: (key) => set((state) => setDefaultAdvertisedEndpointKey(state, key)), - setWorktreeLabel: (worktreePath, label) => - set((state) => setWorktreeLabel(state, worktreePath, label)), + setWorktreeLabel: (environmentId, worktreePath, label) => + set((state) => setWorktreeLabel(state, environmentId, worktreePath, label)), setProjectExpanded: (projectIds, expanded) => set((state) => setProjectExpanded(state, projectIds, expanded)), reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) => @@ -501,10 +536,11 @@ export const useUiStateStore = create((set) => ({ ), })); -export function useWorktreeLabel(worktreePath: string | null | undefined): string | null { - return useUiStateStore((state) => - worktreePath ? (state.worktreeLabelByPath[worktreePath] ?? null) : null, - ); +export function useWorktreeLabel( + environmentId: EnvironmentId, + worktreePath: string | null | undefined, +): string | null { + return useUiStateStore((state) => resolveWorktreeLabel(state, environmentId, worktreePath)); } useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 2833da5979c..c5ffeca2cf5 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -118,19 +118,19 @@ describe("worktreeDisplayName", () => { const path = "/Users/julius/.t3/worktrees/t3code-mvp/t3code-4e609bb8"; it("returns the custom label when one is set for the path", () => { - expect(worktreeDisplayName(path, { [path]: "Checkout UI" })).toBe("Checkout UI"); + expect(worktreeDisplayName(path, "Checkout UI")).toBe("Checkout UI"); }); it("falls back to the path-derived name when no label is set", () => { - expect(worktreeDisplayName(path, {})).toBe("t3code-4e609bb8"); + expect(worktreeDisplayName(path, null)).toBe("t3code-4e609bb8"); }); it("falls back to the path-derived name when the label is blank", () => { - expect(worktreeDisplayName(path, { [path]: " " })).toBe("t3code-4e609bb8"); + expect(worktreeDisplayName(path, " ")).toBe("t3code-4e609bb8"); }); it("trims surrounding whitespace from the custom label", () => { - expect(worktreeDisplayName(path, { [path]: " Checkout UI " })).toBe("Checkout UI"); + expect(worktreeDisplayName(path, " Checkout UI ")).toBe("Checkout UI"); }); }); @@ -138,7 +138,7 @@ describe("formatWorktreeDeleteConfirmation", () => { it("shows both a custom label and the full path for the destructive target", () => { const path = "/Users/julius/.t3/worktrees/t3code-mvp/t3code-4e609bb8"; - expect(formatWorktreeDeleteConfirmation(path, { [path]: "Checkout UI" })).toBe( + expect(formatWorktreeDeleteConfirmation(path, "Checkout UI")).toBe( [ "This thread is the only one linked to this worktree:", "Name: Checkout UI", diff --git a/apps/web/src/worktreeCleanup.ts b/apps/web/src/worktreeCleanup.ts index ffbccd59f6c..4f93594e265 100644 --- a/apps/web/src/worktreeCleanup.ts +++ b/apps/web/src/worktreeCleanup.ts @@ -46,16 +46,16 @@ export function formatWorktreePathForDisplay(worktreePath: string): string { /** * Resolve the name to show for a worktree: the user-assigned label if one exists - * for this path, otherwise the path-derived display name. Labels are keyed by - * worktree PATH (not thread) so threads sharing a worktree show the same name. + * for this path, otherwise the path-derived display name. The caller resolves + * the custom label using the worktree's environment and path. * * Use this everywhere a worktree name renders so the UI stays consistent. */ export function worktreeDisplayName( worktreePath: string, - labelByPath: Readonly>, + customLabel: string | null | undefined, ): string { - const trimmedLabel = labelByPath[worktreePath]?.trim(); + const trimmedLabel = customLabel?.trim(); if (trimmedLabel && trimmedLabel.length > 0) { return trimmedLabel; } @@ -64,11 +64,11 @@ export function worktreeDisplayName( export function formatWorktreeDeleteConfirmation( worktreePath: string, - labelByPath: Readonly>, + customLabel: string | null | undefined, ): string { return [ "This thread is the only one linked to this worktree:", - `Name: ${worktreeDisplayName(worktreePath, labelByPath)}`, + `Name: ${worktreeDisplayName(worktreePath, customLabel)}`, `Path: ${worktreePath}`, "", "Delete the worktree too?", diff --git a/apps/web/src/worktreeRenameStore.ts b/apps/web/src/worktreeRenameStore.ts index 9f6f0489ec5..4303ae648b5 100644 --- a/apps/web/src/worktreeRenameStore.ts +++ b/apps/web/src/worktreeRenameStore.ts @@ -1,26 +1,25 @@ +import type { EnvironmentId } from "@t3tools/contracts"; import { create } from "zustand"; /** * Ephemeral UI state for the "Rename worktree" dialog. The dialog is mounted * once globally (see WorktreeRenameDialog) so any surface — the sidebar thread * context menu, the bottom-bar workspace label — can open it for a given - * worktree path. Not persisted; the labels themselves live in useUiStateStore. + * environment-scoped worktree path. Not persisted; the labels themselves live + * in useUiStateStore. */ interface WorktreeRenameStore { - /** Worktree path currently being renamed, or null when the dialog is closed. */ - targetPath: string | null; - openWorktreeRename: (worktreePath: string) => void; + target: { environmentId: EnvironmentId; worktreePath: string } | null; + openWorktreeRename: (environmentId: EnvironmentId, worktreePath: string) => void; closeWorktreeRename: () => void; } export const useWorktreeRenameStore = create((set) => ({ - targetPath: null, - openWorktreeRename: (worktreePath) => { - // Store the path verbatim — it's the exact key labels are written/read - // under (see setWorktreeLabel). Ignore a blank path. + target: null, + openWorktreeRename: (environmentId, worktreePath) => { if (worktreePath.trim().length > 0) { - set({ targetPath: worktreePath }); + set({ target: { environmentId, worktreePath } }); } }, - closeWorktreeRename: () => set({ targetPath: null }), + closeWorktreeRename: () => set({ target: null }), }));