From 09cd05e1433c811c4450c5d0dec089944d8de3c8 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sun, 15 Feb 2026 01:53:55 -0500 Subject: [PATCH] feat: add command palette search, dashboard rewrite, and shell height fixes (Phase C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ⌘K command palette with debounced search across flows and sessions - Rewrite QuickStartPage as dashboard with stats, filters, sessions panel - Fix h-[calc(100vh-4rem)] → h-full across all pages for CSS Grid shell - Add active session count badge to sidebar Sessions nav item Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/admin/AdminLayout.tsx | 2 +- .../src/components/layout/CommandPalette.tsx | 217 ++++++++++ frontend/src/components/layout/Sidebar.tsx | 11 +- frontend/src/components/layout/TopBar.tsx | 200 +++++---- .../src/pages/ProceduralNavigationPage.tsx | 2 +- frontend/src/pages/QuickStartPage.tsx | 388 ++++++++---------- frontend/src/pages/TreeEditorPage.tsx | 4 +- frontend/src/pages/TreeLibraryPage.tsx | 2 +- frontend/src/pages/TreeNavigationPage.tsx | 2 +- 9 files changed, 504 insertions(+), 324 deletions(-) create mode 100644 frontend/src/components/layout/CommandPalette.tsx diff --git a/frontend/src/components/admin/AdminLayout.tsx b/frontend/src/components/admin/AdminLayout.tsx index 9677d7ff..6773253a 100644 --- a/frontend/src/components/admin/AdminLayout.tsx +++ b/frontend/src/components/admin/AdminLayout.tsx @@ -34,7 +34,7 @@ export function AdminLayout() { }, [mobileOpen, handleKeyDown]) return ( -
+
{/* Desktop sidebar */}
diff --git a/frontend/src/components/layout/CommandPalette.tsx b/frontend/src/components/layout/CommandPalette.tsx new file mode 100644 index 00000000..7de696e4 --- /dev/null +++ b/frontend/src/components/layout/CommandPalette.tsx @@ -0,0 +1,217 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { Search, Loader2, ArrowRight, FileText, Clock } from 'lucide-react' +import { treesApi } from '@/api/trees' +import { sessionsApi } from '@/api/sessions' +import type { TreeListItem } from '@/types' +import type { Session } from '@/types/session' +import { getTreeNavigatePath } from '@/lib/routing' +import { cn } from '@/lib/utils' + +interface CommandPaletteProps { + open: boolean + onClose: () => void +} + +interface ResultItem { + id: string + type: 'tree' | 'session' + title: string + subtitle?: string + icon: 'tree' | 'session' + path: string +} + +export function CommandPalette({ open, onClose }: CommandPaletteProps) { + const navigate = useNavigate() + const inputRef = useRef(null) + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [isSearching, setIsSearching] = useState(false) + const [selectedIndex, setSelectedIndex] = useState(0) + const debounceRef = useRef | null>(null) + + // Focus input when opened + useEffect(() => { + if (open) { + setQuery('') + setResults([]) + setSelectedIndex(0) + // Slight delay to ensure modal is rendered + setTimeout(() => inputRef.current?.focus(), 50) + } + }, [open]) + + // Close on Escape + useEffect(() => { + if (!open) return + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', handler) + return () => document.removeEventListener('keydown', handler) + }, [open, onClose]) + + // Debounced search + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current) + if (query.length < 2) { + setResults([]) + setIsSearching(false) + return + } + setIsSearching(true) + debounceRef.current = setTimeout(async () => { + try { + const [trees, sessions] = await Promise.all([ + treesApi.search(query, 6), + sessionsApi.list({ size: 5 }).catch(() => [] as Session[]), + ]) + + const treeResults: ResultItem[] = trees.map((t: TreeListItem) => ({ + id: t.id, + type: 'tree' as const, + title: t.name, + subtitle: t.description || undefined, + icon: 'tree' as const, + path: getTreeNavigatePath(t.id, t.tree_type), + })) + + // Filter sessions by tree name matching query + const sessionResults: ResultItem[] = sessions + .filter((s: Session) => + s.tree_snapshot?.name?.toLowerCase().includes(query.toLowerCase()) + ) + .slice(0, 3) + .map((s: Session) => ({ + id: s.id, + type: 'session' as const, + title: s.tree_snapshot?.name || 'Session', + subtitle: s.completed_at ? 'Completed' : 'In progress', + icon: 'session' as const, + path: `/sessions/${s.id}`, + })) + + setResults([...treeResults, ...sessionResults]) + } catch { + setResults([]) + } finally { + setIsSearching(false) + } + }, 250) + return () => { if (debounceRef.current) clearTimeout(debounceRef.current) } + }, [query]) + + const handleSelect = useCallback((item: ResultItem) => { + onClose() + navigate(item.path) + }, [navigate, onClose]) + + // Keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setSelectedIndex(i => Math.min(i + 1, results.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedIndex(i => Math.max(i - 1, 0)) + } else if (e.key === 'Enter' && results[selectedIndex]) { + e.preventDefault() + handleSelect(results[selectedIndex]) + } + } + + if (!open) return null + + return ( +
+ {/* Backdrop */} +
+ + {/* Palette */} +
+ {/* Search input */} +
+ + { setQuery(e.target.value); setSelectedIndex(0) }} + onKeyDown={handleKeyDown} + placeholder="Search flows, sessions…" + className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none" + /> + + ESC + +
+ + {/* Results */} +
+ {isSearching ? ( +
+ +
+ ) : query.length >= 2 && results.length === 0 ? ( +
+ No results for “{query}” +
+ ) : results.length > 0 ? ( +
+ {results.map((item, i) => ( + + ))} +
+ ) : ( +
+ Type to search flows and sessions +
+ )} +
+ + {/* Footer hints */} + {results.length > 0 && ( +
+ + ↑↓ + Navigate + + + + Open + +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 3e54c818..6b7fc4ba 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -6,7 +6,7 @@ import { WorkspaceSwitcher } from '@/components/workspace/WorkspaceSwitcher' import { CategoryList } from '@/components/workspace/CategoryList' import { TagCloud } from '@/components/workspace/TagCloud' import { NavItem } from './NavItem' -import { categoriesApi, tagsApi } from '@/api' +import { categoriesApi, tagsApi, sessionsApi } from '@/api' interface CategoryItem { id: string @@ -24,14 +24,16 @@ export function Sidebar() { const [tags, setTags] = useState([]) const [activeCategoryId, setActiveCategoryId] = useState(null) const [activeTags, setActiveTags] = useState([]) + const [activeSessionCount, setActiveSessionCount] = useState(0) - // Fetch categories and tags when workspace changes + // Fetch categories, tags, and active session count when workspace changes useEffect(() => { const fetchData = async () => { try { - const [cats, tagList] = await Promise.all([ + const [cats, tagList, activeSessions] = await Promise.all([ categoriesApi.list(), tagsApi.list().catch(() => []), + sessionsApi.list({ completed: false, size: 50 }).catch(() => []), ]) setCategories(cats.map(c => ({ id: c.id, @@ -40,6 +42,7 @@ export function Sidebar() { count: c.tree_count || 0, }))) setTags(tagList.map((t: { name: string }) => t.name).slice(0, 15)) + setActiveSessionCount(activeSessions.length) } catch { // Silently handle errors } @@ -65,7 +68,7 @@ export function Sidebar() { - +
diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index a0fe57b3..7bf4f2dd 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useCallback } from 'react' import { Link, useNavigate } from 'react-router-dom' import { Search, Zap, Bell, LogOut, User, Shield, Settings } from 'lucide-react' import { useAuthStore } from '@/store/authStore' @@ -6,6 +6,7 @@ import { usePermissions } from '@/hooks/usePermissions' import { useWorkspaceStore } from '@/store/workspaceStore' import { getWorkspaceLabels } from '@/constants/workspaceLabels' import { BrandLogo } from '@/components/common/BrandLogo' +import { CommandPalette } from './CommandPalette' import { cn } from '@/lib/utils' export function TopBar() { @@ -16,6 +17,7 @@ export function TopBar() { const labels = getWorkspaceLabels(activeWorkspace?.slug) const [userMenuOpen, setUserMenuOpen] = useState(false) + const [commandPaletteOpen, setCommandPaletteOpen] = useState(false) const menuRef = useRef(null) const handleLogout = async () => { @@ -34,111 +36,131 @@ export function TopBar() { return () => document.removeEventListener('mousedown', handleClickOutside) }, [userMenuOpen]) + // ⌘K / Ctrl+K global shortcut + const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault() + setCommandPaletteOpen(prev => !prev) + } + }, []) + + useEffect(() => { + document.addEventListener('keydown', handleGlobalKeyDown) + return () => document.removeEventListener('keydown', handleGlobalKeyDown) + }, [handleGlobalKeyDown]) + const initials = user?.name ? user.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) : user?.email?.[0]?.toUpperCase() || '?' return ( -
- {/* Logo area */} - -
- -
- - Resolution - Flow - - + <> +
+ {/* Logo area */} + +
+ +
+ + Resolution + Flow + + - {/* Search bar */} -
- - - - ⌘K - -
- - {/* Spacer */} -
- - {/* Action buttons */} -
- - - {/* User avatar & menu */} -
- + - {userMenuOpen && ( -
-
-

{user?.name || user?.email}

- {effectiveRole && effectiveRole !== 'engineer' && ( - - - {effectiveRole === 'super_admin' ? 'Super Admin' : effectiveRole === 'owner' ? 'Owner' : 'Viewer'} - - )} -
- setUserMenuOpen(false)} - className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground" - > - - Account - - setUserMenuOpen(false)} - className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground" - > - - Settings - - {isSuperAdmin && ( + {/* User avatar & menu */} +
+ + + {userMenuOpen && ( +
+
+

{user?.name || user?.email}

+ {effectiveRole && effectiveRole !== 'engineer' && ( + + + {effectiveRole === 'super_admin' ? 'Super Admin' : effectiveRole === 'owner' ? 'Owner' : 'Viewer'} + + )} +
setUserMenuOpen(false)} className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground" > - - Admin Panel + + Account - )} -
- + + Settings + + {isSuperAdmin && ( + setUserMenuOpen(false)} + className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground" + > + + Admin Panel + + )} +
+ +
-
- )} + )} +
-
-
+
+ + {/* Command Palette */} + setCommandPaletteOpen(false)} /> + ) } diff --git a/frontend/src/pages/ProceduralNavigationPage.tsx b/frontend/src/pages/ProceduralNavigationPage.tsx index 754ec68e..f6ed5b72 100644 --- a/frontend/src/pages/ProceduralNavigationPage.tsx +++ b/frontend/src/pages/ProceduralNavigationPage.tsx @@ -330,7 +330,7 @@ export function ProceduralNavigationPage() { const currentStepState = currentStep ? stepStates.get(currentStep.id) : undefined return ( -
+
{/* Top bar */}
diff --git a/frontend/src/pages/QuickStartPage.tsx b/frontend/src/pages/QuickStartPage.tsx index e7dcd751..4f930513 100644 --- a/frontend/src/pages/QuickStartPage.tsx +++ b/frontend/src/pages/QuickStartPage.tsx @@ -1,12 +1,19 @@ import { useState, useEffect, useRef } from 'react' import { useNavigate, Link } from 'react-router-dom' -import { Search, Clock, ArrowRight, Play, Loader2, Sparkles } from 'lucide-react' +import { Search, Plus, Loader2 } from 'lucide-react' import { treesApi } from '@/api/trees' import { sessionsApi } from '@/api/sessions' import type { TreeListItem } from '@/types' import type { Session } from '@/types/session' import { getTreeNavigatePath } from '@/lib/routing' - +import { useWorkspaceStore } from '@/store/workspaceStore' +import { getWorkspaceLabels } from '@/constants/workspaceLabels' +import { usePermissions } from '@/hooks/usePermissions' +import { QuickStats } from '@/components/dashboard/QuickStats' +import { FiltersBar } from '@/components/dashboard/FiltersBar' +import { SectionGroup } from '@/components/dashboard/SectionGroup' +import { SessionsPanel } from '@/components/dashboard/SessionsPanel' +import { TreeListItem as TreeListItemComponent } from '@/components/dashboard/TreeListItem' function timeAgo(dateStr: string): string { const now = Date.now() @@ -18,48 +25,44 @@ function timeAgo(dateStr: string): string { const hours = Math.floor(minutes / 60) if (hours < 24) return `${hours}h ago` const days = Math.floor(hours / 24) + if (days === 1) return 'Yesterday' return `${days}d ago` } export function QuickStartPage() { const navigate = useNavigate() + const { canCreateTrees } = usePermissions() + const activeWorkspace = useWorkspaceStore(s => s.getActiveWorkspace()) + const labels = getWorkspaceLabels(activeWorkspace?.slug) + const [query, setQuery] = useState('') const [searchResults, setSearchResults] = useState([]) const [isSearching, setIsSearching] = useState(false) const [showResults, setShowResults] = useState(false) - const [activeSessions, setActiveSessions] = useState([]) - const [recentTrees, setRecentTrees] = useState<{ tree_id: string; name: string; lastUsed: string; tree_type?: string }[]>([]) - const [isLoading, setIsLoading] = useState(true) const searchRef = useRef(null) const debounceRef = useRef | null>(null) - // Load sessions on mount + const [trees, setTrees] = useState([]) + const [activeSessions, setActiveSessions] = useState([]) + const [allSessions, setAllSessions] = useState([]) + const [isLoading, setIsLoading] = useState(true) + + const [activeFilter, setActiveFilter] = useState('all') + + // Load data on mount useEffect(() => { async function loadData() { try { - const [active, recent] = await Promise.all([ + const [treeList, active, recent] = await Promise.all([ + treesApi.list({ sort_by: 'updated_at' }), sessionsApi.list({ completed: false, size: 5 }), sessionsApi.list({ size: 10 }), ]) - setActiveSessions(active.slice(0, 3)) - - // Deduplicate recent sessions by tree_id, max 5 - const seen = new Set() - const deduped: { tree_id: string; name: string; lastUsed: string; tree_type?: string }[] = [] - for (const s of recent) { - if (!seen.has(s.tree_id) && deduped.length < 5) { - seen.add(s.tree_id) - deduped.push({ - tree_id: s.tree_id, - name: s.tree_snapshot?.name || 'Unnamed Tree', - lastUsed: s.started_at, - tree_type: s.tree_snapshot?.tree_type, - }) - } - } - setRecentTrees(deduped) + setTrees(treeList) + setActiveSessions(active) + setAllSessions(recent) } catch (err) { - console.error('Failed to load sessions:', err) + console.error('Failed to load dashboard data:', err) } finally { setIsLoading(false) } @@ -70,31 +73,25 @@ export function QuickStartPage() { // Debounced search useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current) - if (query.length < 2) { setSearchResults([]) setShowResults(false) setIsSearching(false) return } - setIsSearching(true) setShowResults(true) debounceRef.current = setTimeout(async () => { try { const results = await treesApi.search(query, 8) setSearchResults(results) - } catch (err) { - console.error('Search failed:', err) + } catch { setSearchResults([]) } finally { setIsSearching(false) } }, 300) - - return () => { - if (debounceRef.current) clearTimeout(debounceRef.current) - } + return () => { if (debounceRef.current) clearTimeout(debounceRef.current) } }, [query]) // Close dropdown on outside click @@ -108,213 +105,154 @@ export function QuickStartPage() { return () => document.removeEventListener('mousedown', handleClick) }, []) + // Compute stats + const totalTrees = trees.length + const openSessions = activeSessions.length + const todaySessions = allSessions.filter(s => { + const d = new Date(s.started_at) + const now = new Date() + return d.toDateString() === now.toDateString() + }).length + const completedSessions = allSessions.filter(s => s.completed_at).length + + // Filter trees + const filteredTrees = activeFilter === 'all' + ? trees + : activeFilter === 'recent' + ? trees.slice(0, 10) + : trees + + // Map sessions for SessionsPanel + const recentSessionItems = allSessions.slice(0, 5).map(s => ({ + id: s.id, + treeName: s.tree_snapshot?.name || 'Unknown', + status: (s.completed_at ? 'completed' : 'in_progress') as 'completed' | 'in_progress', + ticketNumber: s.ticket_number || undefined, + timeAgo: timeAgo(s.started_at), + })) + + const filters = [ + { id: 'all', label: 'All' }, + { id: 'recent', label: 'Recently Used' }, + { id: 'my', label: 'My Flows' }, + { id: 'team', label: 'Team Flows' }, + ] + return ( -
- {/* Hero Section */} -
- {/* Badge */} -
- - DECISION TREE PLATFORM +
+ {/* Page Header */} +
+
+

+ Dashboard +

+

+ Welcome back. Here's what's happening in your workspace. +

- - {/* Main heading */} -

- What are you
- troubleshooting? -

- - {/* Description */} -

- Search our library of proven flows or continue where you left off -

- - {/* Search Bar */} -
-
-
-
- - setQuery(e.target.value)} - onFocus={() => query.length >= 2 && setShowResults(true)} - placeholder="Paste ticket subject or search for a flow..." - className="flex-1 bg-transparent py-4 px-4 text-white placeholder:text-white/30 focus:outline-none" - /> - {isSearching && ( - - )} -
-
- - {/* Search Results Dropdown */} - {showResults && ( -
- {isSearching ? ( -
- -
- ) : searchResults.length === 0 ? ( -
- No results found -
- ) : ( -
    - {searchResults.map((tree) => ( -
  • - -
  • - ))} -
- )} -
+
+ {canCreateTrees && ( + + + {labels.newItem} + )}
- {/* Continue Session Section */} - {activeSessions.length > 0 && ( -
-
-

Active Sessions

-
+ {/* Quick Stats */} + - {/* Primary active session — Bright Glow card */} -
-
-
-
- -
-
-
- Active Session -
-

- {activeSessions[0].tree_snapshot?.name || 'Unnamed Tree'} -

-
+ {/* Filters */} + + + {/* Search (inline, not hero) */} +
+ + setQuery(e.target.value)} + onFocus={() => query.length >= 2 && setShowResults(true)} + placeholder={labels.searchPlaceholder} + className="w-full rounded-lg border border-border bg-card py-2.5 pl-9 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20" + /> + {showResults && ( +
+ {isSearching ? ( +
+
- -
-

- {[activeSessions[0].ticket_number, activeSessions[0].client_name] - .filter(Boolean) - .join(' \u2022 ')} - {activeSessions[0].started_at && ` \u2022 Started ${timeAgo(activeSessions[0].started_at)}`} -

-
- - {/* Additional active sessions */} - {activeSessions.length > 1 && ( -
- {activeSessions.slice(1).map((session) => ( -
- -
-
- - {timeAgo(session.started_at)} -
- - ))} -
- )} -
- )} + + + ))} + + )} +
+ )} +
- {/* Recent Trees Section */} - {!isLoading && recentTrees.length > 0 && ( -
-
-

Recent Flows

+ {/* Recent Sessions */} + + + {/* Tree/Flow List */} + {isLoading ? ( +
+ +
+ ) : ( + + {filteredTrees.slice(0, 20).map(tree => ( + + ))} + {filteredTrees.length > 20 && ( - View all + View all {filteredTrees.length} flows → -
-
- {recentTrees.map((tree) => ( - - ))} -
-
+ )} + )} - - {/* Footer */} -
- - Browse All Flows - - -
) } diff --git a/frontend/src/pages/TreeEditorPage.tsx b/frontend/src/pages/TreeEditorPage.tsx index 3ee1aed5..c0c93519 100644 --- a/frontend/src/pages/TreeEditorPage.tsx +++ b/frontend/src/pages/TreeEditorPage.tsx @@ -353,7 +353,7 @@ export function TreeEditorPage() { // Mobile gate: show read-only message if (isMobile) { return ( -
+

Desktop Required

@@ -373,7 +373,7 @@ export function TreeEditorPage() { } return ( -

+
{/* Draft Restore Prompt */} {showDraftPrompt && ( diff --git a/frontend/src/pages/TreeLibraryPage.tsx b/frontend/src/pages/TreeLibraryPage.tsx index ec1a1dca..2814b9af 100644 --- a/frontend/src/pages/TreeLibraryPage.tsx +++ b/frontend/src/pages/TreeLibraryPage.tsx @@ -251,7 +251,7 @@ export function TreeLibraryPage() { selectedCategoryId || selectedTags.length > 0 || searchQuery || selectedFolderId return ( -
+
{/* Folder Sidebar */} +
{/* Main Content */}