feat: command palette, PSA ticket context, session-to-flow converter (#108)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* feat: rewrite CommandPalette with categorized results and smart ranking

- 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* refactor: use useMemo instead of useCallback for groups builder in CommandPalette

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* feat: inject PSA ticket context into copilot system prompt (Task 10)

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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* feat: integrate FallbackSteps into editor and session runner (Task 18)

Wire FallbackSteps edit mode into StepEditor for procedure_step type
with add/remove/update handlers using crypto.randomUUID(). Add execute
mode rendering in ProceduralNavigationPage with fallbackDecisions state
tracking per parent step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add session-to-flow request/response schemas (Task 19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add session-to-flow AI generation service (Task 20)

Converts completed troubleshooting sessions into reusable procedural flows
with fallback branches. Includes PSA ticket context integration and
AI-generated step validation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add POST /ai/session-to-flow endpoint (Task 21)

Converts a completed session into a reusable procedural flow using AI.
Includes quota checking, usage recording, and proper error handling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add Create Flow from Session button to session detail page (Task 22)

Adds sessionToFlow API client, exports from api/index.ts, and integrates
a prominent "Create Flow from Session" button on SessionDetailPage for
completed sessions. Generates a procedural flow via AI then navigates
to the procedural editor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: cast tree_type to TreeType in session-to-flow creation

Fixes build error where string was not assignable to TreeType.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update Playwright test selectors to match actual UI

- Use Control+k instead of Meta+k (Linux/CI compatibility)
- Use 'AI Assistant' group label instead of 'FlowPilot AI'
- Match actual FlowPilot chat page elements (Start a Conversation, New Chat, textarea)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update Playwright test selectors to match actual UI

- Use specific command palette placeholder to avoid ambiguous matches
- Fix 'Quick Actions' scoping (two elements with same text)
- Fix 'Resolved' exact match on session detail page
- Fix tree editor to use getByText instead of getByDisplayValue
- Fix 'Add Step' strict mode by using .first()
- Fix fallback description placeholder text
- Update playwright.config.ts to use port 5433 and resolutionflow DB
- Update FlowPilot chat selectors to match actual page layout

11/17 new tests now passing. Remaining 6 need procedural session
navigation investigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve all Playwright test failures — 16/16 passing

- Fix procedural session tests: sessions auto-start, no Start button
- Fix strict mode violations: use getByRole('heading') for step titles
- Fix FlowPilot chat: use button role selector for New Chat
- Fix command palette page nav: scope Analytics click to palette modal
- Fix fallback runner: remove non-existent Start button click
- Update playwright.config to port 5433 for local Docker

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #108.
This commit is contained in:
chihlasm
2026-03-16 13:39:17 -04:00
committed by GitHub
parent 8b712b2046
commit 8534dbfb5f
35 changed files with 2132 additions and 210 deletions

View File

@@ -23,3 +23,4 @@ export { kbAcceleratorApi } from './kbAccelerator'
export { scriptsApi } from './scripts'
export { integrationsApi, sessionPsaApi } from './integrations'
export { sidebarApi } from './sidebar'
export { sessionToFlowApi } from './sessionToFlow'

View File

@@ -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<TicketContext> =>
apiClient.get<TicketContext>(`/integrations/psa/tickets/${ticketId}/context`).then(r => r.data),
}

View File

@@ -0,0 +1,16 @@
import { apiClient } from './client'
interface SessionToFlowResponse {
name: string
description: string
tree_type: string
tags: string[]
tree_structure: Record<string, unknown>
}
export const sessionToFlowApi = {
generate: async (sessionId: string): Promise<SessionToFlowResponse> => {
const { data } = await apiClient.post('/ai/session-to-flow', { session_id: sessionId })
return data
},
}

View File

@@ -1,43 +1,94 @@
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 } 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 <Sparkles size={16} className={cls} />
case 'tree': return <FileText size={16} className={cls} />
case 'session': return <Clock 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 user = useAuthStore(s => s.user)
const inputRef = useRef<HTMLInputElement>(null)
const [query, setQuery] = useState('')
const [results, setResults] = useState<ResultItem[]>([])
const [isSearching, setIsSearching] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const [searchFlows, setSearchFlows] = useState<TreeListItem[]>([])
const [searchSessions, setSearchSessions] = useState<Session[]>([])
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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,151 @@ 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 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 })
}
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<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,
}))
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])
// 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 (
<div className="fixed inset-0 z-100 flex items-start justify-center pt-[20vh]">
<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"
@@ -142,7 +297,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
value={query}
onChange={e => { 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"
/>
<kbd className="rounded border border-border bg-background px-1.5 py-0.5 font-label text-[0.625rem] text-muted-foreground">
@@ -151,55 +306,120 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
</div>
{/* Results */}
<div className="max-h-72 overflow-y-auto">
<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>
) : query.length >= 2 && results.length === 0 ? (
) : hasQuery && flatItems.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No results for &ldquo;{query}&rdquo;
</div>
) : results.length > 0 ? (
) : builtGroups.length > 0 ? (
<div className="p-1">
{results.map((item, i) => (
<button
key={item.id}
onClick={() => handleSelect(item)}
onMouseEnter={() => setSelectedIndex(i)}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors',
i === selectedIndex
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50'
)}
>
{item.type === 'tree' ? (
<FileText size={16} className="shrink-0 opacity-60" />
) : (
<Clock size={16} className="shrink-0 opacity-60" />
)}
<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>
)}
{builtGroups.map(group => {
const groupStart = globalIdx
globalIdx += group.items.length
return (
<div key={group.type}>
{/* Section label */}
<div className="px-3 pt-2 pb-1">
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
{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}
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/10 border-primary/20'
: '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 transition-colors',
isSelected
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50'
)}
>
<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>
{i === selectedIndex && (
<ArrowRight size={14} className="shrink-0 opacity-40" />
)}
</button>
))}
)
})}
</div>
) : (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
Type to search flows and sessions
{isEmpty
? 'Type to search flows, pages, or ask FlowPilot a question'
: 'Type to search flows and sessions'}
</div>
)}
</div>
{/* Footer hints */}
{results.length > 0 && (
{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-label"></kbd>

View File

@@ -2,6 +2,7 @@ import { useState } from 'react'
import { ChevronUp, ChevronDown, AlertTriangle, Clock, ExternalLink, CheckSquare, Terminal, Settings2 } from 'lucide-react'
import type { ProceduralStep, StepContentType, IntakeFormField } from '@/types'
import { cn } from '@/lib/utils'
import { FallbackSteps } from '@/components/procedural/FallbackSteps'
const CONTENT_TYPE_OPTIONS: { value: StepContentType; label: string; color: string }[] = [
{ value: 'action', label: 'Action', color: 'text-blue-400' },
@@ -278,6 +279,32 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
</div>}
</div>
)}
{/* Fallback Steps — procedure_step only */}
{step.type === 'procedure_step' && (
<FallbackSteps
fallbackSteps={step.fallback_steps ?? []}
mode="edit"
onAdd={() => {
const newFallback: ProceduralStep = {
id: crypto.randomUUID(),
type: 'procedure_step',
title: '',
}
onUpdate({ fallback_steps: [...(step.fallback_steps ?? []), newFallback] })
}}
onRemove={(index) => {
const updated = (step.fallback_steps ?? []).filter((_, i) => i !== index)
onUpdate({ fallback_steps: updated.length > 0 ? updated : undefined })
}}
onUpdate={(index, updates) => {
const updated = (step.fallback_steps ?? []).map((fb, i) =>
i === index ? { ...fb, ...updates } : fb
)
onUpdate({ fallback_steps: updated })
}}
/>
)}
</div>
</div>
)

