Skip to content

Commit e01ab14

Browse files
committed
Add multi-agent command center grid view
1 parent 4f7aadd commit e01ab14

24 files changed

Lines changed: 1245 additions & 247 deletions

apps/code/src/renderer/components/MainLayout.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { KeyboardShortcutsSheet } from "@components/KeyboardShortcutsSheet";
55

66
import { ArchivedTasksView } from "@features/archive/components/ArchivedTasksView";
77
import { CommandMenu } from "@features/command/components/CommandMenu";
8+
import { CommandCenterView } from "@features/command-center/components/CommandCenterView";
89
import { InboxView } from "@features/inbox/components/InboxView";
910
import { RightSidebar, RightSidebarContent } from "@features/right-sidebar";
1011
import { FolderSettingsView } from "@features/settings/components/FolderSettingsView";
@@ -75,6 +76,8 @@ export function MainLayout() {
7576
{view.type === "inbox" && <InboxView />}
7677

7778
{view.type === "archived" && <ArchivedTasksView />}
79+
80+
{view.type === "command-center" && <CommandCenterView />}
7881
</Box>
7982

8083
{view.type === "task-detail" && view.data && (
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { useCallback, useState } from "react";
2+
import type { CommandCenterCellData } from "../hooks/useCommandCenterData";
3+
import {
4+
getGridDimensions,
5+
type LayoutPreset,
6+
useCommandCenterStore,
7+
} from "../stores/commandCenterStore";
8+
import { CommandCenterPanel } from "./CommandCenterPanel";
9+
10+
interface CommandCenterGridProps {
11+
layout: LayoutPreset;
12+
cells: CommandCenterCellData[];
13+
}
14+
15+
function GridCell({
16+
cell,
17+
zoom,
18+
}: {
19+
cell: CommandCenterCellData;
20+
zoom: number;
21+
}) {
22+
const [isDragOver, setIsDragOver] = useState(false);
23+
24+
const handleDragOver = useCallback((e: React.DragEvent) => {
25+
if (e.dataTransfer.types.includes("text/x-task-id")) {
26+
e.preventDefault();
27+
e.dataTransfer.dropEffect = "copy";
28+
setIsDragOver(true);
29+
}
30+
}, []);
31+
32+
const handleDragLeave = useCallback(() => {
33+
setIsDragOver(false);
34+
}, []);
35+
36+
const handleDrop = useCallback(
37+
(e: React.DragEvent) => {
38+
e.preventDefault();
39+
setIsDragOver(false);
40+
const taskId = e.dataTransfer.getData("text/x-task-id");
41+
if (taskId) {
42+
useCommandCenterStore.getState().assignTask(cell.cellIndex, taskId);
43+
}
44+
},
45+
[cell.cellIndex],
46+
);
47+
48+
return (
49+
// biome-ignore lint/a11y/noStaticElementInteractions: drop target for drag-and-drop task assignment
50+
<div
51+
className="overflow-hidden bg-gray-1"
52+
style={{
53+
outline: isDragOver ? "2px solid var(--accent-9)" : undefined,
54+
outlineOffset: "-2px",
55+
}}
56+
onDragOver={handleDragOver}
57+
onDragLeave={handleDragLeave}
58+
onDrop={handleDrop}
59+
>
60+
<div
61+
className="h-full w-full origin-top-left"
62+
style={{
63+
zoom: zoom !== 1 ? zoom : undefined,
64+
}}
65+
>
66+
<CommandCenterPanel cell={cell} />
67+
</div>
68+
</div>
69+
);
70+
}
71+
72+
export function CommandCenterGrid({ layout, cells }: CommandCenterGridProps) {
73+
const { cols, rows } = getGridDimensions(layout);
74+
const zoom = useCommandCenterStore((s) => s.zoom);
75+
76+
return (
77+
<div
78+
className="h-full bg-gray-6"
79+
style={{
80+
display: "grid",
81+
gridTemplateColumns: `repeat(${cols}, 1fr)`,
82+
gridTemplateRows: `repeat(${rows}, 1fr)`,
83+
gap: "1px",
84+
}}
85+
>
86+
{cells.map((cell) => (
87+
<GridCell key={cell.cellIndex} cell={cell} zoom={zoom} />
88+
))}
89+
</div>
90+
);
91+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { ArrowsOut, Plus, X } from "@phosphor-icons/react";
2+
import { Flex, Text } from "@radix-ui/themes";
3+
import type { Task } from "@shared/types";
4+
import { useNavigationStore } from "@stores/navigationStore";
5+
import { useCallback, useState } from "react";
6+
import type { CommandCenterCellData } from "../hooks/useCommandCenterData";
7+
import { useCommandCenterStore } from "../stores/commandCenterStore";
8+
import { CommandCenterSessionView } from "./CommandCenterSessionView";
9+
import { StatusBadge } from "./StatusBadge";
10+
import { TaskSelector } from "./TaskSelector";
11+
12+
interface CommandCenterPanelProps {
13+
cell: CommandCenterCellData;
14+
}
15+
16+
function EmptyCell({ cellIndex }: { cellIndex: number }) {
17+
const [selectorOpen, setSelectorOpen] = useState(false);
18+
19+
return (
20+
<Flex align="center" justify="center" height="100%">
21+
<TaskSelector
22+
cellIndex={cellIndex}
23+
open={selectorOpen}
24+
onOpenChange={setSelectorOpen}
25+
>
26+
<button
27+
type="button"
28+
onClick={() => setSelectorOpen(true)}
29+
className="flex items-center gap-1.5 rounded-md border border-gray-7 border-dashed px-3 py-1.5 font-mono text-[11px] text-gray-10 transition-colors hover:border-gray-9 hover:text-gray-12"
30+
>
31+
<Plus size={12} />
32+
Add task
33+
</button>
34+
</TaskSelector>
35+
</Flex>
36+
);
37+
}
38+
39+
function PopulatedCell({
40+
cell,
41+
}: {
42+
cell: CommandCenterCellData & { task: Task };
43+
}) {
44+
const navigateToTask = useNavigationStore((s) => s.navigateToTask);
45+
const removeTask = useCommandCenterStore((s) => s.removeTask);
46+
47+
const handleExpand = useCallback(() => {
48+
navigateToTask(cell.task);
49+
}, [navigateToTask, cell.task]);
50+
51+
const handleRemove = useCallback(() => {
52+
removeTask(cell.cellIndex);
53+
}, [removeTask, cell.cellIndex]);
54+
55+
return (
56+
<Flex direction="column" height="100%">
57+
<Flex
58+
align="center"
59+
gap="2"
60+
px="2"
61+
py="1"
62+
className="shrink-0 border-gray-6 border-b"
63+
>
64+
<Text
65+
size="1"
66+
weight="medium"
67+
className="min-w-0 flex-1 truncate font-mono text-[11px]"
68+
title={cell.task.title}
69+
>
70+
{cell.task.title}
71+
</Text>
72+
<Flex align="center" gap="1" className="shrink-0">
73+
<StatusBadge status={cell.status} />
74+
{cell.repoName && (
75+
<span className="rounded bg-gray-3 px-1 py-0.5 font-mono text-[9px] text-gray-10">
76+
{cell.repoName}
77+
</span>
78+
)}
79+
<button
80+
type="button"
81+
onClick={handleExpand}
82+
className="flex h-5 w-5 items-center justify-center rounded text-gray-10 transition-colors hover:bg-gray-4 hover:text-gray-12"
83+
title="Open task"
84+
>
85+
<ArrowsOut size={12} />
86+
</button>
87+
<button
88+
type="button"
89+
onClick={handleRemove}
90+
className="flex h-5 w-5 items-center justify-center rounded text-gray-10 transition-colors hover:bg-gray-4 hover:text-gray-12"
91+
title="Remove from grid"
92+
>
93+
<X size={12} />
94+
</button>
95+
</Flex>
96+
</Flex>
97+
98+
<Flex direction="column" className="min-h-0 flex-1">
99+
<CommandCenterSessionView taskId={cell.task.id} task={cell.task} />
100+
</Flex>
101+
</Flex>
102+
);
103+
}
104+
105+
export function CommandCenterPanel({ cell }: CommandCenterPanelProps) {
106+
if (!cell.taskId || !cell.task) {
107+
return <EmptyCell cellIndex={cell.cellIndex} />;
108+
}
109+
110+
return (
111+
<PopulatedCell cell={cell as CommandCenterCellData & { task: Task }} />
112+
);
113+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { useDraftStore } from "@features/message-editor/stores/draftStore";
2+
import { SessionView } from "@features/sessions/components/SessionView";
3+
import { useSessionCallbacks } from "@features/sessions/hooks/useSessionCallbacks";
4+
import { useSessionConnection } from "@features/sessions/hooks/useSessionConnection";
5+
import { useSessionForTask } from "@features/sessions/stores/sessionStore";
6+
import { useCwd } from "@features/sidebar/hooks/useCwd";
7+
import { useWorkspace } from "@features/workspace/hooks/useWorkspace";
8+
import { Flex } from "@radix-ui/themes";
9+
import type { Task } from "@shared/types";
10+
import { useEffect, useMemo } from "react";
11+
12+
interface CommandCenterSessionViewProps {
13+
taskId: string;
14+
task: Task;
15+
}
16+
17+
export function CommandCenterSessionView({
18+
taskId,
19+
task,
20+
}: CommandCenterSessionViewProps) {
21+
const session = useSessionForTask(taskId);
22+
const repoPath = useCwd(taskId);
23+
const workspace = useWorkspace(taskId);
24+
const { requestFocus } = useDraftStore((s) => s.actions);
25+
26+
const isCloud =
27+
workspace?.mode === "cloud" || task.latest_run?.environment === "cloud";
28+
29+
useSessionConnection({ taskId, task });
30+
31+
const {
32+
handleSendPrompt,
33+
handleCancelPrompt,
34+
handleRetry,
35+
handleNewSession,
36+
handleBashCommand,
37+
} = useSessionCallbacks({ taskId, task });
38+
39+
const isCloudRunNotTerminal =
40+
isCloud &&
41+
(!session?.cloudStatus ||
42+
session.cloudStatus === "started" ||
43+
session.cloudStatus === "in_progress");
44+
45+
const isRunning = isCloud
46+
? isCloudRunNotTerminal
47+
: session?.status === "connected";
48+
const hasError = isCloud ? false : session?.status === "error";
49+
50+
const events = session?.events ?? [];
51+
const isPromptPending = session?.isPromptPending ?? false;
52+
const promptStartedAt = session?.promptStartedAt;
53+
54+
const isNewSessionWithInitialPrompt =
55+
!task.latest_run?.id && !!task.description;
56+
const isResumingExistingSession = !!task.latest_run?.id;
57+
const isInitializing = isCloud
58+
? !session || (events.length === 0 && isCloudRunNotTerminal)
59+
: !session ||
60+
(session.status === "connecting" && events.length === 0) ||
61+
(session.status === "connected" &&
62+
events.length === 0 &&
63+
(isPromptPending ||
64+
isNewSessionWithInitialPrompt ||
65+
isResumingExistingSession));
66+
67+
const cloudBranch = isCloud
68+
? (workspace?.baseBranch ?? task.latest_run?.branch ?? null)
69+
: null;
70+
71+
const readOnlyMessage = useMemo(() => {
72+
if (!isCloud) return undefined;
73+
const status = session?.cloudStatus;
74+
if (
75+
status === "completed" ||
76+
status === "failed" ||
77+
status === "cancelled"
78+
) {
79+
return "This cloud run has finished";
80+
}
81+
return undefined;
82+
}, [isCloud, session?.cloudStatus]);
83+
84+
useEffect(() => {
85+
requestFocus(taskId);
86+
}, [taskId, requestFocus]);
87+
88+
return (
89+
<Flex direction="column" height="100%">
90+
<SessionView
91+
events={events}
92+
taskId={taskId}
93+
isRunning={!!isRunning}
94+
isPromptPending={isCloud ? null : isPromptPending}
95+
promptStartedAt={isCloud ? undefined : promptStartedAt}
96+
onSendPrompt={handleSendPrompt}
97+
onBashCommand={isCloud ? undefined : handleBashCommand}
98+
onCancelPrompt={handleCancelPrompt}
99+
repoPath={repoPath}
100+
cloudBranch={cloudBranch}
101+
hasError={hasError}
102+
errorTitle={session?.errorTitle}
103+
errorMessage={session?.errorMessage}
104+
onRetry={isCloud ? undefined : handleRetry}
105+
onNewSession={isCloud ? undefined : handleNewSession}
106+
isInitializing={isInitializing}
107+
readOnlyMessage={readOnlyMessage}
108+
compact
109+
/>
110+
</Flex>
111+
);
112+
}

0 commit comments

Comments
 (0)