From 9d19e903236847cb89c78c051982955e8576e9e0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 31 Jul 2026 23:17:47 +0200 Subject: [PATCH] Show worktree names in workspace hover cards (#2711) * fix(app): show worktree names in workspace hover cards Managed worktree paths exposed Paseo's internal directory layout instead of the useful worktree name. Carry the owned worktree root through the sidebar model while preserving the full workspace path as the copy target. * fix(workspaces): source owned worktree names from daemon The daemon owns worktree path semantics, so expose the managed worktree slug and let the sidebar model produce a display-ready label. This keeps path parsing out of the hover-card component while preserving the full path for copying. * fix(app): preserve worktree labels in replica cache --- packages/app/e2e/sidebar-workspace.spec.ts | 61 +++++++++++++++++-- .../sidebar/sidebar-projection.test.ts | 2 + .../src/components/workspace-hover-card.tsx | 8 +-- .../hooks/sidebar-status-view-model.test.ts | 3 +- .../sidebar-workspaces-view-model.test.ts | 21 +++++++ .../hooks/sidebar-workspaces-view-model.ts | 5 ++ .../hooks/use-sidebar-shortcut-model.test.tsx | 1 + .../src/runtime/replica-cache/index.test.ts | 12 +++- .../app/src/runtime/replica-cache/index.ts | 1 + packages/app/src/stores/session-store.ts | 2 + .../utils/sidebar-project-row-model.test.ts | 1 + .../app/src/utils/sidebar-shortcuts.test.ts | 1 + packages/protocol/src/messages.ts | 3 + .../protocol/src/messages.workspaces.test.ts | 20 ++++++ packages/server/src/server/session.ts | 9 ++- 15 files changed, 137 insertions(+), 13 deletions(-) diff --git a/packages/app/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts index 69c86af848..793473f162 100644 --- a/packages/app/e2e/sidebar-workspace.spec.ts +++ b/packages/app/e2e/sidebar-workspace.spec.ts @@ -47,6 +47,51 @@ async function waitForSidebarWorkspace(page: import("@playwright/test").Page, wo return row; } +async function openWorkspaceHoverCard(page: import("@playwright/test").Page, workspaceId: string) { + const row = await waitForSidebarWorkspace(page, workspaceId); + await row.hover(); + + const hoverCard = page.getByRole("menu", { name: "Workspace scripts" }); + await expect(hoverCard).toBeVisible({ timeout: 30_000 }); + return hoverCard; +} + +interface PaseoOwnedWorktree { + projectName: string; + workspaceId: string; + worktreeSlug: string; +} + +async function withPaseoOwnedWorktree( + run: (workspace: PaseoOwnedWorktree) => Promise, +): Promise { + const project = await seedWorkspace({ repoPrefix: "sidebar-hover-owned-worktree-" }); + const worktreeSlug = "hover-card-owned-worktree"; + + try { + const created = await project.client.createWorkspace({ + source: { + kind: "worktree", + cwd: project.repoPath, + projectId: project.projectId, + worktreeSlug, + }, + }); + if (!created.workspace) { + throw new Error(created.error ?? "Failed to create Paseo-owned worktree"); + } + expect(path.basename(created.workspace.workspaceDirectory)).toBe(worktreeSlug); + + await run({ + projectName: path.basename(project.repoPath), + workspaceId: created.workspace.id, + worktreeSlug, + }); + } finally { + await project.cleanup(); + } +} + test.describe("Sidebar workspace list", () => { test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => { const workspace = await seedWorkspace({ @@ -146,17 +191,23 @@ test.describe("Sidebar workspace list", () => { await gotoAppShell(page); await waitForSidebarProject(page, path.basename(workspace.repoPath)); - const row = await waitForSidebarWorkspace(page, workspace.workspaceId); - await row.hover(); - - const hoverCard = page.getByTestId("workspace-hover-card"); - await expect(hoverCard).toBeVisible({ timeout: 30_000 }); + const hoverCard = await openWorkspaceHoverCard(page, workspace.workspaceId); await expect(page.getByTestId("hover-card-workspace-host")).toHaveText("localhost"); await expect(hoverCard).not.toContainText(/\b(Online|Connecting|Offline|Error|Idle)\b/); } finally { await workspace.cleanup(); } }); + + test("Paseo-owned worktree hover card shows the worktree directory name", async ({ page }) => { + await withPaseoOwnedWorktree(async ({ projectName, workspaceId, worktreeSlug }) => { + await gotoAppShell(page); + await waitForSidebarProject(page, projectName); + await openWorkspaceHoverCard(page, workspaceId); + + await expect(page.getByTestId("hover-card-workspace-cwd")).toHaveText(worktreeSlug); + }); + }); }); test.describe("Mobile sidebar panelState transition", () => { diff --git a/packages/app/src/components/sidebar/sidebar-projection.test.ts b/packages/app/src/components/sidebar/sidebar-projection.test.ts index 9338d55a47..b5d15d2451 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.test.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.test.ts @@ -19,6 +19,8 @@ function makeWorkspace(id: string, statusBucket: SidebarWorkspaceEntry["statusBu }; const entry: SidebarWorkspaceEntry = { ...placement, + workspaceDirectory: "", + workspaceDirectoryLabel: "", title: null, currentBranch: null, statusBucket, diff --git a/packages/app/src/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx index 37cd114fc6..6d7e13ac9a 100644 --- a/packages/app/src/components/workspace-hover-card.tsx +++ b/packages/app/src/components/workspace-hover-card.tsx @@ -34,7 +34,6 @@ import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import type { PrHint } from "@/git/use-pr-status-query"; import { openExternalUrl } from "@/utils/open-external-url"; -import { shortenPath } from "@/utils/shorten-path"; import { copyToClipboard } from "@/utils/copy-to-clipboard"; import { PrBadge } from "@/components/sidebar-workspace-list"; import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone"; @@ -214,7 +213,6 @@ function WorkspaceHoverCardContent({ contentRef: React.RefObject; }): ReactElement | null { const { t } = useTranslation(); - const cwdDisplay = shortenPath(workspace.workspaceDirectory); const bottomSheetInternal = useBottomSheetModalInternal(true); const [triggerRect, setTriggerRect] = useState(null); const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); @@ -297,11 +295,11 @@ function WorkspaceHoverCardContent({ testID="hover-card-workspace-branch" /> ) : null} - {cwdDisplay ? ( + {workspace.workspaceDirectoryLabel ? ( diff --git a/packages/app/src/hooks/sidebar-status-view-model.test.ts b/packages/app/src/hooks/sidebar-status-view-model.test.ts index c9e026da8a..62dc7142a8 100644 --- a/packages/app/src/hooks/sidebar-status-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-status-view-model.test.ts @@ -17,7 +17,8 @@ function ws( projectViewKey: input.projectViewKey ?? "proj", projectName: input.projectName ?? "Project", projectRootPath: input.projectRootPath, - workspaceDirectory: input.workspaceDirectory, + workspaceDirectory: input.workspaceDirectory ?? "", + workspaceDirectoryLabel: input.workspaceDirectoryLabel ?? "", projectKind: input.projectKind ?? "git", workspaceKind: input.workspaceKind ?? "worktree", name: input.name ?? "main", diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts index d108f9d862..332f90a7d8 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -64,6 +64,27 @@ describe("createSidebarWorkspaceEntry forge threading", () => { }); }); +describe("createSidebarWorkspaceEntry workspace directory label", () => { + it("uses the daemon-provided slug for a Paseo-owned worktree", () => { + const descriptor = workspaceWithForge(undefined, "https://github.com/acme/repo/pull/42"); + descriptor.workspaceDirectory = "/worktrees/feature/packages/app"; + descriptor.worktreeSlug = "feature"; + + const entry = createSidebarWorkspaceEntry({ serverId: "srv", workspace: descriptor }); + + expect(entry.workspaceDirectoryLabel).toBe("feature"); + }); + + it("shortens the workspace path when the daemon omits a worktree slug", () => { + const descriptor = workspaceWithForge(undefined, "https://github.com/acme/repo/pull/42"); + descriptor.workspaceDirectory = "/home/alice/external/feature"; + + const entry = createSidebarWorkspaceEntry({ serverId: "srv", workspace: descriptor }); + + expect(entry.workspaceDirectoryLabel).toBe("~/external/feature"); + }); +}); + interface OrderedItem { key: string; } diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index 330822799d..5c73b03561 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -8,6 +8,7 @@ import type { WorkspaceStructureProject, } from "@/projects/workspace-structure"; import { projectDisplayNameFromProjectId } from "@/utils/project-display-name"; +import { shortenPath } from "@/utils/shorten-path"; import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; @@ -34,6 +35,8 @@ export interface SidebarStatusWorkspacePlacement extends SidebarWorkspacePlaceme } export interface SidebarWorkspaceEntry extends SidebarStatusWorkspacePlacement { + workspaceDirectory: string; + workspaceDirectoryLabel: string; // Raw user-set title (null when the name is derived from branch/directory). // Prefills the rename input and signals whether a reset is available. title: string | null; @@ -155,6 +158,8 @@ export function createSidebarWorkspaceEntry(input: { projectName: projectNameForWorkspace(input.workspace), projectRootPath: input.workspace.projectRootPath, workspaceDirectory: input.workspace.workspaceDirectory, + workspaceDirectoryLabel: + input.workspace.worktreeSlug ?? shortenPath(input.workspace.workspaceDirectory), projectKind: input.workspace.projectKind, workspaceKind: input.workspace.workspaceKind, name: input.workspace.name, diff --git a/packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx b/packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx index c54fa0cd2d..ebb0c5e81c 100644 --- a/packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx +++ b/packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx @@ -21,6 +21,7 @@ function workspace(projectKey: string, workspaceId: string): SidebarWorkspaceEnt projectName: projectKey, projectRootPath: `/repo/${projectKey}`, workspaceDirectory: `/repo/${projectKey}/${workspaceId}`, + workspaceDirectoryLabel: `/repo/${projectKey}/${workspaceId}`, projectKind: "git", workspaceKind: "worktree", name: workspaceId, diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index bf3344de0e..cf8f9978dc 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -94,7 +94,16 @@ function seedSession(): void { store.setAgents(SERVER_ID, new Map([["agent-1", agent("agent-1")]])); store.setWorkspaces( SERVER_ID, - new Map([["workspace-1", normalizeWorkspaceDescriptor(workspace())]]), + new Map([ + [ + "workspace-1", + normalizeWorkspaceDescriptor({ + ...workspace(), + workspaceKind: "worktree", + worktreeSlug: "owned-worktree", + }), + ], + ]), ); store.setProjects(SERVER_ID, [ normalizeProjectDescriptor({ @@ -172,6 +181,7 @@ describe("ReplicaCache", () => { expect(Array.from(session?.projects.keys() ?? [])).toEqual(["project-1"]); expect(session?.agents.get("agent-1")?.updatedAt).toBeInstanceOf(Date); expect(session?.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date); + expect(session?.workspaces.get("workspace-1")?.worktreeSlug).toBe("owned-worktree"); expect(session?.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]); expect(session?.agentAuthoritativeHistoryApplied.get("agent-1")).toBe(true); expect(session?.agentTimelineCursor.get("agent-1")).toEqual({ diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 5158e7d01f..4b4a254396 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -199,6 +199,7 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): WorkspaceDescriptor projectCustomIconRevision: workspace.projectCustomIconRevision ?? null, projectRootPath: workspace.projectRootPath, workspaceDirectory: workspace.workspaceDirectory, + worktreeSlug: workspace.worktreeSlug, projectKind: workspace.projectKind, workspaceKind: workspace.workspaceKind, name: workspace.name, diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 5df9b9cc98..5c9deefd10 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -132,6 +132,7 @@ export interface WorkspaceDescriptor { projectCustomIconRevision?: string | null; projectRootPath: string; workspaceDirectory: string; + worktreeSlug?: WorkspaceDescriptorPayload["worktreeSlug"]; projectKind: WorkspaceDescriptorPayload["projectKind"]; workspaceKind: WorkspaceDescriptorPayload["workspaceKind"]; name: string; @@ -167,6 +168,7 @@ export function normalizeWorkspaceDescriptor( // consumer can read workspace.workspaceDirectory directly. Empty means "no // usable directory" (older daemons may omit it; the wire field is optional). workspaceDirectory: normalizeWorkspacePath(payload.workspaceDirectory) ?? "", + worktreeSlug: payload.worktreeSlug, projectKind: payload.projectKind, workspaceKind: payload.workspaceKind, name: payload.name, diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index 6101963f73..fdd917355c 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -18,6 +18,7 @@ function workspace(overrides: Partial = {}): SidebarWorks projectViewKey: "project-1", projectName: "paseo", workspaceDirectory: "/repo", + workspaceDirectoryLabel: "/repo", projectKind: "git", workspaceKind: "checkout", name: "paseo", diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 196705c2b3..6b008e3dc6 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -27,6 +27,7 @@ function workspace(input: { projectViewKey: input.projectKey ?? "project-default", projectName: input.projectKey ?? "Project", workspaceDirectory: input.workspaceDirectory, + workspaceDirectoryLabel: input.workspaceDirectory, projectKind: "git", workspaceKind: "checkout", name: input.name, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 24ee12e565..378cd55762 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3155,6 +3155,9 @@ export const WorkspaceDescriptorPayloadSchema = z projectCustomIconRevision: z.string().nullable().optional(), projectRootPath: z.string(), workspaceDirectory: z.string().optional(), + // COMPAT(worktreeSlug): added in v0.2.6, remove optional after 2027-01-31. + // Present only for Paseo-owned worktrees; this is the basename of their root directory. + worktreeSlug: z.string().optional(), projectKind: z.enum(["git", "non_git", "directory"]), // COMPAT(workspaces): keep legacy directory workspace kind parseable. workspaceKind: z.enum(["directory", "local_checkout", "checkout", "worktree"]), diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index 72146149fe..03a0bd4321 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -499,6 +499,26 @@ describe("workspace message schemas", () => { throw new Error("Expected workspace_update upsert payload"); } expect(parsed.payload.workspace.workspaceDirectory).toBe("/repo"); + expect(parsed.payload.workspace.worktreeSlug).toBeUndefined(); + }); + + test("preserves a Paseo-owned worktree slug", () => { + const parsed = WorkspaceDescriptorPayloadSchema.parse({ + id: "owned-worktree", + projectId: "project", + projectDisplayName: "repo", + projectRootPath: "/repo", + workspaceDirectory: "/paseo/worktrees/project/feature/packages/app", + worktreeSlug: "feature", + projectKind: "git", + workspaceKind: "worktree", + name: "feature", + status: "done", + activityAt: null, + scripts: [], + }); + + expect(parsed.worktreeSlug).toBe("feature"); }); test("defaults omitted workspace archiving state and preserves present timestamps", () => { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 24c2c9861d..a73bb66161 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,7 +1,7 @@ import equal from "fast-deep-equal"; import { v4 as uuidv4 } from "uuid"; import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises"; -import { resolve, sep } from "path"; +import { basename, resolve, sep } from "path"; import { homedir } from "node:os"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; import { @@ -4276,6 +4276,11 @@ export class Session { diffStat = snapshot.git.diffStat; } + const worktreeSlug = + workspace.isPaseoOwnedWorktree && workspace.worktreeRoot + ? basename(workspace.worktreeRoot) + : undefined; + return { id: workspace.workspaceId, projectId: workspace.projectId, @@ -4286,6 +4291,7 @@ export class Session { projectCustomIconRevision: resolvedProjectRecord?.customIconRevision ?? null, projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd, workspaceDirectory: workspace.cwd, + worktreeSlug, projectKind: (resolvedProjectRecord?.kind ?? "directory") === "git" ? "git" : "non_git", workspaceKind: workspace.kind, name: resolveWorkspaceDisplayName(workspace), @@ -4373,6 +4379,7 @@ export class Session { projectCustomIconRevision: projectRecord?.customIconRevision ?? null, projectRootPath: projectRecord?.rootPath ?? result.repoRoot, workspaceDirectory: result.workspace.cwd, + worktreeSlug: basename(result.worktree.worktreePath), projectKind: projectRecord?.kind ?? "git", workspaceKind: result.workspace.kind, name: resolveWorkspaceName({