diff --git a/packages/app/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts index 69c86af84..793473f16 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 9338d55a4..b5d15d245 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 37cd114fc..6d7e13ac9 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 c9e026da8..62dc7142a 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 d108f9d86..332f90a7d 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 330822799..5c73b0356 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 c54fa0cd2..ebb0c5e81 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 bf3344de0..cf8f9978d 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 5158e7d01..4b4a25439 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 5df9b9cc9..5c9deefd1 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 6101963f7..fdd917355 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 196705c2b..6b008e3dc 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 24ee12e56..378cd5576 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 72146149f..03a0bd432 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 24c2c9861..a73bb6616 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({