mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
106
packages/app/e2e/command-center-workspaces.spec.ts
Normal file
106
packages/app/e2e/command-center-workspaces.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -54,9 +54,10 @@ export const ar: TranslationResources = {
|
||||
close: "إغلاق القائمة",
|
||||
},
|
||||
commandCenter: {
|
||||
placeholder: "اكتب أمرًا أو وكلاء بحث...",
|
||||
placeholder: "ابحث في الأوامر ومساحات العمل والوكلاء...",
|
||||
noMatches: "لا توجد مباريات",
|
||||
actions: "الإجراءات",
|
||||
workspaces: "مساحات العمل",
|
||||
agents: "الوكلاء",
|
||||
newAgent: "وكيل جديد",
|
||||
openProject: "مشروع مفتوح",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -54,9 +54,10 @@ export const ja: TranslationResources = {
|
||||
close: "メニューを閉じる",
|
||||
},
|
||||
commandCenter: {
|
||||
placeholder: "コマンドを入力またはエージェントを検索...",
|
||||
placeholder: "コマンド、ワークスペース、エージェントを検索...",
|
||||
noMatches: "一致なし",
|
||||
actions: "アクション",
|
||||
workspaces: "ワークスペース",
|
||||
agents: "エージェント",
|
||||
newAgent: "新しいエージェント",
|
||||
openProject: "プロジェクトを開く",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -54,9 +54,10 @@ export const ru: TranslationResources = {
|
||||
close: "Закрыть меню",
|
||||
},
|
||||
commandCenter: {
|
||||
placeholder: "Введите команду или найдите агентов...",
|
||||
placeholder: "Поиск команд, рабочих пространств и агентов...",
|
||||
noMatches: "Нет совпадений",
|
||||
actions: "Действия",
|
||||
workspaces: "Рабочие пространства",
|
||||
agents: "Агенты",
|
||||
newAgent: "Новый агент",
|
||||
openProject: "Открыть проект",
|
||||
|
||||
@@ -54,9 +54,10 @@ export const zhCN: TranslationResources = {
|
||||
close: "关闭菜单",
|
||||
},
|
||||
commandCenter: {
|
||||
placeholder: "输入命令或搜索 Agent...",
|
||||
placeholder: "搜索命令、工作区和 Agent...",
|
||||
noMatches: "没有匹配项",
|
||||
actions: "操作",
|
||||
workspaces: "工作区",
|
||||
agents: "Agents",
|
||||
newAgent: "新建 Agent",
|
||||
openProject: "打开项目",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user