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
This commit is contained in:
Mohamed Boudra
2026-07-15 20:40:24 +02:00
committed by GitHub
parent 37bde90d9f
commit 90e0a0e353
17 changed files with 545 additions and 176 deletions

View File

@@ -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();
}
});
});

View File

@@ -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();
}
});
});

View File

@@ -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<typeof createTempGitRepo>[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}`);

View File

@@ -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<AggregatedAgent, "serverId" | "id">): 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<Map<number, View>>;
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({
<CommandCenterRow
active={active}
registerRow={registerRow}
onPress={onPress}
onPress={handlePress}
onLayout={onLayout}
>
{children}
@@ -109,12 +113,12 @@ function CommandCenterRowContainer({
}
interface CommandCenterActionRowProps {
item: Extract<ReturnType<typeof useCommandCenter>["items"][number], { kind: "action" }>;
item: CommandCenterActionItem;
rowIndex: number;
active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
onSelect: (item: ReturnType<typeof useCommandCenter>["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 = <Plus size={16} strokeWidth={2.4} color={theme.colors.foregroundMuted} />;
} else if (action.icon === "settings") {
} else if (item.icon === "settings") {
actionIcon = <Settings size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />;
} else if (action.icon === "home") {
} else if (item.icon === "home") {
actionIcon = <Home size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />;
}
const titleStyle = useMemo(
@@ -142,10 +144,11 @@ function CommandCenterActionRow({
);
return (
<CommandCenterRowContainer
item={item}
rowIndex={rowIndex}
active={active}
rowRefs={rowRefs}
onPress={handlePress}
onSelect={onSelect}
onLayout={onLayout}
>
<View style={styles.rowContent}>
@@ -153,59 +156,25 @@ function CommandCenterActionRow({
{actionIcon ? <View style={styles.iconSlot}>{actionIcon}</View> : null}
<View style={styles.textContent}>
<Text style={titleStyle} numberOfLines={1}>
{action.title}
{item.title}
</Text>
</View>
</View>
{action.shortcutKeys ? (
<Shortcut chord={action.shortcutKeys} style={styles.rowShortcut} />
{item.shortcutKeys ? (
<Shortcut chord={item.shortcutKeys} style={styles.rowShortcut} />
) : null}
</View>
</CommandCenterRowContainer>
);
}
interface CommandCenterAgentRowProps {
item: Extract<ReturnType<typeof useCommandCenter>["items"][number], { kind: "agent" }>;
rowIndex: number;
active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
children: ReactNode;
}
function CommandCenterAgentRow({
rowIndex,
active,
rowRefs,
onLayout,
onSelect,
item,
children,
}: CommandCenterAgentRowProps) {
const handlePress = useCallback(() => onSelect(item), [onSelect, item]);
return (
<CommandCenterRowContainer
rowIndex={rowIndex}
active={active}
rowRefs={rowRefs}
onPress={handlePress}
onLayout={onLayout}
>
{children}
</CommandCenterRowContainer>
);
}
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
</View>
<View style={styles.textContent}>
<Text style={titleStyle} numberOfLines={1}>
{agent.title || t("shell.commandCenter.newAgent")}
{item.title}
</Text>
<Text style={subtitleStyle} numberOfLines={1} testID="command-center-agent-subtitle">
{showHost ? `${agent.serverLabel} · ` : ""}
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
{item.subtitle}
</Text>
</View>
</View>
@@ -239,42 +207,40 @@ function CommandCenterAgentRowContent({ agent, showHost }: CommandCenterAgentRow
}
interface AgentItemsSectionProps {
agentItems: Extract<ReturnType<typeof useCommandCenter>["items"][number], { kind: "agent" }>[];
actionItemsLength: number;
agentItems: CommandCenterAgentItem[];
startIndex: number;
activeIndex: number;
rowRefs: React.MutableRefObject<Map<number, View>>;
onRowLayout: (
rowIndex: number,
) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
onSelect: (item: CommandCenterItem) => void;
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
showHost: boolean;
}
function AgentItemsSection({
agentItems,
actionItemsLength,
startIndex,
activeIndex,
rowRefs,
onRowLayout,
onSelect,
sectionDividerStyle,
sectionLabelStyle,
showHost,
}: AgentItemsSectionProps) {
const { t } = useTranslation();
return (
<>
{actionItemsLength > 0 ? <View style={sectionDividerStyle} /> : null}
{startIndex > 0 ? <View style={sectionDividerStyle} /> : null}
<Text style={sectionLabelStyle}>{t("shell.commandCenter.agents")}</Text>
{agentItems.map((item, index) => {
const rowIndex = actionItemsLength + index;
const rowIndex = startIndex + index;
const agent = item.agent;
return (
<CommandCenterAgentRow
key={agentKey(agent)}
<CommandCenterRowContainer
key={`${agent.serverId}:${agent.id}`}
item={item}
rowIndex={rowIndex}
active={rowIndex === activeIndex}
@@ -282,8 +248,74 @@ function AgentItemsSection({
onLayout={onRowLayout(rowIndex)}
onSelect={onSelect}
>
<CommandCenterAgentRowContent agent={agent} showHost={showHost} />
</CommandCenterAgentRow>
<CommandCenterAgentRowContent item={item} />
</CommandCenterRowContainer>
);
})}
</>
);
}
interface WorkspaceItemsSectionProps {
workspaceItems: CommandCenterWorkspaceItem[];
startIndex: number;
activeIndex: number;
rowRefs: React.MutableRefObject<Map<number, View>>;
onRowLayout: (
rowIndex: number,
) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
onSelect: (item: CommandCenterItem) => void;
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
}
function WorkspaceItemsSection({
workspaceItems,
startIndex,
activeIndex,
rowRefs,
onRowLayout,
onSelect,
sectionDividerStyle,
sectionLabelStyle,
}: WorkspaceItemsSectionProps) {
const { t } = useTranslation();
return (
<>
{startIndex > 0 ? <View style={sectionDividerStyle} /> : null}
<Text style={sectionLabelStyle}>{t("shell.commandCenter.workspaces")}</Text>
{workspaceItems.map((item, index) => {
const rowIndex = startIndex + index;
return (
<CommandCenterRowContainer
key={`${item.serverId}:${item.workspaceId}`}
item={item}
rowIndex={rowIndex}
active={rowIndex === activeIndex}
rowRefs={rowRefs}
onSelect={onSelect}
onLayout={onRowLayout(rowIndex)}
>
<View
style={styles.rowContent}
testID={`command-center-workspace-${item.serverId}:${item.workspaceId}`}
>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<ThemedFolder size={16} strokeWidth={2.2} />
</View>
<View style={styles.textContent}>
<Text style={styles.title} numberOfLines={1}>
{item.title}
</Text>
<Text style={styles.subtitle} numberOfLines={1}>
{item.subtitle}
</Text>
</View>
</View>
</View>
</CommandCenterRowContainer>
);
})}
</>
@@ -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<Map<number, View>>(new Map());
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
const resultsRef = useRef<ScrollView>(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() {
<Text style={sectionLabelStyle}>{t("shell.commandCenter.actions")}</Text>
{actionItems.map((item, index) => (
<CommandCenterActionRow
key={`action:${item.action.id}`}
key={`action:${item.id}`}
item={item}
rowIndex={index}
active={index === activeIndex}
@@ -474,17 +512,29 @@ export function CommandCenter() {
</>
) : null}
{agentItems.length > 0 ? (
<AgentItemsSection
agentItems={agentItems}
actionItemsLength={actionItems.length}
{workspaceItems.length > 0 ? (
<WorkspaceItemsSection
workspaceItems={workspaceItems}
startIndex={actionItems.length}
activeIndex={activeIndex}
rowRefs={rowRefs}
onRowLayout={handleRowLayout}
onSelect={handleSelectItem}
sectionDividerStyle={sectionDividerStyle}
sectionLabelStyle={sectionLabelStyle}
/>
) : null}
{agentItems.length > 0 ? (
<AgentItemsSection
agentItems={agentItems}
startIndex={actionItems.length + workspaceItems.length}
activeIndex={activeIndex}
rowRefs={rowRefs}
onRowLayout={handleRowLayout}
onSelect={handleSelectItem}
sectionDividerStyle={sectionDividerStyle}
sectionLabelStyle={sectionLabelStyle}
showHost={showHost}
/>
) : 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],

View File

@@ -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<CommandCenterAgentItem>((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<Href>(() => {
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<CommandCenterActionItem>((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<CommandCenterActionItem>((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(() => {

View File

@@ -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<typeof useSessionStore.getState>) => 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<ProjectHostRuntimeState[]>([]);
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,

View File

@@ -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");

View File

@@ -54,9 +54,10 @@ export const ar: TranslationResources = {
close: "إغلاق القائمة",
},
commandCenter: {
placeholder: "اكتب أمرًا أو وكلاء بحث...",
placeholder: "ابحث في الأوامر ومساحات العمل والوكلاء...",
noMatches: "لا توجد مباريات",
actions: "الإجراءات",
workspaces: "مساحات العمل",
agents: "الوكلاء",
newAgent: "وكيل جديد",
openProject: "مشروع مفتوح",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -54,9 +54,10 @@ export const ja: TranslationResources = {
close: "メニューを閉じる",
},
commandCenter: {
placeholder: "コマンドを入力またはエージェントを検索...",
placeholder: "コマンド、ワークスペース、エージェントを検索...",
noMatches: "一致なし",
actions: "アクション",
workspaces: "ワークスペース",
agents: "エージェント",
newAgent: "新しいエージェント",
openProject: "プロジェクトを開く",

View File

@@ -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",

View File

@@ -54,9 +54,10 @@ export const ru: TranslationResources = {
close: "Закрыть меню",
},
commandCenter: {
placeholder: "Введите команду или найдите агентов...",
placeholder: "Поиск команд, рабочих пространств и агентов...",
noMatches: "Нет совпадений",
actions: "Действия",
workspaces: "Рабочие пространства",
agents: "Агенты",
newAgent: "Новый агент",
openProject: "Открыть проект",

View File

@@ -54,9 +54,10 @@ export const zhCN: TranslationResources = {
close: "关闭菜单",
},
commandCenter: {
placeholder: "输入命令或搜索 Agent...",
placeholder: "搜索命令、工作区和 Agent...",
noMatches: "没有匹配项",
actions: "操作",
workspaces: "工作区",
agents: "Agents",
newAgent: "新建 Agent",
openProject: "打开项目",

View File

@@ -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: [

View File

@@ -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 } : {}),
};
}