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..8c77004646a 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(environmentId, activeWorktreePath); + const renameTrigger = useWorktreeRenameTrigger(environmentId, 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)} @@ -267,6 +277,7 @@ export const BranchToolbar = memo(function BranchToolbar({ )} void; @@ -26,30 +30,40 @@ interface BranchToolbarEnvModeSelectorProps { export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ envLocked, + environmentId, effectiveEnvMode, activeWorktreePath, onEnvModeChange, }: BranchToolbarEnvModeSelectorProps) { + 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(environmentId, 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 +77,15 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe onValueChange={(value) => onEnvModeChange(value as EnvMode)} items={envModeItems} > - + {effectiveEnvMode === "worktree" ? ( ) : activeWorktreePath ? ( @@ -83,7 +105,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..250e3bc124b 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(thread.environmentId, 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..5cc5db294de --- /dev/null +++ b/apps/web/src/components/WorktreeRenameDialog.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from "react"; + +import { resolveWorktreeLabel, 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 environment + * and worktree path — shared by every thread on that worktree, no disk move. + */ +export function WorktreeRenameDialog() { + const target = useWorktreeRenameStore((state) => state.target); + 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 (target) { + setTitle( + resolveWorktreeLabel( + useUiStateStore.getState(), + target.environmentId, + target.worktreePath, + ) ?? "", + ); + } + }, [target]); + + const submit = () => { + 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(target.environmentId, target.worktreePath, title); + closeWorktreeRename(); + }; + + return ( + { + if (!open) { + closeWorktreeRename(); + } + }} + > + + + Rename worktree + + {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."} + + + +
+ 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..f939b004278 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -22,7 +22,12 @@ 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 { + formatWorktreeDeleteConfirmation, + getOrphanedWorktreePathForThread, + worktreeDisplayName, +} from "../worktreeCleanup"; +import { resolveWorktreeLabel, useUiStateStore } from "../uiStateStore"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useSettings } from "./useSettings"; import { useAtomCommand } from "../state/use-atom-command"; @@ -178,8 +183,15 @@ export function useThreadActions() { survivingThreads, threadRef.threadId, ); + const worktreeLabel = orphanedWorktreePath + ? resolveWorktreeLabel( + useUiStateStore.getState(), + threadRef.environmentId, + orphanedWorktreePath, + ) + : null; const displayWorktreePath = orphanedWorktreePath - ? formatWorktreePathForDisplay(orphanedWorktreePath) + ? worktreeDisplayName(orphanedWorktreePath, worktreeLabel) : null; const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== null; const localApi = readLocalApi(); @@ -187,12 +199,7 @@ 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, worktreeLabel), ), ); if (confirmationResult._tag === "Failure") { @@ -287,6 +294,11 @@ export function useThreadActions() { force: true, }, }); + if (removeResult._tag === "Success") { + useUiStateStore + .getState() + .setWorktreeLabel(threadRef.environmentId, orphanedWorktreePath, ""); + } const refreshResult = removeResult._tag === "Success" ? await refreshVcsStatus({ 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 new file mode 100644 index 00000000000..8e1db5863de --- /dev/null +++ b/apps/web/src/hooks/useWorktreeRenameTrigger.ts @@ -0,0 +1,76 @@ +import type { EnvironmentId, LocalApi } from "@t3tools/contracts"; +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; +} + +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 + * 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( + environmentId: EnvironmentId, + activeWorktreePath: string | null, +): WorktreeRenameTriggerHandlers { + const openWorktreeRename = useWorktreeRenameStore((state) => state.openWorktreeRename); + + const onDoubleClick = useCallback( + (event: MouseEvent) => { + if (!activeWorktreePath) { + return; + } + event.preventDefault(); + event.stopPropagation(); + openWorktreeRename(environmentId, activeWorktreePath); + }, + [activeWorktreePath, environmentId, 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(environmentId, activeWorktreePath); + return; + } + void shouldOpenWorktreeRenameFromContextMenu(api.contextMenu, position).then((shouldOpen) => { + if (shouldOpen) { + openWorktreeRename(environmentId, activeWorktreePath); + } + }); + }, + [activeWorktreePath, environmentId, 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..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,9 +11,11 @@ import { persistState, reorderProjects, resolveProjectExpanded, + resolveWorktreeLabel, setDefaultAdvertisedEndpointKey, setProjectExpanded, setThreadChangedFilesExpanded, + setWorktreeLabel, type UiState, } from "./uiStateStore"; @@ -24,11 +26,15 @@ function makeUiState(overrides: Partial = {}): UiState { threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, + 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(); @@ -140,6 +146,49 @@ describe("uiStateStore pure functions", () => { defaultAdvertisedEndpointKey: null, }); }); + + 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, 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, 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(), localEnvironmentId, pathA, "Alpha"); + + 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", () => { @@ -161,6 +210,12 @@ describe("parsePersistedState", () => { "turn-2": true, }, }, + worktreeLabelByEnvironment: { + "environment-local": { + "/repo/.t3/worktrees/feature-a": " Feature A ", + "/repo/.t3/worktrees/blank": " ", + }, + }, }); expect(parsed).toEqual({ @@ -177,6 +232,11 @@ describe("parsePersistedState", () => { "turn-1": false, }, }, + worktreeLabelByEnvironment: { + "environment-local": { + "/repo/.t3/worktrees/feature-a": "Feature A", + }, + }, }); }); @@ -262,6 +322,11 @@ describe("uiStateStore persistence", () => { }, }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", + worktreeLabelByEnvironment: { + "environment-local": { + "/repo/.t3/worktrees/feature-a": "Feature A", + }, + }, }); persistState(state); @@ -283,6 +348,11 @@ describe("uiStateStore persistence", () => { "turn-1": false, }, }, + worktreeLabelByEnvironment: { + "environment-local": { + "/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..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,6 +26,7 @@ export interface PersistedUiState { projectOrderCwds?: string[]; defaultAdvertisedEndpointKey?: string | null; threadChangedFilesExpandedById?: Record>; + worktreeLabelByEnvironment?: Record>; } export interface UiProjectState { @@ -41,7 +43,11 @@ export interface UiEndpointState { defaultAdvertisedEndpointKey: string | null; } -export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {} +export interface UiWorktreeState { + worktreeLabelByEnvironment: Record>; +} + +export interface UiState extends UiProjectState, UiThreadState, UiEndpointState, UiWorktreeState {} const initialState: UiState = { projectExpandedById: {}, @@ -49,6 +55,7 @@ const initialState: UiState = { threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, + worktreeLabelByEnvironment: {}, }; const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:"; @@ -132,6 +139,7 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { parsed.defaultAdvertisedEndpointKey.length > 0 ? parsed.defaultAdvertisedEndpointKey : null, + worktreeLabelByEnvironment: sanitizePersistedWorktreeLabels(parsed.worktreeLabelByEnvironment), }; } @@ -157,6 +165,35 @@ function readPersistedState(): UiState { } } +function sanitizePersistedWorktreeLabels( + value: PersistedUiState["worktreeLabelByEnvironment"], +): Record> { + if (!value || typeof value !== "object") { + return {}; + } + const labelsByEnvironment: Record> = {}; + for (const [environmentId, valueByPath] of Object.entries(value)) { + if (!environmentId || !valueByPath || typeof valueByPath !== "object") { + continue; + } + + 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 labelsByEnvironment; +} + function sanitizePersistedThreadChangedFilesExpanded( value: PersistedUiState["threadChangedFilesExpandedById"], ): Record> { @@ -211,6 +248,7 @@ export function persistState(state: UiState): void { threadLastVisitedAtById: state.threadLastVisitedAtById, defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, threadChangedFilesExpandedById, + worktreeLabelByEnvironment: state.worktreeLabelByEnvironment, } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { @@ -334,6 +372,59 @@ export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | nu }; } +export function setWorktreeLabel( + state: UiState, + environmentId: EnvironmentId, + worktreePath: string, + label: string, +): UiState { + if (!worktreePath.trim()) { + return state; + } + const trimmedLabel = label.trim(); + const labelsByPath = state.worktreeLabelByEnvironment[environmentId] ?? {}; + const currentLabel = labelsByPath[worktreePath]; + + if (trimmedLabel.length === 0) { + if (currentLabel === undefined) { + return state; + } + 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) { + return state; + } + return { + ...state, + 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[], @@ -416,6 +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: (environmentId: EnvironmentId, worktreePath: string, label: string) => void; setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; reorderProjects: ( currentProjectOrder: readonly string[], @@ -434,6 +526,8 @@ export const useUiStateStore = create((set) => ({ set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), setDefaultAdvertisedEndpointKey: (key) => set((state) => setDefaultAdvertisedEndpointKey(state, key)), + setWorktreeLabel: (environmentId, worktreePath, label) => + set((state) => setWorktreeLabel(state, environmentId, worktreePath, label)), setProjectExpanded: (projectIds, expanded) => set((state) => setProjectExpanded(state, projectIds, expanded)), reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) => @@ -442,6 +536,13 @@ export const useUiStateStore = create((set) => ({ ), })); +export function useWorktreeLabel( + environmentId: EnvironmentId, + worktreePath: string | null | undefined, +): string | null { + return useUiStateStore((state) => resolveWorktreeLabel(state, environmentId, worktreePath)); +} + 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..c5ffeca2cf5 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -2,7 +2,12 @@ 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 { + formatWorktreeDeleteConfirmation, + formatWorktreePathForDisplay, + getOrphanedWorktreePathForThread, + worktreeDisplayName, +} from "./worktreeCleanup"; const localEnvironmentId = EnvironmentId.make("environment-local"); @@ -108,3 +113,39 @@ 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, "Checkout UI")).toBe("Checkout UI"); + }); + + it("falls back to the path-derived name when no label is set", () => { + expect(worktreeDisplayName(path, null)).toBe("t3code-4e609bb8"); + }); + + it("falls back to the path-derived name when the label is blank", () => { + expect(worktreeDisplayName(path, " ")).toBe("t3code-4e609bb8"); + }); + + it("trims surrounding whitespace from the custom label", () => { + expect(worktreeDisplayName(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, "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 109f71ccd9a..4f93594e265 100644 --- a/apps/web/src/worktreeCleanup.ts +++ b/apps/web/src/worktreeCleanup.ts @@ -43,3 +43,34 @@ 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. 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, + customLabel: string | null | undefined, +): string { + const trimmedLabel = customLabel?.trim(); + if (trimmedLabel && trimmedLabel.length > 0) { + return trimmedLabel; + } + return formatWorktreePathForDisplay(worktreePath); +} + +export function formatWorktreeDeleteConfirmation( + worktreePath: string, + customLabel: string | null | undefined, +): string { + return [ + "This thread is the only one linked to this worktree:", + `Name: ${worktreeDisplayName(worktreePath, customLabel)}`, + `Path: ${worktreePath}`, + "", + "Delete the worktree too?", + ].join("\n"); +} diff --git a/apps/web/src/worktreeRenameStore.ts b/apps/web/src/worktreeRenameStore.ts new file mode 100644 index 00000000000..4303ae648b5 --- /dev/null +++ b/apps/web/src/worktreeRenameStore.ts @@ -0,0 +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 + * environment-scoped worktree path. Not persisted; the labels themselves live + * in useUiStateStore. + */ +interface WorktreeRenameStore { + target: { environmentId: EnvironmentId; worktreePath: string } | null; + openWorktreeRename: (environmentId: EnvironmentId, worktreePath: string) => void; + closeWorktreeRename: () => void; +} + +export const useWorktreeRenameStore = create((set) => ({ + target: null, + openWorktreeRename: (environmentId, worktreePath) => { + if (worktreePath.trim().length > 0) { + set({ target: { environmentId, worktreePath } }); + } + }, + closeWorktreeRename: () => set({ target: null }), +}));