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
This commit is contained in:
233
frontend/src/components/CommandPalette.tsx
Normal file
233
frontend/src/components/CommandPalette.tsx
Normal file
@@ -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<string, string> = {
|
||||
active: "⚡",
|
||||
queued: "📋",
|
||||
blocked: "🚫",
|
||||
completed: "✅",
|
||||
cancelled: "❌",
|
||||
};
|
||||
|
||||
const priorityIcons: Record<string, string> = {
|
||||
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<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(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 (
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[70]" onClick={() => setOpen(false)} />
|
||||
<div className="fixed inset-0 flex items-start justify-center z-[71] pt-[15vh] px-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-lg overflow-hidden">
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-100 dark:border-gray-800">
|
||||
<svg className="w-5 h-5 text-gray-400 dark:text-gray-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<kbd className="px-1.5 py-0.5 text-[10px] font-mono text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded">
|
||||
Esc
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div ref={listRef} className="max-h-[50vh] overflow-y-auto py-2">
|
||||
{results.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-gray-400 dark:text-gray-500">
|
||||
{query ? "No tasks found" : "No active tasks"}
|
||||
</div>
|
||||
) : (
|
||||
results.map((task, i) => (
|
||||
<button
|
||||
key={task.id}
|
||||
onClick={() => handleSelect(task)}
|
||||
onMouseEnter={() => setSelectedIndex(i)}
|
||||
className={`w-full text-left px-4 py-2.5 flex items-start gap-3 transition ${
|
||||
i === selectedIndex
|
||||
? "bg-amber-50 dark:bg-amber-900/20"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm shrink-0 mt-0.5">{statusIcons[task.status] || "📋"}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono font-bold text-gray-400 dark:text-gray-500">
|
||||
HQ-{task.taskNumber}
|
||||
</span>
|
||||
<span className="text-[10px]">{priorityIcons[task.priority]}</span>
|
||||
{task.tags?.slice(0, 2).map((tag) => (
|
||||
<span key={tag} className="text-[10px] text-violet-500 dark:text-violet-400">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className={`text-sm truncate ${
|
||||
i === selectedIndex
|
||||
? "text-amber-900 dark:text-amber-200 font-medium"
|
||||
: "text-gray-700 dark:text-gray-300"
|
||||
}`}>
|
||||
{task.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{task.assigneeName && (
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500">
|
||||
👤 {task.assigneeName}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500">
|
||||
{timeAgo(task.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{i === selectedIndex && (
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500 mt-1 shrink-0">
|
||||
↵ Open
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-2.5 border-t border-gray-100 dark:border-gray-800 flex items-center gap-4 text-[10px] text-gray-400 dark:text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1 py-0.5 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded font-mono">↑↓</kbd>
|
||||
Navigate
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1 py-0.5 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded font-mono">↵</kbd>
|
||||
Open
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1 py-0.5 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded font-mono">Esc</kbd>
|
||||
Close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === "/"}
|
||||
onClick={closeSidebar}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition ${
|
||||
isActive
|
||||
? "bg-amber-500/20 text-amber-400"
|
||||
: "text-gray-400 hover:text-white hover:bg-gray-800"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
{navItems.map((item) => {
|
||||
const badge = item.badgeKey === "queue" && activeTasks > 0 ? activeTasks : 0;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === "/"}
|
||||
onClick={closeSidebar}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition ${
|
||||
isActive
|
||||
? "bg-amber-500/20 text-amber-400"
|
||||
: "text-gray-400 hover:text-white hover:bg-gray-800"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{badge > 0 && (
|
||||
<span className="text-[10px] font-bold bg-amber-500 text-white px-1.5 py-0.5 rounded-full min-w-[18px] text-center leading-none">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
{item.badgeKey === "queue" && blockedTasks > 0 && (
|
||||
<span className="text-[10px] font-bold bg-red-500 text-white px-1.5 py-0.5 rounded-full min-w-[18px] text-center leading-none">
|
||||
{blockedTasks}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
{isAdmin && (
|
||||
<NavLink
|
||||
to="/admin"
|
||||
@@ -173,6 +192,7 @@ export function DashboardLayout() {
|
||||
</main>
|
||||
|
||||
<KeyboardShortcutsModal />
|
||||
<CommandPalette />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user