|
| 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 | +} |
0 commit comments