View File

@@ -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<ProceduralStep>) => void
// Execute mode
onComplete?: (stepId: string, notes: string | null, outcome: 'resolved' | 'not_resolved' | 'skipped') => void
completedIds?: Set<string>
}
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 (
<div className="mt-4">
{/* Toggle button */}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-amber-400/80 transition-colors"
>
<AlertCircle className="h-4 w-4 text-amber-400/80 shrink-0" />
<span>{toggleLabel}</span>
{expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
{expanded && (
<div className="mt-3 border-l-2 border-amber-400/20 pl-4">
<div className="space-y-2">
{fallbackSteps.map((fbStep, index) => {
const isCompleted = completedIds?.has(fbStep.id)
return (
<div
key={fbStep.id}
className={cn(
'rounded-lg border p-3 transition-colors',
'bg-white/[0.02] border-border/50',
isCompleted && 'border-emerald-500/30 bg-emerald-500/5'
)}
>
{mode === 'edit' ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
type="text"
value={fbStep.title}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => onRemove?.(index)}
className="shrink-0 rounded p-1.5 text-muted-foreground hover:bg-rose-500/10 hover:text-rose-400 transition-colors"
title="Remove fallback step"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<textarea
value={fbStep.description || ''}
onChange={(e) =>
onUpdate?.(index, { description: e.target.value || undefined })
}
placeholder="Describe this alternative approach..."
rows={2}
className="w-full 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"
/>
</div>
) : (
// Execute mode
<div>
<p className={cn('text-sm font-medium', isCompleted ? 'text-emerald-400' : 'text-foreground')}>
{fbStep.title}
</p>
{fbStep.description && (
<p className="mt-1 text-xs text-muted-foreground">{fbStep.description}</p>
)}
{!isCompleted && (
<div className="mt-3 flex gap-2">
<button
type="button"
onClick={() => onComplete?.(fbStep.id, null, 'resolved')}
className="flex items-center gap-1.5 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-400 hover:bg-emerald-500/20 transition-colors"
>
<Check className="h-3.5 w-3.5" />
This worked
</button>
<button
type="button"
onClick={() => onComplete?.(fbStep.id, null, 'not_resolved')}
className="flex items-center gap-1.5 rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-1.5 text-xs font-medium text-rose-400 hover:bg-rose-500/20 transition-colors"
>
<X className="h-3.5 w-3.5" />
Didn&apos;t help
</button>
</div>
)}
{isCompleted && (
<p className="mt-2 text-xs text-emerald-400/70">Resolved via this fallback</p>
)}
</div>
)}
</div>
)
})}
{mode === 'edit' && (
<button
type="button"
onClick={onAdd}
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-amber-400/20 px-3 py-2 text-xs text-amber-400/60 hover:border-amber-400/40 hover:text-amber-400/80 transition-colors"
>
<Plus className="h-3.5 w-3.5" />
Add fallback step
</button>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -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 (
<div className="border-t border-[rgba(255,255,255,0.06)]">
<button
onClick={() => setOpen(!open)}
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[rgba(255,255,255,0.03)] transition-colors"
>
<span className="text-muted-foreground">{icon}</span>
<span className="flex-1 text-xs font-medium text-foreground">{label}</span>
{count !== undefined && count > 0 && (
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[0.6rem] font-label text-primary">
{count}
</span>
)}
<span className="text-muted-foreground">
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</span>
</button>
{open && (
<div className="px-3 pb-3 pt-1">
{children}
</div>
)}
</div>
)
}
export function TicketContextPanel({ context, loading, error, onRefresh }: TicketContextPanelProps) {
return (
<div className="glass-card-static overflow-hidden rounded-2xl">
{/* Header */}
<div className="flex items-center gap-2 bg-primary/5 px-3 py-2.5">
<Ticket className="h-3.5 w-3.5 text-primary" />
<span className="flex-1 font-label text-[0.625rem] uppercase tracking-[0.1em] text-primary">
Ticket Context
</span>
<button
onClick={onRefresh}
disabled={loading}
title="Refresh ticket context"
className="rounded p-0.5 text-muted-foreground hover:text-foreground disabled:cursor-not-allowed transition-colors"
>
<RefreshCw className={cn('h-3 w-3', loading && 'animate-spin')} />
</button>
</div>
{/* Loading */}
{loading && !context && (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{/* Error */}
{error && !loading && (
<div className="flex items-start gap-2 px-3 py-3">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-rose-400" />
<p className="text-xs text-rose-400">{error}</p>
</div>
)}
{/* Context content */}
{context && !loading && (
<>
{/* Compact summary */}
<div className="px-3 py-2.5">
<div className="flex items-baseline gap-2">
<span className="font-label text-xs font-medium text-primary">#{context.ticket.id}</span>
<span className="flex-1 truncate text-xs text-foreground">{context.ticket.summary}</span>
</div>
<div className="mt-1.5 flex flex-wrap gap-1.5">
<span className="rounded-md bg-card px-1.5 py-0.5 font-label text-[0.6rem] text-muted-foreground border border-[rgba(255,255,255,0.06)]">
{context.ticket.status}
</span>
<span className="rounded-md bg-card px-1.5 py-0.5 font-label text-[0.6rem] text-muted-foreground border border-[rgba(255,255,255,0.06)]">
{context.ticket.priority}
</span>
{context.ticket.sla && (
<span className="rounded-md bg-amber-400/10 px-1.5 py-0.5 font-label text-[0.6rem] text-amber-400 border border-amber-400/20">
SLA: {context.ticket.sla}
</span>
)}
</div>
<p className="mt-1 text-[0.6875rem] text-muted-foreground">{context.company.name}</p>
</div>
{/* Client */}
<AccordionSection label="Client" icon={<Building2 className="h-3.5 w-3.5" />}>
<div className="space-y-1 text-xs">
<p className="font-medium text-foreground">{context.company.name}</p>
{context.company.type && (
<p className="text-muted-foreground">Type: {context.company.type}</p>
)}
{context.company.territory && (
<p className="text-muted-foreground">Territory: {context.company.territory}</p>
)}
{context.company.site && (
<p className="text-muted-foreground">Site: {context.company.site}</p>
)}
{context.company.address && (
<p className="text-muted-foreground">{context.company.address}</p>
)}
{context.company.phone && (
<p className="text-muted-foreground">{context.company.phone}</p>
)}
</div>
</AccordionSection>
{/* Contact */}
{context.contact && (
<AccordionSection label="Contact" icon={<UserCircle className="h-3.5 w-3.5" />}>
<div className="space-y-1 text-xs">
<p className="font-medium text-foreground">{context.contact.name}</p>
{context.contact.title && (
<p className="text-muted-foreground">{context.contact.title}</p>
)}
{context.contact.email && (
<p className="text-muted-foreground">{context.contact.email}</p>
)}
{context.contact.phone && (
<p className="text-muted-foreground">{context.contact.phone}</p>
)}
</div>
</AccordionSection>
)}
{/* Devices */}
{context.configurations.length > 0 && (
<AccordionSection
label="Devices"
icon={<Monitor className="h-3.5 w-3.5" />}
count={context.configurations.length}
>
<div className="space-y-2">
{context.configurations.map((cfg, i) => (
<div key={i} className="rounded-md border border-[rgba(255,255,255,0.06)] bg-card p-2">
<p className="text-xs font-medium text-foreground">{cfg.device_identifier}</p>
<div className="mt-0.5 space-y-0.5 text-[0.6875rem] text-muted-foreground">
{cfg.type && <p>Type: {cfg.type}</p>}
{cfg.os_type && <p>OS: {cfg.os_type}</p>}
{cfg.ip_address && <p>IP: {cfg.ip_address}</p>}
{cfg.serial_number && <p>S/N: {cfg.serial_number}</p>}
{cfg.model_number && <p>Model: {cfg.model_number}</p>}
</div>
</div>
))}
</div>
</AccordionSection>
)}
{/* Notes */}
{context.notes.length > 0 && (
<AccordionSection
label="Notes"
icon={<MessageSquare className="h-3.5 w-3.5" />}
count={context.notes.length}
>
<div className="space-y-2">
{context.notes.map((note, i) => (
<div key={i} className="rounded-md border border-[rgba(255,255,255,0.06)] bg-card p-2">
<div className="mb-1 flex items-center justify-between gap-2">
{note.member && (
<span className="text-[0.6rem] font-label text-muted-foreground">{note.member}</span>
)}
<span className="ml-auto text-[0.6rem] font-label text-muted-foreground">
{new Date(note.date_created).toLocaleDateString()}
</span>
</div>
<p className="whitespace-pre-wrap text-[0.6875rem] text-foreground line-clamp-4">
{note.text}
</p>
</div>
))}
</div>
</AccordionSection>
)}
{/* Related Tickets */}
{context.related_tickets.length > 0 && (
<AccordionSection
label="Related"
icon={<Link2 className="h-3.5 w-3.5" />}
count={context.related_tickets.length}
>
<div className="space-y-1.5">
{context.related_tickets.map((rt) => (
<div
key={rt.id}
className="rounded-md border border-[rgba(255,255,255,0.06)] bg-card px-2 py-1.5"
>
<div className="flex items-baseline gap-1.5">
<span className="font-label text-[0.6rem] text-primary">#{rt.id}</span>
<span className="flex-1 truncate text-[0.6875rem] text-foreground">{rt.summary}</span>
</div>
<div className="mt-0.5 flex gap-1">
<span className="text-[0.6rem] text-muted-foreground">{rt.status}</span>
<span className="text-[0.6rem] text-muted-foreground">·</span>
<span className="text-[0.6rem] text-muted-foreground">{rt.priority}</span>
</div>
</div>
))}
</div>
</AccordionSection>
)}
</>
)}
</div>
)
}

View File

@@ -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'

View File

@@ -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<TicketContext | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(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 }
}

View File

@@ -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'
}

View File

@@ -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<RecentFlow, 'timestamp'>): 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)
}
}

