Files
resolutionflow/frontend/src/components/layout/CommandPalette.tsx
Michael Chihlas 05646465b8
Some checks failed
Mirror to GitHub / mirror (push) Successful in 5s
CI / e2e (pull_request) Failing after 5m32s
CI / frontend (pull_request) Failing after 5m34s
CI / backend (pull_request) Successful in 10m19s
feat(routing): serve public landing at / and move authed index to /home
Stripe's compliance crawler fetches the apex URL without executing JS and
declined live-mode review when `https://resolutionflow.com/` returned the
empty SPA shell that redirected to /landing client-side. Restructure the
router so / serves LandingPage directly:

- `/` → new `PublicLanding` wrapper (LandingPage for anon; Navigate to
  /home for authed users so there's no marketing-frame flicker).
- Authed tree converted to a path-less layout route with absolute child
  paths. QuickStartPage moves to `/home`; all other children
  (`/trees`, `/pilot`, `/admin/*`, `/account/*`, etc.) keep their URLs.
- `/landing` kept as a one-release stale-bookmark redirect to /.
- `ProtectedRoute` unauth redirect flipped /landing → /; `state.from`
  preserved for post-login return.

Reference updates:
- Post-login / post-onboarding destinations → /home: OAuthCallbackPage
  (incl. `?welcome=teammate` query), WelcomeStep1/2/3 dismiss-rest,
  AssistantChatPage post-escalate, WelcomeRouter completion/dismiss
  redirects, VerifyEmailPage's three "Go to dashboard" links.
- Authed chrome → /home: TopBar logo, AppLayout mobile nav + drawer
  logo, CommandPalette Dashboard entry.
- Dashboard onboarding → /home: NextStepCard `ran_session.ctaPath`,
  SetupChecklist `ran_session.path`, SessionHistoryPage empty-state CTA.
- Public back-links → /: TermsPage, PrivacyPage, PoliciesPage,
  ContactPage, PromotionsPage, PublicTemplatesPage (header + footer).
  SharedSessionPage's `to="/"` left as-is — now correctly lands anon
  visitors on the public landing.

Crawlability:
- New `frontend/public/robots.txt` allowlisting public pages and
  disallowing the authed app.
- New `frontend/public/sitemap.xml` for /, /pricing, /contact-sales,
  /contact, /templates, /terms, /privacy, /policies, /promotions.
- `PageMeta` gains an `og:url` (defaults to `window.location.href`) and
  flips `twitter:card` to `summary_large_image` when an `ogImage` is
  passed.

Tests:
- `AppLayout.test.tsx` updated to mount at `/home`.
- New `ProtectedRoute.test.tsx` asserts unauthenticated `/home`
  redirects to `/` (not `/landing`) and preserves origin in `state.from`.

If Stripe's crawler still cannot see the site after this (zero-JS
crawler), the documented next escalation is server-side prerendering of
public routes via `vite-plugin-ssg`. Out of scope here.

Plan: docs/plans/2026-05-13-public-landing-routing-refactor.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 01:58:10 -04:00

500 lines
21 KiB
TypeScript

