From 6459734bc7edd30e75d8924b66d96fb42ab009ad Mon Sep 17 00:00:00 2001 From: Hammer Date: Thu, 29 Jan 2026 11:08:02 +0000 Subject: [PATCH] feat: command palette (Ctrl+K) and sidebar notification badges - Command palette: Ctrl+K opens global task search with arrow key navigation - Shows active/recent tasks when empty, filters on type - Enter to open task detail page, Esc to close - Sidebar: active task count badge (amber) and blocked count (red) on Queue nav - Updated keyboard shortcuts modal with Ctrl+K --- frontend/src/components/CommandPalette.tsx | 233 ++++++++++++++++++ frontend/src/components/DashboardLayout.tsx | 70 ++++-- .../src/components/KeyboardShortcutsModal.tsx | 1 + 3 files changed, 279 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/CommandPalette.tsx diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx new file mode 100644 index 0000000..f6dc4f5 --- /dev/null +++ b/frontend/src/components/CommandPalette.tsx @@ -0,0 +1,233 @@ +import { useState, useEffect, useRef, useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { useTasks } from "../hooks/useTasks"; +import type { Task } from "../lib/types"; + +const statusIcons: Record = { + active: "⚡", + queued: "📋", + blocked: "🚫", + completed: "✅", + cancelled: "❌", +}; + +const priorityIcons: Record = { + critical: "🔴", + high: "🟠", + medium: "🔵", + low: "⚪", +}; + +function timeAgo(dateStr: string): string { + const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000); + if (seconds < 60) return "just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +interface CommandPaletteProps { + onSelectTask?: (task: Task) => void; +} + +export function CommandPalette({ onSelectTask }: CommandPaletteProps) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const { tasks } = useTasks(30000); // Slower poll for palette + const navigate = useNavigate(); + const inputRef = useRef(null); + const listRef = useRef(null); + + // Open/close with Ctrl+K + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + setOpen((prev) => { + if (!prev) { + setQuery(""); + setSelectedIndex(0); + } + return !prev; + }); + } + if (e.key === "Escape" && open) { + e.preventDefault(); + e.stopPropagation(); + setOpen(false); + } + }; + window.addEventListener("keydown", handleKey, true); + return () => window.removeEventListener("keydown", handleKey, true); + }, [open]); + + // Focus input when opened + useEffect(() => { + if (open) { + setTimeout(() => inputRef.current?.focus(), 50); + } + }, [open]); + + const results = useMemo(() => { + if (!query.trim()) { + // Show active and recent tasks when no query + return tasks + .filter((t) => t.status === "active" || t.status === "queued" || t.status === "blocked") + .slice(0, 10); + } + const q = query.toLowerCase(); + return tasks + .filter( + (t) => + t.title.toLowerCase().includes(q) || + (t.description && t.description.toLowerCase().includes(q)) || + (t.taskNumber && `hq-${t.taskNumber}`.includes(q)) || + (t.assigneeName && t.assigneeName.toLowerCase().includes(q)) || + (t.tags && t.tags.some((tag) => tag.toLowerCase().includes(q))) + ) + .slice(0, 15); + }, [tasks, query]); + + // Keep selected index in bounds + useEffect(() => { + setSelectedIndex(0); + }, [query]); + + const handleSelect = (task: Task) => { + setOpen(false); + if (onSelectTask) { + onSelectTask(task); + } else { + navigate(`/task/HQ-${task.taskNumber}`); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === "Enter" && results[selectedIndex]) { + e.preventDefault(); + handleSelect(results[selectedIndex]); + } + }; + + // Scroll selected item into view + useEffect(() => { + if (!listRef.current) return; + const el = listRef.current.children[selectedIndex] as HTMLElement; + if (el) el.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + if (!open) return null; + + return ( + <> +
setOpen(false)} /> +
+
+ {/* Search input */} +
+ + + + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search tasks... (type to filter)" + className="flex-1 bg-transparent border-none outline-none text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 text-sm" + /> + + Esc + +
+ + {/* Results */} +
+ {results.length === 0 ? ( +
+ {query ? "No tasks found" : "No active tasks"} +
+ ) : ( + results.map((task, i) => ( + + )) + )} +
+ + {/* Footer */} +
+ + ↑↓ + Navigate + + + + Open + + + Esc + Close + +
+
+
+ + ); +} diff --git a/frontend/src/components/DashboardLayout.tsx b/frontend/src/components/DashboardLayout.tsx index e484841..ceb7fdc 100644 --- a/frontend/src/components/DashboardLayout.tsx +++ b/frontend/src/components/DashboardLayout.tsx @@ -1,23 +1,29 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { NavLink, Outlet } from "react-router-dom"; import { useCurrentUser } from "../hooks/useCurrentUser"; +import { useTasks } from "../hooks/useTasks"; import { useTheme } from "../hooks/useTheme"; import { KeyboardShortcutsModal } from "./KeyboardShortcutsModal"; +import { CommandPalette } from "./CommandPalette"; import { signOut } from "../lib/auth-client"; const navItems = [ - { to: "/", label: "Dashboard", icon: "🔨" }, - { to: "/queue", label: "Queue", icon: "📋" }, - { to: "/projects", label: "Projects", icon: "📁" }, - { to: "/activity", label: "Activity", icon: "📝" }, - { to: "/chat", label: "Chat", icon: "💬" }, -]; + { to: "/", label: "Dashboard", icon: "🔨", badgeKey: null }, + { to: "/queue", label: "Queue", icon: "📋", badgeKey: "queue" }, + { to: "/projects", label: "Projects", icon: "📁", badgeKey: null }, + { to: "/activity", label: "Activity", icon: "📝", badgeKey: null }, + { to: "/chat", label: "Chat", icon: "💬", badgeKey: null }, +] as const; export function DashboardLayout() { const { user, isAdmin } = useCurrentUser(); + const { tasks } = useTasks(15000); const { theme, setTheme } = useTheme(); const [sidebarOpen, setSidebarOpen] = useState(false); + const activeTasks = useMemo(() => tasks.filter((t) => t.status === "active").length, [tasks]); + const blockedTasks = useMemo(() => tasks.filter((t) => t.status === "blocked").length, [tasks]); + const cycleTheme = () => { const next = theme === "light" ? "dark" : theme === "dark" ? "system" : "light"; setTheme(next); @@ -97,24 +103,37 @@ export function DashboardLayout() { {/* Navigation */}
); } diff --git a/frontend/src/components/KeyboardShortcutsModal.tsx b/frontend/src/components/KeyboardShortcutsModal.tsx index 8870662..ae4bf97 100644 --- a/frontend/src/components/KeyboardShortcutsModal.tsx +++ b/frontend/src/components/KeyboardShortcutsModal.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; const shortcuts = [ + { keys: ["Ctrl", "K"], action: "Open command palette", context: "Global" }, { keys: ["Ctrl", "N"], action: "Create new task", context: "Queue" }, { keys: ["Esc"], action: "Close panel / Cancel edit", context: "Global" }, { keys: ["?"], action: "Show keyboard shortcuts", context: "Global" },