View File

@@ -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<ChatListItem[]>([])
const [activeChatId, setActiveChatId] = useState<string | null>(null)
const [messages, setMessages] = useState<MessageWithMeta[]>([])
@@ -22,12 +25,56 @@ export default function AssistantChatPage() {
const [showConclude, setShowConclude] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(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' })

View File

@@ -5,7 +5,8 @@ import { treesApi } from '@/api/trees'
import { sessionsApi } from '@/api/sessions'
import { stepsApi } from '@/api/steps'
import type { Tree, Session, ProceduralStep, DecisionRecord, RuntimeStep, CustomProceduralStep, IntakeFormField } from '@/types'
import type { CustomStep } from '@/types/session'
import type { CustomStep, FallbackStepRecord } from '@/types/session'
import { FallbackSteps } from '@/components/procedural/FallbackSteps'
import type { Step } from '@/types/step'
import { StepChecklist } from '@/components/procedural/StepChecklist'
import { StepDetail } from '@/components/procedural/StepDetail'
@@ -29,6 +30,9 @@ 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'
import { useTicketContext } from '@/hooks/useTicketContext'
import { TicketContextPanel } from '@/components/session/TicketContextPanel'
interface StepState {
notes: string
@@ -81,6 +85,9 @@ export function ProceduralNavigationPage() {
const [batchProgress, setBatchProgress] = useState<{ completed: number; total: number } | null>(null)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Fallback step decisions
const [fallbackDecisions, setFallbackDecisions] = useState<FallbackStepRecord[]>([])
// Custom step state
const [runtimeSteps, setRuntimeSteps] = useState<RuntimeStep[]>([])
const [sessionCustomSteps, setSessionCustomSteps] = useState<CustomStep[]>([])
@@ -91,6 +98,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)
@@ -213,6 +226,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) {
@@ -424,6 +438,22 @@ export function ProceduralNavigationPage() {
setShowCsatModal(false)
}
const handleFallbackComplete = (
parentStepId: string,
fallbackStepId: string,
notes: string | null,
outcome: 'resolved' | 'not_resolved' | 'skipped'
) => {
const record: FallbackStepRecord = {
parent_step_id: parentStepId,
fallback_step_id: fallbackStepId,
completed_at: new Date().toISOString(),
notes,
outcome,
}
setFallbackDecisions((prev) => [...prev, record])
}
const handleStepCreated = (step: Step | CustomStepDraft, isFromLibrary: boolean) => {
setPendingCustomStep(step)
setPendingIsFromLibrary(isFromLibrary)
@@ -671,6 +701,18 @@ export function ProceduralNavigationPage() {
onStepClick={setCurrentStepIndex}
/>
{/* PSA Ticket Context Panel */}
{session?.psa_ticket_id && (
<div className="mt-3 border-t border-border pt-3">
<TicketContextPanel
context={ticketContext}
loading={ticketContextLoading}
error={ticketContextError}
onRefresh={refreshTicketContext}
/>
</div>
)}
{/* Session Variables button */}
{intakeFields.length > 0 && (
<div className="mt-3 border-t border-border pt-3">
@@ -712,6 +754,22 @@ export function ProceduralNavigationPage() {
/>
)}
{/* Fallback steps — shown when step has fallback alternatives */}
{currentStep && !('isCustom' in currentStep && currentStep.isCustom) && 'fallback_steps' in currentStep && (
<FallbackSteps
fallbackSteps={(currentStep as ProceduralStep).fallback_steps ?? []}
mode="execute"
completedIds={new Set(
fallbackDecisions
.filter((d) => d.parent_step_id === currentStep.id && d.outcome === 'resolved')
.map((d) => d.fallback_step_id)
)}
onComplete={(fallbackStepId, notes, outcome) =>
handleFallbackComplete(currentStep.id, fallbackStepId, notes, outcome)
}
/>
)}
{/* Add custom step — only on current active incomplete non-custom step */}
{currentStep && !completedStepIds.has(currentStep.id) && !('isCustom' in currentStep && currentStep.isCustom) && (
<div className="mt-4">

View File

@@ -1,9 +1,11 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Copy, Check, Eye, Save, Share2, CheckCircle2, AlertTriangle, ArrowUpRight, HelpCircle, Flag } from 'lucide-react'
import { Copy, Check, Eye, Save, Share2, CheckCircle2, AlertTriangle, ArrowUpRight, HelpCircle, Flag, Sparkles, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { sessionsApi } from '@/api/sessions'
import { stepsApi } from '@/api/steps'
import { treesApi } from '@/api/trees'
import { sessionToFlowApi } from '@/api/sessionToFlow'
import { ExportPreviewModal } from '@/components/session/ExportPreviewModal'
import { SaveSessionAsTreeModal } from '@/components/session/SaveSessionAsTreeModal'
import { ShareSessionModal } from '@/components/session/ShareSessionModal'
@@ -19,6 +21,7 @@ import { hasRatedSession, markSessionRated } from '@/lib/sessionRatings'
import { Spinner } from '@/components/common/Spinner'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import { getTreeEditorPath } from '@/lib/routing'
export function SessionDetailPage() {
const { id } = useParams<{ id: string }>()
@@ -46,6 +49,7 @@ export function SessionDetailPage() {
const [includeSummary, setIncludeSummary] = useState(false)
const [redactionMode, setRedactionMode] = useState<'none' | 'mask'>('none')
const [redactionSummary, setRedactionSummary] = useState<RedactionSummary | null>(null)
const [isGeneratingFlow, setIsGeneratingFlow] = useState(false)
useEffect(() => {
if (id) {
@@ -247,6 +251,28 @@ export function SessionDetailPage() {
}
}
const handleCreateFlowFromSession = async () => {
if (!session) return
setIsGeneratingFlow(true)
try {
const flowData = await sessionToFlowApi.generate(session.id)
const tree = await treesApi.create({
name: flowData.name,
description: flowData.description,
tree_type: flowData.tree_type as import('@/types').TreeType,
tree_structure: flowData.tree_structure,
tags: flowData.tags,
})
toast.success('Flow generated! Opening editor...')
navigate(getTreeEditorPath(tree.id, 'procedural'))
} catch (err) {
console.error('Failed to generate flow from session:', err)
toast.error('Failed to generate flow. Please try again.')
} finally {
setIsGeneratingFlow(false)
}
}
const getDefaultTreeName = () => {
if (!session) return ''
const treeName = session.tree_snapshot?.name || 'Tree'
@@ -398,7 +424,27 @@ export function SessionDetailPage() {
</Button>
</div>
</div>
) : !session.completed_at ? (
) : null}
{/* Create Flow from Session — only for completed sessions */}
{session.completed_at && (
<div className="mb-4">
<Button
onClick={handleCreateFlowFromSession}
disabled={isGeneratingFlow}
className="bg-gradient-brand text-[#101114] font-semibold rounded-[10px] hover:opacity-90 active:scale-[0.97] disabled:opacity-60"
>
{isGeneratingFlow ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
{isGeneratingFlow ? 'Generating Flow...' : 'Create Flow from Session'}
</Button>
</div>
)}
{!session.completed_at ? (
/* In-progress banner */
<div className="mb-6 flex items-center justify-between gap-4 rounded-xl border border-amber-500/20 bg-amber-500/5 px-5 py-4">
<div className="flex items-center gap-3">

View File

@@ -27,6 +27,9 @@ 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'
import { useTicketContext } from '@/hooks/useTicketContext'
import { TicketContextPanel } from '@/components/session/TicketContextPanel'
interface LocationState {
sessionId?: string
@@ -76,6 +79,12 @@ export function TreeNavigationPage() {
const [showUpdateModal, setShowUpdateModal] = useState(false)
const [ticketInfo, setTicketInfo] = useState<PSATicketInfo | null>(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)
@@ -325,6 +334,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) {
@@ -778,6 +788,18 @@ export function TreeNavigationPage() {
</div>
</div>
{/* PSA Ticket Context Panel */}
{session?.psa_ticket_id && (
<div className="mb-6">
<TicketContextPanel
context={ticketContext}
loading={ticketContextLoading}
error={ticketContextError}
onRefresh={refreshTicketContext}
/>
</div>
)}
{/* Breadcrumb */}
<div className="mb-6 flex items-center gap-2 overflow-x-auto text-sm">
{pathTaken.map((nodeId, index) => {

View File

@@ -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 {

View File

@@ -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 {