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 { NavLink, Outlet } from "react-router-dom";
|
||||||
import { useCurrentUser } from "../hooks/useCurrentUser";
|
import { useCurrentUser } from "../hooks/useCurrentUser";
|
||||||
|
import { useTasks } from "../hooks/useTasks";
|
||||||
import { useTheme } from "../hooks/useTheme";
|
import { useTheme } from "../hooks/useTheme";
|
||||||
import { KeyboardShortcutsModal } from "./KeyboardShortcutsModal";
|
import { KeyboardShortcutsModal } from "./KeyboardShortcutsModal";
|
||||||
|
import { CommandPalette } from "./CommandPalette";
|
||||||
import { signOut } from "../lib/auth-client";
|
import { signOut } from "../lib/auth-client";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: "/", label: "Dashboard", icon: "🔨" },
|
{ to: "/", label: "Dashboard", icon: "🔨", badgeKey: null },
|
||||||
{ to: "/queue", label: "Queue", icon: "📋" },
|
{ to: "/queue", label: "Queue", icon: "📋", badgeKey: "queue" },
|
||||||
{ to: "/projects", label: "Projects", icon: "📁" },
|
{ to: "/projects", label: "Projects", icon: "📁", badgeKey: null },
|
||||||
{ to: "/activity", label: "Activity", icon: "📝" },
|
{ to: "/activity", label: "Activity", icon: "📝", badgeKey: null },
|
||||||
{ to: "/chat", label: "Chat", icon: "💬" },
|
{ to: "/chat", label: "Chat", icon: "💬", badgeKey: null },
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export function DashboardLayout() {
|
export function DashboardLayout() {
|
||||||
const { user, isAdmin } = useCurrentUser();
|
const { user, isAdmin } = useCurrentUser();
|
||||||
|
const { tasks } = useTasks(15000);
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
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 cycleTheme = () => {
|
||||||
const next = theme === "light" ? "dark" : theme === "dark" ? "system" : "light";
|
const next = theme === "light" ? "dark" : theme === "dark" ? "system" : "light";
|
||||||
setTheme(next);
|
setTheme(next);
|
||||||
@@ -97,24 +103,37 @@ export function DashboardLayout() {
|
|||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 px-3 py-4 space-y-1">
|
<nav className="flex-1 px-3 py-4 space-y-1">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => {
|
||||||
<NavLink
|
const badge = item.badgeKey === "queue" && activeTasks > 0 ? activeTasks : 0;
|
||||||
key={item.to}
|
return (
|
||||||
to={item.to}
|
<NavLink
|
||||||
end={item.to === "/"}
|
key={item.to}
|
||||||
onClick={closeSidebar}
|
to={item.to}
|
||||||
className={({ isActive }) =>
|
end={item.to === "/"}
|
||||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition ${
|
onClick={closeSidebar}
|
||||||
isActive
|
className={({ isActive }) =>
|
||||||
? "bg-amber-500/20 text-amber-400"
|
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition ${
|
||||||
: "text-gray-400 hover:text-white hover:bg-gray-800"
|
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>
|
<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 && (
|
{isAdmin && (
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/admin"
|
to="/admin"
|
||||||
@@ -173,6 +192,7 @@ export function DashboardLayout() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<KeyboardShortcutsModal />
|
<KeyboardShortcutsModal />
|
||||||
|
<CommandPalette />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
const shortcuts = [
|
const shortcuts = [
|
||||||
|
{ keys: ["Ctrl", "K"], action: "Open command palette", context: "Global" },
|
||||||
{ keys: ["Ctrl", "N"], action: "Create new task", context: "Queue" },
|
{ keys: ["Ctrl", "N"], action: "Create new task", context: "Queue" },
|
||||||
{ keys: ["Esc"], action: "Close panel / Cancel edit", context: "Global" },
|
{ keys: ["Esc"], action: "Close panel / Cancel edit", context: "Global" },
|
||||||
{ keys: ["?"], action: "Show keyboard shortcuts", context: "Global" },
|
{ keys: ["?"], action: "Show keyboard shortcuts", context: "Global" },
|
||||||
|
|||||||
Reference in New Issue
Block a user