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 542 additions and 173 deletions
@@ -5,6 +5,7 @@ import { gotoAppShell } from "./helpers/app";
import { createIdleAgent } from "./helpers/archive-tab"; import { createIdleAgent } from "./helpers/archive-tab";
import { openCommandCenter } from "./helpers/command-center"; import { openCommandCenter } from "./helpers/command-center";
import { addOfflineHostAndReload } from "./helpers/hosts"; import { addOfflineHostAndReload } from "./helpers/hosts";
import { expectAgentTabActive } from "./helpers/launcher";
import { seedWorkspace } from "./helpers/seed-client"; import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id"; import { getServerId } from "./helpers/server-id";
@@ -46,4 +47,26 @@ test.describe("Command center host labels", () => {
await seeded.cleanup(); 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();
}
});
}); });
@@ -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();
}
});
});
+2
View File
@@ -183,6 +183,7 @@ export interface SeededWorkspace {
export async function seedWorkspace(options: { export async function seedWorkspace(options: {
repoPrefix: string; repoPrefix: string;
title?: string;
/** Repo fixture options; only applies to git projects (the default). */ /** Repo fixture options; only applies to git projects (the default). */
repo?: Parameters<typeof createTempGitRepo>[1]; repo?: Parameters<typeof createTempGitRepo>[1];
/** Set to false to seed a plain non-git directory instead of a git repo. */ /** 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 { try {
const created = await client.createWorkspace({ const created = await client.createWorkspace({
source: { kind: "directory", path: project.path }, source: { kind: "directory", path: project.path },
title: options.title,
}); });
if (!created.workspace) { if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${project.path}`); throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
+139 -85
View File
@@ -9,13 +9,15 @@ import {
} from "react-native"; } from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; 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 { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center"; import {
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; useCommandCenter,
import { useHosts } from "@/runtime/host-runtime"; type CommandCenterActionItem,
import { formatTimeAgo } from "@/utils/time"; type CommandCenterAgentItem,
import { shortenPath } from "@/utils/shorten-path"; type CommandCenterItem,
type CommandCenterWorkspaceItem,
} from "@/hooks/use-command-center";
import { AgentStatusDot } from "@/components/agent-status-dot"; import { AgentStatusDot } from "@/components/agent-status-dot";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { isNative, isWeb } from "@/constants/platform"; import { isNative, isWeb } from "@/constants/platform";
@@ -30,13 +32,12 @@ import {
BottomSheetTextInput, BottomSheetTextInput,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
return `${agent.serverId}:${agent.id}`;
}
const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({ const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted, placeholderTextColor: theme.colors.foregroundMuted,
})); }));
const ThemedFolder = withUnistyles(Folder, (theme) => ({
color: theme.colors.foregroundMuted,
}));
interface CommandCenterRowProps { interface CommandCenterRowProps {
active: boolean; active: boolean;
@@ -73,22 +74,25 @@ const CommandCenterRow = memo(function CommandCenterRow({
}); });
interface CommandCenterRowContainerProps { interface CommandCenterRowContainerProps {
item: CommandCenterItem;
rowIndex: number; rowIndex: number;
active: boolean; active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>; rowRefs: React.MutableRefObject<Map<number, View>>;
onPress: () => void; onSelect: (item: CommandCenterItem) => void;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void; onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
children: ReactNode; children: ReactNode;
} }
function CommandCenterRowContainer({ function CommandCenterRowContainer({
item,
rowIndex, rowIndex,
active, active,
rowRefs, rowRefs,
onPress, onSelect,
onLayout, onLayout,
children, children,
}: CommandCenterRowContainerProps) { }: CommandCenterRowContainerProps) {
const handlePress = useCallback(() => onSelect(item), [onSelect, item]);
const registerRow = useCallback( const registerRow = useCallback(
(el: View | null) => { (el: View | null) => {
if (el) rowRefs.current.set(rowIndex, el); if (el) rowRefs.current.set(rowIndex, el);
@@ -100,7 +104,7 @@ function CommandCenterRowContainer({
<CommandCenterRow <CommandCenterRow
active={active} active={active}
registerRow={registerRow} registerRow={registerRow}
onPress={onPress} onPress={handlePress}
onLayout={onLayout} onLayout={onLayout}
> >
{children} {children}
@@ -109,12 +113,12 @@ function CommandCenterRowContainer({
} }
interface CommandCenterActionRowProps { interface CommandCenterActionRowProps {
item: Extract<ReturnType<typeof useCommandCenter>["items"][number], { kind: "action" }>; item: CommandCenterActionItem;
rowIndex: number; rowIndex: number;
active: boolean; active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>; rowRefs: React.MutableRefObject<Map<number, View>>;
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void; onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void; onSelect: (item: CommandCenterItem) => void;
} }
function CommandCenterActionRow({ function CommandCenterActionRow({
@@ -126,14 +130,12 @@ function CommandCenterActionRow({
onSelect, onSelect,
}: CommandCenterActionRowProps) { }: CommandCenterActionRowProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const handlePress = useCallback(() => onSelect(item), [onSelect, item]);
const action = item.action;
let actionIcon: React.ReactNode = null; let actionIcon: React.ReactNode = null;
if (action.icon === "plus") { if (item.icon === "plus") {
actionIcon = <Plus size={16} strokeWidth={2.4} color={theme.colors.foregroundMuted} />; 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} />; 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} />; actionIcon = <Home size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />;
} }
const titleStyle = useMemo( const titleStyle = useMemo(
@@ -142,10 +144,11 @@ function CommandCenterActionRow({
); );
return ( return (
<CommandCenterRowContainer <CommandCenterRowContainer
item={item}
rowIndex={rowIndex} rowIndex={rowIndex}
active={active} active={active}
rowRefs={rowRefs} rowRefs={rowRefs}
onPress={handlePress} onSelect={onSelect}
onLayout={onLayout} onLayout={onLayout}
> >
<View style={styles.rowContent}> <View style={styles.rowContent}>
@@ -153,59 +156,25 @@ function CommandCenterActionRow({
{actionIcon ? <View style={styles.iconSlot}>{actionIcon}</View> : null} {actionIcon ? <View style={styles.iconSlot}>{actionIcon}</View> : null}
<View style={styles.textContent}> <View style={styles.textContent}>
<Text style={titleStyle} numberOfLines={1}> <Text style={titleStyle} numberOfLines={1}>
{action.title} {item.title}
</Text> </Text>
</View> </View>
</View> </View>
{action.shortcutKeys ? ( {item.shortcutKeys ? (
<Shortcut chord={action.shortcutKeys} style={styles.rowShortcut} /> <Shortcut chord={item.shortcutKeys} style={styles.rowShortcut} />
) : null} ) : null}
</View> </View>
</CommandCenterRowContainer> </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 { interface CommandCenterAgentRowContentProps {
agent: AggregatedAgent; item: CommandCenterAgentItem;
showHost: boolean;
} }
function CommandCenterAgentRowContent({ agent, showHost }: CommandCenterAgentRowContentProps) { function CommandCenterAgentRowContent({ item }: CommandCenterAgentRowContentProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation(); const agent = item.agent;
const titleStyle = useMemo( const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }], () => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground], [theme.colors.foreground],
@@ -226,11 +195,10 @@ function CommandCenterAgentRowContent({ agent, showHost }: CommandCenterAgentRow
</View> </View>
<View style={styles.textContent}> <View style={styles.textContent}>
<Text style={titleStyle} numberOfLines={1}> <Text style={titleStyle} numberOfLines={1}>
{agent.title || t("shell.commandCenter.newAgent")} {item.title}
</Text> </Text>
<Text style={subtitleStyle} numberOfLines={1} testID="command-center-agent-subtitle"> <Text style={subtitleStyle} numberOfLines={1} testID="command-center-agent-subtitle">
{showHost ? `${agent.serverLabel} · ` : ""} {item.subtitle}
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
</Text> </Text>
</View> </View>
</View> </View>
@@ -239,42 +207,40 @@ function CommandCenterAgentRowContent({ agent, showHost }: CommandCenterAgentRow
} }
interface AgentItemsSectionProps { interface AgentItemsSectionProps {
agentItems: Extract<ReturnType<typeof useCommandCenter>["items"][number], { kind: "agent" }>[]; agentItems: CommandCenterAgentItem[];
actionItemsLength: number; startIndex: number;
activeIndex: number; activeIndex: number;
rowRefs: React.MutableRefObject<Map<number, View>>; rowRefs: React.MutableRefObject<Map<number, View>>;
onRowLayout: ( onRowLayout: (
rowIndex: number, rowIndex: number,
) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => void; ) => (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"]; sectionDividerStyle: React.ComponentProps<typeof View>["style"];
sectionLabelStyle: React.ComponentProps<typeof Text>["style"]; sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
showHost: boolean;
} }
function AgentItemsSection({ function AgentItemsSection({
agentItems, agentItems,
actionItemsLength, startIndex,
activeIndex, activeIndex,
rowRefs, rowRefs,
onRowLayout, onRowLayout,
onSelect, onSelect,
sectionDividerStyle, sectionDividerStyle,
sectionLabelStyle, sectionLabelStyle,
showHost,
}: AgentItemsSectionProps) { }: AgentItemsSectionProps) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
{actionItemsLength > 0 ? <View style={sectionDividerStyle} /> : null} {startIndex > 0 ? <View style={sectionDividerStyle} /> : null}
<Text style={sectionLabelStyle}>{t("shell.commandCenter.agents")}</Text> <Text style={sectionLabelStyle}>{t("shell.commandCenter.agents")}</Text>
{agentItems.map((item, index) => { {agentItems.map((item, index) => {
const rowIndex = actionItemsLength + index; const rowIndex = startIndex + index;
const agent = item.agent; const agent = item.agent;
return ( return (
<CommandCenterAgentRow <CommandCenterRowContainer
key={agentKey(agent)} key={`${agent.serverId}:${agent.id}`}
item={item} item={item}
rowIndex={rowIndex} rowIndex={rowIndex}
active={rowIndex === activeIndex} active={rowIndex === activeIndex}
@@ -282,8 +248,74 @@ function AgentItemsSection({
onLayout={onRowLayout(rowIndex)} onLayout={onRowLayout(rowIndex)}
onSelect={onSelect} onSelect={onSelect}
> >
<CommandCenterAgentRowContent agent={agent} showHost={showHost} /> <CommandCenterAgentRowContent item={item} />
</CommandCenterAgentRow> </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 isCompact = useIsCompactFormFactor();
const showBottomSheet = isCompact && isNative; 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 rowRefs = useRef<Map<number, View>>(new Map());
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map()); const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
const resultsRef = useRef<ScrollView>(null); const resultsRef = useRef<ScrollView>(null);
@@ -409,6 +438,7 @@ export function CommandCenter() {
); );
const actionItems = useMemo(() => items.filter((item) => item.kind === "action"), [items]); 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 agentItems = useMemo(() => items.filter((item) => item.kind === "agent"), [items]);
const panelStyle = useMemo( const panelStyle = useMemo(
@@ -438,6 +468,14 @@ export function CommandCenter() {
() => [styles.sectionDivider, { backgroundColor: theme.colors.border }], () => [styles.sectionDivider, { backgroundColor: theme.colors.border }],
[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( const handleKeyPress = useCallback(
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => { ({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => {
@@ -462,7 +500,7 @@ export function CommandCenter() {
<Text style={sectionLabelStyle}>{t("shell.commandCenter.actions")}</Text> <Text style={sectionLabelStyle}>{t("shell.commandCenter.actions")}</Text>
{actionItems.map((item, index) => ( {actionItems.map((item, index) => (
<CommandCenterActionRow <CommandCenterActionRow
key={`action:${item.action.id}`} key={`action:${item.id}`}
item={item} item={item}
rowIndex={index} rowIndex={index}
active={index === activeIndex} active={index === activeIndex}
@@ -474,17 +512,29 @@ export function CommandCenter() {
</> </>
) : null} ) : null}
{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 ? ( {agentItems.length > 0 ? (
<AgentItemsSection <AgentItemsSection
agentItems={agentItems} agentItems={agentItems}
actionItemsLength={actionItems.length} startIndex={actionItems.length + workspaceItems.length}
activeIndex={activeIndex} activeIndex={activeIndex}
rowRefs={rowRefs} rowRefs={rowRefs}
onRowLayout={handleRowLayout} onRowLayout={handleRowLayout}
onSelect={handleSelectItem} onSelect={handleSelectItem}
sectionDividerStyle={sectionDividerStyle} sectionDividerStyle={sectionDividerStyle}
sectionLabelStyle={sectionLabelStyle} sectionLabelStyle={sectionLabelStyle}
showHost={showHost}
/> />
) : null} ) : null}
</> </>
@@ -502,6 +552,8 @@ export function CommandCenter() {
onDismiss={handleSheetDismiss} onDismiss={handleSheetDismiss}
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
enablePanDownToClose enablePanDownToClose
backgroundStyle={sheetBackgroundStyle}
handleIndicatorStyle={sheetHandleStyle}
keyboardBehavior="extend" keyboardBehavior="extend"
keyboardBlurBehavior="restore" keyboardBlurBehavior="restore"
accessible={false} accessible={false}
@@ -603,7 +655,7 @@ const styles = StyleSheet.create((theme) => ({
borderBottomWidth: 1, borderBottomWidth: 1,
}, },
input: { input: {
fontSize: theme.fontSize.lg, fontSize: theme.fontSize.base,
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
outlineStyle: "none", outlineStyle: "none",
} as object, } as object,
@@ -657,13 +709,15 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0, flexShrink: 0,
}, },
title: { title: {
fontSize: theme.fontSize.base, fontSize: theme.fontSize.sm,
fontWeight: "400", fontWeight: "400",
lineHeight: 20, lineHeight: 20,
color: theme.colors.foreground,
}, },
subtitle: { subtitle: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.xs,
lineHeight: 18, lineHeight: 18,
color: theme.colors.foregroundMuted,
}, },
emptyText: { emptyText: {
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[4],
+160 -65
View File
@@ -20,17 +20,24 @@ import { getIsElectronRuntime } from "@/constants/layout";
import { navigateToAgent } from "@/utils/navigate-to-agent"; import { navigateToAgent } from "@/utils/navigate-to-agent";
import { focusWithRetries } from "@/utils/web-focus"; import { focusWithRetries } from "@/utils/web-focus";
import { isWeb } from "@/constants/platform"; 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_ACTION_ITEMS: CommandCenterActionItem[] = [];
const EMPTY_WORKSPACE_ITEMS: CommandCenterWorkspaceItem[] = [];
const EMPTY_AGENT_ITEMS: CommandCenterAgentItem[] = [];
const EMPTY_COMMAND_CENTER_ITEMS: CommandCenterItem[] = []; const EMPTY_COMMAND_CENTER_ITEMS: CommandCenterItem[] = [];
function isMatch(agent: AggregatedAgent, query: string): boolean { function buildSearchText(...fields: string[]): string {
if (!query) return true; return fields.join(" ").toLowerCase();
const q = query.toLowerCase(); }
const title = (agent.title ?? "New agent").toLowerCase();
const cwd = agent.cwd.toLowerCase(); function matchesQuery(searchText: string, query: string): boolean {
return title.includes(q) || cwd.includes(q); const normalized = query.trim().toLowerCase();
return !normalized || searchText.includes(normalized);
} }
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number { 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 { export interface CommandCenterActionItem {
kind: "action"; kind: "action";
id: string; id: string;
@@ -106,17 +100,30 @@ export interface CommandCenterActionItem {
icon?: "plus" | "settings" | "home"; icon?: "plus" | "settings" | "home";
route?: Href; route?: Href;
shortcutKeys?: ShortcutKey[][]; 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 = export type CommandCenterItem =
| { | CommandCenterActionItem
kind: "action"; | CommandCenterWorkspaceItem
action: CommandCenterActionItem; | CommandCenterAgentItem;
}
| {
kind: "agent";
agent: AggregatedAgent;
};
function resolveActionShortcutKeys( function resolveActionShortcutKeys(
actionId: string | undefined, actionId: string | undefined,
@@ -150,15 +157,96 @@ export function useCommandCenter() {
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const { agents } = useAggregatedAgents(); 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(() => { const agentResults = useMemo(() => {
if (!open || agents.length === 0) { if (!open || agents.length === 0) {
return EMPTY_AGENTS; return EMPTY_AGENT_ITEMS;
} }
const filtered = agents.filter((agent) => isMatch(agent, query)); const items = agents.map<CommandCenterAgentItem>((agent) => {
filtered.sort(sortAgents); 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; return filtered;
}, [agents, open, query]); }, [agents, open, query, showAgentHost, t, workspaceTitleByKey]);
const settingsRoute = useMemo<Href>(() => { const settingsRoute = useMemo<Href>(() => {
return buildSettingsRoute(); return buildSettingsRoute();
@@ -170,43 +258,33 @@ export function useCommandCenter() {
if (!open) { if (!open) {
return EMPTY_ACTION_ITEMS; return EMPTY_ACTION_ITEMS;
} }
return COMMAND_CENTER_ACTIONS.filter((action) => { return COMMAND_CENTER_ACTIONS.filter(
if (action.routeKind === "home" && !homeRoute) return false; (action) => action.routeKind !== "home" || Boolean(homeRoute),
return matchesActionQuery(query, action, t(action.titleKey)); )
}).map<CommandCenterActionItem>((action) => { .map<CommandCenterActionItem>((action) => {
let route: Href | undefined; let route: Href | undefined;
if (action.routeKind === "settings") route = settingsRoute; if (action.routeKind === "settings") route = settingsRoute;
else if (action.routeKind === "home") route = homeRoute; else if (action.routeKind === "home") route = homeRoute;
return { const title = t(action.titleKey);
kind: "action", return {
id: action.id, kind: "action",
title: t(action.titleKey), id: action.id,
icon: action.icon, title,
route, icon: action.icon,
shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides), route,
}; shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides),
}); searchText: buildSearchText(title, ...action.keywords),
};
})
.filter((action) => matchesQuery(action.searchText, query));
}, [open, query, settingsRoute, homeRoute, overrides, t]); }, [open, query, settingsRoute, homeRoute, overrides, t]);
const items = useMemo(() => { const items = useMemo(() => {
if (!open) { if (!open) {
return EMPTY_COMMAND_CENTER_ITEMS; return EMPTY_COMMAND_CENTER_ITEMS;
} }
const next: CommandCenterItem[] = []; return [...actionItems, ...workspaceResults, ...agentResults];
for (const action of actionItems) { }, [actionItems, workspaceResults, agentResults, open]);
next.push({
kind: "action",
action,
});
}
for (const agent of agentResults) {
next.push({
kind: "agent",
agent,
});
}
return next;
}, [actionItems, agentResults, open]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setOpen(false); setOpen(false);
@@ -227,6 +305,19 @@ export function useCommandCenter() {
[setOpen], [setOpen],
); );
const handleSelectWorkspace = useCallback(
(workspace: CommandCenterWorkspaceItem) => {
didNavigateRef.current = true;
clearCommandCenterFocusRestoreElement();
setOpen(false);
navigateToWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
});
},
[setOpen],
);
const openProjectPicker = useOpenProjectPicker(); const openProjectPicker = useOpenProjectPicker();
const handleSelectAction = useCallback( const handleSelectAction = useCallback(
@@ -249,12 +340,16 @@ export function useCommandCenter() {
const handleSelectItem = useCallback( const handleSelectItem = useCallback(
(item: CommandCenterItem) => { (item: CommandCenterItem) => {
if (item.kind === "action") { if (item.kind === "action") {
handleSelectAction(item.action); handleSelectAction(item);
return;
}
if (item.kind === "workspace") {
handleSelectWorkspace(item);
return; return;
} }
handleSelectAgent(item.agent); handleSelectAgent(item.agent);
}, },
[handleSelectAction, handleSelectAgent], [handleSelectAction, handleSelectAgent, handleSelectWorkspace],
); );
useEffect(() => { useEffect(() => {
+40 -11
View File
@@ -50,6 +50,13 @@ export interface UseProjectsResult {
refetch: () => void; refetch: () => void;
} }
export interface UseProjectsOptions {
enabled?: boolean;
}
const EMPTY_PROJECT_HOST_REPLICAS: ProjectHostReplica[] = [];
const EMPTY_PROJECT_HOST_RUNTIME_STATES: ProjectHostRuntimeState[] = [];
function toProjectHostRuntimeState( function toProjectHostRuntimeState(
serverId: string, serverId: string,
snapshot: HostRuntimeSnapshot | null, snapshot: HostRuntimeSnapshot | null,
@@ -68,7 +75,11 @@ function toProjectHostRuntimeState(
function selectProjectHostReplicas( function selectProjectHostReplicas(
hosts: readonly { serverId: string; label: string }[], hosts: readonly { serverId: string; label: string }[],
enabled: boolean,
): (state: ReturnType<typeof useSessionStore.getState>) => ProjectHostReplica[] { ): (state: ReturnType<typeof useSessionStore.getState>) => ProjectHostReplica[] {
if (!enabled) {
return () => EMPTY_PROJECT_HOST_REPLICAS;
}
return (state) => return (state) =>
hosts.map((host) => { hosts.map((host) => {
const session = state.sessions[host.serverId]; 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 runtime = getHostRuntimeStore();
const previousStatesRef = useRef<ProjectHostRuntimeState[]>([]); const previousStatesRef = useRef<ProjectHostRuntimeState[]>([]);
const runtimeSnapshotTick = useSyncExternalStore( const subscribe = useCallback(
(onStoreChange) => runtime.subscribeAll(onStoreChange), (onStoreChange: () => void) =>
() => runtime.getVersion(), enabled ? runtime.subscribeAll(onStoreChange) : () => undefined,
() => runtime.getVersion(), [enabled, runtime],
); );
const getSnapshot = useCallback(() => (enabled ? runtime.getVersion() : 0), [enabled, runtime]);
const runtimeSnapshotTick = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return useMemo(() => { return useMemo(() => {
if (!enabled) {
previousStatesRef.current = EMPTY_PROJECT_HOST_RUNTIME_STATES;
return EMPTY_PROJECT_HOST_RUNTIME_STATES;
}
void runtimeSnapshotTick; void runtimeSnapshotTick;
const nextStates = serverIds.map((serverId) => const nextStates = serverIds.map((serverId) =>
toProjectHostRuntimeState(serverId, runtime.getSnapshot(serverId)), toProjectHostRuntimeState(serverId, runtime.getSnapshot(serverId)),
@@ -137,22 +157,31 @@ function useProjectHostRuntimeStates(serverIds: readonly string[]): ProjectHostR
} }
previousStatesRef.current = nextStates; previousStatesRef.current = nextStates;
return 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 hosts = useHosts();
const runtime = getHostRuntimeStore(); const runtime = getHostRuntimeStore();
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); const serverIds = useMemo(
const replicas = useStoreWithEqualityFn(useSessionStore, selectProjectHostReplicas(hosts), equal); () => (enabled ? hosts.map((host) => host.serverId) : []),
const runtimeStates = useProjectHostRuntimeStates(serverIds); [enabled, hosts],
);
const replicaSelector = useMemo(
() => selectProjectHostReplicas(hosts, enabled),
[enabled, hosts],
);
const replicas = useStoreWithEqualityFn(useSessionStore, replicaSelector, equal);
const runtimeStates = useProjectHostRuntimeStates(serverIds, enabled);
const derived = useMemo( const derived = useMemo(
() => deriveProjectsFromReplica({ replicas, runtimeStates }), () => deriveProjectsFromReplica({ replicas, runtimeStates }),
[replicas, runtimeStates], [replicas, runtimeStates],
); );
const refetch = useCallback(() => { const refetch = useCallback(() => {
if (!enabled) return;
runtime.refreshAllAgentDirectories({ serverIds }); runtime.refreshAllAgentDirectories({ serverIds });
}, [runtime, serverIds]); }, [enabled, runtime, serverIds]);
return { return {
...derived, ...derived,
+2 -1
View File
@@ -176,9 +176,10 @@ describe("translation resources", () => {
expect(en.shell.menu.toggleSidebar).toBe("Toggle sidebar"); expect(en.shell.menu.toggleSidebar).toBe("Toggle sidebar");
expect(en.shell.menu.open).toBe("Open menu"); expect(en.shell.menu.open).toBe("Open menu");
expect(en.shell.menu.close).toBe("Close 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.noMatches).toBe("No matches");
expect(en.shell.commandCenter.actions).toBe("Actions"); 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.agents).toBe("Agents");
expect(en.shell.commandCenter.newAgent).toBe("New agent"); expect(en.shell.commandCenter.newAgent).toBe("New agent");
expect(en.shell.commandCenter.openProject).toBe("Open project"); expect(en.shell.commandCenter.openProject).toBe("Open project");
+2 -1
View File
@@ -54,9 +54,10 @@ export const ar: TranslationResources = {
close: "إغلاق القائمة", close: "إغلاق القائمة",
}, },
commandCenter: { commandCenter: {
placeholder: "اكتب أمرًا أو وكلاء بحث...", placeholder: "ابحث في الأوامر ومساحات العمل والوكلاء...",
noMatches: "لا توجد مباريات", noMatches: "لا توجد مباريات",
actions: "الإجراءات", actions: "الإجراءات",
workspaces: "مساحات العمل",
agents: "الوكلاء", agents: "الوكلاء",
newAgent: "وكيل جديد", newAgent: "وكيل جديد",
openProject: "مشروع مفتوح", openProject: "مشروع مفتوح",
+2 -1
View File
@@ -52,9 +52,10 @@ export const en = {
close: "Close menu", close: "Close menu",
}, },
commandCenter: { commandCenter: {
placeholder: "Type a command or search agents...", placeholder: "Search commands, workspaces, and agents...",
noMatches: "No matches", noMatches: "No matches",
actions: "Actions", actions: "Actions",
workspaces: "Workspaces",
agents: "Agents", agents: "Agents",
newAgent: "New agent", newAgent: "New agent",
openProject: "Open project", openProject: "Open project",
+2 -1
View File
@@ -54,9 +54,10 @@ export const es: TranslationResources = {
close: "Cerrar menú", close: "Cerrar menú",
}, },
commandCenter: { commandCenter: {
placeholder: "Escriba un comando o busque agentes...", placeholder: "Buscar comandos, espacios de trabajo y agentes...",
noMatches: "No hay coincidencias", noMatches: "No hay coincidencias",
actions: "Comportamiento", actions: "Comportamiento",
workspaces: "Espacios de trabajo",
agents: "Agentes", agents: "Agentes",
newAgent: "Nuevo agente", newAgent: "Nuevo agente",
openProject: "Abrir proyecto", openProject: "Abrir proyecto",
+2 -1
View File
@@ -55,9 +55,10 @@ export const fr: TranslationResources = {
close: "Fermer le menu", close: "Fermer le menu",
}, },
commandCenter: { commandCenter: {
placeholder: "Tapez une commande ou recherchez des agents...", placeholder: "Rechercher des commandes, espaces de travail et agents...",
noMatches: "Aucune correspondance", noMatches: "Aucune correspondance",
actions: "Actes", actions: "Actes",
workspaces: "Espaces de travail",
agents: "Agents", agents: "Agents",
newAgent: "Nouvel agent", newAgent: "Nouvel agent",
openProject: "Projet ouvert", openProject: "Projet ouvert",
+2 -1
View File
@@ -54,9 +54,10 @@ export const ja: TranslationResources = {
close: "メニューを閉じる", close: "メニューを閉じる",
}, },
commandCenter: { commandCenter: {
placeholder: "コマンドを入力またはエージェントを検索...", placeholder: "コマンド、ワークスペース、エージェントを検索...",
noMatches: "一致なし", noMatches: "一致なし",
actions: "アクション", actions: "アクション",
workspaces: "ワークスペース",
agents: "エージェント", agents: "エージェント",
newAgent: "新しいエージェント", newAgent: "新しいエージェント",
openProject: "プロジェクトを開く", openProject: "プロジェクトを開く",
+2 -1
View File
@@ -54,9 +54,10 @@ export const ptBR: TranslationResources = {
close: "Fechar menu", close: "Fechar menu",
}, },
commandCenter: { commandCenter: {
placeholder: "Digite um comando ou busque agentes...", placeholder: "Buscar comandos, espaços de trabalho e agentes...",
noMatches: "Nenhuma correspondência", noMatches: "Nenhuma correspondência",
actions: "Ações", actions: "Ações",
workspaces: "Espaços de trabalho",
agents: "Agentes", agents: "Agentes",
newAgent: "Novo agente", newAgent: "Novo agente",
openProject: "Abrir projeto", openProject: "Abrir projeto",
+2 -1
View File
@@ -54,9 +54,10 @@ export const ru: TranslationResources = {
close: "Закрыть меню", close: "Закрыть меню",
}, },
commandCenter: { commandCenter: {
placeholder: "Введите команду или найдите агентов...", placeholder: "Поиск команд, рабочих пространств и агентов...",
noMatches: "Нет совпадений", noMatches: "Нет совпадений",
actions: "Действия", actions: "Действия",
workspaces: "Рабочие пространства",
agents: "Агенты", agents: "Агенты",
newAgent: "Новый агент", newAgent: "Новый агент",
openProject: "Открыть проект", openProject: "Открыть проект",
+2 -1
View File
@@ -54,9 +54,10 @@ export const zhCN: TranslationResources = {
close: "关闭菜单", close: "关闭菜单",
}, },
commandCenter: { commandCenter: {
placeholder: "输入命令或搜索 Agent...", placeholder: "搜索命令、工作区和 Agent...",
noMatches: "没有匹配项", noMatches: "没有匹配项",
actions: "操作", actions: "操作",
workspaces: "工作区",
agents: "Agents", agents: "Agents",
newAgent: "新建 Agent", newAgent: "新建 Agent",
openProject: "打开项目", openProject: "打开项目",
+48 -2
View File
@@ -32,6 +32,8 @@ function workspace(input: {
projectId?: string; projectId?: string;
projectName?: string; projectName?: string;
remoteUrl?: string | null; remoteUrl?: string | null;
currentBranch?: string;
archivingAt?: string | null;
}): WorkspaceDescriptor { }): WorkspaceDescriptor {
return { return {
id: input.id, id: input.id,
@@ -43,12 +45,12 @@ function workspace(input: {
workspaceKind: "local_checkout", workspaceKind: "local_checkout",
name: input.id, name: input.id,
status: "done", status: "done",
archivingAt: null, archivingAt: input.archivingAt ?? null,
statusEnteredAt: null, statusEnteredAt: null,
diffStat: null, diffStat: null,
scripts: [], scripts: [],
gitRuntime: { gitRuntime: {
currentBranch: "main", currentBranch: input.currentBranch ?? "main",
remoteUrl: input.remoteUrl ?? input.project?.checkout.remoteUrl ?? null, remoteUrl: input.remoteUrl ?? input.project?.checkout.remoteUrl ?? null,
isPaseoOwnedWorktree: false, isPaseoOwnedWorktree: false,
isDirty: false, isDirty: false,
@@ -62,6 +64,50 @@ function workspace(input: {
} }
describe("buildProjects", () => { 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", () => { it("groups two daemons with the same GitHub project key into one project with one host entry per daemon", () => {
const result = buildProjects({ const result = buildProjects({
hosts: [ hosts: [
+6 -1
View File
@@ -5,9 +5,11 @@ import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure"
export interface WorkspaceSummary { export interface WorkspaceSummary {
id: string; id: string;
name: string; name: string;
title?: string;
workspaceKind: WorkspaceDescriptor["workspaceKind"]; workspaceKind: WorkspaceDescriptor["workspaceKind"];
status: WorkspaceDescriptor["status"]; status: WorkspaceDescriptor["status"];
currentBranch: string | null; currentBranch: string | null;
archivingAt?: string;
} }
export interface ProjectHostEntry { export interface ProjectHostEntry {
@@ -116,12 +118,15 @@ function resolveHostRepoRoot(group: HostGroup): string {
} }
function toWorkspaceSummary(workspace: WorkspaceDescriptor): WorkspaceSummary { function toWorkspaceSummary(workspace: WorkspaceDescriptor): WorkspaceSummary {
const currentBranch = workspace.gitRuntime?.currentBranch?.trim();
return { return {
id: workspace.id, id: workspace.id,
name: workspace.name, name: workspace.name,
...(workspace.title ? { title: workspace.title } : {}),
workspaceKind: workspace.workspaceKind, workspaceKind: workspace.workspaceKind,
status: workspace.status, status: workspace.status,
currentBranch: workspace.gitRuntime?.currentBranch ?? null, currentBranch: currentBranch && currentBranch !== "HEAD" ? currentBranch : null,
...(workspace.archivingAt ? { archivingAt: workspace.archivingAt } : {}),
}; };
} }