Skip to content
This repository was archived by the owner on Apr 11, 2026. It is now read-only.

Commit fd5aabd

Browse files
z23ccclaude
andcommitted
feat: Wave 4 — DAG interactivity, Dashboard+HUD, Cmd+K, Agent Console
DAG View (.5): - Drag-to-connect dependencies with optimistic UI + cycle rollback - Edge deletion on hover, CPM critical path highlight (red edges) - Node sizing by estimated_seconds, in-progress pulse animation - Wave timeline component, real-time WS updates Dashboard + HUD + Epic Detail (.6): - HUD status bar (WebSocket-driven, all pages) - Epic cards with colored progress bars, empty state, create button - Epic Detail with sortable table, Start Work button, skeleton loading Cmd+K + Sidebar (.7): - Spotlight-style command palette (Cmd+K), keyboard nav, task actions - Task detail sidebar (400px drawer), operation buttons, mobile fullscreen Agent Console (.8): - Structured log viewer with level coloring, auto-scroll - Filter toolbar (agent/task/level), message search - Task action buttons, agent health indicator, client status section Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8594998 commit fd5aabd

14 files changed

Lines changed: 1807 additions & 324 deletions

frontend/src/App.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import Agents from "./pages/Agents";
99
import Memory from "./pages/Memory";
1010
import Settings from "./pages/Settings";
1111
import Replay from "./pages/Replay";
12+
import CommandPalette from "./components/CommandPalette";
13+
import TaskSidebar from "./components/TaskSidebar";
14+
import { TaskSidebarProvider } from "./components/TaskSidebarContext";
1215
import { startToastBridge } from "./lib/toast-bridge";
1316

1417
export default function App() {
@@ -18,7 +21,7 @@ export default function App() {
1821
}, []);
1922

