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 { 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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: "مشروع مفتوح",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: "プロジェクトを開く",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: "Открыть проект",
|
||||||
|
|||||||
@@ -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: "打开项目",
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user