diff --git a/packages/app/e2e/command-center-workspaces.spec.ts b/packages/app/e2e/command-center-workspaces.spec.ts index e03c21a3f..d515d3669 100644 --- a/packages/app/e2e/command-center-workspaces.spec.ts +++ b/packages/app/e2e/command-center-workspaces.spec.ts @@ -55,9 +55,15 @@ test.describe("Command center workspaces", () => { ); await expect(row).toBeVisible({ timeout: 30_000 }); await expect(row).toContainText(WORKSPACE_TITLE); - await expect(row).toContainText(PRIMARY_HOST_LABEL); await expect(row).toContainText(WORKSPACE_BRANCH); + // The subtitle disambiguates by project: host · project · branch (multi-host). + const subtitle = row.getByTestId("command-center-workspace-subtitle"); + await expect(subtitle).toContainText(PRIMARY_HOST_LABEL); + await expect(subtitle).toContainText(seeded.projectDisplayName); + await expect(subtitle).toContainText(WORKSPACE_BRANCH); + + // The agent subtitle is unchanged by the shared-helper refactor. const agentRow = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`); await expect(agentRow).toContainText(AGENT_TITLE); await expect(agentRow).toContainText(PRIMARY_HOST_LABEL); @@ -89,6 +95,10 @@ test.describe("Command center workspaces", () => { await expect(agentRow).toBeVisible(); await expect(row).not.toBeVisible(); + // The project name is now part of the workspace searchText. + await input.fill(seeded.projectDisplayName); + await expect(row).toBeVisible(); + await input.fill(AGENT_TITLE); await expect(agentRow).toBeVisible(); await expect(row).not.toBeVisible(); @@ -103,4 +113,38 @@ test.describe("Command center workspaces", () => { await seeded.cleanup(); } }); + + test("single-host workspace subtitle omits the host and shows project · branch", async ({ + page, + }) => { + const seeded = await seedWorkspace({ + repoPrefix: "command-center-workspace-single-", + title: WORKSPACE_TITLE, + }); + + try { + execFileSync("git", ["checkout", "-b", WORKSPACE_BRANCH], { + cwd: seeded.repoPath, + stdio: "ignore", + }); + const refreshed = await seeded.client.checkoutRefresh(seeded.repoPath); + if (!refreshed.success) { + throw new Error(`Failed to refresh checkout: ${JSON.stringify(refreshed.error)}`); + } + + // No secondary host: with a single host, the host label is gated away. + await gotoAppShell(page); + + const panel = await openCommandCenter(page); + const row = panel.getByTestId( + `command-center-workspace-${getServerId()}:${seeded.workspaceId}`, + ); + await expect(row).toBeVisible({ timeout: 30_000 }); + + const subtitle = row.getByTestId("command-center-workspace-subtitle"); + await expect(subtitle).toHaveText(`${seeded.projectDisplayName} · ${WORKSPACE_BRANCH}`); + } finally { + await seeded.cleanup(); + } + }); }); diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index 584c80bfc..11d59048c 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -52,6 +52,7 @@ import type { CommandCenterContribution, CommandCenterIconProps } from "./contri import { useCommandCenterActions, useCommandCenterContributions } from "./provider"; import { buildContributionSections, + joinSubtitleParts, moveActiveResultId, preserveActiveResultId, projectCommandCenterRows, @@ -203,7 +204,7 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe const { t } = useTranslation(); const { agents } = useAggregatedAgents(); const { projects } = useProjects({ enabled: open }); - const showAgentHost = useHosts().length > 1; + const showHost = useHosts().length > 1; return useMemo(() => { if (!open) return []; @@ -213,9 +214,11 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe for (const workspace of host.workspaces) { if (workspace.archivingAt) continue; const title = workspace.title ?? workspace.name; - const subtitle = workspace.currentBranch - ? `${host.serverName} · ${workspace.currentBranch}` - : host.serverName; + const subtitle = joinSubtitleParts([ + showHost ? host.serverName : null, + project.projectName, + workspace.currentBranch, + ]); const searchText = `${title} ${subtitle}`.toLowerCase(); allWorkspaces.push({ kind: "workspace", @@ -251,13 +254,11 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe ? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`) : undefined; const location = workspaceTitle ?? shortenPath(agent.cwd); - const subtitle = [ - showAgentHost ? agent.serverLabel : null, + const subtitle = joinSubtitleParts([ + showHost ? agent.serverLabel : null, location, formatTimeAgo(agent.lastActivityAt), - ] - .filter((part): part is string => Boolean(part)) - .join(" · "); + ]); return { kind: "agent", id: `agent:${agent.serverId}:${agent.id}`, @@ -282,7 +283,7 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe }, { id: "agents", rank: 3, title: t("shell.commandCenter.agents"), results: agentResults }, ]; - }, [agents, open, projects, query, showAgentHost, t]); + }, [agents, open, projects, query, showHost, t]); } interface CommandCenterState { @@ -461,7 +462,11 @@ function ResultContent({ result }: { result: CommandCenterResult }) { {result.title} - + {result.subtitle} diff --git a/packages/app/src/command-center/results.test.ts b/packages/app/src/command-center/results.test.ts index 3405a6a51..0bc8b7626 100644 --- a/packages/app/src/command-center/results.test.ts +++ b/packages/app/src/command-center/results.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { CommandCenterContribution } from "./contributions"; import { buildContributionSections, + joinSubtitleParts, moveActiveResultId, preserveActiveResultId, projectCommandCenterRows, @@ -103,3 +104,35 @@ describe("Command Center result projection", () => { expect(projection.offsets[151]).toBe(32 + 150 * 56); }); }); + +describe("joinSubtitleParts", () => { + it("joins all present parts with a middle dot", () => { + expect(joinSubtitleParts(["a", "b", "c"])).toBe("a · b · c"); + }); + + it("drops null and undefined parts", () => { + expect(joinSubtitleParts(["host", null, "master"])).toBe("host · master"); + expect(joinSubtitleParts(["host", undefined, "master"])).toBe("host · master"); + }); + + it("drops empty strings (Boolean parity — agents subtitle refactor guard)", () => { + expect(joinSubtitleParts(["", "paseo", "master"])).toBe("paseo · master"); + }); + + it("returns an empty string when every part is null or empty", () => { + expect(joinSubtitleParts([null, undefined, ""])).toBe(""); + }); + + it("returns a single part unchanged, with no separator", () => { + expect(joinSubtitleParts([null, "paseo", null])).toBe("paseo"); + }); + + it("builds the workspace subtitle in host · project · branch order", () => { + // Single-host: host gated away, project leads. + expect(joinSubtitleParts([null, "paseo", "master"])).toBe("paseo · master"); + // Multi-host: host first, then project, then branch. + expect(joinSubtitleParts(["host", "paseo", "master"])).toBe("host · paseo · master"); + // No branch: degrades to project (or host · project). + expect(joinSubtitleParts([null, "paseo", null])).toBe("paseo"); + }); +}); diff --git a/packages/app/src/command-center/results.ts b/packages/app/src/command-center/results.ts index ab2f3a837..da3ea5c25 100644 --- a/packages/app/src/command-center/results.ts +++ b/packages/app/src/command-center/results.ts @@ -63,6 +63,11 @@ function matchesQuery(searchText: string, query: string): boolean { return !normalized || searchText.includes(normalized); } +/** Join non-empty subtitle parts with " · ", dropping null/undefined/empty. */ +export function joinSubtitleParts(parts: readonly (string | null | undefined)[]): string { + return parts.filter((part): part is string => Boolean(part)).join(" · "); +} + function contributionSearchText(contribution: CommandCenterContribution): string { const presentationText = contribution.presentation.kind === "action"