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:
2026-01-29 11:08:02 +00:00
parent 9279956e33
commit 6459734bc7
3 changed files with 279 additions and 25 deletions

View 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>
</>
);
}

View File

@@ -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,7 +103,9 @@ 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) => {
const badge = item.badgeKey === "queue" && activeTasks > 0 ? activeTasks : 0;
return (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
@@ -112,9 +120,20 @@ export function DashboardLayout() {
} }
> >
<span className="text-lg">{item.icon}</span> <span className="text-lg">{item.icon}</span>
{item.label} <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> </NavLink>
))} );
})}
{isAdmin && ( {isAdmin && (
<NavLink <NavLink
to="/admin" to="/admin"
@@ -173,6 +192,7 @@ export function DashboardLayout() {
</main> </main>
<KeyboardShortcutsModal /> <KeyboardShortcutsModal />
<CommandPalette />
</div> </div>
); );
} }

View File

@@ -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" },