From 90e0a0e353a90e12d91d88a91e64dda7984928c0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Jul 2026 20:40:24 +0200 Subject: [PATCH] Find and open workspaces from the command center (#2096) * feat(app): add workspaces to command center * fix(app): scale down command center typography * fix(app): avoid workspace theme subscription * fix(app): theme command center bottom sheet * fix(app): tighten command center workspace results --- packages/app/e2e/command-center-host.spec.ts | 23 ++ .../app/e2e/command-center-workspaces.spec.ts | 106 ++++++++ packages/app/e2e/helpers/seed-client.ts | 2 + .../app/src/components/command-center.tsx | 230 +++++++++++------- packages/app/src/hooks/use-command-center.ts | 225 ++++++++++++----- packages/app/src/hooks/use-projects.ts | 51 +++- packages/app/src/i18n/resources.test.ts | 3 +- packages/app/src/i18n/resources/ar.ts | 3 +- packages/app/src/i18n/resources/en.ts | 3 +- packages/app/src/i18n/resources/es.ts | 3 +- packages/app/src/i18n/resources/fr.ts | 3 +- packages/app/src/i18n/resources/ja.ts | 3 +- packages/app/src/i18n/resources/pt-BR.ts | 3 +- packages/app/src/i18n/resources/ru.ts | 3 +- packages/app/src/i18n/resources/zh-CN.ts | 3 +- packages/app/src/utils/projects.test.ts | 50 +++- packages/app/src/utils/projects.ts | 7 +- 17 files changed, 545 insertions(+), 176 deletions(-) create mode 100644 packages/app/e2e/command-center-workspaces.spec.ts diff --git a/packages/app/e2e/command-center-host.spec.ts b/packages/app/e2e/command-center-host.spec.ts index e0d78d2d6..652bca55f 100644 --- a/packages/app/e2e/command-center-host.spec.ts +++ b/packages/app/e2e/command-center-host.spec.ts @@ -5,6 +5,7 @@ import { gotoAppShell } from "./helpers/app"; import { createIdleAgent } from "./helpers/archive-tab"; import { openCommandCenter } from "./helpers/command-center"; import { addOfflineHostAndReload } from "./helpers/hosts"; +import { expectAgentTabActive } from "./helpers/launcher"; import { seedWorkspace } from "./helpers/seed-client"; import { getServerId } from "./helpers/server-id"; @@ -46,4 +47,26 @@ test.describe("Command center host labels", () => { await seeded.cleanup(); } }); + + test("selecting an agent result opens its workspace tab", async ({ page }) => { + const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" }); + const title = `cc-navigation-${randomUUID().slice(0, 8)}`; + + try { + const agent = await createIdleAgent(seeded.client, { + cwd: seeded.repoPath, + workspaceId: seeded.workspaceId, + title, + }); + + await gotoAppShell(page); + const panel = await openCommandCenter(page); + await panel.getByTestId("command-center-input").fill(title); + await page.keyboard.press("Enter"); + + await expectAgentTabActive(page, agent.id); + } finally { + await seeded.cleanup(); + } + }); }); diff --git a/packages/app/e2e/command-center-workspaces.spec.ts b/packages/app/e2e/command-center-workspaces.spec.ts new file mode 100644 index 000000000..e03c21a3f --- /dev/null +++ b/packages/app/e2e/command-center-workspaces.spec.ts @@ -0,0 +1,106 @@ +import { execFileSync } from "node:child_process"; +import { expect } from "@playwright/test"; +import { test } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { createIdleAgent } from "./helpers/archive-tab"; +import { openCommandCenter } from "./helpers/command-center"; +import { addOfflineHostAndReload } from "./helpers/hosts"; +import { expectAppRoute } from "./helpers/route-assertions"; +import { seedWorkspace } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; + +const PRIMARY_HOST_LABEL = "Primary Host"; +const SECONDARY_HOST_ID = "host-command-center-workspaces-secondary"; +const WORKSPACE_TITLE = "Payments Refactor"; +const WORKSPACE_BRANCH = "feature/cmd-k-workspaces"; +const AGENT_TITLE = "Fix checkout retries"; + +test.describe("Command center workspaces", () => { + test.describe.configure({ timeout: 180_000 }); + + test("workspace results show their title, host, and branch and open the workspace", async ({ + page, + }) => { + const seeded = await seedWorkspace({ + repoPrefix: "command-center-workspace-", + 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)}`); + } + const agent = await createIdleAgent(seeded.client, { + cwd: seeded.repoPath, + workspaceId: seeded.workspaceId, + title: AGENT_TITLE, + }); + + await gotoAppShell(page); + await addOfflineHostAndReload(page, { + serverId: SECONDARY_HOST_ID, + label: "Secondary Host", + primaryLabel: PRIMARY_HOST_LABEL, + }); + + const panel = await openCommandCenter(page); + const row = panel.getByTestId( + `command-center-workspace-${getServerId()}:${seeded.workspaceId}`, + ); + 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); + + const agentRow = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`); + await expect(agentRow).toContainText(AGENT_TITLE); + await expect(agentRow).toContainText(PRIMARY_HOST_LABEL); + await expect(agentRow).toContainText(WORKSPACE_TITLE); + await expect(agentRow).not.toContainText(seeded.repoPath); + + const workspaceSectionTop = await panel + .getByText("Workspaces", { exact: true }) + .evaluate((element) => element.getBoundingClientRect().top); + const agentSectionTop = await panel + .getByText("Agents", { exact: true }) + .evaluate((element) => element.getBoundingClientRect().top); + expect(workspaceSectionTop).toBeLessThan(agentSectionTop); + + const input = panel.getByTestId("command-center-input"); + await input.fill(PRIMARY_HOST_LABEL); + await expect(row).toBeVisible(); + await expect(agentRow).toBeVisible(); + + await input.fill(WORKSPACE_BRANCH); + await expect(row).toBeVisible(); + await expect(agentRow).not.toBeVisible(); + + await input.fill(WORKSPACE_TITLE); + await expect(row).toBeVisible(); + await expect(agentRow).toBeVisible(); + + await input.fill(seeded.repoPath); + await expect(agentRow).toBeVisible(); + await expect(row).not.toBeVisible(); + + await input.fill(AGENT_TITLE); + await expect(agentRow).toBeVisible(); + await expect(row).not.toBeVisible(); + + await input.fill(WORKSPACE_TITLE); + await page.keyboard.press("Enter"); + + await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), { + timeout: 30_000, + }); + } finally { + await seeded.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/helpers/seed-client.ts b/packages/app/e2e/helpers/seed-client.ts index 1f2ab0447..3cb5abdfe 100644 --- a/packages/app/e2e/helpers/seed-client.ts +++ b/packages/app/e2e/helpers/seed-client.ts @@ -183,6 +183,7 @@ export interface SeededWorkspace { export async function seedWorkspace(options: { repoPrefix: string; + title?: string; /** Repo fixture options; only applies to git projects (the default). */ repo?: Parameters[1]; /** Set to false to seed a plain non-git directory instead of a git repo. */ @@ -196,6 +197,7 @@ export async function seedWorkspace(options: { try { const created = await client.createWorkspace({ source: { kind: "directory", path: project.path }, + title: options.title, }); if (!created.workspace) { throw new Error(created.error ?? `Failed to create workspace ${project.path}`); diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx index 1288bafa0..fe609169f 100644 --- a/packages/app/src/components/command-center.tsx +++ b/packages/app/src/components/command-center.tsx @@ -9,13 +9,15 @@ import { } from "react-native"; import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { Home, Plus, Settings } from "lucide-react-native"; +import { Folder, Home, Plus, Settings } from "lucide-react-native"; import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; -import { useCommandCenter } from "@/hooks/use-command-center"; -import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; -import { useHosts } from "@/runtime/host-runtime"; -import { formatTimeAgo } from "@/utils/time"; -import { shortenPath } from "@/utils/shorten-path"; +import { + useCommandCenter, + type CommandCenterActionItem, + type CommandCenterAgentItem, + type CommandCenterItem, + type CommandCenterWorkspaceItem, +} from "@/hooks/use-command-center"; import { AgentStatusDot } from "@/components/agent-status-dot"; import { Shortcut } from "@/components/ui/shortcut"; import { isNative, isWeb } from "@/constants/platform"; @@ -30,13 +32,12 @@ import { BottomSheetTextInput, } from "@gorhom/bottom-sheet"; -function agentKey(agent: Pick): string { - return `${agent.serverId}:${agent.id}`; -} - const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({ placeholderTextColor: theme.colors.foregroundMuted, })); +const ThemedFolder = withUnistyles(Folder, (theme) => ({ + color: theme.colors.foregroundMuted, +})); interface CommandCenterRowProps { active: boolean; @@ -73,22 +74,25 @@ const CommandCenterRow = memo(function CommandCenterRow({ }); interface CommandCenterRowContainerProps { + item: CommandCenterItem; rowIndex: number; active: boolean; rowRefs: React.MutableRefObject>; - onPress: () => void; + onSelect: (item: CommandCenterItem) => void; onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void; children: ReactNode; } function CommandCenterRowContainer({ + item, rowIndex, active, rowRefs, - onPress, + onSelect, onLayout, children, }: CommandCenterRowContainerProps) { + const handlePress = useCallback(() => onSelect(item), [onSelect, item]); const registerRow = useCallback( (el: View | null) => { if (el) rowRefs.current.set(rowIndex, el); @@ -100,7 +104,7 @@ function CommandCenterRowContainer({ {children} @@ -109,12 +113,12 @@ function CommandCenterRowContainer({ } interface CommandCenterActionRowProps { - item: Extract["items"][number], { kind: "action" }>; + item: CommandCenterActionItem; rowIndex: number; active: boolean; rowRefs: React.MutableRefObject>; onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void; - onSelect: (item: ReturnType["items"][number]) => void; + onSelect: (item: CommandCenterItem) => void; } function CommandCenterActionRow({ @@ -126,14 +130,12 @@ function CommandCenterActionRow({ onSelect, }: CommandCenterActionRowProps) { const { theme } = useUnistyles(); - const handlePress = useCallback(() => onSelect(item), [onSelect, item]); - const action = item.action; let actionIcon: React.ReactNode = null; - if (action.icon === "plus") { + if (item.icon === "plus") { actionIcon = ; - } else if (action.icon === "settings") { + } else if (item.icon === "settings") { actionIcon = ; - } else if (action.icon === "home") { + } else if (item.icon === "home") { actionIcon = ; } const titleStyle = useMemo( @@ -142,10 +144,11 @@ function CommandCenterActionRow({ ); return ( @@ -153,59 +156,25 @@ function CommandCenterActionRow({ {actionIcon ? {actionIcon} : null} - {action.title} + {item.title} - {action.shortcutKeys ? ( - + {item.shortcutKeys ? ( + ) : null} ); } -interface CommandCenterAgentRowProps { - item: Extract["items"][number], { kind: "agent" }>; - rowIndex: number; - active: boolean; - rowRefs: React.MutableRefObject>; - onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void; - onSelect: (item: ReturnType["items"][number]) => void; - children: ReactNode; -} - -function CommandCenterAgentRow({ - rowIndex, - active, - rowRefs, - onLayout, - onSelect, - item, - children, -}: CommandCenterAgentRowProps) { - const handlePress = useCallback(() => onSelect(item), [onSelect, item]); - return ( - - {children} - - ); -} - interface CommandCenterAgentRowContentProps { - agent: AggregatedAgent; - showHost: boolean; + item: CommandCenterAgentItem; } -function CommandCenterAgentRowContent({ agent, showHost }: CommandCenterAgentRowContentProps) { +function CommandCenterAgentRowContent({ item }: CommandCenterAgentRowContentProps) { const { theme } = useUnistyles(); - const { t } = useTranslation(); + const agent = item.agent; const titleStyle = useMemo( () => [styles.title, { color: theme.colors.foreground }], [theme.colors.foreground], @@ -226,11 +195,10 @@ function CommandCenterAgentRowContent({ agent, showHost }: CommandCenterAgentRow - {agent.title || t("shell.commandCenter.newAgent")} + {item.title} - {showHost ? `${agent.serverLabel} · ` : ""} - {shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)} + {item.subtitle} @@ -239,42 +207,40 @@ function CommandCenterAgentRowContent({ agent, showHost }: CommandCenterAgentRow } interface AgentItemsSectionProps { - agentItems: Extract["items"][number], { kind: "agent" }>[]; - actionItemsLength: number; + agentItems: CommandCenterAgentItem[]; + startIndex: number; activeIndex: number; rowRefs: React.MutableRefObject>; onRowLayout: ( rowIndex: number, ) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => void; - onSelect: (item: ReturnType["items"][number]) => void; + onSelect: (item: CommandCenterItem) => void; sectionDividerStyle: React.ComponentProps["style"]; sectionLabelStyle: React.ComponentProps["style"]; - showHost: boolean; } function AgentItemsSection({ agentItems, - actionItemsLength, + startIndex, activeIndex, rowRefs, onRowLayout, onSelect, sectionDividerStyle, sectionLabelStyle, - showHost, }: AgentItemsSectionProps) { const { t } = useTranslation(); return ( <> - {actionItemsLength > 0 ? : null} + {startIndex > 0 ? : null} {t("shell.commandCenter.agents")} {agentItems.map((item, index) => { - const rowIndex = actionItemsLength + index; + const rowIndex = startIndex + index; const agent = item.agent; return ( - - - + + + ); + })} + + ); +} + +interface WorkspaceItemsSectionProps { + workspaceItems: CommandCenterWorkspaceItem[]; + startIndex: number; + activeIndex: number; + rowRefs: React.MutableRefObject>; + onRowLayout: ( + rowIndex: number, + ) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => void; + onSelect: (item: CommandCenterItem) => void; + sectionDividerStyle: React.ComponentProps["style"]; + sectionLabelStyle: React.ComponentProps["style"]; +} + +function WorkspaceItemsSection({ + workspaceItems, + startIndex, + activeIndex, + rowRefs, + onRowLayout, + onSelect, + sectionDividerStyle, + sectionLabelStyle, +}: WorkspaceItemsSectionProps) { + const { t } = useTranslation(); + + return ( + <> + {startIndex > 0 ? : null} + {t("shell.commandCenter.workspaces")} + {workspaceItems.map((item, index) => { + const rowIndex = startIndex + index; + return ( + + + + + + + + + {item.title} + + + {item.subtitle} + + + + + ); })} @@ -307,9 +339,6 @@ export function CommandCenter() { const isCompact = useIsCompactFormFactor(); const showBottomSheet = isCompact && isNative; - // Host names only earn their space once results can span more than one host. - const showHost = useHosts().length > 1; - const rowRefs = useRef>(new Map()); const rowLayouts = useRef>(new Map()); const resultsRef = useRef(null); @@ -409,6 +438,7 @@ export function CommandCenter() { ); const actionItems = useMemo(() => items.filter((item) => item.kind === "action"), [items]); + const workspaceItems = useMemo(() => items.filter((item) => item.kind === "workspace"), [items]); const agentItems = useMemo(() => items.filter((item) => item.kind === "agent"), [items]); const panelStyle = useMemo( @@ -438,6 +468,14 @@ export function CommandCenter() { () => [styles.sectionDivider, { backgroundColor: theme.colors.border }], [theme.colors.border], ); + const sheetBackgroundStyle = useMemo( + () => ({ backgroundColor: theme.colors.surface0 }), + [theme.colors.surface0], + ); + const sheetHandleStyle = useMemo( + () => ({ backgroundColor: theme.colors.palette.zinc[600] }), + [theme.colors.palette.zinc], + ); const handleKeyPress = useCallback( ({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => { @@ -462,7 +500,7 @@ export function CommandCenter() { {t("shell.commandCenter.actions")} {actionItems.map((item, index) => ( ) : null} - {agentItems.length > 0 ? ( - 0 ? ( + + ) : null} + + {agentItems.length > 0 ? ( + ) : null} @@ -502,6 +552,8 @@ export function CommandCenter() { onDismiss={handleSheetDismiss} backdropComponent={renderBackdrop} enablePanDownToClose + backgroundStyle={sheetBackgroundStyle} + handleIndicatorStyle={sheetHandleStyle} keyboardBehavior="extend" keyboardBlurBehavior="restore" accessible={false} @@ -603,7 +655,7 @@ const styles = StyleSheet.create((theme) => ({ borderBottomWidth: 1, }, input: { - fontSize: theme.fontSize.lg, + fontSize: theme.fontSize.base, paddingVertical: theme.spacing[1], outlineStyle: "none", } as object, @@ -657,13 +709,15 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 0, }, title: { - fontSize: theme.fontSize.base, + fontSize: theme.fontSize.sm, fontWeight: "400", lineHeight: 20, + color: theme.colors.foreground, }, subtitle: { - fontSize: theme.fontSize.sm, + fontSize: theme.fontSize.xs, lineHeight: 18, + color: theme.colors.foregroundMuted, }, emptyText: { paddingHorizontal: theme.spacing[4], diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 60ba70297..414fb4c94 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -20,17 +20,24 @@ import { getIsElectronRuntime } from "@/constants/layout"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { focusWithRetries } from "@/utils/web-focus"; import { isWeb } from "@/constants/platform"; +import { useProjects } from "@/hooks/use-projects"; +import { useHosts } from "@/runtime/host-runtime"; +import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; +import { formatTimeAgo } from "@/utils/time"; +import { shortenPath } from "@/utils/shorten-path"; -const EMPTY_AGENTS: AggregatedAgent[] = []; const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = []; +const EMPTY_WORKSPACE_ITEMS: CommandCenterWorkspaceItem[] = []; +const EMPTY_AGENT_ITEMS: CommandCenterAgentItem[] = []; const EMPTY_COMMAND_CENTER_ITEMS: CommandCenterItem[] = []; -function isMatch(agent: AggregatedAgent, query: string): boolean { - if (!query) return true; - const q = query.toLowerCase(); - const title = (agent.title ?? "New agent").toLowerCase(); - const cwd = agent.cwd.toLowerCase(); - return title.includes(q) || cwd.includes(q); +function buildSearchText(...fields: string[]): string { + return fields.join(" ").toLowerCase(); +} + +function matchesQuery(searchText: string, query: string): boolean { + const normalized = query.trim().toLowerCase(); + return !normalized || searchText.includes(normalized); } function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number { @@ -86,19 +93,6 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [ }, ]; -function matchesActionQuery( - query: string, - action: CommandCenterActionDefinition, - title: string, -): boolean { - const normalized = query.trim().toLowerCase(); - if (!normalized) return true; - if (title.toLowerCase().includes(normalized)) { - return true; - } - return action.keywords.some((keyword) => keyword.includes(normalized)); -} - export interface CommandCenterActionItem { kind: "action"; id: string; @@ -106,17 +100,30 @@ export interface CommandCenterActionItem { icon?: "plus" | "settings" | "home"; route?: Href; shortcutKeys?: ShortcutKey[][]; + searchText: string; +} + +export interface CommandCenterWorkspaceItem { + kind: "workspace"; + serverId: string; + workspaceId: string; + title: string; + subtitle: string; + searchText: string; +} + +export interface CommandCenterAgentItem { + kind: "agent"; + agent: AggregatedAgent; + title: string; + subtitle: string; + searchText: string; } export type CommandCenterItem = - | { - kind: "action"; - action: CommandCenterActionItem; - } - | { - kind: "agent"; - agent: AggregatedAgent; - }; + | CommandCenterActionItem + | CommandCenterWorkspaceItem + | CommandCenterAgentItem; function resolveActionShortcutKeys( actionId: string | undefined, @@ -150,15 +157,96 @@ export function useCommandCenter() { const [activeIndex, setActiveIndex] = useState(0); const { agents } = useAggregatedAgents(); + const { projects } = useProjects({ enabled: open }); + const hosts = useHosts(); + const showAgentHost = hosts.length > 1; + + const allWorkspaceItems = useMemo(() => { + const results: CommandCenterWorkspaceItem[] = []; + for (const project of projects) { + for (const host of project.hosts) { + 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; + results.push({ + kind: "workspace", + serverId: host.serverId, + workspaceId: workspace.id, + title, + subtitle, + searchText: buildSearchText(title, subtitle), + }); + } + } + } + results.sort((left, right) => { + const titleDelta = left.title.localeCompare(right.title, undefined, { + numeric: true, + sensitivity: "base", + }); + if (titleDelta !== 0) return titleDelta; + const hostDelta = left.subtitle.localeCompare(right.subtitle, undefined, { + numeric: true, + sensitivity: "base", + }); + if (hostDelta !== 0) return hostDelta; + return `${left.serverId}:${left.workspaceId}`.localeCompare( + `${right.serverId}:${right.workspaceId}`, + ); + }); + return results; + }, [projects]); + + const workspaceTitleByKey = useMemo( + () => + new Map( + allWorkspaceItems.map((workspace) => [ + `${workspace.serverId}:${workspace.workspaceId}`, + workspace.title, + ]), + ), + [allWorkspaceItems], + ); + + const workspaceResults = useMemo(() => { + if (!open || allWorkspaceItems.length === 0) { + return EMPTY_WORKSPACE_ITEMS; + } + return allWorkspaceItems.filter((workspace) => matchesQuery(workspace.searchText, query)); + }, [allWorkspaceItems, open, query]); const agentResults = useMemo(() => { if (!open || agents.length === 0) { - return EMPTY_AGENTS; + return EMPTY_AGENT_ITEMS; } - const filtered = agents.filter((agent) => isMatch(agent, query)); - filtered.sort(sortAgents); + const items = agents.map((agent) => { + const title = agent.title || t("shell.commandCenter.newAgent"); + const workspaceTitle = agent.workspaceId + ? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`) + : undefined; + const location = workspaceTitle ?? shortenPath(agent.cwd); + const subtitle = [ + showAgentHost ? agent.serverLabel : null, + location, + formatTimeAgo(agent.lastActivityAt), + ] + .filter((part): part is string => Boolean(part)) + .join(" · "); + return { + kind: "agent", + agent, + title, + subtitle, + searchText: buildSearchText(title, subtitle, agent.cwd), + }; + }); + const filtered = items.filter((item) => matchesQuery(item.searchText, query)); + filtered.sort((left, right) => sortAgents(left.agent, right.agent)); return filtered; - }, [agents, open, query]); + }, [agents, open, query, showAgentHost, t, workspaceTitleByKey]); const settingsRoute = useMemo(() => { return buildSettingsRoute(); @@ -170,43 +258,33 @@ export function useCommandCenter() { if (!open) { return EMPTY_ACTION_ITEMS; } - return COMMAND_CENTER_ACTIONS.filter((action) => { - if (action.routeKind === "home" && !homeRoute) return false; - return matchesActionQuery(query, action, t(action.titleKey)); - }).map((action) => { - let route: Href | undefined; - if (action.routeKind === "settings") route = settingsRoute; - else if (action.routeKind === "home") route = homeRoute; - return { - kind: "action", - id: action.id, - title: t(action.titleKey), - icon: action.icon, - route, - shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides), - }; - }); + return COMMAND_CENTER_ACTIONS.filter( + (action) => action.routeKind !== "home" || Boolean(homeRoute), + ) + .map((action) => { + let route: Href | undefined; + if (action.routeKind === "settings") route = settingsRoute; + else if (action.routeKind === "home") route = homeRoute; + const title = t(action.titleKey); + return { + kind: "action", + id: action.id, + title, + icon: action.icon, + route, + shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides), + searchText: buildSearchText(title, ...action.keywords), + }; + }) + .filter((action) => matchesQuery(action.searchText, query)); }, [open, query, settingsRoute, homeRoute, overrides, t]); const items = useMemo(() => { if (!open) { return EMPTY_COMMAND_CENTER_ITEMS; } - const next: CommandCenterItem[] = []; - for (const action of actionItems) { - next.push({ - kind: "action", - action, - }); - } - for (const agent of agentResults) { - next.push({ - kind: "agent", - agent, - }); - } - return next; - }, [actionItems, agentResults, open]); + return [...actionItems, ...workspaceResults, ...agentResults]; + }, [actionItems, workspaceResults, agentResults, open]); const handleClose = useCallback(() => { setOpen(false); @@ -227,6 +305,19 @@ export function useCommandCenter() { [setOpen], ); + const handleSelectWorkspace = useCallback( + (workspace: CommandCenterWorkspaceItem) => { + didNavigateRef.current = true; + clearCommandCenterFocusRestoreElement(); + setOpen(false); + navigateToWorkspace({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + }); + }, + [setOpen], + ); + const openProjectPicker = useOpenProjectPicker(); const handleSelectAction = useCallback( @@ -249,12 +340,16 @@ export function useCommandCenter() { const handleSelectItem = useCallback( (item: CommandCenterItem) => { if (item.kind === "action") { - handleSelectAction(item.action); + handleSelectAction(item); + return; + } + if (item.kind === "workspace") { + handleSelectWorkspace(item); return; } handleSelectAgent(item.agent); }, - [handleSelectAction, handleSelectAgent], + [handleSelectAction, handleSelectAgent, handleSelectWorkspace], ); useEffect(() => { diff --git a/packages/app/src/hooks/use-projects.ts b/packages/app/src/hooks/use-projects.ts index 61a17a03b..d6fc3803f 100644 --- a/packages/app/src/hooks/use-projects.ts +++ b/packages/app/src/hooks/use-projects.ts @@ -50,6 +50,13 @@ export interface UseProjectsResult { refetch: () => void; } +export interface UseProjectsOptions { + enabled?: boolean; +} + +const EMPTY_PROJECT_HOST_REPLICAS: ProjectHostReplica[] = []; +const EMPTY_PROJECT_HOST_RUNTIME_STATES: ProjectHostRuntimeState[] = []; + function toProjectHostRuntimeState( serverId: string, snapshot: HostRuntimeSnapshot | null, @@ -68,7 +75,11 @@ function toProjectHostRuntimeState( function selectProjectHostReplicas( hosts: readonly { serverId: string; label: string }[], + enabled: boolean, ): (state: ReturnType) => ProjectHostReplica[] { + if (!enabled) { + return () => EMPTY_PROJECT_HOST_REPLICAS; + } return (state) => hosts.map((host) => { const session = state.sessions[host.serverId]; @@ -119,15 +130,24 @@ export function deriveProjectsFromReplica(input: { }; } -function useProjectHostRuntimeStates(serverIds: readonly string[]): ProjectHostRuntimeState[] { +function useProjectHostRuntimeStates( + serverIds: readonly string[], + enabled: boolean, +): ProjectHostRuntimeState[] { const runtime = getHostRuntimeStore(); const previousStatesRef = useRef([]); - const runtimeSnapshotTick = useSyncExternalStore( - (onStoreChange) => runtime.subscribeAll(onStoreChange), - () => runtime.getVersion(), - () => runtime.getVersion(), + const subscribe = useCallback( + (onStoreChange: () => void) => + enabled ? runtime.subscribeAll(onStoreChange) : () => undefined, + [enabled, runtime], ); + const getSnapshot = useCallback(() => (enabled ? runtime.getVersion() : 0), [enabled, runtime]); + const runtimeSnapshotTick = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); return useMemo(() => { + if (!enabled) { + previousStatesRef.current = EMPTY_PROJECT_HOST_RUNTIME_STATES; + return EMPTY_PROJECT_HOST_RUNTIME_STATES; + } void runtimeSnapshotTick; const nextStates = serverIds.map((serverId) => toProjectHostRuntimeState(serverId, runtime.getSnapshot(serverId)), @@ -137,22 +157,31 @@ function useProjectHostRuntimeStates(serverIds: readonly string[]): ProjectHostR } previousStatesRef.current = nextStates; return nextStates; - }, [runtime, runtimeSnapshotTick, serverIds]); + }, [enabled, runtime, runtimeSnapshotTick, serverIds]); } -export function useProjects(): UseProjectsResult { +export function useProjects(options: UseProjectsOptions = {}): UseProjectsResult { + const enabled = options.enabled ?? true; const hosts = useHosts(); const runtime = getHostRuntimeStore(); - const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); - const replicas = useStoreWithEqualityFn(useSessionStore, selectProjectHostReplicas(hosts), equal); - const runtimeStates = useProjectHostRuntimeStates(serverIds); + const serverIds = useMemo( + () => (enabled ? hosts.map((host) => host.serverId) : []), + [enabled, hosts], + ); + const replicaSelector = useMemo( + () => selectProjectHostReplicas(hosts, enabled), + [enabled, hosts], + ); + const replicas = useStoreWithEqualityFn(useSessionStore, replicaSelector, equal); + const runtimeStates = useProjectHostRuntimeStates(serverIds, enabled); const derived = useMemo( () => deriveProjectsFromReplica({ replicas, runtimeStates }), [replicas, runtimeStates], ); const refetch = useCallback(() => { + if (!enabled) return; runtime.refreshAllAgentDirectories({ serverIds }); - }, [runtime, serverIds]); + }, [enabled, runtime, serverIds]); return { ...derived, diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 19a17acdc..dbfa68d85 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -176,9 +176,10 @@ describe("translation resources", () => { expect(en.shell.menu.toggleSidebar).toBe("Toggle sidebar"); expect(en.shell.menu.open).toBe("Open menu"); expect(en.shell.menu.close).toBe("Close menu"); - expect(en.shell.commandCenter.placeholder).toBe("Type a command or search agents..."); + expect(en.shell.commandCenter.placeholder).toBe("Search commands, workspaces, and agents..."); expect(en.shell.commandCenter.noMatches).toBe("No matches"); expect(en.shell.commandCenter.actions).toBe("Actions"); + expect(en.shell.commandCenter.workspaces).toBe("Workspaces"); expect(en.shell.commandCenter.agents).toBe("Agents"); expect(en.shell.commandCenter.newAgent).toBe("New agent"); expect(en.shell.commandCenter.openProject).toBe("Open project"); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index db9cb8e70..99b6f3566 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -54,9 +54,10 @@ export const ar: TranslationResources = { close: "إغلاق القائمة", }, commandCenter: { - placeholder: "اكتب أمرًا أو وكلاء بحث...", + placeholder: "ابحث في الأوامر ومساحات العمل والوكلاء...", noMatches: "لا توجد مباريات", actions: "الإجراءات", + workspaces: "مساحات العمل", agents: "الوكلاء", newAgent: "وكيل جديد", openProject: "مشروع مفتوح", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index b1099e66f..3da4cff63 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -52,9 +52,10 @@ export const en = { close: "Close menu", }, commandCenter: { - placeholder: "Type a command or search agents...", + placeholder: "Search commands, workspaces, and agents...", noMatches: "No matches", actions: "Actions", + workspaces: "Workspaces", agents: "Agents", newAgent: "New agent", openProject: "Open project", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 085435438..98bae6e3b 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -54,9 +54,10 @@ export const es: TranslationResources = { close: "Cerrar menú", }, commandCenter: { - placeholder: "Escriba un comando o busque agentes...", + placeholder: "Buscar comandos, espacios de trabajo y agentes...", noMatches: "No hay coincidencias", actions: "Comportamiento", + workspaces: "Espacios de trabajo", agents: "Agentes", newAgent: "Nuevo agente", openProject: "Abrir proyecto", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 302d268c0..8c7f48495 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -55,9 +55,10 @@ export const fr: TranslationResources = { close: "Fermer le menu", }, commandCenter: { - placeholder: "Tapez une commande ou recherchez des agents...", + placeholder: "Rechercher des commandes, espaces de travail et agents...", noMatches: "Aucune correspondance", actions: "Actes", + workspaces: "Espaces de travail", agents: "Agents", newAgent: "Nouvel agent", openProject: "Projet ouvert", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index a59d26e72..3b703aa93 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -54,9 +54,10 @@ export const ja: TranslationResources = { close: "メニューを閉じる", }, commandCenter: { - placeholder: "コマンドを入力またはエージェントを検索...", + placeholder: "コマンド、ワークスペース、エージェントを検索...", noMatches: "一致なし", actions: "アクション", + workspaces: "ワークスペース", agents: "エージェント", newAgent: "新しいエージェント", openProject: "プロジェクトを開く", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index b5db0d657..755d46265 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -54,9 +54,10 @@ export const ptBR: TranslationResources = { close: "Fechar menu", }, commandCenter: { - placeholder: "Digite um comando ou busque agentes...", + placeholder: "Buscar comandos, espaços de trabalho e agentes...", noMatches: "Nenhuma correspondência", actions: "Ações", + workspaces: "Espaços de trabalho", agents: "Agentes", newAgent: "Novo agente", openProject: "Abrir projeto", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index afa6d4c0a..ed5313e67 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -54,9 +54,10 @@ export const ru: TranslationResources = { close: "Закрыть меню", }, commandCenter: { - placeholder: "Введите команду или найдите агентов...", + placeholder: "Поиск команд, рабочих пространств и агентов...", noMatches: "Нет совпадений", actions: "Действия", + workspaces: "Рабочие пространства", agents: "Агенты", newAgent: "Новый агент", openProject: "Открыть проект", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index e947d3622..4c788fce8 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -54,9 +54,10 @@ export const zhCN: TranslationResources = { close: "关闭菜单", }, commandCenter: { - placeholder: "输入命令或搜索 Agent...", + placeholder: "搜索命令、工作区和 Agent...", noMatches: "没有匹配项", actions: "操作", + workspaces: "工作区", agents: "Agents", newAgent: "新建 Agent", openProject: "打开项目", diff --git a/packages/app/src/utils/projects.test.ts b/packages/app/src/utils/projects.test.ts index f1425ce92..62f39ca2c 100644 --- a/packages/app/src/utils/projects.test.ts +++ b/packages/app/src/utils/projects.test.ts @@ -32,6 +32,8 @@ function workspace(input: { projectId?: string; projectName?: string; remoteUrl?: string | null; + currentBranch?: string; + archivingAt?: string | null; }): WorkspaceDescriptor { return { id: input.id, @@ -43,12 +45,12 @@ function workspace(input: { workspaceKind: "local_checkout", name: input.id, status: "done", - archivingAt: null, + archivingAt: input.archivingAt ?? null, statusEnteredAt: null, diffStat: null, scripts: [], gitRuntime: { - currentBranch: "main", + currentBranch: input.currentBranch ?? "main", remoteUrl: input.remoteUrl ?? input.project?.checkout.remoteUrl ?? null, isPaseoOwnedWorktree: false, isDirty: false, @@ -62,6 +64,50 @@ function workspace(input: { } describe("buildProjects", () => { + it("normalizes detached and blank branches out of workspace summaries", () => { + const result = buildProjects({ + hosts: [ + { + serverId: "local", + serverName: "Local", + isOnline: true, + workspaces: [ + workspace({ id: "detached", repoRoot: "/repo/app", currentBranch: "HEAD" }), + workspace({ id: "blank", repoRoot: "/repo/app", currentBranch: " " }), + ], + }, + ], + }); + + expect(result.projects[0]?.hosts[0]?.workspaces).toMatchObject([ + { id: "detached", currentBranch: null }, + { id: "blank", currentBranch: null }, + ]); + }); + + it("carries active archive state into workspace summaries", () => { + const archivingAt = "2026-07-15T18:00:00.000Z"; + const result = buildProjects({ + hosts: [ + { + serverId: "local", + serverName: "Local", + isOnline: true, + workspaces: [ + workspace({ id: "archiving", repoRoot: "/repo/app", archivingAt }), + workspace({ id: "active", repoRoot: "/repo/app" }), + ], + }, + ], + }); + + expect(result.projects[0]?.hosts[0]?.workspaces).toMatchObject([ + { id: "archiving", archivingAt }, + { id: "active" }, + ]); + expect(result.projects[0]?.hosts[0]?.workspaces[1]).not.toHaveProperty("archivingAt"); + }); + it("groups two daemons with the same GitHub project key into one project with one host entry per daemon", () => { const result = buildProjects({ hosts: [ diff --git a/packages/app/src/utils/projects.ts b/packages/app/src/utils/projects.ts index 9892bcf1c..df2f19761 100644 --- a/packages/app/src/utils/projects.ts +++ b/packages/app/src/utils/projects.ts @@ -5,9 +5,11 @@ import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure" export interface WorkspaceSummary { id: string; name: string; + title?: string; workspaceKind: WorkspaceDescriptor["workspaceKind"]; status: WorkspaceDescriptor["status"]; currentBranch: string | null; + archivingAt?: string; } export interface ProjectHostEntry { @@ -116,12 +118,15 @@ function resolveHostRepoRoot(group: HostGroup): string { } function toWorkspaceSummary(workspace: WorkspaceDescriptor): WorkspaceSummary { + const currentBranch = workspace.gitRuntime?.currentBranch?.trim(); return { id: workspace.id, name: workspace.name, + ...(workspace.title ? { title: workspace.title } : {}), workspaceKind: workspace.workspaceKind, status: workspace.status, - currentBranch: workspace.gitRuntime?.currentBranch ?? null, + currentBranch: currentBranch && currentBranch !== "HEAD" ? currentBranch : null, + ...(workspace.archivingAt ? { archivingAt: workspace.archivingAt } : {}), }; }