From 3427ca9f45c050832613a536f2e6ac80e048f1c7 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/2] 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 --- .../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 | 42 ++++++++ apps/web/src/uiStateStore.ts | 100 +++++++++++++++++- apps/web/src/worktreeCleanup.test.ts | 26 ++++- apps/web/src/worktreeCleanup.ts | 19 ++++ apps/web/src/worktreeRenameStore.ts | 25 +++++ 14 files changed, 464 insertions(+), 19 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 27c5c311c60..2e856789c1b 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -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, @@ -80,6 +82,10 @@ 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 @@ -87,10 +93,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 ? ( @@ -114,7 +120,11 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ if (isLocked) { return ( - + {triggerContent} ); @@ -125,6 +135,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} @@ -172,7 +184,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 dc5acaaadc7..c050547f71a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -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, @@ -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" }, @@ -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; 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 7325a96913d..60adbc1d6bf 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -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"; @@ -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(); 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 88283d451c3..57141bfbfa8 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -13,6 +13,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query"; 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"; @@ -127,6 +128,7 @@ function RootRouteView() { + ); diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index c6f445b0c32..7513c71223b 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -13,6 +13,7 @@ import { setDefaultAdvertisedEndpointKey, setProjectExpanded, setThreadChangedFilesExpanded, + setWorktreeLabel, syncProjects, syncThreads, type UiState, @@ -25,6 +26,7 @@ function makeUiState(overrides: Partial = {}): UiState { threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, + worktreeLabelByPath: {}, ...overrides, }; } @@ -116,6 +118,34 @@ describe("uiStateStore pure functions", () => { }); }); + it("setWorktreeLabel stores, trims, and clears labels keyed by 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" }); + + // Setting the same (trimmed) label is a no-op that preserves identity. + expect(setWorktreeLabel(labeled, path, "Feature A")).toBe(labeled); + + // Blank label clears the custom name. + const cleared = setWorktreeLabel(labeled, path, " "); + expect(cleared.worktreeLabelByPath).toEqual({}); + + // Clearing an absent path is a no-op that preserves identity. + expect(setWorktreeLabel(initialState, path, "")).toBe(initialState); + }); + + it("setWorktreeLabel keeps labels for sibling worktrees independent", () => { + const pathA = "/repo/.t3/worktrees/feature-a"; + const pathB = "/repo/.t3/worktrees/feature-b"; + const state = setWorktreeLabel(makeUiState(), pathA, "Alpha"); + + const next = setWorktreeLabel(state, pathB, "Beta"); + + expect(next.worktreeLabelByPath).toEqual({ [pathA]: "Alpha", [pathB]: "Beta" }); + }); + it("reorderProjects moves all member keys of a multi-member group together", () => { const keyALocal = "env-local:proj-a"; const keyARemote = "env-remote:proj-a"; @@ -579,6 +609,18 @@ describe("uiStateStore persistence round-trip", () => { expect(persisted.defaultAdvertisedEndpointKey).toBe("desktop-core:lan:http"); }); + it("persists worktree labels by path", () => { + const path = "/repo/.t3/worktrees/feature-a"; + const state = setWorktreeLabel(makeUiState(), path, "Feature A"); + + persistState(state); + + const persisted = JSON.parse( + localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", + ) as PersistedUiState; + expect(persisted.worktreeLabelByPath).toEqual({ [path]: "Feature A" }); + }); + it("preserves expand state across restart when project's logical key changes", () => { // After restart, in-memory previousExpandedById is empty, so the // previousLogicalKey-to-state bridge in syncProjects cannot help. The diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index f16495bed7f..bcf6100edaf 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -1,6 +1,8 @@ import { Debouncer } from "@tanstack/react-pacer"; import { create } from "zustand"; +import { worktreeDisplayName } from "./worktreeCleanup"; + export const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; const LEGACY_PERSISTED_STATE_KEYS = [ "t3code:renderer-state:v8", @@ -21,6 +23,12 @@ export interface PersistedUiState { projectOrderCwds?: string[]; defaultAdvertisedEndpointKey?: string | null; threadChangedFilesExpandedById?: Record>; + /** + * Cosmetic display labels for worktrees, keyed by worktree PATH (not thread). + * A worktree can be shared by multiple threads, so keying by path keeps the + * label consistent across every thread pointing at the same worktree. + */ + worktreeLabelByPath?: Record; } export interface UiProjectState { @@ -37,7 +45,16 @@ export interface UiEndpointState { defaultAdvertisedEndpointKey: string | null; } -export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {} +export interface UiWorktreeState { + /** worktree path -> custom display label. See PersistedUiState.worktreeLabelByPath. */ + worktreeLabelByPath: Record; +} + +export interface UiState + extends UiProjectState, + UiThreadState, + UiEndpointState, + UiWorktreeState {} export interface SyncProjectInput { /** Physical project key (env + cwd). Used for manual sort order. */ @@ -58,6 +75,7 @@ const initialState: UiState = { threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, + worktreeLabelByPath: {}, }; const persistedCollapsedProjectCwds = new Set(); @@ -103,12 +121,32 @@ function readPersistedState(): UiState { threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( parsed.threadChangedFilesExpandedById, ), + worktreeLabelByPath: sanitizePersistedWorktreeLabels(parsed.worktreeLabelByPath), }; } catch { return initialState; } } +function sanitizePersistedWorktreeLabels( + value: PersistedUiState["worktreeLabelByPath"], +): Record { + if (!value || typeof value !== "object") { + return {}; + } + const nextState: Record = {}; + for (const [path, label] of Object.entries(value)) { + if (!path || typeof label !== "string") { + continue; + } + const trimmed = label.trim(); + if (trimmed.length > 0) { + nextState[path] = trimmed; + } + } + return nextState; +} + function sanitizePersistedThreadChangedFilesExpanded( value: PersistedUiState["threadChangedFilesExpandedById"], ): Record> { @@ -195,6 +233,7 @@ export function persistState(state: UiState): void { projectOrderCwds, defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, threadChangedFilesExpandedById, + worktreeLabelByPath: state.worktreeLabelByPath, } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { @@ -566,6 +605,40 @@ export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | nu }; } +export function setWorktreeLabel(state: UiState, worktreePath: string, label: string): UiState { + const trimmedPath = worktreePath.trim(); + if (!trimmedPath) { + return state; + } + const trimmedLabel = label.trim(); + const currentLabel = state.worktreeLabelByPath[trimmedPath]; + + // Empty label clears any existing custom name (falls back to the path-derived + // display name). + if (trimmedLabel.length === 0) { + if (currentLabel === undefined) { + return state; + } + const nextWorktreeLabelByPath = { ...state.worktreeLabelByPath }; + delete nextWorktreeLabelByPath[trimmedPath]; + return { + ...state, + worktreeLabelByPath: nextWorktreeLabelByPath, + }; + } + + if (currentLabel === trimmedLabel) { + return state; + } + return { + ...state, + worktreeLabelByPath: { + ...state.worktreeLabelByPath, + [trimmedPath]: trimmedLabel, + }, + }; +} + export function toggleProject(state: UiState, projectId: string): UiState { const expanded = state.projectExpandedById[projectId] ?? true; return { @@ -641,6 +714,7 @@ interface UiStateStore extends UiState { clearThreadUi: (threadId: string) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; + setWorktreeLabel: (worktreePath: string, label: string) => void; toggleProject: (projectId: string) => void; setProjectExpanded: (projectId: string, expanded: boolean) => void; reorderProjects: ( @@ -662,6 +736,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)), toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), setProjectExpanded: (projectId, expanded) => set((state) => setProjectExpanded(state, projectId, expanded)), @@ -669,6 +745,28 @@ export const useUiStateStore = create((set) => ({ set((state) => reorderProjects(state, draggedProjectIds, targetProjectIds)), })); +/** + * Subscribe to the custom label for a single worktree path. Returns null when + * no custom label is set (callers fall back to the path-derived name). + */ +export function useWorktreeLabel(worktreePath: string | null | undefined): string | null { + return useUiStateStore((state) => + worktreePath ? (state.worktreeLabelByPath[worktreePath] ?? null) : null, + ); +} + +/** + * Subscribe to the resolved display name for a worktree path: the custom label + * when present, otherwise the path-derived name. Returns null for an empty path. + */ +export function useWorktreeDisplayName(worktreePath: string | null | undefined): string | null { + const label = useWorktreeLabel(worktreePath); + if (!worktreePath) { + return null; + } + return worktreeDisplayName(worktreePath, label ? { [worktreePath]: label } : {}); +} + 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 0354a966996..9dd1ab5af5e 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 8c09e89afa4..b6fb3d06feb 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 fcda8a95603621a1eb442ef8b2c6af4cfada7ad2 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/2] 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 --- apps/web/src/hooks/useThreadActions.ts | 4 ++++ apps/web/src/uiStateStore.ts | 26 +++++++------------------- apps/web/src/worktreeCleanup.ts | 3 +-- apps/web/src/worktreeRenameStore.ts | 7 ++++--- 4 files changed, 16 insertions(+), 24 deletions(-) diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 60adbc1d6bf..6045cef8313 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -231,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, }); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index bcf6100edaf..9d444bdb01e 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -1,8 +1,6 @@ import { Debouncer } from "@tanstack/react-pacer"; import { create } from "zustand"; -import { worktreeDisplayName } from "./worktreeCleanup"; - export const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; const LEGACY_PERSISTED_STATE_KEYS = [ "t3code:renderer-state:v8", @@ -606,12 +604,14 @@ export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | nu } export function setWorktreeLabel(state: UiState, worktreePath: string, label: string): UiState { - const trimmedPath = worktreePath.trim(); - if (!trimmedPath) { + // The worktree path is an exact identifier — key by it verbatim so writers + // and readers (useWorktreeLabel, worktreeDisplayName) always agree. Reject a + // blank path outright. + if (!worktreePath.trim()) { return state; } const trimmedLabel = label.trim(); - const currentLabel = state.worktreeLabelByPath[trimmedPath]; + const currentLabel = state.worktreeLabelByPath[worktreePath]; // Empty label clears any existing custom name (falls back to the path-derived // display name). @@ -620,7 +620,7 @@ export function setWorktreeLabel(state: UiState, worktreePath: string, label: st return state; } const nextWorktreeLabelByPath = { ...state.worktreeLabelByPath }; - delete nextWorktreeLabelByPath[trimmedPath]; + delete nextWorktreeLabelByPath[worktreePath]; return { ...state, worktreeLabelByPath: nextWorktreeLabelByPath, @@ -634,7 +634,7 @@ export function setWorktreeLabel(state: UiState, worktreePath: string, label: st ...state, worktreeLabelByPath: { ...state.worktreeLabelByPath, - [trimmedPath]: trimmedLabel, + [worktreePath]: trimmedLabel, }, }; } @@ -755,18 +755,6 @@ export function useWorktreeLabel(worktreePath: string | null | undefined): strin ); } -/** - * Subscribe to the resolved display name for a worktree path: the custom label - * when present, otherwise the path-derived name. Returns null for an empty path. - */ -export function useWorktreeDisplayName(worktreePath: string | null | undefined): string | null { - const label = useWorktreeLabel(worktreePath); - if (!worktreePath) { - return null; - } - return worktreeDisplayName(worktreePath, label ? { [worktreePath]: label } : {}); -} - useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); if (typeof window !== "undefined" && typeof window.addEventListener === "function") { diff --git a/apps/web/src/worktreeCleanup.ts b/apps/web/src/worktreeCleanup.ts index b6fb3d06feb..ac58d609454 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 }),