import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { PILOT_INLINE_SCRIPT_EVENT } from '@/lib/pilotEvents'
import {
Search, Loader2, ArrowRight, FileText, Clock,
Sparkles, LayoutDashboard, Tag, Plus, BookOpen, Terminal, Zap,
} from 'lucide-react'
import { treesApi } from '@/api/trees'
import { sessionsApi } from '@/api/sessions'
import { aiSessionsApi } from '@/api/aiSessions'
import type { TreeListItem } from '@/types'
import type { Session } from '@/types/session'
import type { AISessionSearchResult } from '@/types/ai-session'
import { getTreeNavigatePath } from '@/lib/routing'
import { cn } from '@/lib/utils'
import { detectIntent } from '@/lib/paletteIntent'
import { getRecentFlows } from '@/lib/recentFlows'
import { useAuthStore } from '@/store/authStore'
interface CommandPaletteProps {
open: boolean
onClose: () => void
}
type GroupType = 'flowpilot' | 'pages' | 'flows' | 'sessions' | 'ai-sessions' | 'tags' | 'quick-actions' | 'recent-flows'
interface PaletteItem {
id: string
group: GroupType
title: string
subtitle?: string
path: string
icon: 'sparkles' | 'tree' | 'session' | 'ai-session' | 'page' | 'tag' | 'action' | 'recent'
}
interface Group {
type: GroupType
label: string
items: PaletteItem[]
}
const PAGES: PaletteItem[] = [
{ id: 'page-dashboard', group: 'pages', title: 'Dashboard', path: '/home', icon: 'page' },
{ id: 'page-flows', group: 'pages', title: 'All Flows', subtitle: 'Browse your flow library', path: '/trees', icon: 'page' },
{ id: 'page-sessions', group: 'pages', title: 'Sessions', subtitle: 'View session history', path: '/sessions', icon: 'page' },
{ id: 'page-flowpilot', group: 'pages', title: 'FlowPilot', subtitle: 'AI troubleshooting', path: '/pilot', icon: 'page' },
{ id: 'page-scripts', group: 'pages', title: 'Script Generator', subtitle: 'Generate PowerShell scripts', path: '/scripts', icon: 'page' },
{ id: 'page-analytics', group: 'pages', title: 'Analytics', subtitle: 'Team usage & metrics', path: '/analytics', icon: 'page' },
{ id: 'page-settings', group: 'pages', title: 'Settings', subtitle: 'Account & preferences', path: '/account', icon: 'page' },
{ id: 'page-library', group: 'pages', title: 'Solutions Library', subtitle: 'Team solutions', path: '/step-library', icon: 'page' },
]
const ADMIN_PAGES: PaletteItem[] = [
{ id: 'page-admin', group: 'pages', title: 'Admin', subtitle: 'Platform administration', path: '/admin', icon: 'page' },
]
const QUICK_ACTIONS: PaletteItem[] = [
{ id: 'action-new-flow', group: 'quick-actions', title: 'Create New Flow', subtitle: 'Start from scratch or use AI', path: '/trees', icon: 'action' },
{ id: 'action-new-project', group: 'quick-actions', title: 'New Project', subtitle: 'Create a step-by-step project', path: '/flows/new', icon: 'action' },
{ id: 'action-kb', group: 'quick-actions', title: 'Import from KB', subtitle: 'KB Accelerator', path: '/kb-accelerator', icon: 'action' },
{ id: 'action-scripts', group: 'quick-actions', title: 'Open Script Generator', subtitle: 'Generate automation scripts', path: '/scripts', icon: 'action' },
]
// Phase 5: only surfaced when on a /pilot/:id route. Fires the inline-script
// open event instead of navigating away to /scripts. The path is a sentinel
// — handleSelect intercepts it and dispatches a window event rather than
// navigating, so the chat page can toggle its inline panel without coupling
// the global palette to chat-page state.
const PILOT_INLINE_SCRIPT_PATH = '__pilot_inline_script__'
const SCRIPTS_INLINE_QUICK_ACTION: PaletteItem = {
id: 'action-scripts-inline',
group: 'quick-actions',
title: 'Open inline Script Generator',
subtitle: 'For the active suggested fix in this session',
path: PILOT_INLINE_SCRIPT_PATH,
icon: 'action',
}
function ItemIcon({ icon, className }: { icon: PaletteItem['icon'], className?: string }) {
const cls = cn('shrink-0', className)
switch (icon) {
case 'sparkles': return <Sparkles size={16} className={cls} />
case 'tree': return <FileText size={16} className={cls} />
case 'session': return <Clock size={16} className={cls} />
case 'ai-session': return <Zap size={16} className={cls} />
case 'page': return <LayoutDashboard size={16} className={cls} />
case 'tag': return <Tag size={16} className={cls} />
case 'action': return <Plus size={16} className={cls} />
case 'recent': return <BookOpen size={16} className={cls} />
default: return <Terminal size={16} className={cls} />
}
}
export function CommandPalette({ open, onClose }: CommandPaletteProps) {
const navigate = useNavigate()
const location = useLocation()
const user = useAuthStore(s => s.user)
// True when the user is currently on a FlowPilot session deep-link.
// Used to surface the "Open inline Script Generator" palette entry only
// when it's actually actionable (the chat page listens for the event;
// dispatching it from /trees would do nothing).
const onPilotSession = location.pathname.startsWith('/pilot/')
const inputRef = useRef<HTMLInputElement>(null)
const [query, setQuery] = useState('')
const [isSearching, setIsSearching] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const [searchFlows, setSearchFlows] = useState<TreeListItem[]>([])
const [searchSessions, setSearchSessions] = useState<Session[]>([])
const [searchAISessions, setSearchAISessions] = useState<AISessionSearchResult[]>([])
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Focus input when opened
useEffect(() => {
if (open) {
setQuery('')
setSearchFlows([])
setSearchSessions([])
setSearchAISessions([])
setSelectedIndex(0)
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.trim().length < 2) {
setSearchFlows([])
setSearchSessions([])
setSearchAISessions([])
setIsSearching(false)
return
}
setIsSearching(true)
debounceRef.current = setTimeout(async () => {
try {
const [flows, sessions, aiSessions] = await Promise.all([
treesApi.search(query, 6),
sessionsApi.list({ size: 5 }).catch(() => [] as Session[]),
aiSessionsApi.search(query, 5).catch(() => [] as AISessionSearchResult[]),
])
setSearchFlows(flows)
// Filter sessions by tree name
const filtered = sessions.filter((s: Session) =>
s.tree_snapshot?.name?.toLowerCase().includes(query.toLowerCase())
).slice(0, 3)
setSearchSessions(filtered)
setSearchAISessions(aiSessions)
} catch {
setSearchFlows([])
setSearchSessions([])
setSearchAISessions([])
} finally {
setIsSearching(false)
}
}, 250)
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
}, [query])
// Build groups based on intent and search results
const builtGroups = useMemo((): Group[] => {
const trimmed = query.trim()
const intent = detectIntent(trimmed)
const lower = trimmed.toLowerCase()
if (intent === 'empty') {
// Empty state: recent flows + quick actions
const recentFlows = getRecentFlows(5)
const recentItems: PaletteItem[] = recentFlows.map(f => ({
id: `recent-${f.id}`,
group: 'recent-flows' as GroupType,
title: f.name,
subtitle: f.tree_type,
path: getTreeNavigatePath(f.id, f.tree_type),
icon: 'recent' as const,
}))
const result: Group[] = []
if (recentItems.length > 0) {
result.push({ type: 'recent-flows', label: 'Recent Flows', items: recentItems })
}
const quickActions = onPilotSession
? [SCRIPTS_INLINE_QUICK_ACTION, ...QUICK_ACTIONS]
: QUICK_ACTIONS
result.push({ type: 'quick-actions', label: 'Quick Actions', items: quickActions })
return result
}
// Build FlowPilot item
const flowPilotItem: PaletteItem = {
id: 'flowpilot-ai',
group: 'flowpilot',
title: 'Troubleshoot with FlowPilot',
subtitle: trimmed,
path: '/pilot',
icon: 'sparkles',
}
// Filter pages
const allPages = user?.is_super_admin ? [...PAGES, ...ADMIN_PAGES] : PAGES
const filteredPages = allPages.filter(p =>
p.title.toLowerCase().includes(lower) ||
(p.subtitle?.toLowerCase().includes(lower) ?? false)
)
// Build flow items
const flowItems: PaletteItem[] = searchFlows.map(f => ({
id: `flow-${f.id}`,
group: 'flows' as GroupType,
title: f.name,
subtitle: f.description || undefined,
path: getTreeNavigatePath(f.id, f.tree_type),
icon: 'tree' as const,
}))
// Extract unique tags from search results
const tagSet = new Set<string>()
for (const f of searchFlows) {
if (Array.isArray(f.tags)) {
for (const t of f.tags) {
if (t.toLowerCase().includes(lower)) tagSet.add(t)
}
}
}
const tagItems: PaletteItem[] = Array.from(tagSet).slice(0, 4).map(tag => ({
id: `tag-${tag}`,
group: 'tags' as GroupType,
title: tag,
subtitle: 'Browse flows with this tag',
path: `/trees?tag=${encodeURIComponent(tag)}`,
icon: 'tag' as const,
}))
// Build session items
const sessionItems: PaletteItem[] = searchSessions.map(s => ({
id: `session-${s.id}`,
group: 'sessions' as GroupType,
title: s.tree_snapshot?.name || 'Session',
subtitle: s.completed_at ? 'Completed' : 'In progress',
path: `/sessions/${s.id}`,
icon: 'session' as const,
}))
// Build AI session items
const aiSessionItems: PaletteItem[] = searchAISessions.map(s => {
const title = s.problem_summary
? s.problem_summary.slice(0, 60) + (s.problem_summary.length > 60 ? '…' : '')
: 'FlowPilot Session'
const statusLabel = s.status === 'resolved' ? 'Resolved' : s.status === 'escalated' ? 'Escalated' : 'Active'
const subtitle = [s.problem_domain, statusLabel].filter(Boolean).join(' · ')
return {
id: `ai-session-${s.id}`,
group: 'ai-sessions' as GroupType,
title,
subtitle: subtitle || undefined,
path: `/pilot/${s.id}`,
icon: 'ai-session' as const,
}
})
const result: Group[] = []
if (intent === 'question') {
// FlowPilot prominent at top
result.push({ type: 'flowpilot', label: 'AI Assistant', items: [flowPilotItem] })
if (flowItems.length > 0) result.push({ type: 'flows', label: 'Flows', items: flowItems })
if (sessionItems.length > 0) result.push({ type: 'sessions', label: 'Sessions', items: sessionItems })
if (aiSessionItems.length > 0) result.push({ type: 'ai-sessions', label: 'FlowPilot Sessions', items: aiSessionItems })
if (tagItems.length > 0) result.push({ type: 'tags', label: 'Tags', items: tagItems })
} else if (intent === 'page') {
// Pages first, FlowPilot at bottom
if (filteredPages.length > 0) result.push({ type: 'pages', label: 'Pages', items: filteredPages })
if (flowItems.length > 0) result.push({ type: 'flows', label: 'Flows', items: flowItems })
if (sessionItems.length > 0) result.push({ type: 'sessions', label: 'Sessions', items: sessionItems })
if (aiSessionItems.length > 0) result.push({ type: 'ai-sessions', label: 'FlowPilot Sessions', items: aiSessionItems })
if (tagItems.length > 0) result.push({ type: 'tags', label: 'Tags', items: tagItems })
result.push({ type: 'flowpilot', label: 'AI Assistant', items: [flowPilotItem] })
} else {
// keyword: FlowPilot at top, flows/sessions/tags below
result.push({ type: 'flowpilot', label: 'AI Assistant', items: [flowPilotItem] })
if (flowItems.length > 0) result.push({ type: 'flows', label: 'Flows', items: flowItems })
if (sessionItems.length > 0) result.push({ type: 'sessions', label: 'Sessions', items: sessionItems })
if (aiSessionItems.length > 0) result.push({ type: 'ai-sessions', label: 'FlowPilot Sessions', items: aiSessionItems })
if (tagItems.length > 0) result.push({ type: 'tags', label: 'Tags', items: tagItems })
if (filteredPages.length > 0) result.push({ type: 'pages', label: 'Pages', items: filteredPages })
}
return result
}, [query, searchFlows, searchSessions, searchAISessions, user, onPilotSession])
// Flatten all items for keyboard navigation
const flatItems: PaletteItem[] = builtGroups.flatMap(g => g.items)
const handleSelect = useCallback((item: PaletteItem) => {
onClose()
if (item.path === PILOT_INLINE_SCRIPT_PATH) {
// Phase 5: window event lets the chat page open the inline panel
// without coupling the global palette to chat-page state.
window.dispatchEvent(new CustomEvent(PILOT_INLINE_SCRIPT_EVENT))
return
}
if (item.group === 'flowpilot') {
navigate(item.path, { state: { prefill: query.trim() } })
} else {
navigate(item.path)
}
}, [navigate, onClose, query])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIndex(i => Math.min(i + 1, flatItems.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIndex(i => Math.max(i - 1, 0))
} else if (e.key === 'Enter' && flatItems[selectedIndex]) {
e.preventDefault()
handleSelect(flatItems[selectedIndex])
}
}
// Track global flat index for selection highlight
let globalIdx = 0
const intent = detectIntent(query.trim())
const hasQuery = query.trim().length >= 2
const isEmpty = intent === 'empty'
const isQuestion = intent === 'question'
if (!open) return null
return (
<div className="fixed inset-0 z-[100] flex items-start justify-center pt-[20vh]">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-xs animate-fade-in"
onClick={onClose}
/>
{/* Palette */}
<div className="relative w-full max-w-lg rounded-xl border border-border bg-card shadow-2xl animate-scale-in">
{/* Search input */}
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<Search size={18} className="shrink-0 text-muted-foreground" />
<input
ref={inputRef}
type="text"
value={query}
onChange={e => { setQuery(e.target.value); setSelectedIndex(0) }}
onKeyDown={handleKeyDown}
placeholder="Search flows, sessions, tags... or describe an issue to troubleshoot"
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-hidden"
/>
<kbd className="rounded border border-border bg-background px-1.5 py-0.5 font-mono text-[0.625rem] text-muted-foreground">
ESC
</kbd>
</div>
{/* Results */}
<div className="max-h-[28rem] overflow-y-auto">
{isSearching ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : hasQuery && flatItems.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No results for &ldquo;{query}&rdquo;
</div>
) : builtGroups.length > 0 ? (
<div className="p-1">
{builtGroups.map(group => {
const groupStart = globalIdx
globalIdx += group.items.length
return (
<div key={group.type}>
{/* Section label */}
<div className="px-3 pt-3 pb-1">
<span className="font-mono text-[0.6875rem] uppercase tracking-[0.12em] text-[#fbbf24]">
{group.label}
</span>
</div>
{group.items.map((item, i) => {
const itemGlobalIdx = groupStart + i
const isSelected = itemGlobalIdx === selectedIndex
const isFlowPilot = item.group === 'flowpilot'
if (isFlowPilot) {
// Special prominent styling for question intent at top
return (
<button
key={item.id}
data-testid={item.id === 'flowpilot' ? 'command-palette-flowpilot' : undefined}
onClick={() => handleSelect(item)}
onMouseEnter={() => setSelectedIndex(itemGlobalIdx)}
className={cn(
'flex w-full items-center gap-3 rounded-[10px] px-3 py-2.5 text-left transition-colors',
'bg-primary/5 border border-primary/10',
isQuestion ? 'mb-1' : '',
isSelected
? 'bg-primary/15 border-primary/30'
: 'hover:bg-primary/10 hover:border-primary/20'
)}
>
<div className={cn(
'flex h-7 w-7 shrink-0 items-center justify-center rounded-lg',
isQuestion ? 'bg-primary/15' : 'bg-primary/10'
)}>
<Sparkles size={14} className="text-primary" />
</div>
<div className="min-w-0 flex-1">
<p className={cn(
'text-sm font-medium truncate',
isQuestion ? 'text-primary' : 'text-foreground'
)}>
{item.title}
</p>
{item.subtitle && (
<p className="text-[0.6875rem] text-muted-foreground truncate italic">
&ldquo;{item.subtitle}&rdquo;
</p>
)}
</div>
{isSelected && (
<ArrowRight size={14} className="shrink-0 text-primary opacity-60" />
)}
</button>
)
}
return (
<button
key={item.id}
onClick={() => handleSelect(item)}
onMouseEnter={() => setSelectedIndex(itemGlobalIdx)}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left border transition-colors',
isSelected
? 'bg-card-hover border-border-hover text-foreground'
: 'border-transparent text-muted-foreground hover:bg-card-hover hover:border-border-hover hover:text-foreground'
)}
>
<ItemIcon
icon={item.icon}
className={isSelected ? 'opacity-80' : 'opacity-50'}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{item.title}</p>
{item.subtitle && (
<p className="text-[0.6875rem] text-muted-foreground truncate">{item.subtitle}</p>
)}
</div>
{isSelected && (
<ArrowRight size={14} className="shrink-0 opacity-40" />
)}
</button>
)
})}
</div>
)
})}
</div>
) : (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
{isEmpty
? 'Type to search flows, pages, or ask FlowPilot a question'
: 'Type to search flows and sessions'}
</div>
)}
</div>
{/* Footer hints */}
{flatItems.length > 0 && (
<div className="flex items-center gap-4 border-t border-border px-4 py-2">
<span className="flex items-center gap-1 text-[0.625rem] text-muted-foreground">
<kbd className="rounded border border-border bg-background px-1 py-px font-mono"></kbd>
Navigate
</span>
<span className="flex items-center gap-1 text-[0.625rem] text-muted-foreground">
<kbd className="rounded border border-border bg-background px-1 py-px font-mono"></kbd>
Open
</span>
</div>
)}
</div>
</div>
)
}