From 1201acfa3732fa87c2b3c4642eff6700aacbe5a6 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 16 Mar 2026 00:47:37 -0400 Subject: [PATCH 01/27] feat: add paletteIntent utility for command palette query classification Detects query intent ('question' | 'keyword' | 'page' | 'empty') to drive smart result ordering in the enhanced command palette. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/lib/paletteIntent.ts | 59 +++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 frontend/src/lib/paletteIntent.ts diff --git a/frontend/src/lib/paletteIntent.ts b/frontend/src/lib/paletteIntent.ts new file mode 100644 index 00000000..9b1f9df1 --- /dev/null +++ b/frontend/src/lib/paletteIntent.ts @@ -0,0 +1,59 @@ +/** + * Detects the intent behind a command palette query. + * Returns one of: 'question' | 'keyword' | 'page' | 'empty' + */ + +const QUESTION_WORDS = [ + 'how', 'why', 'what', 'when', 'where', 'who', 'which', + 'fix', 'help', 'troubleshoot', 'resolve', 'debug', 'diagnose', +] + +const PAGE_NAMES = [ + 'dashboard', 'home', + 'flows', 'trees', 'all flows', + 'sessions', 'history', + 'analytics', 'reports', + 'settings', 'account', 'profile', + 'admin', 'administration', 'users', + 'assistant', 'ai', 'copilot', 'flowpilot', + 'scripts', 'script generator', + 'kb', 'knowledge base', 'kb accelerator', + 'library', 'step library', +] + +export type PaletteIntent = 'question' | 'keyword' | 'page' | 'empty' + +export function detectIntent(query: string): PaletteIntent { + const trimmed = query.trim() + + if (!trimmed) { + return 'empty' + } + + const lower = trimmed.toLowerCase() + + // Check if it matches a known page name + if (PAGE_NAMES.some(p => lower === p || lower.startsWith(p + ' ') || lower.endsWith(' ' + p))) { + return 'page' + } + + // Check for question indicators: + // - Contains a question mark + if (lower.includes('?')) { + return 'question' + } + + // - Starts with a question word + const firstWord = lower.split(/\s+/)[0] + if (QUESTION_WORDS.includes(firstWord)) { + return 'question' + } + + // - 5 or more words + const wordCount = trimmed.split(/\s+/).length + if (wordCount >= 5) { + return 'question' + } + + return 'keyword' +} -- 2.49.1 From 124f794535ec0138978d2dfe1183cebe7473f727 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 16 Mar 2026 00:47:41 -0400 Subject: [PATCH 02/27] feat: add recentFlows localStorage utility for command palette empty state Tracks recently visited flows (capped at 10) with deduplication by id, surfaced in command palette when query is empty. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/lib/recentFlows.ts | 40 +++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 frontend/src/lib/recentFlows.ts diff --git a/frontend/src/lib/recentFlows.ts b/frontend/src/lib/recentFlows.ts new file mode 100644 index 00000000..9f8c033f --- /dev/null +++ b/frontend/src/lib/recentFlows.ts @@ -0,0 +1,40 @@ +/** + * localStorage utility for tracking recently visited flows. + */ + +const STORAGE_KEY = 'rf_recent_flows' +const MAX_ENTRIES = 10 + +export interface RecentFlow { + id: string + name: string + tree_type: string + timestamp: number +} + +export function getRecentFlows(limit = 5): RecentFlow[] { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) as RecentFlow[] + return Array.isArray(parsed) ? parsed.slice(0, limit) : [] + } catch { + return [] + } +} + +export function addRecentFlow(flow: Omit): void { + try { + const existing = getRecentFlows(MAX_ENTRIES) + // Deduplicate by id — remove any existing entry with the same id + const deduped = existing.filter(f => f.id !== flow.id) + // Add to front with current timestamp + const updated: RecentFlow[] = [ + { ...flow, timestamp: Date.now() }, + ...deduped, + ].slice(0, MAX_ENTRIES) + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)) + } catch { + // Silently ignore localStorage errors (private browsing, quota exceeded) + } +} -- 2.49.1 From c35c0230d9a767bfb14c1b7ae0cd9b3a8aafe694 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 16 Mar 2026 00:47:48 -0400 Subject: [PATCH 03/27] feat: rewrite CommandPalette with categorized results and smart ranking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds FlowPilot AI result (always present when query is non-empty) - Intent-aware ordering: question → FlowPilot prominent; page → pages first; keyword → FlowPilot at top with flows/sessions/tags below - Pages section with admin-gated items (uses useAuthStore) - Tags extracted from flow search results with ?tag= navigation - Quick Actions for create/import/scripts - Empty state shows recent flows + quick actions - Grouped rendering with section labels per design system - Keyboard nav flattened across groups Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/layout/CommandPalette.tsx | 380 ++++++++++++++---- 1 file changed, 301 insertions(+), 79 deletions(-) diff --git a/frontend/src/components/layout/CommandPalette.tsx b/frontend/src/components/layout/CommandPalette.tsx index 0b9bb7cf..73110415 100644 --- a/frontend/src/components/layout/CommandPalette.tsx +++ b/frontend/src/components/layout/CommandPalette.tsx @@ -1,43 +1,94 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { useNavigate } from 'react-router-dom' -import { Search, Loader2, ArrowRight, FileText, Clock } from 'lucide-react' +import { + Search, Loader2, ArrowRight, FileText, Clock, + Sparkles, LayoutDashboard, Tag, Plus, BookOpen, Terminal, +} 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' +import { detectIntent } from '@/lib/paletteIntent' +import { getRecentFlows } from '@/lib/recentFlows' +import { useAuthStore } from '@/store/authStore' interface CommandPaletteProps { open: boolean onClose: () => void } -interface ResultItem { +type GroupType = 'flowpilot' | 'pages' | 'flows' | 'sessions' | 'tags' | 'quick-actions' | 'recent-flows' + +interface PaletteItem { id: string - type: 'tree' | 'session' + group: GroupType title: string subtitle?: string - icon: 'tree' | 'session' path: string + icon: 'sparkles' | 'tree' | 'session' | 'page' | 'tag' | 'action' | 'recent' +} + +interface Group { + type: GroupType + label: string + items: PaletteItem[] +} + +const PAGES: PaletteItem[] = [ + { id: 'page-dashboard', group: 'pages', title: 'Dashboard', path: '/', 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-assistant', group: 'pages', title: 'AI Assistant', subtitle: 'FlowPilot chat', path: '/assistant', 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: 'Step Library', subtitle: 'Reusable steps', path: '/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-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' }, +] + +function ItemIcon({ icon, className }: { icon: PaletteItem['icon'], className?: string }) { + const cls = cn('shrink-0', className) + switch (icon) { + case 'sparkles': return + case 'tree': return + case 'session': return + case 'page': return + case 'tag': return + case 'action': return + case 'recent': return + default: return + } } export function CommandPalette({ open, onClose }: CommandPaletteProps) { const navigate = useNavigate() + const user = useAuthStore(s => s.user) const inputRef = useRef(null) const [query, setQuery] = useState('') - const [results, setResults] = useState([]) const [isSearching, setIsSearching] = useState(false) const [selectedIndex, setSelectedIndex] = useState(0) + const [searchFlows, setSearchFlows] = useState([]) + const [searchSessions, setSearchSessions] = useState([]) const debounceRef = useRef | null>(null) // Focus input when opened useEffect(() => { if (open) { setQuery('') - setResults([]) + setSearchFlows([]) + setSearchSessions([]) setSelectedIndex(0) - // Slight delay to ensure modal is rendered setTimeout(() => inputRef.current?.focus(), 50) } }, [open]) @@ -55,46 +106,28 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) { // Debounced search useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current) - if (query.length < 2) { - setResults([]) + if (query.trim().length < 2) { + setSearchFlows([]) + setSearchSessions([]) setIsSearching(false) return } setIsSearching(true) debounceRef.current = setTimeout(async () => { try { - const [trees, sessions] = await Promise.all([ + const [flows, 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]) + 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) } catch { - setResults([]) + setSearchFlows([]) + setSearchSessions([]) } finally { setIsSearching(false) } @@ -102,29 +135,153 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) { return () => { if (debounceRef.current) clearTimeout(debounceRef.current) } }, [query]) - const handleSelect = useCallback((item: ResultItem) => { - onClose() - navigate(item.path) - }, [navigate, onClose]) + // Build groups based on intent and search results + const groups = useCallback((): 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 }) + } + result.push({ type: 'quick-actions', label: 'Quick Actions', items: QUICK_ACTIONS }) + return result + } + + // Build FlowPilot item + const flowPilotItem: PaletteItem = { + id: 'flowpilot-ai', + group: 'flowpilot', + title: 'Ask FlowPilot AI', + subtitle: trimmed, + path: '/assistant', + 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() + 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, + })) + + 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 (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 (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 (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, user]) + + const builtGroups = groups() + + // Flatten all items for keyboard navigation + const flatItems: PaletteItem[] = builtGroups.flatMap(g => g.items) + + const handleSelect = useCallback((item: PaletteItem) => { + onClose() + if (item.group === 'flowpilot') { + navigate(item.path, { state: { prefill: query.trim() } }) + } else { + navigate(item.path) + } + }, [navigate, onClose, query]) - // Keyboard navigation const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault() - setSelectedIndex(i => Math.min(i + 1, results.length - 1)) + 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' && results[selectedIndex]) { + } else if (e.key === 'Enter' && flatItems[selectedIndex]) { e.preventDefault() - handleSelect(results[selectedIndex]) + 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 ( -
+
{/* Backdrop */}
{ setQuery(e.target.value); setSelectedIndex(0) }} onKeyDown={handleKeyDown} - placeholder="Search flows, sessions…" + placeholder="Search flows, ask a question, navigate…" className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-hidden" /> @@ -151,55 +308,120 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
{/* Results */} -
+
{isSearching ? (
- ) : query.length >= 2 && results.length === 0 ? ( + ) : hasQuery && flatItems.length === 0 ? (
No results for “{query}”
- ) : results.length > 0 ? ( + ) : builtGroups.length > 0 ? (
- {results.map((item, i) => ( - + ) + } + + return ( + + ) + })}
- {i === selectedIndex && ( - - )} - - ))} + ) + })}
) : (
- Type to search flows and sessions + {isEmpty + ? 'Type to search flows, pages, or ask FlowPilot a question' + : 'Type to search flows and sessions'}
)}
{/* Footer hints */} - {results.length > 0 && ( + {flatItems.length > 0 && (
↑↓ -- 2.49.1 From 2d602f687b6e52ade0b6bbe13e1b7e8cb51b87c2 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 16 Mar 2026 00:47:52 -0400 Subject: [PATCH 04/27] feat: add FlowPilot prefill handoff from command palette to AssistantChatPage When navigated to /assistant with location.state.prefill, automatically creates a new chat and sends the prefill message without user interaction. Clears location state after handling to prevent re-trigger on back navigation. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/pages/AssistantChatPage.tsx | 47 ++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index ccdceba0..1eb51cfb 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef, useCallback } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' import { Sparkles, Send, Loader2, Flag } from 'lucide-react' import { PageMeta } from '@/components/common/PageMeta' import { assistantChatApi } from '@/api/assistantChat' @@ -14,6 +15,8 @@ interface MessageWithMeta extends ChatMessageType { } export default function AssistantChatPage() { + const location = useLocation() + const navigate = useNavigate() const [chats, setChats] = useState([]) const [activeChatId, setActiveChatId] = useState(null) const [messages, setMessages] = useState([]) @@ -22,12 +25,56 @@ export default function AssistantChatPage() { const [showConclude, setShowConclude] = useState(false) const messagesEndRef = useRef(null) const inputRef = useRef(null) + const prefillHandledRef = useRef(false) // Load chat list useEffect(() => { loadChats() }, []) + // Handle prefill from command palette handoff + useEffect(() => { + const prefill = (location.state as { prefill?: string } | null)?.prefill + if (!prefill || prefillHandledRef.current) return + prefillHandledRef.current = true + + // Clear the location state so back-navigation doesn't retrigger + navigate(location.pathname, { replace: true, state: {} }) + + const sendPrefill = async () => { + try { + const chat = await assistantChatApi.createChat() + setChats(prev => [ + { id: chat.id, title: chat.title, message_count: 0, pinned: false, created_at: chat.created_at, updated_at: chat.updated_at }, + ...prev, + ]) + setActiveChatId(chat.id) + setMessages([{ role: 'user', content: prefill }]) + setLoading(true) + + const response = await assistantChatApi.sendMessage(chat.id, prefill) + setMessages(prev => [ + ...prev, + { role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows }, + ]) + setChats(prev => + prev.map(c => + c.id === chat.id + ? { ...c, message_count: 2, title: prefill.slice(0, 100), updated_at: new Date().toISOString() } + : c + ) + ) + } catch { + toast.error('Failed to start AI conversation') + } finally { + setLoading(false) + } + } + + sendPrefill() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + // Auto-scroll useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) -- 2.49.1 From 05c77509e4dadb60c23699c65d3620d665d73edb Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 16 Mar 2026 00:47:56 -0400 Subject: [PATCH 05/27] feat: track recently visited flows for command palette empty state Calls addRecentFlow after tree data loads in both TreeNavigationPage and ProceduralNavigationPage so the command palette can surface recent flows when the query is empty. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/pages/ProceduralNavigationPage.tsx | 2 ++ frontend/src/pages/TreeNavigationPage.tsx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/frontend/src/pages/ProceduralNavigationPage.tsx b/frontend/src/pages/ProceduralNavigationPage.tsx index ccd5ca56..72aa5ee2 100644 --- a/frontend/src/pages/ProceduralNavigationPage.tsx +++ b/frontend/src/pages/ProceduralNavigationPage.tsx @@ -29,6 +29,7 @@ import { TicketPickerModal } from '@/components/session/TicketPickerModal' import { TicketLinkIndicator } from '@/components/session/TicketLinkIndicator' import { UpdateTicketModal } from '@/components/session/UpdateTicketModal' import type { PSATicketInfo } from '@/types/integrations' +import { addRecentFlow } from '@/lib/recentFlows' interface StepState { notes: string @@ -213,6 +214,7 @@ export function ProceduralNavigationPage() { return } setTree(treeData) + addRecentFlow({ id: treeData.id, name: treeData.name, tree_type: treeData.tree_type }) // If resuming an existing session if (locationState?.sessionId) { diff --git a/frontend/src/pages/TreeNavigationPage.tsx b/frontend/src/pages/TreeNavigationPage.tsx index ead63c88..25c23eb6 100644 --- a/frontend/src/pages/TreeNavigationPage.tsx +++ b/frontend/src/pages/TreeNavigationPage.tsx @@ -27,6 +27,7 @@ import { TicketPickerModal } from '@/components/session/TicketPickerModal' import { TicketLinkIndicator } from '@/components/session/TicketLinkIndicator' import { UpdateTicketModal } from '@/components/session/UpdateTicketModal' import type { PSATicketInfo } from '@/types/integrations' +import { addRecentFlow } from '@/lib/recentFlows' interface LocationState { sessionId?: string @@ -325,6 +326,7 @@ export function TreeNavigationPage() { } setTree(treeData) + addRecentFlow({ id: treeData.id, name: treeData.name, tree_type: treeData.tree_type }) // If resuming a session if (locationState?.sessionId) { -- 2.49.1 From b5de60e6d5d2317e4d26228fcb360212dd103e87 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 16 Mar 2026 00:59:26 -0400 Subject: [PATCH 06/27] refactor: use useMemo instead of useCallback for groups builder in CommandPalette Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/layout/CommandPalette.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/layout/CommandPalette.tsx b/frontend/src/components/layout/CommandPalette.tsx index 73110415..b995480f 100644 --- a/frontend/src/components/layout/CommandPalette.tsx +++ b/frontend/src/components/layout/CommandPalette.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from 'react' +import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useNavigate } from 'react-router-dom' import { Search, Loader2, ArrowRight, FileText, Clock, @@ -136,7 +136,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) { }, [query]) // Build groups based on intent and search results - const groups = useCallback((): Group[] => { + const builtGroups = useMemo((): Group[] => { const trimmed = query.trim() const intent = detectIntent(trimmed) const lower = trimmed.toLowerCase() @@ -243,8 +243,6 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) { return result }, [query, searchFlows, searchSessions, user]) - const builtGroups = groups() - // Flatten all items for keyboard navigation const flatItems: PaletteItem[] = builtGroups.flatMap(g => g.items) -- 2.49.1 From b8ca23d00d383f195de7764c6844246372fab3f2 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 16 Mar 2026 01:04:58 -0400 Subject: [PATCH 07/27] feat: add PSA ticket context Pydantic schemas (Task 6) Add TicketDetails, CompanyInfo, ContactInfo, ConfigItem, TicketNote, RelatedTicket, and TicketContext models in schemas/psa_context.py for structured ticket context enrichment used by AI prompt injection. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/schemas/psa_context.py | 68 ++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 backend/app/schemas/psa_context.py diff --git a/backend/app/schemas/psa_context.py b/backend/app/schemas/psa_context.py new file mode 100644 index 00000000..b27c7cd7 --- /dev/null +++ b/backend/app/schemas/psa_context.py @@ -0,0 +1,68 @@ +"""Pydantic schemas for PSA ticket context enrichment.""" +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel + + +class TicketDetails(BaseModel): + id: int + summary: str + status: str + priority: str + board: str + sla: str | None = None + date_entered: datetime + resources: str | None = None + + +class CompanyInfo(BaseModel): + id: int + name: str + site: str | None = None + address: str | None = None + phone: str | None = None + type: str | None = None + territory: str | None = None + + +class ContactInfo(BaseModel): + name: str + email: str | None = None + phone: str | None = None + title: str | None = None + + +class ConfigItem(BaseModel): + device_identifier: str + type: str | None = None + os_type: str | None = None + serial_number: str | None = None + ip_address: str | None = None + model_number: str | None = None + + +class TicketNote(BaseModel): + text: str + member: str | None = None + date_created: datetime + internal_analysis_flag: bool = False + + +class RelatedTicket(BaseModel): + id: int + summary: str + status: str + priority: str + board: str + + +class TicketContext(BaseModel): + ticket: TicketDetails + company: CompanyInfo + contact: ContactInfo | None = None + configurations: list[ConfigItem] = [] + notes: list[TicketNote] = [] + related_tickets: list[RelatedTicket] = [] + fetched_at: datetime -- 2.49.1 From 94f27b089cf518c4bb36704a5717dedc7c1a66f1 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 16 Mar 2026 01:05:02 -0400 Subject: [PATCH 08/27] feat: add ticket context prompt formatter (Task 7) format_ticket_context_for_prompt() in services/psa/ticket_context.py serializes TicketContext into structured text for AI system prompts, with 10-note limit, 200-char text previews, and human-readable timestamps. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/services/psa/ticket_context.py | 84 ++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 backend/app/services/psa/ticket_context.py diff --git a/backend/app/services/psa/ticket_context.py b/backend/app/services/psa/ticket_context.py new file mode 100644 index 00000000..ecfc1904 --- /dev/null +++ b/backend/app/services/psa/ticket_context.py @@ -0,0 +1,84 @@ +"""Format PSA ticket context as structured text for AI system prompts.""" +from __future__ import annotations + +from app.schemas.psa_context import TicketContext + + +def format_ticket_context_for_prompt(ctx: TicketContext) -> str: + """Serialize a TicketContext into a structured text block for AI prompts.""" + lines: list[str] = ["=== TICKET CONTEXT ==="] + + # Ticket summary line + t = ctx.ticket + lines.append(f'Ticket: #{t.id} — "{t.summary}"') + lines.append(f"Status: {t.status} | Priority: {t.priority}") + lines.append(f"Board: {t.board}") + if t.sla: + lines.append(f"SLA Deadline: {t.sla}") + if t.resources: + lines.append(f"Assigned To: {t.resources}") + + # Company block + lines.append("") + c = ctx.company + lines.append(f"Client: {c.name}") + if c.site: + lines.append(f"Site: {c.site}") + if c.address: + lines.append(f"Address: {c.address}") + if c.phone: + lines.append(f"Phone: {c.phone}") + if c.type: + lines.append(f"Type: {c.type}") + if c.territory: + lines.append(f"Territory: {c.territory}") + + # Contact block + if ctx.contact: + contact = ctx.contact + contact_parts = [contact.name] + if contact.email: + contact_parts.append(f"({contact.email})") + if contact.title: + contact_parts.append(f"— {contact.title}") + contact_line = " ".join(contact_parts) + if contact.phone: + contact_line += f" — {contact.phone}" + lines.append("") + lines.append(f"Contact: {contact_line}") + + # Devices + if ctx.configurations: + lines.append("") + lines.append("Devices:") + for cfg in ctx.configurations: + parts = [cfg.device_identifier] + if cfg.type: + parts.append(cfg.type) + if cfg.os_type: + parts.append(cfg.os_type) + if cfg.ip_address: + parts.append(cfg.ip_address) + lines.append("- " + " | ".join(parts)) + + # Recent Notes (limit 10, text preview 200 chars) + if ctx.notes: + lines.append("") + lines.append("Recent Notes:") + for note in ctx.notes[:10]: + date_str = note.date_created.strftime("%b %d, %I:%M %p") + member_str = f"{note.member}: " if note.member else "" + text_preview = note.text[:200] + if len(note.text) > 200: + text_preview += "..." + lines.append(f"- [{date_str}] {member_str}{text_preview}") + + # Related open tickets + if ctx.related_tickets: + lines.append("") + lines.append("Related Open Tickets:") + for rt in ctx.related_tickets: + lines.append(f'- #{rt.id}: "{rt.summary}" ({rt.status}, {rt.priority})') + + lines.append("=== END CONTEXT ===") + return "\n".join(lines) -- 2.49.1 From f384d5893722fa15438bed5ab002f207b2466515 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 16 Mar 2026 01:05:07 -0400 Subject: [PATCH 09/27] feat: add get_ticket_context() to ConnectWise provider (Task 8) Fetches ticket details, company, contact, configurations, notes, and related open tickets in parallel via asyncio.gather with partial failure tolerance. Results are cached with a 5-minute TTL per ticket/connection. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/services/psa/connectwise/provider.py | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) diff --git a/backend/app/services/psa/connectwise/provider.py b/backend/app/services/psa/connectwise/provider.py index d84ef73b..a4aca59b 100644 --- a/backend/app/services/psa/connectwise/provider.py +++ b/backend/app/services/psa/connectwise/provider.py @@ -1,6 +1,10 @@ """ConnectWise implementation of PSAProvider.""" from __future__ import annotations +import asyncio +import logging +from datetime import datetime, timezone + from app.services.psa.base import PSAProvider from app.services.psa.cache import psa_cache from app.services.psa.types import ( @@ -14,6 +18,8 @@ from app.services.psa.types import ( ) from .client import ConnectWiseClient +logger = logging.getLogger(__name__) + class ConnectWiseProvider(PSAProvider): """ConnectWise PSA provider implementation.""" @@ -263,6 +269,251 @@ class ConnectWiseProvider(PSAProvider): psa_cache.set(cache_key, result, ttl_seconds=900) return result + # ── Ticket Context ──────────────────────────────────────────────── + + async def get_ticket_context( + self, ticket_id: int, connection_id: str | None = None + ): + """Fetch rich ticket context for AI prompt injection. + + Returns a TicketContext with ticket details, company, contact, + configurations, recent notes, and related open tickets. + Results are cached for 5 minutes per ticket. + """ + from app.schemas.psa_context import ( + TicketContext, + TicketDetails, + CompanyInfo, + ContactInfo, + ConfigItem, + TicketNote, + RelatedTicket, + ) + + cache_key = f"{connection_id or 'default'}:ticket_context:{ticket_id}" + cached = psa_cache.get(cache_key) + if cached is not None: + return cached + + # Fetch ticket first to get company_id and contact_id + ticket_data = await self.client.get( + f"/service/tickets/{ticket_id}", + params={ + "fields": "id,summary,status,priority,board,sla,dateEntered,resources,company,contact" + }, + ) + + company_id = ticket_data.get("company", {}).get("id") if ticket_data.get("company") else None + contact_id = ticket_data.get("contact", {}).get("id") if ticket_data.get("contact") else None + + # Build parallel fetch tasks + configs_task = asyncio.create_task( + self.client.get( + f"/service/tickets/{ticket_id}/configurations", + params={ + "fields": "id,deviceIdentifier,type,osType,serialNumber,ipAddress,modelNumber" + }, + ) + ) + notes_task = asyncio.create_task( + self.client.get( + f"/service/tickets/{ticket_id}/notes", + params={ + "pageSize": "20", + "orderBy": "dateCreated desc", + "fields": "id,text,member,dateCreated,internalAnalysisFlag", + }, + ) + ) + company_task = asyncio.create_task( + self.client.get( + f"/company/companies/{company_id}", + params={ + "fields": "id,name,site,addressLine1,city,state,zip,phoneNumber,type,territory" + }, + ) + ) if company_id else None + + related_task = asyncio.create_task( + self.client.get( + "/service/tickets", + params={ + "conditions": f"company/id={company_id} AND closedFlag=false AND id != {ticket_id}", + "pageSize": "5", + "orderBy": "id desc", + "fields": "id,summary,status,priority,board", + }, + ) + ) if company_id else None + + contact_task = asyncio.create_task( + self.client.get( + f"/company/contacts/{contact_id}", + params={ + "fields": "id,firstName,lastName,title,defaultPhoneNbr,communicationItems" + }, + ) + ) if contact_id else None + + # Gather all tasks with partial failure tolerance + tasks_to_await = [t for t in [configs_task, notes_task, company_task, related_task, contact_task] if t is not None] + task_results = await asyncio.gather(*tasks_to_await, return_exceptions=True) + + # Unpack results in order (skipping None tasks) + result_iter = iter(task_results) + configs_raw = next(result_iter) + notes_raw = next(result_iter) + company_raw = next(result_iter) if company_task else None + related_raw = next(result_iter) if related_task else None + contact_raw = next(result_iter) if contact_task else None + + # Map ticket details + def _parse_dt(val: str | None) -> datetime: + if not val: + return datetime.now(timezone.utc) + try: + # CW returns ISO 8601 strings — ensure timezone aware + dt = datetime.fromisoformat(val.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + except (ValueError, AttributeError): + return datetime.now(timezone.utc) + + ticket_details = TicketDetails( + id=ticket_data["id"], + summary=ticket_data.get("summary", ""), + status=ticket_data.get("status", {}).get("name", "") if isinstance(ticket_data.get("status"), dict) else str(ticket_data.get("status", "")), + priority=ticket_data.get("priority", {}).get("name", "") if isinstance(ticket_data.get("priority"), dict) else str(ticket_data.get("priority", "")), + board=ticket_data.get("board", {}).get("name", "") if isinstance(ticket_data.get("board"), dict) else str(ticket_data.get("board", "")), + sla=ticket_data.get("sla", {}).get("name") if isinstance(ticket_data.get("sla"), dict) else ticket_data.get("sla"), + date_entered=_parse_dt(ticket_data.get("dateEntered")), + resources=ticket_data.get("resources"), + ) + + # Map company + company_info: CompanyInfo + if isinstance(company_raw, dict): + addr_parts = [ + company_raw.get("addressLine1"), + company_raw.get("city"), + company_raw.get("state"), + company_raw.get("zip"), + ] + address = ", ".join(p for p in addr_parts if p) or None + company_info = CompanyInfo( + id=company_raw["id"], + name=company_raw.get("name", ""), + site=company_raw.get("site", {}).get("name") if isinstance(company_raw.get("site"), dict) else company_raw.get("site"), + address=address, + phone=company_raw.get("phoneNumber"), + type=company_raw.get("type", {}).get("name") if isinstance(company_raw.get("type"), dict) else company_raw.get("type"), + territory=company_raw.get("territory", {}).get("name") if isinstance(company_raw.get("territory"), dict) else company_raw.get("territory"), + ) + else: + if isinstance(company_raw, Exception): + logger.warning("Failed to fetch company for ticket %s: %s", ticket_id, company_raw) + # Fallback: use data from ticket itself + company_info = CompanyInfo( + id=company_id or 0, + name=ticket_data.get("company", {}).get("name", "") if isinstance(ticket_data.get("company"), dict) else "", + ) + + # Map contact + contact_info: ContactInfo | None = None + if isinstance(contact_raw, dict): + first = contact_raw.get("firstName", "") + last = contact_raw.get("lastName", "") + full_name = f"{first} {last}".strip() or "Unknown" + + # Extract email from communicationItems + email: str | None = None + comm_items = contact_raw.get("communicationItems", []) + if isinstance(comm_items, list): + for item in comm_items: + if isinstance(item, dict) and item.get("communicationType") == "Email": + email = item.get("value") + break + + contact_info = ContactInfo( + name=full_name, + email=email, + phone=contact_raw.get("defaultPhoneNbr"), + title=contact_raw.get("title"), + ) + elif isinstance(contact_raw, Exception): + logger.warning("Failed to fetch contact for ticket %s: %s", ticket_id, contact_raw) + + # Map configurations + configurations: list[ConfigItem] = [] + if isinstance(configs_raw, list): + for cfg in configs_raw: + if not isinstance(cfg, dict): + continue + configurations.append(ConfigItem( + device_identifier=cfg.get("deviceIdentifier", ""), + type=cfg.get("type", {}).get("name") if isinstance(cfg.get("type"), dict) else cfg.get("type"), + os_type=cfg.get("osType", {}).get("name") if isinstance(cfg.get("osType"), dict) else cfg.get("osType"), + serial_number=cfg.get("serialNumber"), + ip_address=cfg.get("ipAddress"), + model_number=cfg.get("modelNumber"), + )) + elif isinstance(configs_raw, Exception): + logger.warning("Failed to fetch configs for ticket %s: %s", ticket_id, configs_raw) + + # Map notes + notes: list[TicketNote] = [] + if isinstance(notes_raw, list): + for note in notes_raw: + if not isinstance(note, dict): + continue + member_name: str | None = None + member_obj = note.get("member") + if isinstance(member_obj, dict): + first = member_obj.get("firstName", "") + last = member_obj.get("lastName", "") + member_name = f"{first} {last}".strip() or member_obj.get("identifier") + elif isinstance(member_obj, str): + member_name = member_obj + + notes.append(TicketNote( + text=note.get("text", ""), + member=member_name, + date_created=_parse_dt(note.get("dateCreated")), + internal_analysis_flag=note.get("internalAnalysisFlag", False), + )) + elif isinstance(notes_raw, Exception): + logger.warning("Failed to fetch notes for ticket %s: %s", ticket_id, notes_raw) + + # Map related tickets + related_tickets: list[RelatedTicket] = [] + if isinstance(related_raw, list): + for rt in related_raw: + if not isinstance(rt, dict): + continue + related_tickets.append(RelatedTicket( + id=rt["id"], + summary=rt.get("summary", ""), + status=rt.get("status", {}).get("name", "") if isinstance(rt.get("status"), dict) else str(rt.get("status", "")), + priority=rt.get("priority", {}).get("name", "") if isinstance(rt.get("priority"), dict) else str(rt.get("priority", "")), + board=rt.get("board", {}).get("name", "") if isinstance(rt.get("board"), dict) else str(rt.get("board", "")), + )) + elif isinstance(related_raw, Exception): + logger.warning("Failed to fetch related tickets for ticket %s: %s", ticket_id, related_raw) + + ctx = TicketContext( + ticket=ticket_details, + company=company_info, + contact=contact_info, + configurations=configurations, + notes=notes, + related_tickets=related_tickets, + fetched_at=datetime.now(timezone.utc), + ) + + psa_cache.set(cache_key, ctx, ttl_seconds=300) + return ctx + # ── Private helpers ─────────────────────────────────────────────── @staticmethod -- 2.49.1 From c76bd54f1a05b157edf8c106334c614e6f41032a Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 16 Mar 2026 01:05:11 -0400 Subject: [PATCH 10/27] feat: add GET /integrations/psa/tickets/{id}/context endpoint (Task 9) Returns rich TicketContext for a ticket ID. Handles PSA auth failures (returns structured error), ticket-not-found (404), and general PSA errors (502). Requires active PSA connection for the user's account. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/endpoints/integrations.py | 55 +++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/backend/app/api/endpoints/integrations.py b/backend/app/api/endpoints/integrations.py index f4a5cd7c..ba78b93c 100644 --- a/backend/app/api/endpoints/integrations.py +++ b/backend/app/api/endpoints/integrations.py @@ -319,6 +319,61 @@ async def search_tickets( raise HTTPException(status_code=502, detail=str(e)) +@router.get("/tickets/{ticket_id}/context") +async def get_ticket_context( + ticket_id: int, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Get rich ticket context (company, contact, configs, notes, related tickets) for AI prompt injection.""" + from app.services.psa.registry import get_provider_for_account + from app.services.psa.exceptions import ( + PSAError, + PSAAuthError, + PSAPermissionError, + PSANotFoundError, + PSAConnectionError, + ) + from app.schemas.psa_context import TicketContext + + if not current_user.account_id: + raise HTTPException(status_code=400, detail="User has no account") + + # Look up the active connection for connection_id + conn_result = await db.execute( + select(PsaConnection).where( + PsaConnection.account_id == current_user.account_id, + PsaConnection.is_active.is_(True), + ) + ) + connection = conn_result.scalar_one_or_none() + if not connection: + raise HTTPException(status_code=404, detail="No active PSA connection configured") + + try: + provider = await get_provider_for_account(current_user.account_id, db) + except PSAConnectionError: + raise HTTPException(status_code=404, detail="No active PSA connection configured") + except PSAError as e: + raise HTTPException(status_code=502, detail=str(e)) + + try: + ctx: TicketContext = await provider.get_ticket_context( + ticket_id=ticket_id, + connection_id=str(connection.id), + ) + return ctx + except (PSAAuthError, PSAPermissionError): + raise HTTPException( + status_code=502, + detail={"error": "psa_auth_failed", "message": "PSA credentials may have expired."}, + ) + except PSANotFoundError: + raise HTTPException(status_code=404, detail="Ticket not found") + except PSAError as e: + raise HTTPException(status_code=502, detail=str(e)) + + @router.get("/tickets/{ticket_id}") async def get_ticket( ticket_id: str, -- 2.49.1 From 3cbd1a4628865ac4adac27a266a1515696b3fe47 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 16 Mar 2026 01:05:16 -0400 Subject: [PATCH 11/27] feat: inject PSA ticket context into copilot system prompt (Task 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a copilot conversation has an associated session with a linked PSA ticket, fetch the ticket context and append it to the system prompt. Failure is non-critical — errors are logged and the copilot proceeds without context rather than failing the request. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/services/copilot_service.py | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/backend/app/services/copilot_service.py b/backend/app/services/copilot_service.py index 4fc77a5c..175735ee 100644 --- a/backend/app/services/copilot_service.py +++ b/backend/app/services/copilot_service.py @@ -180,6 +180,40 @@ async def send_message( system_prompt += _build_flow_context(tree, conversation.current_node_id) system_prompt += build_rag_context(rag_results) + # Inject PSA ticket context if session has a linked ticket + if conversation.session_id: + try: + from app.models.session import Session as SessionModel + session_result = await db.execute( + select(SessionModel).where(SessionModel.id == conversation.session_id) + ) + session = session_result.scalar_one_or_none() + if session and session.psa_ticket_id: + try: + from app.services.psa.registry import get_provider_for_account + from app.services.psa.ticket_context import format_ticket_context_for_prompt + + provider = await get_provider_for_account(conversation.account_id, db) + connection_id = str(session.psa_connection_id) if session.psa_connection_id else None + ticket_ctx = await provider.get_ticket_context( + ticket_id=int(session.psa_ticket_id), + connection_id=connection_id, + ) + system_prompt += "\n\n" + format_ticket_context_for_prompt(ticket_ctx) + except Exception as psa_err: + logger.warning( + "Failed to fetch PSA ticket context for copilot (session=%s, ticket=%s): %s", + conversation.session_id, + session.psa_ticket_id, + psa_err, + ) + except Exception as session_err: + logger.warning( + "Failed to look up session for copilot PSA context (session_id=%s): %s", + conversation.session_id, + session_err, + ) + # Build messages for AI ai_messages = [] for msg in conversation.messages: -- 2.49.1 From 94d697646c89cc570a32ea5ef29f4a0a5fb4f898 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 16 Mar 2026 01:09:05 -0400 Subject: [PATCH 12/27] feat: add PSA context API client with TypeScript interfaces Defines TicketDetails, CompanyInfo, ContactInfo, ConfigItemInfo, TicketNote, RelatedTicket, and TicketContext interfaces matching backend psa_context.py schemas. Exports psaContextApi with getTicketContext(). Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/api/psaContext.ts | 70 ++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 frontend/src/api/psaContext.ts diff --git a/frontend/src/api/psaContext.ts b/frontend/src/api/psaContext.ts new file mode 100644 index 00000000..e77a48eb --- /dev/null +++ b/frontend/src/api/psaContext.ts @@ -0,0 +1,70 @@ +import { apiClient } from './client' + +// TypeScript interfaces matching backend Pydantic schemas in psa_context.py + +export interface TicketDetails { + id: number + summary: string + status: string + priority: string + board: string + sla: string | null + date_entered: string + resources: string | null +} + +export interface CompanyInfo { + id: number + name: string + site: string | null + address: string | null + phone: string | null + type: string | null + territory: string | null +} + +export interface ContactInfo { + name: string + email: string | null + phone: string | null + title: string | null +} + +export interface ConfigItemInfo { + device_identifier: string + type: string | null + os_type: string | null + serial_number: string | null + ip_address: string | null + model_number: string | null +} + +export interface TicketNote { + text: string + member: string | null + date_created: string + internal_analysis_flag: boolean +} + +export interface RelatedTicket { + id: number + summary: string + status: string + priority: string + board: string +} + +export interface TicketContext { + ticket: TicketDetails + company: CompanyInfo + contact: ContactInfo | null + configurations: ConfigItemInfo[] + notes: TicketNote[] + related_tickets: RelatedTicket[] + fetched_at: string +} + +export const psaContextApi = { + getTicketContext: (ticketId: string | number): Promise => + apiClient.get(`/integrations/psa/tickets/${ticketId}/context`).then(r => r.data), +} -- 2.49.1 From 3fc04ee8d580f2712d0ac12d7c0d8753a69891fd Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 16 Mar 2026 01:09:09 -0400 Subject: [PATCH 13/27] feat: add useTicketContext hook for PSA ticket context fetching Accepts psaTicketId and psaConnectionId, fetches context on mount when both IDs are present, and exposes refresh() for manual re-fetch. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/hooks/useTicketContext.ts | 40 ++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 frontend/src/hooks/useTicketContext.ts diff --git a/frontend/src/hooks/useTicketContext.ts b/frontend/src/hooks/useTicketContext.ts new file mode 100644 index 00000000..523c8d71 --- /dev/null +++ b/frontend/src/hooks/useTicketContext.ts @@ -0,0 +1,40 @@ +import { useState, useEffect, useCallback } from 'react' +import { psaContextApi, type TicketContext } from '@/api/psaContext' + +interface UseTicketContextResult { + context: TicketContext | null + loading: boolean + error: string | null + refresh: () => void +} + +export function useTicketContext( + psaTicketId: string | null | undefined, + psaConnectionId: string | null | undefined +): UseTicketContextResult { + const [context, setContext] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const fetchContext = useCallback(async () => { + if (!psaTicketId || !psaConnectionId) return + + setLoading(true) + setError(null) + try { + const data = await psaContextApi.getTicketContext(psaTicketId) + setContext(data) + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load ticket context' + setError(message) + } finally { + setLoading(false) + } + }, [psaTicketId, psaConnectionId]) + + useEffect(() => { + fetchContext() + }, [fetchContext]) + + return { context, loading, error, refresh: fetchContext } +} -- 2.49.1 From 28c01b08b8c91b1fbb5c6cd33233e09545031346 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 16 Mar 2026 01:09:15 -0400 Subject: [PATCH 14/27] feat: add TicketContextPanel component with accordion sections Glass-card panel showing ticket summary, status/priority/SLA, and accordion sections for Client, Contact, Devices, Notes, and Related tickets. Matches design system with font-label labels and ice-cyan accents. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/session/TicketContextPanel.tsx | 242 ++++++++++++++++++ frontend/src/components/session/index.ts | 1 + 2 files changed, 243 insertions(+) create mode 100644 frontend/src/components/session/TicketContextPanel.tsx diff --git a/frontend/src/components/session/TicketContextPanel.tsx b/frontend/src/components/session/TicketContextPanel.tsx new file mode 100644 index 00000000..7148568c --- /dev/null +++ b/frontend/src/components/session/TicketContextPanel.tsx @@ -0,0 +1,242 @@ +import { useState } from 'react' +import { + Ticket, + Building2, + UserCircle, + Monitor, + MessageSquare, + Link2, + ChevronDown, + ChevronRight, + RefreshCw, + Loader2, + AlertTriangle, +} from 'lucide-react' +import { cn } from '@/lib/utils' +import type { TicketContext } from '@/api/psaContext' + +interface TicketContextPanelProps { + context: TicketContext | null + loading: boolean + error: string | null + onRefresh: () => void +} + +interface AccordionSectionProps { + label: string + icon: React.ReactNode + count?: number + children: React.ReactNode +} + +function AccordionSection({ label, icon, count, children }: AccordionSectionProps) { + const [open, setOpen] = useState(false) + + return ( +
+ + {open && ( +
+ {children} +
+ )} +
+ ) +} + +export function TicketContextPanel({ context, loading, error, onRefresh }: TicketContextPanelProps) { + return ( +
+ {/* Header */} +
+ + + Ticket Context + + +
+ + {/* Loading */} + {loading && !context && ( +
+ +
+ )} + + {/* Error */} + {error && !loading && ( +
+ +

{error}

+
+ )} + + {/* Context content */} + {context && !loading && ( + <> + {/* Compact summary */} +
+
+ #{context.ticket.id} + {context.ticket.summary} +
+
+ + {context.ticket.status} + + + {context.ticket.priority} + + {context.ticket.sla && ( + + SLA: {context.ticket.sla} + + )} +
+

{context.company.name}

+
+ + {/* Client */} + }> +
+

{context.company.name}

+ {context.company.type && ( +

Type: {context.company.type}

+ )} + {context.company.territory && ( +

Territory: {context.company.territory}

+ )} + {context.company.site && ( +

Site: {context.company.site}

+ )} + {context.company.address && ( +

{context.company.address}

+ )} + {context.company.phone && ( +

{context.company.phone}

+ )} +
+
+ + {/* Contact */} + {context.contact && ( + }> +
+

{context.contact.name}

+ {context.contact.title && ( +

{context.contact.title}

+ )} + {context.contact.email && ( +

{context.contact.email}

+ )} + {context.contact.phone && ( +

{context.contact.phone}

+ )} +
+
+ )} + + {/* Devices */} + {context.configurations.length > 0 && ( + } + count={context.configurations.length} + > +
+ {context.configurations.map((cfg, i) => ( +
+

{cfg.device_identifier}

+
+ {cfg.type &&

Type: {cfg.type}

} + {cfg.os_type &&

OS: {cfg.os_type}

} + {cfg.ip_address &&

IP: {cfg.ip_address}

} + {cfg.serial_number &&

S/N: {cfg.serial_number}

} + {cfg.model_number &&

Model: {cfg.model_number}

} +
+
+ ))} +
+
+ )} + + {/* Notes */} + {context.notes.length > 0 && ( + } + count={context.notes.length} + > +
+ {context.notes.map((note, i) => ( +
+
+ {note.member && ( + {note.member} + )} + + {new Date(note.date_created).toLocaleDateString()} + +
+

+ {note.text} +

+
+ ))} +
+
+ )} + + {/* Related Tickets */} + {context.related_tickets.length > 0 && ( + } + count={context.related_tickets.length} + > +
+ {context.related_tickets.map((rt) => ( +
+
+ #{rt.id} + {rt.summary} +
+
+ {rt.status} + · + {rt.priority} +
+
+ ))} +
+
+ )} + + )} +
+ ) +} diff --git a/frontend/src/components/session/index.ts b/frontend/src/components/session/index.ts index 78be3134..dfd1d696 100644 --- a/frontend/src/components/session/index.ts +++ b/frontend/src/components/session/index.ts @@ -3,3 +3,4 @@ export { ContinuationModal, type DescendantNode } from './ContinuationModal' export { ForkTreeModal } from './ForkTreeModal' export { ScratchpadSidebar } from './ScratchpadSidebar' export { SessionOutcomeModal } from './SessionOutcomeModal' +export { TicketContextPanel } from './TicketContextPanel' -- 2.49.1 From d97d84a0353d80455e30319ba89b9ba8bb5e4206 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 16 Mar 2026 01:09:19 -0400 Subject: [PATCH 15/27] feat: mount TicketContextPanel in session runners when ticket is linked ProceduralNavigationPage renders panel in left sidebar below step checklist. TreeNavigationPage renders panel above breadcrumb trail. Both use useTicketContext hook and show panel only when psa_ticket_id is set. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/pages/ProceduralNavigationPage.tsx | 20 +++++++++++++++++++ frontend/src/pages/TreeNavigationPage.tsx | 20 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/frontend/src/pages/ProceduralNavigationPage.tsx b/frontend/src/pages/ProceduralNavigationPage.tsx index 72aa5ee2..25de94d8 100644 --- a/frontend/src/pages/ProceduralNavigationPage.tsx +++ b/frontend/src/pages/ProceduralNavigationPage.tsx @@ -30,6 +30,8 @@ import { TicketLinkIndicator } from '@/components/session/TicketLinkIndicator' import { UpdateTicketModal } from '@/components/session/UpdateTicketModal' import type { PSATicketInfo } from '@/types/integrations' import { addRecentFlow } from '@/lib/recentFlows' +import { useTicketContext } from '@/hooks/useTicketContext' +import { TicketContextPanel } from '@/components/session/TicketContextPanel' interface StepState { notes: string @@ -92,6 +94,12 @@ export function ProceduralNavigationPage() { const [isSavingStep, setIsSavingStep] = useState(false) const [copilotOpen, setCopilotOpen] = useState(false) + // PSA ticket context + const { context: ticketContext, loading: ticketContextLoading, error: ticketContextError, refresh: refreshTicketContext } = useTicketContext( + session?.psa_ticket_id, + session?.psa_connection_id + ) + // PSA ticket link state const [hasConnection, setHasConnection] = useState(false) const [showTicketPicker, setShowTicketPicker] = useState(false) @@ -673,6 +681,18 @@ export function ProceduralNavigationPage() { onStepClick={setCurrentStepIndex} /> + {/* PSA Ticket Context Panel */} + {session?.psa_ticket_id && ( +
+ +
+ )} + {/* Session Variables button */} {intakeFields.length > 0 && (
diff --git a/frontend/src/pages/TreeNavigationPage.tsx b/frontend/src/pages/TreeNavigationPage.tsx index 25c23eb6..ceb1a8c7 100644 --- a/frontend/src/pages/TreeNavigationPage.tsx +++ b/frontend/src/pages/TreeNavigationPage.tsx @@ -28,6 +28,8 @@ import { TicketLinkIndicator } from '@/components/session/TicketLinkIndicator' import { UpdateTicketModal } from '@/components/session/UpdateTicketModal' import type { PSATicketInfo } from '@/types/integrations' import { addRecentFlow } from '@/lib/recentFlows' +import { useTicketContext } from '@/hooks/useTicketContext' +import { TicketContextPanel } from '@/components/session/TicketContextPanel' interface LocationState { sessionId?: string @@ -77,6 +79,12 @@ export function TreeNavigationPage() { const [showUpdateModal, setShowUpdateModal] = useState(false) const [ticketInfo, setTicketInfo] = useState(null) + // PSA ticket context + const { context: ticketContext, loading: ticketContextLoading, error: ticketContextError, refresh: refreshTicketContext } = useTicketContext( + session?.psa_ticket_id, + session?.psa_connection_id + ) + const handleCopyCommand = (text: string) => { navigator.clipboard.writeText(text) setCopiedCommand(text) @@ -780,6 +788,18 @@ export function TreeNavigationPage() {
+ {/* PSA Ticket Context Panel */} + {session?.psa_ticket_id && ( +
+ +
+ )} + {/* Breadcrumb */}
{pathTaken.map((nodeId, index) => { -- 2.49.1 From 5e4d323ef12711c867c5e59ef9263aaa09190c9c Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 16 Mar 2026 01:13:29 -0400 Subject: [PATCH 16/27] feat: add fallback_steps to TypeScript types (Task 15) Add optional fallback_steps field to ProceduralStep interface. Add FallbackStepRecord interface and fallback_decisions field to Session. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/types/session.ts | 9 +++++++++ frontend/src/types/tree.ts | 1 + 2 files changed, 10 insertions(+) diff --git a/frontend/src/types/session.ts b/frontend/src/types/session.ts index 9f87b191..fb600e51 100644 --- a/frontend/src/types/session.ts +++ b/frontend/src/types/session.ts @@ -42,6 +42,14 @@ export interface TreeSnapshot extends TreeStructure { tree_type?: string } +export interface FallbackStepRecord { + parent_step_id: string + fallback_step_id: string + completed_at: string | null + notes: string | null + outcome: 'resolved' | 'not_resolved' | 'skipped' +} + export interface Session { id: string tree_id: string @@ -66,6 +74,7 @@ export interface Session { target_label?: string psa_ticket_id?: string | null psa_connection_id?: string | null + fallback_decisions?: FallbackStepRecord[] } export interface SessionCreate { diff --git a/frontend/src/types/tree.ts b/frontend/src/types/tree.ts index 9294f6d9..c2d32562 100644 --- a/frontend/src/types/tree.ts +++ b/frontend/src/types/tree.ts @@ -122,6 +122,7 @@ export interface ProceduralStep { section_header?: string reference_url?: string library_visibility?: 'team' | 'public' + fallback_steps?: ProceduralStep[] // Optional fallback alternatives } export interface CustomProceduralStep { -- 2.49.1 From a0ba253428856e4629575d98d15307812d187553 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 16 Mar 2026 01:13:34 -0400 Subject: [PATCH 17/27] feat: add backend validation for fallback steps (Task 16) Validate fallback_steps in procedural flow validation: required fields, no nested fallback_steps, no duplicate IDs. Add FallbackStepRecord schema and fallback_decisions field to SessionResponse. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/core/ai_tree_validator.py | 25 ++++++++++++++++++++++++ backend/app/core/tree_validation.py | 28 +++++++++++++++++++++++++++ backend/app/schemas/session.py | 11 +++++++++++ 3 files changed, 64 insertions(+) diff --git a/backend/app/core/ai_tree_validator.py b/backend/app/core/ai_tree_validator.py index 97cc9d78..0f97caf9 100644 --- a/backend/app/core/ai_tree_validator.py +++ b/backend/app/core/ai_tree_validator.py @@ -301,6 +301,31 @@ def validate_generated_procedural_steps(tree: dict[str, Any]) -> list[str]: f"Must be one of: {', '.join(sorted(VALID_CONTENT_TYPES))}" ) + # Validate fallback_steps if present (one level deep only) + fallback_steps = step.get("fallback_steps") + if fallback_steps is not None: + if not isinstance(fallback_steps, list): + errors.append(f"Step '{step_id or f'index {i}'}' fallback_steps must be an array") + else: + fallback_ids: set[str] = set() + for j, fb_step in enumerate(fallback_steps): + if not isinstance(fb_step, dict): + errors.append(f"Fallback step at {step_id}[{j}] is not an object") + continue + fb_id = fb_step.get("id") + if not fb_id or not isinstance(fb_id, str): + errors.append(f"Fallback step at {step_id}[{j}] missing or invalid 'id'") + elif fb_id in all_ids or fb_id in fallback_ids: + errors.append(f"Duplicate fallback step ID: '{fb_id}' (collides with primary or other fallback steps)") + else: + fallback_ids.add(fb_id) + all_ids.add(fb_id) + fb_title = fb_step.get("title") + if not fb_title or not isinstance(fb_title, str): + errors.append(f"Fallback step '{fb_id or f'{step_id}[{j}]'}' missing or invalid 'title'") + if fb_step.get("fallback_steps"): + errors.append(f"Fallback step '{fb_id or f'{step_id}[{j}]'}' cannot have its own fallback_steps (one level deep only)") + # Must have exactly one procedure_end as the last step if procedure_end_count == 0: errors.append("Procedural flow must have exactly one 'procedure_end' step") diff --git a/backend/app/core/tree_validation.py b/backend/app/core/tree_validation.py index 68918570..bb7f209e 100644 --- a/backend/app/core/tree_validation.py +++ b/backend/app/core/tree_validation.py @@ -208,6 +208,34 @@ def validate_procedural_structure(tree_structure: dict[str, Any]) -> tuple[bool, if content_type and content_type not in VALID_CONTENT_TYPES: errors.append({"field": f"{path}.content_type", "message": f"Invalid content_type: {content_type}. Must be one of: {', '.join(VALID_CONTENT_TYPES)}"}) + # Validate fallback_steps if present (one level deep only) + fallback_steps = step.get("fallback_steps") + if fallback_steps is not None: + if not isinstance(fallback_steps, list): + errors.append({"field": f"{path}.fallback_steps", "message": "fallback_steps must be an array"}) + else: + fallback_ids: set[str] = set() + for j, fb_step in enumerate(fallback_steps): + fb_path = f"{path}.fallback_steps[{j}]" + if not isinstance(fb_step, dict): + errors.append({"field": fb_path, "message": "Fallback step must be an object"}) + continue + fb_id = fb_step.get("id") + if not fb_id: + errors.append({"field": f"{fb_path}.id", "message": "Fallback step must have an id"}) + elif fb_id in seen_ids or fb_id in fallback_ids: + errors.append({"field": f"{fb_path}.id", "message": f"Duplicate fallback step id: {fb_id}"}) + else: + fallback_ids.add(fb_id) + seen_ids.add(fb_id) + if not fb_step.get("title"): + errors.append({"field": f"{fb_path}.title", "message": "Fallback step must have a non-empty title"}) + fb_type = fb_step.get("type") + if fb_type and fb_type not in VALID_STEP_TYPES: + errors.append({"field": f"{fb_path}.type", "message": f"Invalid fallback step type: {fb_type}"}) + if fb_step.get("fallback_steps"): + errors.append({"field": f"{fb_path}.fallback_steps", "message": "Fallback steps cannot have their own fallback_steps (one level deep only)"}) + # Must have exactly one end step if end_count == 0: errors.append({"field": "tree_structure.steps", "message": "Procedural tree must have a procedure_end step as the last step"}) diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py index b8e10f9b..58f4e2df 100644 --- a/backend/app/schemas/session.py +++ b/backend/app/schemas/session.py @@ -98,6 +98,9 @@ class SessionResponse(BaseModel): psa_ticket_id: Optional[str] = None psa_connection_id: Optional[UUID] = None + # Fallback step decisions + fallback_decisions: list[dict[str, Any]] = Field(default_factory=list) + class Config: from_attributes = True @@ -123,6 +126,14 @@ class SessionComplete(BaseModel): next_steps: Optional[str] = None +class FallbackStepRecord(BaseModel): + parent_step_id: str + fallback_step_id: str + completed_at: str | None = None + notes: str | None = None + outcome: Literal['resolved', 'not_resolved', 'skipped'] + + class SessionVariablesUpdate(BaseModel): """Partial update to session variables (dict merge).""" variables: dict[str, str] = Field(..., description="Key-value pairs to merge into session_variables") -- 2.49.1 From 7359ec822203401f91a831aae455c7bb414ed878 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 16 Mar 2026 01:13:41 -0400 Subject: [PATCH 18/27] feat: create FallbackSteps UI component (Task 17) Collapsible component supporting edit and execute modes. Edit mode provides title/description inputs with add/remove controls. Execute mode shows "This worked" / "Didn't help" action buttons with emerald/ rose styling. Amber accent styling throughout. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/procedural/FallbackSteps.tsx | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 frontend/src/components/procedural/FallbackSteps.tsx diff --git a/frontend/src/components/procedural/FallbackSteps.tsx b/frontend/src/components/procedural/FallbackSteps.tsx new file mode 100644 index 00000000..0f8ff8fa --- /dev/null +++ b/frontend/src/components/procedural/FallbackSteps.tsx @@ -0,0 +1,153 @@ +import { useState } from 'react' +import { AlertCircle, ChevronDown, ChevronRight, Plus, Trash2, Check, X } from 'lucide-react' +import type { ProceduralStep } from '@/types' +import { cn } from '@/lib/utils' + +interface FallbackStepsProps { + fallbackSteps: ProceduralStep[] + mode: 'edit' | 'execute' + // Edit mode + onAdd?: () => void + onRemove?: (index: number) => void + onUpdate?: (index: number, updates: Partial) => void + // Execute mode + onComplete?: (stepId: string, notes: string | null, outcome: 'resolved' | 'not_resolved' | 'skipped') => void + completedIds?: Set +} + +export function FallbackSteps({ + fallbackSteps, + mode, + onAdd, + onRemove, + onUpdate, + onComplete, + completedIds, +}: FallbackStepsProps) { + const [expanded, setExpanded] = useState(false) + + // In execute mode, hide if no fallback steps + if (mode === 'execute' && fallbackSteps.length === 0) { + return null + } + + const toggleLabel = + mode === 'execute' + ? "Didn't work?" + : `Fallback branches (${fallbackSteps.length})` + + return ( +
+ {/* Toggle button */} + + + {expanded && ( +
+
+ {fallbackSteps.map((fbStep, index) => { + const isCompleted = completedIds?.has(fbStep.id) + + return ( +
+ {mode === 'edit' ? ( +
+
+ onUpdate?.(index, { title: e.target.value })} + placeholder="Fallback step title" + className="flex-1 rounded border border-border bg-card px-2.5 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20" + /> + +
+