diff --git a/apps/web/package.json b/apps/web/package.json index 1af2719..3b84c15 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,20 +10,48 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@github/hotkey": "^3.1.4", + "@radix-ui/react-accordion": "^1.2.20", + "@radix-ui/react-alert-dialog": "^1.1.23", + "@radix-ui/react-aspect-ratio": "^1.1.15", + "@radix-ui/react-avatar": "^1.2.6", + "@radix-ui/react-checkbox": "^1.3.11", + "@radix-ui/react-context-menu": "^2.3.7", + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-hover-card": "^1.1.23", + "@radix-ui/react-label": "^2.1.15", + "@radix-ui/react-menubar": "^1.1.24", + "@radix-ui/react-navigation-menu": "^1.2.22", + "@radix-ui/react-popover": "^1.1.23", + "@radix-ui/react-progress": "^1.1.16", + "@radix-ui/react-radio-group": "^1.4.7", + "@radix-ui/react-scroll-area": "^1.2.18", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-separator": "^1.1.15", + "@radix-ui/react-slider": "^1.4.7", "@radix-ui/react-slot": "^1.3.0", + "@radix-ui/react-switch": "^1.3.7", + "@radix-ui/react-tabs": "^1.1.21", + "@radix-ui/react-toggle": "^1.1.18", + "@radix-ui/react-toggle-group": "^1.1.19", + "@radix-ui/react-tooltip": "^1.2.16", "@tanstack/react-query": "^5.62.0", "@tanstack/react-router": "^1.98.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "^1.24.0", "react": "^19.1.0", "react-dom": "^19.1.0", "tailwind-merge": "^3.6.0", "tailwindcss-animate": "^1.0.7", - "zod": "^4.4.3" + "zod": "^4.4.3", + "zustand": "^5.0.14" }, "devDependencies": { "@tanstack/react-query-devtools": "^5.62.0", + "@tanstack/react-router-devtools": "^1.167.0", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@vitejs/plugin-react": "^4.3.4", diff --git a/apps/web/src/components/shell/command-palette.tsx b/apps/web/src/components/shell/command-palette.tsx new file mode 100644 index 0000000..5b7a646 --- /dev/null +++ b/apps/web/src/components/shell/command-palette.tsx @@ -0,0 +1,274 @@ +import { useEffect, useState, useCallback, useRef } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { + LayoutDashboard, + ListTodo, + Flame, + FolderKanban, + NotebookPen, + Share2, + CalendarDays, + Bot, + Settings, + Plus, + Search, + Sun, + Moon, + Palette, + type LucideIcon, +} from "lucide-react"; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { useThemeStore, type AccentColor, ACCENT_PALETTE } from "@/lib/stores/use-theme-store"; + +interface NavItem { + label: string; + href: string; + icon: LucideIcon; +} + +const navItems: NavItem[] = [ + { label: "Dashboard", href: "/", icon: LayoutDashboard }, + { label: "Tasks", href: "/tasks", icon: ListTodo }, + { label: "Habits", href: "/habits", icon: Flame }, + { label: "Projects", href: "/projects", icon: FolderKanban }, + { label: "Notes", href: "/notes", icon: NotebookPen }, + { label: "Graph", href: "/graph", icon: Share2 }, + { label: "Calendar", href: "/calendar", icon: CalendarDays }, + { label: "Agent Activity", href: "/agents/activity", icon: Bot }, + { label: "Settings", href: "/settings", icon: Settings }, +]; + +interface QuickAction { + label: string; + icon: LucideIcon; + action: () => void; +} + +export function CommandPalette() { + const navigate = useNavigate(); + const { mode, setMode, accent, setAccent } = useThemeStore(); + const [open, setOpen] = useState(false); + const [searchResults, setSearchResults] = useState< + Array<{ type: string; items: Array<{ id: string; title: string }> }> + >([]); + const searchTimeoutRef = useRef>(); + + // Listen for open-command-palette event + useEffect(() => { + const handler = () => setOpen(true); + document.addEventListener("open-command-palette", handler); + return () => document.removeEventListener("open-command-palette", handler); + }, []); + + // Quick actions + const quickActions: QuickAction[] = [ + { + label: "New task", + icon: ListTodo, + action: () => navigate({ to: "/tasks" }), + }, + { + label: "New habit", + icon: Flame, + action: () => navigate({ to: "/habits" }), + }, + { + label: "New project", + icon: FolderKanban, + action: () => navigate({ to: "/projects" }), + }, + { + label: "New note", + icon: NotebookPen, + action: () => navigate({ to: "/notes" }), + }, + { + label: "New event", + icon: CalendarDays, + action: () => navigate({ to: "/calendar" }), + }, + ]; + + // Theme actions + const themeActions: QuickAction[] = [ + { + label: mode === "dark" ? "Switch to light mode" : "Switch to dark mode", + icon: mode === "dark" ? Sun : Moon, + action: () => setMode(mode === "dark" ? "light" : "dark"), + }, + ...(Object.entries(ACCENT_PALETTE) as [AccentColor, { name: string; hsl: string }][]).map( + ([key, val]) => ({ + label: `Accent: ${val.name}`, + icon: Palette, + action: () => setAccent(key), + }) + ), + ]; + + // Search handler + const handleSearch = useCallback(async (query: string) => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + if (!query.trim()) { + setSearchResults([]); + return; + } + + // Check for @mention + if (query.startsWith("@")) { + const mentionQuery = query.slice(1).trim(); + if (mentionQuery) { + try { + const res = await fetch(`/api/agents?q=${encodeURIComponent(mentionQuery)}`); + if (res.ok) { + const data = await res.json(); + setSearchResults([ + { + type: "Agents", + items: (data.agents || data.results || []).map((a: { id: string; name: string }) => ({ + id: a.id, + title: a.name, + })), + }, + ]); + } + } catch { + // Ignore + } + } + return; + } + + // Debounced API search + searchTimeoutRef.current = setTimeout(async () => { + try { + const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=5`); + if (res.ok) { + const data = await res.json(); + setSearchResults(data.results || []); + } + } catch { + // Ignore search errors + } + }, 300); + }, []); + + const runCommand = useCallback( + (command: () => void) => { + setOpen(false); + command(); + }, + [] + ); + + return ( + + + + No results found. + + {/* Navigation */} + + {navItems.map((item) => ( + runCommand(() => navigate({ to: item.href }))} + > + + {item.label} + + ))} + + + {/* Quick Actions */} + + {quickActions.map((action) => ( + runCommand(action.action)} + > + + {action.label} + + ))} + + + {/* Theme */} + + {themeActions.map((action) => ( + runCommand(action.action)} + > + + {action.label} + + ))} + + + {/* Search Results */} + {searchResults.length > 0 && ( + <> + + {searchResults.map((group) => ( + + {group.items.map((item) => ( + { + const typeRoute = + group.type === "tasks" + ? "/tasks" + : group.type === "habits" + ? "/habits" + : group.type === "projects" + ? "/projects" + : group.type === "notes" + ? "/notes" + : "/search"; + runCommand(() => navigate({ to: `${typeRoute}/${item.id}` })); + }} + > + + {item.title} + + ))} + + ))} + + )} + + {/* Footer hint */} +
+ + ↑↓ navigate + + + select + + + esc close + +
+
+
+ ); +} diff --git a/apps/web/src/components/shell/shortcuts-help.tsx b/apps/web/src/components/shell/shortcuts-help.tsx new file mode 100644 index 0000000..f1ecc07 --- /dev/null +++ b/apps/web/src/components/shell/shortcuts-help.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; + +const shortcutGroups = [ + { + heading: "Navigation", + shortcuts: [ + { keys: "g then d", description: "Go to Dashboard" }, + { keys: "g then t", description: "Go to Tasks" }, + { keys: "g then h", description: "Go to Habits" }, + { keys: "g then p", description: "Go to Projects" }, + { keys: "g then n", description: "Go to Notes" }, + { keys: "g then c", description: "Go to Calendar" }, + { keys: "g then g", description: "Go to Graph" }, + { keys: "g then s", description: "Go to Settings" }, + ], + }, + { + heading: "General", + shortcuts: [ + { keys: "⌘K / Ctrl+K", description: "Open command palette" }, + { keys: "/", description: "Focus search" }, + { keys: "?", description: "Show this help" }, + { keys: "Esc", description: "Close dialogs / panels" }, + ], + }, + { + heading: "Command Palette", + shortcuts: [ + { keys: "↑↓", description: "Navigate items" }, + { keys: "Enter", description: "Select item" }, + { keys: "Esc", description: "Close palette" }, + ], + }, +]; + +export function ShortcutsHelp() { + const [open, setOpen] = useState(false); + + useEffect(() => { + const handler = () => setOpen(true); + document.addEventListener("open-shortcuts-help", handler); + return () => document.removeEventListener("open-shortcuts-help", handler); + }, []); + + return ( + + + + Keyboard Shortcuts + + Use these shortcuts to navigate quickly. + + +
+ {shortcutGroups.map((group) => ( +
+

+ {group.heading} +

+
+ {group.shortcuts.map((s) => ( +
+ {s.description} + + {s.keys} + +
+ ))} +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/web/src/components/shell/sidebar.tsx b/apps/web/src/components/shell/sidebar.tsx new file mode 100644 index 0000000..06cb825 --- /dev/null +++ b/apps/web/src/components/shell/sidebar.tsx @@ -0,0 +1,249 @@ +import { Link, useLocation } from "@tanstack/react-router"; +import { cn } from "@/lib/utils"; +import { useSidebarStore } from "@/lib/stores/use-sidebar-store"; +import { + LayoutDashboard, + ListTodo, + Flame, + FolderKanban, + NotebookPen, + BookOpen, + Share2, + LayoutGrid, + CalendarDays, + Search, + BarChart3, + Bot, + PenLine, + Settings, + ChevronLeft, + ChevronRight, + LogOut, + User, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; + +interface NavItem { + href: string; + label: string; + icon: React.ComponentType<{ className?: string }>; + shortcut?: string; +} + +const navItems: NavItem[] = [ + { href: "/", label: "Dashboard", icon: LayoutDashboard, shortcut: "g d" }, + { href: "/tasks", label: "Tasks", icon: ListTodo, shortcut: "g t" }, + { href: "/habits", label: "Habits", icon: Flame, shortcut: "g h" }, + { href: "/projects", label: "Projects", icon: FolderKanban, shortcut: "g p" }, + { href: "/notes", label: "Notes", icon: NotebookPen, shortcut: "g n" }, + { href: "/calendar", label: "Calendar", icon: CalendarDays, shortcut: "g c" }, + { href: "/graph", label: "Graph", icon: Share2, shortcut: "g g" }, + { href: "/search", label: "Search", icon: Search }, + { href: "/analytics", label: "Analytics", icon: BarChart3 }, + { href: "/agents/activity", label: "Agent Activity", icon: Bot }, + { href: "/canvas", label: "Canvas", icon: LayoutGrid }, + { href: "/daily", label: "Daily Notes", icon: PenLine }, +]; + +const bottomItems: NavItem[] = [ + { href: "/settings", label: "Settings", icon: Settings, shortcut: "g s" }, +]; + +export function Sidebar() { + const location = useLocation(); + const { collapsed, toggle, mobileOpen, setMobileOpen } = useSidebarStore(); + + const isActive = (href: string) => { + if (href === "/") return location.pathname === "/"; + return location.pathname.startsWith(href); + }; + + const renderNavLink = (item: NavItem, isCollapsed: boolean, onNavigate?: () => void) => { + const active = isActive(item.href); + const link = ( + +