2023
return (
21-
<>
24+
<TaskSidebarProvider>
2225
<Toaster
2326
theme="dark"
2427
position="bottom-right"
@@ -30,6 +33,8 @@ export default function App() {
3033
},
3134
}}
3235
/>
36+
<CommandPalette />
37+
<TaskSidebar />
3338
<Routes>
3439
<Route element={<Layout />}>
3540
<Route path="/" element={<Dashboard />} />
@@ -41,6 +46,6 @@ export default function App() {
4146
<Route path="/replay/:id" element={<Replay />} />
4247
</Route>
4348
</Routes>
44-
</>
49+
</TaskSidebarProvider>
4550
);
4651
}
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import { useState, useEffect, useCallback, useRef } from "react";
2+
import { useNavigate } from "react-router-dom";
3+
import useSWR from "swr";
4+
import {
5+
Search,
6+
Play,
7+
Ban,
8+
RotateCcw,
9+
SkipForward,
10+
LayoutDashboard,
11+
Bot,
12+
Brain,
13+
Settings,
14+
Plus,
15+
type LucideIcon,
16+
} from "lucide-react";
17+
import { toast } from "sonner";
18+
import { apiPost, swrFetcher } from "../lib/api";
19+
20+
interface Task {
21+
id: string;
22+
title: string;
23+
status: string;
24+
}
25+
26+
interface TasksResponse {
27+
count: number;
28+
tasks: Task[];
29+
}
30+
31+
interface Command {
32+
id: string;
33+
label: string;
34+
description: string;
35+
icon: LucideIcon;
36+
action: () => void | Promise<void>;
37+
}
38+
39+
export default function CommandPalette() {
40+
const [open, setOpen] = useState(false);
41+
const [query, setQuery] = useState("");
42+
const [selectedIndex, setSelectedIndex] = useState(0);
43+
const inputRef = useRef<HTMLInputElement>(null);
44+
const listRef = useRef<HTMLDivElement>(null);
45+
const navigate = useNavigate();
46+
47+
const { data: tasksData } = useSWR<TasksResponse>(
48+
open ? "/tasks" : null,
49+
swrFetcher,
50+
);
51+
52+
const tasks = tasksData?.tasks ?? [];
53+
54+
const close = useCallback(() => {
55+
setOpen(false);
56+
setQuery("");
57+
setSelectedIndex(0);
58+
}, []);
59+
60+
const execute = useCallback(async (cmd: Command) => {
61+
close();
62+
try {
63+
await cmd.action();
64+
} catch (err) {
65+
toast.error(`Command failed: ${err instanceof Error ? err.message : "unknown error"}`);
66+
}
67+
}, [close]);
68+
69+
// Build command list
70+
const commands: Command[] = [];
71+
72+
// Task commands
73+
for (const task of tasks) {
74+
if (task.status === "todo" || task.status === "blocked") {
75+
commands.push({
76+
id: `start-${task.id}`,
77+
label: `start ${task.id}`,
78+
description: task.title,
79+
icon: Play,
80+
action: async () => {
81+
await apiPost(`/tasks/${task.id}/start`, {});
82+
toast.success(`Started ${task.id}`);
83+
},
84+
});
85+
}
86+
if (task.status === "in_progress") {
87+
commands.push({
88+
id: `block-${task.id}`,
89+
label: `block ${task.id}`,
90+
description: task.title,
91+
icon: Ban,
92+
action: async () => {
93+
await apiPost(`/tasks/${task.id}/block`, {});
94+
toast.success(`Blocked ${task.id}`);
95+
},
96+
});
97+
}
98+
if (task.status === "blocked" || task.status === "failed") {
99+
commands.push({
100+
id: `restart-${task.id}`,
101+
label: `restart ${task.id}`,
102+
description: task.title,
103+
icon: RotateCcw,
104+
action: async () => {
105+
await apiPost(`/tasks/${task.id}/restart`, {});
106+
toast.success(`Restarted ${task.id}`);
107+
},
108+
});
109+
}
110+
if (task.status !== "done" && task.status !== "skipped") {
111+
commands.push({
112+
id: `skip-${task.id}`,
113+
label: `skip ${task.id}`,
114+
description: task.title,
115+
icon: SkipForward,
116+
action: async () => {
117+
await apiPost(`/tasks/${task.id}/skip`, {});
118+
toast.success(`Skipped ${task.id}`);
119+
},
120+
});
121+
}
122+
}
123+
124+
// Navigation commands
125+
commands.push(
126+
{
127+
id: "go-dashboard",
128+
label: "go Dashboard",
129+
description: "Navigate to Dashboard",
130+
icon: LayoutDashboard,
131+
action: () => navigate("/"),
132+
},
133+
{
134+
id: "go-agents",
135+
label: "go Agents",
136+
description: "Navigate to Agents",
137+
icon: Bot,
138+
action: () => navigate("/agents"),
139+
},
140+
{
141+
id: "go-memory",
142+
label: "go Memory",
143+
description: "Navigate to Memory",
144+
icon: Brain,
145+
action: () => navigate("/memory"),
146+
},
147+
{
148+
id: "go-settings",
149+
label: "go Settings",
150+
description: "Navigate to Settings",
151+
icon: Settings,
152+
action: () => navigate("/settings"),
153+
},
154+
{
155+
id: "create-epic",
156+
label: "create epic",
157+
description: "Navigate to create a new epic",
158+
icon: Plus,
159+
action: () => navigate("/"),
160+
},
161+
);
162+
163+
// Filter
164+
const q = query.toLowerCase().trim();
165+
const filtered = q
166+
? commands.filter(
167+
(cmd) =>
168+
cmd.label.toLowerCase().includes(q) ||
169+
cmd.description.toLowerCase().includes(q),
170+
)
171+
: commands;
172+
173+
// Clamp selection
174+
const clampedIndex = Math.min(selectedIndex, Math.max(filtered.length - 1, 0));
175+
176+
// Keyboard shortcut to open
177+
useEffect(() => {
178+
const handler = (e: KeyboardEvent) => {
179+
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
180+
e.preventDefault();
181+
setOpen((prev) => !prev);
182+
}
183+
};
184+
document.addEventListener("keydown", handler);
185+
return () => document.removeEventListener("keydown", handler);
186+
}, []);
187+
188+
// Focus input when opened
189+
useEffect(() => {
190+
if (open) {
191+
requestAnimationFrame(() => inputRef.current?.focus());
192+
}
193+
}, [open]);
194+
195+
// Scroll selected item into view
196+
useEffect(() => {
197+
if (!listRef.current) return;
198+
const item = listRef.current.children[clampedIndex] as HTMLElement | undefined;
199+
item?.scrollIntoView({ block: "nearest" });
200+
}, [clampedIndex]);
201+
202+
const handleKeyDown = (e: React.KeyboardEvent) => {
203+
switch (e.key) {
204+
case "ArrowDown":
205+
e.preventDefault();
206+
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
207+
break;
208+
case "ArrowUp":
209+
e.preventDefault();
210+
setSelectedIndex((i) => Math.max(i - 1, 0));
211+
break;
212+
case "Enter":
213+
e.preventDefault();
214+
if (filtered[clampedIndex]) {
215+
execute(filtered[clampedIndex]);
216+
}
217+
break;
218+
case "Escape":
219+
e.preventDefault();
220+
close();
221+
break;
222+
}
223+
};
224+
225+
// Reset selection on query change
226+
useEffect(() => {
227+
setSelectedIndex(0);
228+
}, [query]);
229+
230+
if (!open) return null;
231+
232+
return (
233+
<div
234+
className="fixed inset-0 z-[60] flex items-start justify-center pt-[20vh] bg-black/50 backdrop-blur-sm"
235+
onClick={close}
236+
>
237+
<div
238+
className="w-full max-w-[600px] mx-4 rounded-[var(--radius-md)] border border-border bg-bg-secondary shadow-2xl overflow-hidden"
239+
onClick={(e) => e.stopPropagation()}
240+
>
241+
{/* Search input */}
242+
<div className="flex items-center gap-3 px-4 border-b border-border">
243+
<Search size={16} className="text-text-muted shrink-0" />
244+
<input
245+
ref={inputRef}
246+
type="text"
247+
value={query}
248+
onChange={(e) => setQuery(e.target.value)}
249+
onKeyDown={handleKeyDown}
250+
placeholder="Type a command..."
251+
className="flex-1 bg-transparent py-3 text-sm text-text-primary placeholder:text-text-muted outline-none"
252+
/>
253+
<kbd className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-bg-tertiary text-text-muted shrink-0">
254+
ESC
255+
</kbd>
256+
</div>
257+
258+
{/* Results */}
259+
<div ref={listRef} className="max-h-[300px] overflow-auto py-1">
260+
{filtered.length === 0 ? (
261+
<div className="px-4 py-6 text-center text-sm text-text-muted">
262+
No matching commands
263+
</div>
264+
) : (
265+
filtered.map((cmd, i) => {
266+
const Icon = cmd.icon;
267+
const isSelected = i === clampedIndex;
268+
return (
269+
<button
270+
key={cmd.id}
271+
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors cursor-pointer ${
272+
isSelected
273+
? "bg-accent/10 text-accent"
274+
: "text-text-secondary hover:bg-bg-tertiary"
275+
}`}
276+
onClick={() => execute(cmd)}
277+
onMouseEnter={() => setSelectedIndex(i)}
278+
>
279+
<Icon size={16} className="shrink-0" />
280+
<div className="min-w-0 flex-1">
281+
<span className="text-sm font-medium">{cmd.label}</span>
282+
<span className="ml-2 text-xs text-text-muted">{cmd.description}</span>
283+
</div>
284+
</button>
285+
);
286+
})
287+
)}
288+
</div>
289+
</div>
290+
</div>
291+
);
292+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import {
2+
BaseEdge,
3+
EdgeLabelRenderer,
4+
getBezierPath,
5+
type EdgeProps,
6+
} from "@xyflow/react";
7+
8+
interface DeletableEdgeData {
9+
isCritical?: boolean;
10+
onDelete?: (id: string) => void;
11+
}
12+
13+
export default function DeletableEdge({
14+
id,
15+
sourceX,
16+
sourceY,
17+
targetX,
18+
targetY,
19+
sourcePosition,
20+
targetPosition,
21+
data,
22+
markerEnd,
23+
}: EdgeProps) {
24+
const edgeData = data as DeletableEdgeData | undefined;
25+
const isCritical = edgeData?.isCritical ?? false;
26+
const onDelete = edgeData?.onDelete;
27+
28+
const [edgePath, labelX, labelY] = getBezierPath({
29+
sourceX,
30+
sourceY,
31+
targetX,
32+
targetY,
33+
sourcePosition,
34+
targetPosition,
35+
});
36+
37+
return (
38+
<>
39+
<BaseEdge
40+
id={id}
41+
path={edgePath}
42+
markerEnd={markerEnd}
43+
style={{
44+
stroke: isCritical ? "var(--color-status-blocked)" : "#555",
45+
strokeWidth: isCritical ? 3 : 1.5,
46+
}}
47+
/>
48+
{onDelete && (
49+
<EdgeLabelRenderer>
50+
<button
51+
className="edge-delete-btn"
52+
style={{
53+
position: "absolute",
54+
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
55+
pointerEvents: "all",
56+
}}
57+
onClick={() => onDelete(id)}
58+
title="Remove dependency"
59+
>
60+
&times;
61+
</button>
62+
</EdgeLabelRenderer>
63+
)}
64+
</>
65+
);
66+
}

0 commit comments

Comments
 (0)