From 19565f6605a83083b5968cd5dc89e163510e9bed Mon Sep 17 00:00:00 2001 From: Christoph Leiter Date: Fri, 24 Jul 2026 15:15:50 +0200 Subject: [PATCH] fix(app): show project name in command center workspace search (#2345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Searching the command center for a branch name like "master" returned a Workspaces list where every row looked identical: the title was the branch and the subtitle read " · ". On a single-host machine the hostname repeats on every row, so there was no way to tell which project each "master" workspace belonged to. Add the project name to the workspace subtitle (host · project · branch) and gate the host label behind multi-host, matching the Agents section. The host is dropped on single-host setups, so the subtitle reads "project · branch" and rows are distinguishable. Because searchText is derived from the subtitle, typing a project name now matches its workspaces too. Extract the shared join into joinSubtitleParts() and route both the workspace and agent subtitles through it, removing the duplicated filter/join the Agents section hand-rolled. Co-authored-by: Claude Opus 4.8 --- .../app/e2e/command-center-workspaces.spec.ts | 46 ++++++++++++++++++- .../app/src/command-center/command-center.tsx | 27 ++++++----- .../app/src/command-center/results.test.ts | 33 +++++++++++++ packages/app/src/command-center/results.ts | 5 ++ 4 files changed, 99 insertions(+), 12 deletions(-) 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"