refactor: resolve merge conflicts — combine main improvements with token normalization

- .gitignore: keep both graphify-out/ entries and main's .gitnexus entry
- ScriptCodeBlock/ScriptPreviewModal: take main's border-border and text-accent-text
  for filename labels; use neutral ghost style for Save button in ScriptCodeBlock;
  use bg-accent (normalized from bg-primary) for Save button in ScriptPreviewModal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-04-06 20:23:36 -04:00
51 changed files with 4039 additions and 2656 deletions

View File

@@ -189,7 +189,7 @@ function ChatItem({
return (
<div
onClick={onSelect}
onClick={confirming ? e => e.stopPropagation() : onSelect}
className={cn(
'group flex items-center gap-2 px-3 py-2.5 mx-1.5 rounded-lg cursor-pointer transition-colors',
confirming

View File

@@ -130,6 +130,14 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
}
}, [handleMouseMove, handleMouseUp])
// Refs so the debounced save always uses the latest questions/actions/tasks
const questionsRef = useRef(questions)
const actionsRef = useRef(actions)
const tasksRef = useRef(tasks)
useEffect(() => { questionsRef.current = questions }, [questions])
useEffect(() => { actionsRef.current = actions }, [actions])
useEffect(() => { tasksRef.current = tasks }, [tasks])
// Save task state to sessionStorage on every change + debounce to backend
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
@@ -139,9 +147,9 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(() => {
aiSessionsApi.saveTaskLane(sessionId, {
questions: questions.map(q => ({ text: q.text, context: q.context })),
actions: actions.map(a => ({ label: a.label, command: a.command, description: a.description })),
responses: tasks as unknown as Array<Record<string, unknown>>,
questions: questionsRef.current.map(q => ({ text: q.text, context: q.context })),
actions: actionsRef.current.map(a => ({ label: a.label, command: a.command, description: a.description })),
responses: tasksRef.current as unknown as Array<Record<string, unknown>>,
}).catch(() => { /* silent — best-effort save */ })
}, 2000)
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }

View File

@@ -1,65 +1,19 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Plus, ChevronDown, Sparkles, FolderTree, ListOrdered } from 'lucide-react'
import { Link } from 'react-router-dom'
import { Plus, ChevronDown, FolderTree, ListOrdered } from 'lucide-react'
import { cn } from '@/lib/utils'
import { editorAIApi } from '@/api/editorAI'
import { apiClient } from '@/api/client'
import { AIPromptDialog } from '@/components/editor-ai/AIPromptDialog'
type AIFlowType = 'troubleshooting' | 'procedural' | 'maintenance'
interface CreateFlowDropdownProps {
aiEnabled: boolean
className?: string
/** Button label — defaults to "Create Flow" */
label?: string
}
export function CreateFlowDropdown({
aiEnabled,
className,
label = 'Create Flow',
}: CreateFlowDropdownProps) {
const [showMenu, setShowMenu] = useState(false)
const [aiPromptOpen, setAiPromptOpen] = useState(false)
const [aiPromptFlowType, setAiPromptFlowType] = useState<AIFlowType>('troubleshooting')
const navigate = useNavigate()
const handleAIGenerate = async (prompt: string) => {
// Start an AI session
const session = await editorAIApi.startSession(
aiPromptFlowType === 'maintenance' ? 'procedural' : aiPromptFlowType
)
const sessionId = session.session_id
// Send the user's prompt
await editorAIApi.sendMessage({
sessionId,
content: prompt,
actionType: 'generate_full',
})
// Generate the full flow
await editorAIApi.generateFull(sessionId)
// Import to create the tree
const { data: importResult } = await apiClient.post(
`/ai/chat/sessions/${sessionId}/import`,
{}
)
const treeId = importResult.tree_id
// Navigate to the editor
if (aiPromptFlowType === 'troubleshooting') {
navigate(`/trees/${treeId}/edit`, {
state: { aiPanelOpen: true, sessionId },
})
} else {
navigate(`/flows/${treeId}/edit`, {
state: { aiPanelOpen: true, sessionId },
})
}
}
return (
<div className={cn('relative', className)}>
@@ -74,43 +28,25 @@ export function CreateFlowDropdown({
{showMenu && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowMenu(false)} />
<div className="absolute right-0 z-20 mt-1 w-64 rounded-lg border border-border bg-card p-1 shadow-xl">
{/* Troubleshooting */}
<div className="absolute right-0 z-20 mt-1 w-60 rounded-lg border border-border bg-card p-1 shadow-xl">
<Link
to="/trees/new"
onClick={() => setShowMenu(false)}
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-[var(--color-bg-elevated)] transition-colors"
>
<FolderTree className="h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<div className="font-medium">Troubleshooting Tree</div>
<div className="font-medium">Troubleshooting Flow</div>
<div className="text-xs text-muted-foreground">Branching decision flow</div>
</div>
</Link>
{aiEnabled && (
<button
type="button"
onClick={() => {
setShowMenu(false)
setAiPromptFlowType('troubleshooting')
setAiPromptOpen(true)
}}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground hover:bg-accent"
>
<Sparkles className="h-3.5 w-3.5 text-primary ml-0.5" />
<div className="text-left">
<div className="text-xs text-primary font-medium">Build with Flow Assist</div>
</div>
</button>
)}
<div className="my-1 border-t border-border" />
{/* Procedural */}
<Link
to="/flows/new"
onClick={() => setShowMenu(false)}
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-[var(--color-bg-elevated)] transition-colors"
>
<ListOrdered className="h-4 w-4 text-muted-foreground" />
<div className="flex-1">
@@ -118,51 +54,9 @@ export function CreateFlowDropdown({
<div className="text-xs text-muted-foreground">Step-by-step procedure</div>
</div>
</Link>
{aiEnabled && (
<button
type="button"
onClick={() => {
setShowMenu(false)
setAiPromptFlowType('procedural')
setAiPromptOpen(true)
}}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground hover:bg-accent"
>
<Sparkles className="h-3.5 w-3.5 text-primary ml-0.5" />
<div className="text-left">
<div className="text-xs text-primary font-medium">Build with Flow Assist</div>
</div>
</button>
)}
<div className="my-1 border-t border-border" />
{aiEnabled && (
<button
type="button"
onClick={() => {
setShowMenu(false)
setAiPromptFlowType('procedural')
setAiPromptOpen(true)
}}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground hover:bg-accent"
>
<Sparkles className="h-3.5 w-3.5 text-primary ml-0.5" />
<div className="text-left">
<div className="text-xs text-primary font-medium">Build with Flow Assist</div>
</div>
</button>
)}
</div>
</>
)}
<AIPromptDialog
isOpen={aiPromptOpen}
onClose={() => setAiPromptOpen(false)}
onGenerate={handleAIGenerate}
flowType={aiPromptFlowType}
/>
</div>
)
}

View File

@@ -34,11 +34,11 @@ export function TagBadges({
}}
disabled={!onTagClick}
className={cn(
'rounded-full font-sans text-xs transition-colors',
'rounded-full font-sans transition-colors',
size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-sm',
variant === 'default'
? 'bg-accent text-muted-foreground hover:bg-accent'
: 'bg-accent/50 text-muted-foreground hover:bg-accent',
? 'bg-[var(--color-bg-elevated)] text-muted-foreground border border-border hover:text-foreground hover:border-[var(--color-border-hover)]'
: 'bg-[rgba(255,255,255,0.04)] text-muted-foreground border border-border hover:text-foreground',
!onTagClick && 'cursor-default'
)}
>
@@ -48,9 +48,9 @@ export function TagBadges({
{hiddenCount > 0 && (
<span
className={cn(
'rounded-full font-sans text-xs',
'rounded-full font-sans',
size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-sm',
'bg-accent/50 text-muted-foreground'
'bg-[rgba(255,255,255,0.04)] text-muted-foreground border border-border'
)}
title={tags.slice(maxVisible).join(', ')}
>

View File

@@ -3,12 +3,21 @@ import { useNavigate } from 'react-router-dom'
import { AlertTriangle, Clock, Hash, Ticket, Loader2, RefreshCw } from 'lucide-react'
import { aiSessionsApi } from '@/api'
import type { AISessionSummary } from '@/types/ai-session'
import { timeAgo } from '@/lib/timeAgo'
interface EscalationQueueProps {
onPickup?: (sessionId: string) => void
onCountChange?: (count: number) => void
}
export function EscalationQueue({ onPickup }: EscalationQueueProps) {
function waitTimeColor(createdAt: string): string {
const hours = (Date.now() - new Date(createdAt).getTime()) / 3_600_000
if (hours >= 4) return '#f87171' // danger
if (hours >= 1) return '#fbbf24' // warning/amber
return '#848b9b' // muted
}
export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProps) {
const navigate = useNavigate()
const [sessions, setSessions] = useState<AISessionSummary[]>([])
const [isLoading, setIsLoading] = useState(true)
@@ -19,7 +28,12 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
setError(null)
try {
const data = await aiSessionsApi.getEscalationQueue()
setSessions(data)
// Sort oldest-first — longest waiting = most urgent
const sorted = [...data].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
)
setSessions(sorted)
onCountChange?.(sorted.length)
} catch {
setError('Failed to load escalation queue')
} finally {
@@ -29,6 +43,7 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
useEffect(() => {
loadQueue()
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount
}, [])
const handlePickup = (sessionId: string) => {
@@ -80,7 +95,7 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between px-1">
<h3 className="font-sans text-xs text-[0.625rem] uppercase tracking-wider text-text-muted">
<h3 className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground">
Awaiting pickup ({sessions.length})
</h3>
<button
@@ -93,7 +108,7 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
</div>
{sessions.map((session) => (
<div key={session.id} className="card-interactive p-3 sm:p-4 space-y-3">
<div key={session.id} className="card-flat p-3 sm:p-4 space-y-3">
<div>
<p className="text-sm font-semibold text-foreground">
{session.problem_summary || 'Untitled session'}
@@ -107,7 +122,7 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{session.problem_domain && (
<span className="font-sans text-xs rounded-md bg-accent-dim px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider text-primary">
<span className="font-sans rounded-md bg-accent-dim px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider text-accent-text">
{session.problem_domain}
</span>
)}
@@ -115,24 +130,29 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
<Hash size={10} />
{session.step_count} steps
</span>
<span className="flex items-center gap-1">
<span
className="flex items-center gap-1 font-medium"
style={{ color: waitTimeColor(session.created_at) }}
>
<Clock size={10} />
{new Date(session.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
{timeAgo(session.created_at)}
</span>
{session.psa_ticket_id && (
<span className="flex items-center gap-1 text-primary">
<span className="flex items-center gap-1 text-accent-text">
<Ticket size={10} />
#{session.psa_ticket_id}
</span>
)}
</div>
<button
onClick={() => handlePickup(session.id)}
className="w-full min-h-[44px] rounded-lg bg-primary text-white px-4 py-2 text-sm font-semibold hover:brightness-110 active:scale-[0.98] transition-all"
>
Pick Up Session
</button>
<div className="flex justify-end">
<button
onClick={() => handlePickup(session.id)}
className="rounded-lg bg-primary text-white px-4 py-2 text-sm font-semibold hover:brightness-110 active:scale-[0.98] transition-all"
>
Pick Up
</button>
</div>
</div>
))}
</div>

View File

@@ -187,6 +187,23 @@ export function SessionDocView({
))}
</div>
{/* Follow-up recommendations */}
{documentation.follow_up_recommendations.length > 0 && (
<div className="card-flat p-3 sm:p-4">
<h4 className="font-sans text-xs text-[0.625rem] uppercase tracking-wider text-muted-foreground mb-2">
Follow-up
</h4>
<ul className="space-y-1">
{documentation.follow_up_recommendations.map((rec, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-foreground">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
{rec}
</li>
))}
</ul>
</div>
)}
{/* Rating */}
{onRate && (
<div className="card-flat p-3 sm:p-4 text-center">

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { FileText, User, Mail, Zap, AlignLeft, Copy, Check, RotateCcw, ArrowLeftRight, Loader2 } from 'lucide-react'
import { FileText, User, Mail, HelpCircle, Zap, AlignLeft, Copy, Check, RotateCcw, ArrowLeftRight, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import type { StatusUpdateAudience, StatusUpdateLength, StatusUpdateContext, StatusUpdateResponse } from '@/types/ai-session'
@@ -12,10 +12,11 @@ interface StatusUpdateModalProps {
hasPsaTicket?: boolean
}
const AUDIENCES: { value: StatusUpdateAudience; icon: typeof FileText; label: string; description: string }[] = [
const AUDIENCES: { value: StatusUpdateAudience; icon: typeof FileText; label: string; description: string; skipLength?: boolean }[] = [
{ value: 'ticket_notes', icon: FileText, label: 'Ticket Notes', description: 'Technical, for your PSA' },
{ value: 'client_update', icon: User, label: 'Client Update', description: 'Professional, non-technical' },
{ value: 'email_draft', icon: Mail, label: 'Email Draft', description: 'Full email with subject line' },
{ value: 'request_info', icon: HelpCircle, label: 'Request Information', description: 'Ask the client specific questions', skipLength: true },
]
const LENGTHS: { value: StatusUpdateLength; icon: typeof Zap; label: string; description: string }[] = [
@@ -38,9 +39,24 @@ export function StatusUpdateModal({ open, onClose, onGenerate, context = 'status
escalation: 'Share Escalation',
}
const handleAudienceSelect = (value: StatusUpdateAudience) => {
const handleAudienceSelect = async (value: StatusUpdateAudience) => {
setAudience(value)
setStep('length')
const opt = AUDIENCES.find(a => a.value === value)
if (opt?.skipLength) {
// Skip length selection — always concise for request_info
setLength('quick')
setStep('generating')
try {
const res = await onGenerate(value, 'quick', context)
setResult(res)
setStep('result')
} catch {
setStep('audience')
setAudience(null)
}
} else {
setStep('length')
}
}
const handleLengthSelect = async (value: StatusUpdateLength) => {
@@ -170,7 +186,7 @@ export function StatusUpdateModal({ open, onClose, onGenerate, context = 'status
<div className="flex flex-col items-center justify-center py-8 gap-3">
<Loader2 size={24} className="animate-spin text-accent" />
<p className="text-sm text-muted-foreground">
Generating {audience === 'email_draft' ? 'email draft' : audience === 'client_update' ? 'client update' : 'ticket notes'}...
{audience === 'request_info' ? 'Drafting information request...' : audience === 'email_draft' ? 'Generating email draft...' : audience === 'client_update' ? 'Generating client update...' : 'Generating ticket notes...'}
</p>
</div>
)}

View File

@@ -47,11 +47,15 @@ export function Sidebar() {
const [flyoutIndex, setFlyoutIndex] = useState<string | null>(null)
const flyoutTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
const sidebarRef = useRef<HTMLElement>(null)
const statsRequestId = useRef(0)
/* ── Stats fetching ───────────────────────────────── */
const refreshStats = useCallback(() => {
sidebarApi.getStats().then(setStats).catch(() => {})
const requestId = ++statsRequestId.current
sidebarApi.getStats()
.then(data => { if (requestId === statsRequestId.current) setStats(data) })
.catch(() => {})
}, [])
useEffect(() => { refreshStats() }, [location.pathname, refreshStats])
@@ -84,8 +88,7 @@ export function Sidebar() {
badge: stats?.tree_counts.total || undefined,
matchPaths: ['/trees', '/flows', '/my-trees', '/step-library', '/review-queue'],
children: [
{ href: '/trees', label: 'Guided Flows', count: stats?.tree_counts.total || undefined },
{ href: '/trees?type=troubleshooting', label: 'Troubleshooting', count: stats?.tree_counts.troubleshooting || undefined },
{ href: '/trees', label: 'Flow Library', count: stats?.tree_counts.total || undefined },
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
{ href: '/step-library', label: 'Solutions Library' },
{ href: '/review-queue', label: 'Review Queue' },
@@ -123,12 +126,11 @@ export function Sidebar() {
title: 'KNOWLEDGE',
items: [
{
href: '/trees', icon: GitBranch, label: 'Guided Flows', shortLabel: 'Flows',
href: '/trees', icon: GitBranch, label: 'Flow Library', shortLabel: 'Flows',
badge: stats?.tree_counts.total || undefined,
matchPaths: ['/trees', '/flows', '/my-trees'],
children: [
{ href: '/trees', label: 'All Flows' },
{ href: '/trees?type=troubleshooting', label: 'Troubleshooting', count: stats?.tree_counts.troubleshooting || undefined },
{ href: '/trees', label: 'Flow Library' },
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
],
},

View File

@@ -41,7 +41,7 @@ export function CollapsibleEditorSection({
onClick={handleToggle}
aria-expanded={isExpanded}
aria-controls={sectionId}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50"
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-white/[0.05]"
>
<ChevronRight
className={cn(

View File

@@ -30,7 +30,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
<span className="text-sm font-medium text-muted-foreground">Edit Section Header</span>
<button
onClick={onCollapse}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
className="rounded p-1 text-muted-foreground hover:bg-elevated hover:text-foreground"
>
<ChevronUp className="h-4 w-4" />
</button>
@@ -42,7 +42,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
value={step.title}
onChange={(e) => onUpdate({ title: e.target.value })}
placeholder="Section title"
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
className="w-full rounded border border-border bg-elevated px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/>
</div>
</div>
@@ -54,14 +54,14 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
{/* Header */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-accent text-xs font-medium text-foreground">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/[0.10] text-xs font-medium text-foreground">
{stepNumber}
</span>
<span className="text-sm font-medium text-foreground">Edit Step</span>
</div>
<button
onClick={onCollapse}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
className="rounded p-1 text-muted-foreground hover:bg-elevated hover:text-foreground"
>
<ChevronUp className="h-4 w-4" />
</button>
@@ -75,7 +75,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
type="text"
value={step.title}
onChange={(e) => onUpdate({ title: e.target.value })}
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
className="w-full rounded border border-border bg-elevated px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/>
</div>
@@ -91,7 +91,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
onChange={(e) => onUpdate({ estimated_minutes: e.target.value ? parseInt(e.target.value) : undefined })}
placeholder="—"
min={1}
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
className="w-full rounded border border-border bg-elevated px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/>
</div>
@@ -103,7 +103,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
onChange={(e) => onUpdate({ description: e.target.value })}
placeholder="Step instructions. Use [VAR:name] for variables."
rows={4}
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
className="w-full rounded border border-border bg-elevated px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/>
{availableVariables.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
@@ -132,44 +132,44 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
onChange={(e) => onUpdate({ commands: e.target.value || undefined })}
placeholder="Install-WindowsFeature AD-Domain-Services -IncludeManagementTools"
rows={3}
className="w-full rounded border border-border bg-card px-3 py-2 font-mono text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
className="w-full rounded border border-border bg-elevated px-3 py-2 font-mono text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/>
</div>
{/* More Options toggle */}
{/* Content Type */}
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">Type</label>
<div className="flex gap-1">
{CONTENT_TYPE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => onUpdate({ content_type: opt.value })}
className={cn(
'rounded px-2 py-1 text-xs font-medium transition-colors',
step.content_type === opt.value
? 'bg-white/15 ' + opt.color
: 'text-muted-foreground hover:bg-elevated hover:text-foreground'
)}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Advanced Options toggle */}
<button
type="button"
onClick={() => setShowMore(!showMore)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-muted-foreground"
className="flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<Settings2 className="h-3 w-3" />
More Options
Advanced Options
{showMore ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</button>
{showMore && (
<div className="space-y-4 border-t border-border pt-4">
{/* Content Type */}
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">Content Type</label>
<div className="flex gap-1">
{CONTENT_TYPE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => onUpdate({ content_type: opt.value })}
className={cn(
'rounded px-2 py-1 text-xs font-medium transition-colors',
step.content_type === opt.value
? 'bg-white/15 ' + opt.color
: 'text-muted-foreground hover:bg-accent hover:text-muted-foreground'
)}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Warning text */}
{(step.content_type === 'warning' || step.warning_text) && (
<div>
@@ -195,7 +195,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
value={step.expected_outcome || ''}
onChange={(e) => onUpdate({ expected_outcome: e.target.value || undefined })}
placeholder="Server should respond with..."
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
className="w-full rounded border border-border bg-elevated px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/>
</div>
@@ -211,7 +211,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
value={step.verification_prompt || ''}
onChange={(e) => onUpdate({ verification_prompt: e.target.value || undefined })}
placeholder="Confirm the role was installed"
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
className="w-full rounded border border-border bg-elevated px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/>
</div>
<div>
@@ -219,7 +219,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
<select
value={step.verification_type || ''}
onChange={(e) => onUpdate({ verification_type: e.target.value as 'checkbox' | 'text_input' || undefined })}
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
className="w-full rounded border border-border bg-elevated px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
>
<option value="">None</option>
<option value="checkbox">Checkbox (confirm done)</option>
@@ -240,7 +240,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
value={step.reference_url || ''}
onChange={(e) => onUpdate({ reference_url: e.target.value || undefined })}
placeholder="https://learn.microsoft.com/..."
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
className="w-full rounded border border-border bg-elevated px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/>
</div>
<div className="flex items-end pb-1">
@@ -267,7 +267,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
onChange={(e) => onUpdate({
library_visibility: e.target.value === '' ? undefined : e.target.value as 'team' | 'public'
})}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
className="w-full rounded-lg border border-border bg-elevated px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
>
<option value="">Inherit from flow</option>
<option value="team">Team only</option>

View File

@@ -1,5 +1,5 @@
import { useRef, useEffect, useCallback, type ReactNode } from 'react'
import { Plus, GripVertical, Trash2, ChevronDown, CheckCircle2, AlertTriangle, Info, Zap, Shield, SeparatorHorizontal } from 'lucide-react'
import { Plus, GripVertical, Trash2, ChevronDown, CheckCircle2, AlertTriangle, Info, Zap, ListOrdered, SeparatorHorizontal } from 'lucide-react'
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
import type { DragEndEvent } from '@dnd-kit/core'
import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'
@@ -104,7 +104,7 @@ export function StepList({ onStepContextMenu }: StepListProps) {
<div>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-muted-foreground" />
<ListOrdered className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Steps</h2>
<span className="text-sm text-muted-foreground">
({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''}
@@ -114,14 +114,14 @@ export function StepList({ onStepContextMenu }: StepListProps) {
<div className="flex items-center gap-2">
<button
onClick={() => addSectionHeader()}
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-elevated hover:text-foreground"
>
<SeparatorHorizontal className="h-3.5 w-3.5" />
Add Section
</button>
<button
onClick={() => addStep()}
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-elevated hover:text-foreground"
>
<Plus className="h-3.5 w-3.5" />
Add Step
@@ -138,7 +138,7 @@ export function StepList({ onStepContextMenu }: StepListProps) {
return (
<div
key={step.id}
className="flex items-center gap-2 rounded-lg border border-dashed border-border bg-accent/50 px-3 py-2"
className="flex items-center gap-2 rounded-lg border border-dashed border-border bg-elevated/40 px-3 py-2"
>
<CheckCircle2 className="h-4 w-4 text-emerald-400/50" />
<input
@@ -238,7 +238,7 @@ export function StepList({ onStepContextMenu }: StepListProps) {
<div
className={cn(
'group flex flex-col rounded-xl border border-border px-3 py-2.5 transition-colors',
'hover:border-primary/30 hover:bg-accent/50',
'hover:border-white/[0.15] hover:bg-elevated',
isGhost && 'border-l-2 border-dashed border-l-primary/40! opacity-60'
)}
data-step-id={step.id}
@@ -254,7 +254,7 @@ export function StepList({ onStepContextMenu }: StepListProps) {
<GripVertical className="h-4 w-4" />
</button>
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-xs font-medium text-muted-foreground">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white/[0.10] text-xs font-medium text-muted-foreground">
{stepNumber}
</span>
@@ -277,7 +277,7 @@ export function StepList({ onStepContextMenu }: StepListProps) {
<button
onClick={() => setExpandedStepId(step.id)}
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-elevated hover:text-foreground"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
@@ -324,7 +324,7 @@ export function StepList({ onStepContextMenu }: StepListProps) {
{/* Add step button at bottom */}
<button
onClick={() => addStep()}
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-border py-2 text-sm text-muted-foreground transition-colors hover:border-primary/30 hover:text-muted-foreground"
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-white/20 py-2 text-sm text-muted-foreground transition-colors hover:border-primary/40 hover:bg-elevated/30 hover:text-foreground"
>
<Plus className="h-3.5 w-3.5" />
Add Step

View File

@@ -52,7 +52,10 @@ export function StepChecklist({ steps, currentStepIndex, completedStepIds, onSte
) : (
<Circle className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-accent text-[10px] font-medium">
<span className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold',
isCurrent ? 'bg-[#0e1016] text-accent' : 'bg-accent text-[#0e1016]'
)}>
{index + 1}
</span>
<span className="min-w-0 flex-1 flex items-center gap-1.5 overflow-hidden">

View File

@@ -91,7 +91,7 @@ export function StepDetail({
<div className="space-y-4">
{/* Step header */}
<div className="flex items-start gap-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-accent text-sm font-semibold text-foreground">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-accent text-sm font-bold text-[#0e1016]">
{stepNumber}
</span>
<div className="min-w-0 flex-1">

View File

@@ -1,17 +1,27 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { Send } from 'lucide-react'
import { Send, Terminal, UserPlus, HardDrive, RotateCcw } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
const SUGGESTIONS: { icon: LucideIcon; label: string }[] = [
{ icon: UserPlus, label: 'Create a new AD user' },
{ icon: HardDrive, label: 'Check disk space on all servers' },
{ icon: RotateCcw, label: 'Restart a Windows service' },
{ icon: Terminal, label: 'Reset MFA for a user' },
]
interface ScriptBuilderInputProps {
onSend: (content: string) => void
disabled: boolean
placeholder?: string
showSuggestions?: boolean
}
export function ScriptBuilderInput({
onSend,
disabled,
placeholder = 'Describe the script you need...',
showSuggestions = false,
}: ScriptBuilderInputProps) {
const [value, setValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
@@ -44,35 +54,54 @@ export function ScriptBuilderInput({
const canSend = value.trim().length > 0 && !disabled
return (
<div className="flex items-end gap-2 p-3 border-t" style={{ borderColor: 'var(--color-border-default)' }}>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
rows={1}
className={cn(
"flex-1 resize-none rounded-xl px-4 py-2.5 text-sm",
"bg-card border border-border text-foreground placeholder:text-muted-foreground",
"focus:outline-none focus:border-[rgba(96,165,250,0.3)] transition-colors",
"disabled:opacity-50"
)}
style={{ maxHeight: 120 }}
/>
<button
onClick={handleSend}
disabled={!canSend}
className={cn(
"shrink-0 flex items-center justify-center w-10 h-10 rounded-xl transition-all",
canSend
? "bg-primary text-white hover:brightness-110 active:scale-[0.98]"
: "bg-[rgba(255,255,255,0.04)] text-text-muted cursor-not-allowed"
)}
>
<Send size={18} />
</button>
<div className="border-t border-border p-3 space-y-2">
<div className="flex items-end gap-2">
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
rows={1}
className={cn(
"flex-1 resize-none rounded-xl px-4 py-2.5 text-sm",
"bg-card border border-border text-foreground placeholder:text-muted-foreground",
"focus:outline-none focus:border-[rgba(249,115,22,0.25)] focus:ring-1 focus:ring-[rgba(249,115,22,0.1)] transition-colors",
"disabled:opacity-50"
)}
style={{ maxHeight: 120 }}
/>
<button
onClick={handleSend}
disabled={!canSend}
className={cn(
"shrink-0 flex items-center justify-center w-10 h-10 rounded-xl transition-all",
canSend
? "bg-primary text-white hover:brightness-110 active:scale-[0.98]"
: "bg-[var(--color-bg-elevated)] text-muted-foreground cursor-not-allowed"
)}
>
<Send size={18} />
</button>
</div>
{showSuggestions && (
<div className="flex flex-wrap gap-2">
{SUGGESTIONS.map(({ icon: Icon, label }) => (
<button
key={label}
type="button"
disabled={disabled}
onClick={() => { if (!disabled) onSend(label) }}
className="group flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-xs text-muted-foreground transition-all hover:border-[var(--color-border-hover)] hover:bg-[var(--color-bg-elevated)] hover:text-foreground active:scale-[0.97] disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none"
>
<Icon size={11} className="text-muted shrink-0 group-hover:text-[#f97316] transition-colors" />
{label}
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -5,7 +5,6 @@ import bash from 'react-syntax-highlighter/dist/esm/languages/hljs/bash'
import python from 'react-syntax-highlighter/dist/esm/languages/hljs/python'
import atomOneDark from 'react-syntax-highlighter/dist/esm/styles/hljs/atom-one-dark'
import { Eye, Copy, Check, BookmarkPlus } from 'lucide-react'
import { cn } from '@/lib/utils'
SyntaxHighlighter.registerLanguage('powershell', powershell)
SyntaxHighlighter.registerLanguage('bash', bash)
@@ -52,10 +51,10 @@ export function ScriptCodeBlock({
}
return (
<div className="mt-3 rounded-lg border bg-[rgba(0,0,0,0.3)] border-[rgba(255,255,255,0.06)] overflow-hidden">
<div className="mt-3 rounded-lg border border-border bg-[var(--color-bg-code)] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-[rgba(255,255,255,0.06)]">
<span className="font-mono text-xs text-accent truncate">
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<span className="font-mono text-xs text-accent-text truncate">
{filename || 'script'}
</span>
{lineCount != null && (
@@ -85,40 +84,31 @@ export function ScriptCodeBlock({
{previewLines}
</SyntaxHighlighter>
{remainingLines > 0 && (
<div className="px-3 pb-2 font-mono text-[0.625rem] text-text-muted">
<div className="px-3 pb-2 font-mono text-[0.625rem] text-muted-foreground">
{"··· "}{remainingLines} more line{remainingLines !== 1 ? 's' : ''}
</div>
)}
</button>
{/* Action buttons */}
<div className="flex items-center gap-2 px-3 py-2 border-t border-[rgba(255,255,255,0.06)]">
<div className="flex items-center gap-2 px-3 py-2 border-t border-border">
<button
onClick={onViewFull}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all",
"bg-primary text-white hover:brightness-110 active:scale-[0.98]"
)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all bg-primary text-white hover:brightness-110 active:scale-[0.98]"
>
<Eye size={14} />
View Full Script
</button>
<button
onClick={handleCopy}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors",
"bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground hover:border-[rgba(255,255,255,0.12)]"
)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors border border-border text-foreground hover:border-[var(--color-border-hover)] hover:bg-[var(--color-bg-elevated)]"
>
{copied ? <Check size={14} className="text-success" /> : <Copy size={14} />}
{copied ? 'Copied' : 'Copy'}
</button>
<button
onClick={(e) => { e.stopPropagation(); onSave() }}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors",
"bg-success-dim border border-success/20 text-success hover:bg-emerald-500/15"
)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors border border-default text-secondary hover:text-primary hover:border-hover hover:bg-elevated"
>
<BookmarkPlus size={14} />
Save to Library

View File

@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'
import atomOneDark from 'react-syntax-highlighter/dist/esm/styles/hljs/atom-one-dark'
import { X, Copy, Check, BookmarkPlus } from 'lucide-react'
import { cn } from '@/lib/utils'
const LANGUAGE_MAP: Record<string, string> = {
powershell: 'powershell',
@@ -55,44 +54,38 @@ export function ScriptPreviewModal({
return (
<div
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center"
className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div className="bg-card rounded-xl border border-[rgba(255,255,255,0.08)] max-w-[900px] w-full mx-4 max-h-[85vh] flex flex-col">
<div className="bg-card rounded-xl border border-border max-w-[900px] w-full mx-4 max-h-[85vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[rgba(255,255,255,0.06)]">
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border">
<div className="flex items-center gap-3 min-w-0">
<span className="font-mono text-sm text-accent truncate">
<span className="font-mono text-sm text-accent-text truncate">
{filename || 'script'}
</span>
<span className="shrink-0 font-mono text-[0.625rem] uppercase tracking-wider px-2 py-0.5 rounded-full bg-[rgba(255,255,255,0.06)] text-muted-foreground">
<span className="shrink-0 font-mono text-[0.625rem] uppercase tracking-wider px-2 py-0.5 rounded-full bg-[var(--color-bg-elevated)] text-muted-foreground">
{LANGUAGE_LABELS[language] || language}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors",
"bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground hover:border-[rgba(255,255,255,0.12)]"
)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors border border-border text-foreground hover:border-[var(--color-border-hover)] hover:bg-[var(--color-bg-elevated)]"
>
{copied ? <Check size={14} className="text-success" /> : <Copy size={14} />}
{copied ? 'Copied' : 'Copy'}
</button>
<button
onClick={onSave}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors",
"bg-success-dim border border-success/20 text-success hover:bg-emerald-500/15"
)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-accent text-white hover:brightness-110"
>
<BookmarkPlus size={14} />
Save to Library
</button>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-[var(--color-bg-elevated)] transition-colors"
>
<X size={18} />
</button>
@@ -125,16 +118,13 @@ export function ScriptPreviewModal({
</div>
{/* Footer */}
<div className="flex items-center justify-between px-5 py-3 border-t border-[rgba(255,255,255,0.06)]">
<div className="flex items-center justify-between px-5 py-3 border-t border-border">
<span className="font-mono text-[0.625rem] text-muted-foreground">
{lineCount} line{lineCount !== 1 ? 's' : ''}
</span>
<button
onClick={onClose}
className={cn(
"px-4 py-1.5 rounded-lg text-xs font-medium transition-colors",
"bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground hover:border-[rgba(255,255,255,0.12)]"
)}
className="px-4 py-1.5 rounded-lg text-xs font-medium transition-colors border border-border text-foreground hover:border-[var(--color-border-hover)] hover:bg-[var(--color-bg-elevated)]"
>
Close & Return to Chat
</button>

View File

@@ -108,7 +108,7 @@ export function ParameterDetectorStepper({
{/* Progress */}
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
Candidate {currentIndex + 1} of {candidates.length}
Variable {currentIndex + 1} of {candidates.length}
</p>
<div className="flex items-center gap-1">
{candidates.map((_, i) => (
@@ -127,7 +127,7 @@ export function ParameterDetectorStepper({
{/* Matched line */}
<div className="rounded-lg bg-black/20 px-3 py-2">
<p className="font-sans text-xs text-amber-400 break-all">
<p className="font-sans text-xs text-warning break-all">
{current.matchedLine}
</p>
<p className="font-sans text-xs text-[0.5rem] text-muted-foreground mt-1">
@@ -145,7 +145,7 @@ export function ParameterDetectorStepper({
placeholder="param_key"
/>
{existingKeys.includes(key) && (
<p className="text-[0.625rem] text-amber-400 mt-0.5">Key already exists consider a different name</p>
<p className="text-[0.625rem] text-warning mt-0.5">Key already exists consider a different name</p>
)}
</div>
<div>
@@ -174,7 +174,7 @@ export function ParameterDetectorStepper({
<select
value={type}
onChange={e => setType(e.target.value as ScriptParameter['type'])}
className="w-full rounded-lg border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(96,165,250,0.3)]"
className="w-full rounded-lg border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(249,115,22,0.25)] focus:ring-1 focus:ring-[rgba(249,115,22,0.1)]"
>
{PARAM_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>

View File

@@ -92,7 +92,7 @@ export function ParameterizeAndSavePanel({
if (detected.length > 0) {
setShowStepper(true)
} else {
setDetectionSummary('No parameters detected — script will be saved as-is. Parameter detection currently supports PowerShell only.')
setDetectionSummary('No configurable values found — the script will be saved as-is. Variable detection currently supports PowerShell only.')
}
}, [])
@@ -298,7 +298,7 @@ export function ParameterizeAndSavePanel({
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
Detect Parameters
Find Variables
</button>
</section>
)}
@@ -313,7 +313,7 @@ export function ParameterizeAndSavePanel({
<pre className="p-3 text-xs font-mono text-foreground whitespace-pre-wrap break-all">
{workingScript.split(/({{.*?}})/).map((part, i) =>
/^{{.*}}$/.test(part)
? <span key={i} className="text-amber-400 font-semibold">{part}</span>
? <span key={i} className="text-warning font-semibold">{part}</span>
: <span key={i}>{part}</span>
)}
</pre>
@@ -332,7 +332,7 @@ export function ParameterizeAndSavePanel({
{showStepper && candidates.length > 0 && (
<section className="space-y-2">
<p className="font-sans text-xs uppercase tracking-widest text-muted-foreground">
Detected Parameters
Configurable Variables
</p>
<ParameterDetectorStepper
candidates={candidates}
@@ -348,7 +348,7 @@ export function ParameterizeAndSavePanel({
{parameters.length > 0 && !showStepper && (
<section className="space-y-2">
<p className="font-sans text-xs uppercase tracking-widest text-muted-foreground">
Parameters ({parameters.length})
Variables ({parameters.length})
</p>
<div className="space-y-1">
{parameters.map((p) => (
@@ -357,7 +357,7 @@ export function ParameterizeAndSavePanel({
className="flex items-center justify-between rounded-lg bg-elevated px-3 py-2"
>
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-amber-400">{`{{${p.key}}}`}</code>
<code className="text-xs font-mono text-warning">{`{{${p.key}}}`}</code>
<span className="text-xs text-muted-foreground">{p.label}</span>
</div>
<span className="text-[0.625rem] text-muted-foreground uppercase tracking-wide">

View File

@@ -3,9 +3,9 @@ import { cn } from '@/lib/utils'
import type { ScriptTemplateListItem } from '@/types'
const COMPLEXITY_CLASSES: Record<ScriptTemplateListItem['complexity'], string> = {
beginner: 'text-emerald-400 bg-emerald-400/10',
intermediate: 'text-amber-400 bg-amber-400/10',
advanced: 'text-rose-500 bg-rose-500/10',
beginner: 'text-success bg-success-dim',
intermediate: 'text-warning bg-warning-dim',
advanced: 'text-danger bg-danger-dim',
}
interface Props {
@@ -28,7 +28,7 @@ export function TemplateCard({ template, onConfigure }: Props) {
<div className="flex items-center gap-1.5 shrink-0">
{template.requires_elevation && (
<span title="Requires administrator elevation">
<ShieldAlert size={13} className="text-amber-400" />
<ShieldAlert size={13} className="text-warning" />
</span>
)}
<span className={cn('font-sans text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded', COMPLEXITY_CLASSES[template.complexity])}>
@@ -62,7 +62,7 @@ export function TemplateCard({ template, onConfigure }: Props) {
<button
type="button"
onClick={() => onConfigure(template.id)}
className="shrink-0 bg-primary/10 border border-primary/20 text-primary text-xs px-2.5 py-1 rounded-md hover:bg-primary/20 transition-colors"
className="shrink-0 bg-accent-dim border border-primary/20 text-accent-text text-xs px-2.5 py-1 rounded-md hover:bg-primary/20 transition-colors"
>
Configure
</button>

View File

@@ -208,8 +208,8 @@ export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalPr
className={cn(
'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors',
copiedCommandIndex === index
? 'bg-emerald-400/10 text-emerald-400'
: 'bg-accent text-muted-foreground hover:bg-accent hover:text-foreground'
? 'bg-success-dim text-success'
: 'border border-border bg-[var(--color-bg-elevated)] text-muted-foreground hover:text-foreground hover:border-[var(--color-border-hover)]'
)}
>
{copiedCommandIndex === index ? (

View File

@@ -224,7 +224,7 @@ export function StepForm({ onSubmit, onCancel, initialData, submitLabel, isSubmi
<button
type="button"
onClick={addCommand}
className="flex items-center gap-1 rounded-md bg-accent px-2 py-1 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
className="flex items-center gap-1 rounded-md border border-border bg-[var(--color-bg-elevated)] px-2 py-1 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-[var(--color-border-hover)]"
>
<Plus className="h-3 w-3" />
Add Command
@@ -304,7 +304,7 @@ export function StepForm({ onSubmit, onCancel, initialData, submitLabel, isSubmi
<button
type="button"
onClick={addTag}
className="rounded-md bg-accent px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
className="rounded-md border border-border bg-[var(--color-bg-elevated)] px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-[var(--color-border-hover)]"
>
Add
</button>

View File

@@ -226,7 +226,7 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
'rounded-full px-2.5 py-1 text-xs transition-colors',
selectedTag === tag.tag
? 'bg-primary text-white'
: 'bg-accent text-muted-foreground hover:bg-accent'
: 'border border-border bg-[var(--color-bg-elevated)] text-muted-foreground hover:text-foreground hover:border-[var(--color-border-hover)]'
)}
>
{tag.tag} ({tag.count})

View File

@@ -1,7 +1,7 @@
import * as Sentry from "@sentry/react";
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN || "https://23937b8c0cea2484f6a9d5b97d0b7d4b@o4511005918887936.ingest.us.sentry.io/4511005926883328",
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.MODE,
integrations: [

View File

@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock, Plug, Palette, ShieldCheck } from 'lucide-react'
import { PageMeta } from '@/components/common/PageMeta'
import { BrandingSettings } from '@/components/settings/BrandingSettings'
import { accountsApi } from '@/api/accounts'
import type { Account, AccountMember, AccountInvite } from '@/types'
import { TransferOwnershipModal } from '@/components/account/TransferOwnershipModal'
@@ -22,7 +21,6 @@ export function AccountSettingsPage() {
const { isAccountOwner } = usePermissions()
const { plan, limits, usage } = useSubscription()
const { defaultExportFormat, setDefaultExportFormat } = useUserPreferencesStore()
const user = useAuthStore((s) => s.user)
const subscription = useAuthStore((s) => s.subscription)
const [account, setAccount] = useState<Account | null>(null)
@@ -45,8 +43,6 @@ export function AccountSettingsPage() {
const [inviteEmail, setInviteEmail] = useState('')
const [inviteRole, setInviteRole] = useState('engineer')
const [isInviting, setIsInviting] = useState(false)
const [inviteError, setInviteError] = useState<string | null>(null)
const [inviteSuccess, setInviteSuccess] = useState<string | null>(null)
useEffect(() => {
loadData()
@@ -86,7 +82,9 @@ export function AccountSettingsPage() {
const updated = await accountsApi.updateMyAccount({ name: editedName.trim() })
setAccount(updated)
setIsEditingName(false)
toast.success('Account name updated')
} catch (err) {
toast.error('Failed to update account name')
console.error('Failed to update account name:', err)
} finally {
setIsSavingName(false)
@@ -98,17 +96,14 @@ export function AccountSettingsPage() {
if (!inviteEmail.trim()) return
setIsInviting(true)
setInviteError(null)
setInviteSuccess(null)
try {
await accountsApi.createInvite({ email: inviteEmail.trim(), role: inviteRole })
setInviteSuccess(`Invitation sent to ${inviteEmail}`)
toast.success(`Invitation sent to ${inviteEmail}`)
setInviteEmail('')
// Refresh invites list
const invitesData = await accountsApi.getInvites()
setInvites(invitesData)
} catch (err) {
setInviteError('Failed to send invitation')
toast.error('Failed to send invitation')
console.error(err)
} finally {
setIsInviting(false)
@@ -135,7 +130,9 @@ export function AccountSettingsPage() {
try {
await accountsApi.removeMember(userId)
setMembers(members.filter((m) => m.id !== userId))
toast.success('Member removed')
} catch (err) {
toast.error('Failed to remove member')
console.error('Failed to remove member:', err)
}
}
@@ -150,7 +147,7 @@ export function AccountSettingsPage() {
if (error) {
return (
<div className="rounded-md border border-red-400/20 bg-red-400/10 p-4 text-red-400">
<div className="rounded-md border border-danger/20 bg-danger-dim p-4 text-danger">
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
{error}
@@ -230,7 +227,7 @@ export function AccountSettingsPage() {
{isAccountOwner && (
<button
onClick={() => setIsEditingName(true)}
className="text-xs text-foreground hover:underline"
className="rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
Edit
</button>
@@ -261,9 +258,9 @@ export function AccountSettingsPage() {
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium',
plan === 'free' && 'bg-accent text-muted-foreground',
plan === 'pro' && 'bg-accent text-foreground',
plan === 'team' && 'bg-accent text-foreground'
plan === 'free' && 'bg-muted text-muted-foreground',
plan === 'pro' && 'bg-accent-dim text-accent-text',
plan === 'team' && 'bg-accent-dim text-accent-text'
)}
>
<Crown className="h-3.5 w-3.5" />
@@ -273,11 +270,11 @@ export function AccountSettingsPage() {
<span
className={cn(
'inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium',
sub.status === 'active' && 'bg-green-500/10 text-emerald-400',
sub.status === 'trialing' && 'bg-blue-500/10 text-blue-400',
sub.status === 'past_due' && 'bg-yellow-500/10 text-yellow-400',
sub.status === 'canceled' && 'bg-red-400/10 text-red-400',
sub.status === 'orphaned' && 'bg-accent text-muted-foreground'
sub.status === 'active' && 'bg-success-dim text-success',
sub.status === 'trialing' && 'bg-info-dim text-info',
sub.status === 'past_due' && 'bg-warning-dim text-warning',
sub.status === 'canceled' && 'bg-danger-dim text-danger',
sub.status === 'orphaned' && 'bg-muted text-muted-foreground'
)}
>
{sub.status.charAt(0).toUpperCase() + sub.status.slice(1).replace('_', ' ')}
@@ -295,7 +292,7 @@ export function AccountSettingsPage() {
{limits && usage && (
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<UsageStat
label="Trees"
label="Flows"
current={usage.tree_count}
max={limits.max_trees}
/>
@@ -350,12 +347,13 @@ export function AccountSettingsPage() {
</div>
<div className="flex items-center gap-3">
{member.account_role === 'owner' ? (
<span className="rounded-full px-2.5 py-0.5 text-xs font-medium bg-accent text-foreground">
<span className="rounded-full px-2.5 py-0.5 text-xs font-medium bg-accent-dim text-accent-text">
owner
</span>
) : (
<select
value={member.account_role}
aria-label={`Role for ${member.name}`}
onChange={async (e) => {
try {
const updated = await accountsApi.updateMemberRole(member.id, e.target.value)
@@ -375,15 +373,15 @@ export function AccountSettingsPage() {
</select>
)}
{!member.is_active && (
<span className="rounded-full bg-red-400/10 px-2 py-0.5 text-xs text-red-400">
<span className="rounded-full bg-danger-dim px-2 py-0.5 text-xs text-danger">
Inactive
</span>
)}
{member.account_role !== 'owner' && (
<button
onClick={() => handleRemoveMember(member.id)}
className="text-muted-foreground hover:text-red-400"
title="Remove member"
className="p-1 text-muted-foreground hover:text-danger"
aria-label={`Remove ${member.name}`}
>
<X className="h-4 w-4" />
</button>
@@ -438,12 +436,6 @@ export function AccountSettingsPage() {
</Button>
</div>
{inviteError && (
<p className="text-sm text-red-400">{inviteError}</p>
)}
{inviteSuccess && (
<p className="text-sm text-emerald-400">{inviteSuccess}</p>
)}
</form>
{/* Pending Invites */}
@@ -467,14 +459,14 @@ export function AccountSettingsPage() {
</p>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-accent px-2.5 py-0.5 text-xs text-muted-foreground">
<span className="rounded-full bg-muted px-2.5 py-0.5 text-xs text-muted-foreground">
{invite.role}
</span>
<button
onClick={() => handleResendInvite(invite.id)}
disabled={resendingId === invite.id}
className="text-muted-foreground hover:text-foreground disabled:opacity-50"
title="Resend invite"
className="p-1 text-muted-foreground hover:text-foreground disabled:opacity-50"
aria-label={`Resend invite to ${invite.email}`}
>
{resendingId === invite.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -494,7 +486,7 @@ export function AccountSettingsPage() {
{/* Profile Settings Link */}
<Link
to="/account/profile"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border-hover transition-colors"
>
<div className="flex items-center gap-3">
<UserCog className="h-5 w-5 text-muted-foreground" />
@@ -506,95 +498,89 @@ export function AccountSettingsPage() {
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
{/* Team Categories Link (owners only) */}
{/* Team Settings section (owners only) */}
{isAccountOwner && (
<Link
to="/account/categories"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all"
>
<div className="flex items-center gap-3">
<FolderTree className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold text-foreground">Team Categories</h2>
<p className="text-sm text-muted-foreground">Manage tree categories for your team</p>
</div>
</div>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
)}
<>
<p className="pt-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
Team Settings
</p>
{/* Target Lists Link (owners only) */}
{isAccountOwner && (
<Link
to="/account/target-lists"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all"
>
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold text-foreground">Target Lists</h2>
<p className="text-sm text-muted-foreground">Saved server lists for maintenance flow batch launching</p>
<Link
to="/account/categories"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border-hover transition-colors"
>
<div className="flex items-center gap-3">
<FolderTree className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold text-foreground">Team Categories</h2>
<p className="text-sm text-muted-foreground">Manage flow categories for your team</p>
</div>
</div>
</div>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
)}
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
{/* Chat Retention Link (owners only) */}
{isAccountOwner && (
<Link
to="/account/chat-retention"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all"
>
<div className="flex items-center gap-3">
<Clock className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold text-foreground">Chat Retention</h2>
<p className="text-sm text-muted-foreground">Configure AI assistant conversation retention policies</p>
<Link
to="/account/target-lists"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border-hover transition-colors"
>
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold text-foreground">Target Lists</h2>
<p className="text-sm text-muted-foreground">Saved server and device lists for your team</p>
</div>
</div>
</div>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
)}
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
{/* Integrations Link (owners only) */}
{isAccountOwner && (
<Link
to="/account/integrations"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all"
>
<div className="flex items-center gap-3">
<Plug className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold text-foreground">Integrations</h2>
<p className="text-sm text-muted-foreground">Connect your PSA to sync session documentation to tickets</p>
<Link
to="/account/chat-retention"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border-hover transition-colors"
>
<div className="flex items-center gap-3">
<Clock className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold text-foreground">Chat Retention</h2>
<p className="text-sm text-muted-foreground">Configure AI assistant conversation retention policies</p>
</div>
</div>
</div>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
)}
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
{/* Branding Link (owners only) */}
{isAccountOwner && (
<Link
to="/account/branding"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all"
>
<div className="flex items-center gap-3">
<Palette className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold text-foreground">Branding</h2>
<p className="text-sm text-muted-foreground">Customize logo, accent color, and company name</p>
<Link
to="/account/integrations"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border-hover transition-colors"
>
<div className="flex items-center gap-3">
<Plug className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold text-foreground">Integrations</h2>
<p className="text-sm text-muted-foreground">Connect your PSA to sync session documentation to tickets</p>
</div>
</div>
</div>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
<Link
to="/account/branding"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border-hover transition-colors"
>
<div className="flex items-center gap-3">
<Palette className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold text-foreground">Branding</h2>
<p className="text-sm text-muted-foreground">Customize logo, accent color, and company name</p>
</div>
</div>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
</>
)}
{/* Feedback Link (all users) */}
<Link
to="/feedback"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border-hover transition-colors"
>
<div className="flex items-center gap-3">
<MessageSquareText className="h-5 w-5 text-muted-foreground" />
@@ -606,11 +592,6 @@ export function AccountSettingsPage() {
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
{/* Branding Section (owners only) */}
{isAccountOwner && user?.team_id && (
<BrandingSettings teamId={user.team_id} />
)}
{/* Preferences Section */}
<div className="bg-card border border-border rounded-xl p-4 sm:p-6">
<div className="flex items-center gap-2">
@@ -647,13 +628,13 @@ export function AccountSettingsPage() {
</select>
</div>
</div>
{/* SSO Section (Task 11) */}
{/* SSO Section */}
{isAccountOwner && (
<div className="bg-card border border-border rounded-xl p-4 sm:p-6">
<div className="flex items-center gap-2 mb-3">
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Single Sign-On (SSO)</h2>
<span className="inline-flex items-center rounded-full bg-accent-dim px-2.5 py-0.5 text-xs font-sans text-xs font-medium text-primary">
<span className="inline-flex items-center rounded-full bg-accent-dim px-2.5 py-0.5 text-xs font-medium text-primary">
Enterprise
</span>
</div>
@@ -665,8 +646,8 @@ export function AccountSettingsPage() {
href="mailto:support@resolutionflow.com?subject=SSO%20Setup%20Request"
className={cn(
'inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium',
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground',
'hover:border-[rgba(255,255,255,0.12)] transition-all'
'bg-muted border border-border text-foreground',
'hover:border-border-hover transition-colors'
)}
>
Contact Us
@@ -675,9 +656,9 @@ export function AccountSettingsPage() {
)}
{/* Danger Zone */}
<div className="rounded-xl border border-rose-500/20 p-4 sm:p-6">
<div className="rounded-xl border border-danger/20 p-4 sm:p-6">
<div className="flex items-center gap-2 mb-4">
<AlertTriangle className="h-5 w-5 text-rose-500" />
<AlertTriangle className="h-5 w-5 text-danger" />
<h2 className="text-lg font-semibold text-foreground">Danger Zone</h2>
</div>
@@ -693,7 +674,7 @@ export function AccountSettingsPage() {
variant="secondary"
size="sm"
onClick={() => setShowTransferModal(true)}
className="border-amber-500/30 text-amber-400 hover:bg-amber-500/10"
className="border-warning/30 text-warning hover:bg-warning-dim"
>
Transfer
</Button>
@@ -774,7 +755,7 @@ function UsageStat({
<p
className={cn(
'mt-1 text-lg font-semibold',
isAtLimit ? 'text-red-400' : isNearLimit ? 'text-yellow-400' : 'text-foreground'
isAtLimit ? 'text-danger' : isNearLimit ? 'text-warning' : 'text-foreground'
)}
>
{current}
@@ -783,11 +764,11 @@ function UsageStat({
</span>
</p>
{!isUnlimited && (
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-accent">
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full rounded-full transition-all',
isAtLimit ? 'bg-red-400' : isNearLimit ? 'bg-yellow-500' : 'bg-primary'
isAtLimit ? 'bg-danger' : isNearLimit ? 'bg-warning' : 'bg-primary'
)}
style={{ width: `${percentage}%` }}
/>

View File

@@ -12,7 +12,7 @@ import { analytics } from '@/lib/analytics'
import { toast } from '@/lib/toast'
import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/ChatSidebar'
import { ChatMessage } from '@/components/assistant/ChatMessage'
import { TaskLane } from '@/components/assistant/TaskLane'
import { TaskLane, clearTaskState } from '@/components/assistant/TaskLane'
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat'
@@ -81,6 +81,9 @@ export default function AssistantChatPage() {
const fileInputRef = useRef<HTMLInputElement>(null)
const dragCounterRef = useRef(0)
const prefillHandledRef = useRef(false)
// Tracks the most recently requested active chat ID so in-flight selectChat
// calls that complete after the user switches chats don't clobber new state.
const currentChatRef = useRef<string | null>(activeChatId)
// Persist active chat ID to sessionStorage
useEffect(() => {
@@ -214,6 +217,7 @@ export default function AssistantChatPage() {
}
const selectChat = useCallback(async (chatId: string) => {
currentChatRef.current = chatId
setActiveChatId(chatId)
// Clear TaskLane when switching chats — will restore from backend if available
setShowTaskLane(false)
@@ -221,6 +225,10 @@ export default function AssistantChatPage() {
setActiveActions([])
try {
const detail = await aiSessionsApi.getSession(chatId)
// Guard: if the user switched to a different chat while this API call was
// in flight (e.g. clicked "New Chat"), discard stale results so we don't
// clobber the new session's task lane state.
if (currentChatRef.current !== chatId) return
setMessages(
(detail.conversation_messages || []).map(m => ({
role: m.role as 'user' | 'assistant',
@@ -234,7 +242,7 @@ export default function AssistantChatPage() {
if (q.length > 0 || a.length > 0) {
// Pre-load user's saved responses into sessionStorage BEFORE setting props
// so TaskLane can restore them on mount/prop-change
const responses = (detail.pending_task_lane as Record<string, unknown>).responses as unknown[] | undefined
const responses = detail.pending_task_lane.responses
if (responses && responses.length > 0) {
try {
sessionStorage.setItem(`rf-tasklane-state:${chatId}`, JSON.stringify(responses))
@@ -251,6 +259,15 @@ export default function AssistantChatPage() {
}, [])
const handleNewChat = async () => {
// Invalidate currentChatRef BEFORE the await so any in-flight handleSend/handleTaskSubmit
// for the previous session sees a mismatch and bails — prevents stale task lane appearing
// in the new empty session (same pattern as selectChat, which sets ref before its await).
currentChatRef.current = null
// Clear stale state immediately — don't wait for API to return
setShowTaskLane(false)
setActiveQuestions([])
setActiveActions([])
setMessages([])
try {
const session = await aiSessionsApi.createChatSession({
intake_type: 'free_text',
@@ -264,13 +281,9 @@ export default function AssistantChatPage() {
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
currentChatRef.current = session.session_id
setChats(prev => [chatItem, ...prev])
setActiveChatId(session.session_id)
setMessages([])
// Clear TaskLane from previous session
setShowTaskLane(false)
setActiveQuestions([])
setActiveActions([])
} catch {
toast.error('Failed to create chat')
}
@@ -306,11 +319,14 @@ export default function AssistantChatPage() {
setMessages(prev => [...prev, { role: 'user', content: userMessage }])
setLoading(true)
const sentForChatId = activeChatId
try {
const response = await aiSessionsApi.sendChatMessage(activeChatId, {
message: userMessage,
upload_ids: completedUploadIds.length > 0 ? completedUploadIds : undefined,
})
// Guard: discard if user switched to a different chat while this was in flight
if (currentChatRef.current !== sentForChatId) return
analytics.aiFeatureUsed({ feature: 'assistant_chat' })
setMessages(prev => [
...prev,
@@ -318,19 +334,20 @@ export default function AssistantChatPage() {
])
setChats(prev =>
prev.map(c =>
c.id === activeChatId
c.id === sentForChatId
? { ...c, message_count: c.message_count + 2, title: c.message_count === 0 ? userMessage.slice(0, 100) : c.title, updated_at: new Date().toISOString() }
: c
)
)
// Load branches if fork was created
if (response.fork && activeChatId) {
branching.loadBranches(activeChatId)
if (response.fork && sentForChatId) {
branching.loadBranches(sentForChatId)
}
// Show task lane if AI sent questions or actions
const hasQuestions = response.questions && response.questions.length > 0
const hasActions = response.actions && response.actions.length > 0
if (hasQuestions || hasActions) {
clearTaskState(sentForChatId)
setActiveQuestions(response.questions || [])
setActiveActions(response.actions || [])
setShowTaskLane(true)
@@ -349,7 +366,8 @@ export default function AssistantChatPage() {
const handleTaskSubmit = async (responses: Array<{ type: string; state: string; value: string; text?: string; label?: string }>) => {
if (!activeChatId || loading) return
// Format task responses into a structured message for the AI
// Format task responses into a structured message for the AI.
// Pending tasks are included so the AI knows they weren't completed yet.
const parts: string[] = []
for (const r of responses) {
const name = r.type === 'question' ? `Q: ${r.text}` : r.label || 'Check'
@@ -357,6 +375,8 @@ export default function AssistantChatPage() {
parts.push(`**${name}:**\n\`\`\`\n${r.value.trim()}\n\`\`\``)
} else if (r.state === 'skipped') {
parts.push(`**${name}:** _(skipped)_`)
} else {
parts.push(`**${name}:** _(not yet completed)_`)
}
}
const userMessage = parts.join('\n\n')
@@ -364,18 +384,22 @@ export default function AssistantChatPage() {
setMessages(prev => [...prev, { role: 'user', content: userMessage }])
setLoading(true)
const sentForChatId = activeChatId
try {
const response = await aiSessionsApi.sendChatMessage(activeChatId, { message: userMessage })
// Guard: discard if user switched to a different chat while this was in flight
if (currentChatRef.current !== sentForChatId) return
setMessages(prev => [
...prev,
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions },
])
if (response.fork && activeChatId) {
branching.loadBranches(activeChatId)
if (response.fork && sentForChatId) {
branching.loadBranches(sentForChatId)
}
// Update task lane based on AI response
const hasQuestions = response.questions && response.questions.length > 0
const hasActions = response.actions && response.actions.length > 0
clearTaskState(sentForChatId)
if (hasQuestions || hasActions) {
setActiveQuestions(response.questions || [])
setActiveActions(response.actions || [])
@@ -416,6 +440,12 @@ export default function AssistantChatPage() {
}
const handleResumeNew = async (summary: string) => {
// Invalidate currentChatRef BEFORE the await — same guard as handleNewChat
currentChatRef.current = null
// Clear stale state immediately — don't wait for API to return
setShowTaskLane(false)
setActiveQuestions([])
setActiveActions([])
try {
const resumePrompt = `I'm continuing a previous troubleshooting session. Here's where we left off:\n\n${summary}\n\nPlease review this context and help me continue from where we stopped.`
const session = await aiSessionsApi.createChatSession({
@@ -430,6 +460,7 @@ export default function AssistantChatPage() {
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
currentChatRef.current = session.session_id
setChats(prev => [chatItem, ...prev])
setActiveChatId(session.session_id)
setMessages([{ role: 'user', content: resumePrompt }])

View File

@@ -1,20 +1,27 @@
import { useState } from 'react'
import { AlertTriangle } from 'lucide-react'
import { EscalationQueue } from '@/components/flowpilot'
export default function EscalationQueuePage() {
const [count, setCount] = useState<number | null>(null)
return (
<div className="mx-auto max-w-3xl p-6">
<div className="mx-auto max-w-4xl p-6">
<div className="flex items-center gap-3 mb-6">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/10">
<AlertTriangle size={16} className="text-amber-400" />
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-warning-dim">
<AlertTriangle size={16} className="text-warning" />
</span>
<div>
<h1 className="font-heading text-xl font-bold text-foreground">Escalation Queue</h1>
<p className="text-sm text-muted-foreground">Sessions from your team waiting for pickup</p>
<p className="text-sm text-muted-foreground">
{count !== null && count > 0
? `${count} session${count !== 1 ? 's' : ''} waiting for pickup`
: 'Sessions from your team waiting for pickup'}
</p>
</div>
</div>
<EscalationQueue />
<EscalationQueue onCountChange={setCount} />
</div>
)
}

View File

@@ -5,21 +5,44 @@ import '@/styles/landing.css'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
const FAQ_ITEMS = [
{
q: 'How is this different from just using ChatGPT?',
a: 'FlowPilot is purpose-built for MSP troubleshooting. It understands your stack (AD, Exchange, networking, VPN), captures every diagnostic step as you work, and generates formatted ticket notes ready for your PSA. ChatGPT doesn\u2019t build documentation and can\u2019t push notes to ConnectWise.',
},
{
q: 'Is my data safe?',
a: 'Troubleshooting sessions are encrypted and isolated per team. We never use your data to train AI models. You control what gets documented and exported.',
},
{
q: 'What PSA tools do you integrate with?',
a: 'Launching with ConnectWise PSA \u2014 session documentation exports directly as internal ticket notes. Atera and Syncro integrations are next. During beta, you can copy formatted notes into any PSA.',
},
{
q: 'What counts as a \u201csession\u201d?',
a: 'One session = one troubleshooting conversation. Describe an issue, work through it with FlowPilot, resolve it. Whether that takes 2 minutes or 2 hours, it\u2019s one session. Free plan: 20 sessions/month. Pro and Team: unlimited.',
},
{
q: 'What if FlowPilot gets it wrong?',
a: 'FlowPilot is a copilot, not autopilot. Every suggestion is a recommendation \u2014 you decide what to act on. And because every step is documented, you always have a full audit trail of what was tried and why.',
},
]
export default function LandingPage() {
const [navScrolled, setNavScrolled] = useState(false)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [betaEmail, setBetaEmail] = useState('')
const [betaStatus, setBetaStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
const [betaError, setBetaError] = useState('')
const [openFaq, setOpenFaq] = useState<number | null>(null)
const mobileMenuRef = useRef<HTMLDivElement>(null)
// Nav scroll effect
useEffect(() => {
const handleScroll = () => setNavScrolled(window.scrollY > 40)
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
// Close mobile menu on click outside
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (mobileMenuRef.current && !mobileMenuRef.current.contains(e.target as Node)) {
@@ -32,10 +55,8 @@ export default function LandingPage() {
}
}, [mobileMenuOpen])
// Close mobile menu on scroll to section
const handleMobileNavClick = () => setMobileMenuOpen(false)
// Scroll reveal
useEffect(() => {
const els = document.querySelectorAll('.landing-reveal')
const observer = new IntersectionObserver(
@@ -52,442 +73,402 @@ export default function LandingPage() {
const handleBetaSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
if (!betaEmail.trim() || betaStatus === 'sending') return
const trimmed = betaEmail.trim()
if (!trimmed || betaStatus === 'sending') return
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
setBetaStatus('error')
setBetaError('Enter a valid email address.')
return
}
setBetaStatus('sending')
setBetaError('')
try {
const resp = await fetch(`${API_URL}/api/v1/beta-signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: betaEmail }),
body: JSON.stringify({ email: trimmed }),
})
if (!resp.ok) throw new Error('Signup failed')
setBetaStatus('sent')
setBetaEmail('')
} catch {
setBetaStatus('error')
setTimeout(() => setBetaStatus('idle'), 3000)
setBetaError('Could not complete signup. Please try again or email hello@resolutionflow.com.')
}
}, [betaEmail, betaStatus])
const toggleFaq = (index: number) => {
setOpenFaq(prev => prev === index ? null : index)
}
return (
<>
<PageMeta
title="ResolutionFlow From Issue to Resolution, Documented"
description="Your AI troubleshooting copilot. Describe the issue, get help fixing it, and get clean ticket notes automatically."
title="ResolutionFlow \u2014 From Issue to Resolution, Documented"
description="Your AI troubleshooting copilot. Describe the issue, get help fixing it, and get clean ticket notes \u2014 automatically."
/>
<div className="landing-page">
<div className="landing-ambient-glow" />
<div className="landing-grid-pattern" />
<a href="#main" className="landing-skip-link">Skip to content</a>
<div className="landing-page-content">
{/* Navigation */}
<nav className={`landing-nav ${navScrolled ? 'scrolled' : ''}`} ref={mobileMenuRef}>
<div className="landing-nav-inner">
<a href="#" className="landing-nav-logo">
<div className="landing-nav-logo-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="#000" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="5" r="2"/>
<line x1="12" y1="7" x2="12" y2="11"/>
<circle cx="6" cy="15" r="2"/>
<circle cx="18" cy="15" r="2"/>
<line x1="12" y1="11" x2="6" y2="13"/>
<line x1="12" y1="11" x2="18" y2="13"/>
</svg>
</div>
<div className="landing-nav-wordmark">Resolution<span>Flow</span></div>
</a>
<ul className="landing-nav-links">
<li><a href="#features">Features</a></li>
<li><a href="#how-it-works">How It Works</a></li>
<li><a href="#pricing">Pricing</a></li>
</ul>
<div className="landing-nav-cta">
<Link to="/login" className="landing-btn-ghost">Sign In</Link>
<Link to="/register" className="landing-btn-primary">Get Started Free</Link>
{/* Navigation */}
<nav className={`landing-nav ${navScrolled ? 'scrolled' : ''}`} ref={mobileMenuRef}>
<div className="landing-nav-inner">
<a href="#" className="landing-nav-logo">
<div className="landing-nav-logo-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="#000" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="5" r="2" />
<line x1="12" y1="7" x2="12" y2="11" />
<circle cx="6" cy="15" r="2" />
<circle cx="18" cy="15" r="2" />
<line x1="12" y1="11" x2="6" y2="13" />
<line x1="12" y1="11" x2="18" y2="13" />
</svg>
</div>
<button
className={`landing-hamburger ${mobileMenuOpen ? 'open' : ''}`}
onClick={() => setMobileMenuOpen(v => !v)}
aria-label="Toggle menu"
aria-expanded={mobileMenuOpen}
>
<span />
<span />
<span />
</button>
<div className="landing-nav-wordmark">Resolution<span>Flow</span></div>
</a>
<ul className="landing-nav-links">
<li><a href="#features">Features</a></li>
<li><a href="#how-it-works">How It Works</a></li>
<li><a href="#pricing">Pricing</a></li>
<li><a href="#faq">FAQ</a></li>
</ul>
<div className="landing-nav-cta">
<Link to="/login" className="landing-btn-ghost">Sign In</Link>
<Link to="/register" className="landing-btn-primary">Get Started Free</Link>
</div>
{mobileMenuOpen && (
<div className="landing-mobile-menu">
<a href="#features" onClick={handleMobileNavClick}>Features</a>
<a href="#how-it-works" onClick={handleMobileNavClick}>How It Works</a>
<a href="#pricing" onClick={handleMobileNavClick}>Pricing</a>
<div className="landing-mobile-menu-divider" />
<Link to="/login" onClick={handleMobileNavClick}>Sign In</Link>
<Link to="/register" className="landing-btn-primary" onClick={handleMobileNavClick} style={{ textAlign: 'center' }}>Get Started Free</Link>
</div>
)}
</nav>
<button
className={`landing-hamburger ${mobileMenuOpen ? 'open' : ''}`}
onClick={() => setMobileMenuOpen(v => !v)}
aria-label="Toggle menu"
aria-expanded={mobileMenuOpen}
>
<span /><span /><span />
</button>
</div>
{mobileMenuOpen && (
<div className="landing-mobile-menu">
<a href="#features" onClick={handleMobileNavClick}>Features</a>
<a href="#how-it-works" onClick={handleMobileNavClick}>How It Works</a>
<a href="#pricing" onClick={handleMobileNavClick}>Pricing</a>
<a href="#faq" onClick={handleMobileNavClick}>FAQ</a>
<div className="landing-mobile-menu-divider" />
<Link to="/login" onClick={handleMobileNavClick}>Sign In</Link>
<Link to="/register" className="landing-btn-primary" onClick={handleMobileNavClick} style={{ textAlign: 'center' }}>Get Started Free</Link>
</div>
)}
</nav>
{/* Hero */}
<main id="main" className="landing-main">
{/* Hero — left-aligned, two columns */}
<section className="landing-hero">
<div className="landing-hero-badge">Now in Beta Join early access</div>
<h1>
Resolve tickets faster.<br />
<span className="landing-gradient-text">Notes write themselves.</span>
</h1>
<p className="landing-hero-sub">
ResolutionFlow is your AI troubleshooting copilot. Describe the issue, get expert guidance fixing it, and get clean ticket documentation without writing a single note.
</p>
<div className="landing-hero-actions">
<Link to="/register" className="landing-btn-hero-primary">Start Free</Link>
<a href="#how-it-works" className="landing-btn-hero-secondary">See How It Works</a>
<div className="landing-hero-inner">
<div className="landing-hero-content">
<div className="landing-hero-badge">Now in Beta</div>
<h1>
Resolve tickets faster.<br />
<span className="landing-hero-accent">Notes write themselves.</span>
</h1>
<p className="landing-hero-sub">
Your AI troubleshooting copilot for MSPs. Describe the issue, get expert guidance, and get clean ticket documentation &mdash; without writing a single note.
</p>
<div className="landing-hero-actions">
<Link to="/register" className="landing-btn-hero-primary">Start Free</Link>
<a href="#how-it-works" className="landing-btn-hero-secondary">See How It Works</a>
</div>
<p className="landing-hero-credibility">
Built by a 15-year MSP veteran who got tired of empty ticket notes.
</p>
</div>
</div>
</section>
{/* Social Proof Bar */}
<div className="landing-social-proof-bar">
<p>Built by MSP engineers, for MSP engineers</p>
<div className="landing-proof-stats">
<div className="landing-proof-stat">
<div className="number">15+</div>
<div className="label">Years MSP Experience</div>
</div>
<div className="landing-proof-stat">
<div className="number">70%</div>
<div className="label">Less Time on Documentation</div>
</div>
<div className="landing-proof-stat">
<div className="number">100%</div>
<div className="label">Auto-Generated Documentation</div>
</div>
</div>
</div>
{/* App Preview */}
<div className="landing-app-preview">
<div className="landing-preview-window">
<div className="landing-preview-titlebar">
<div className="landing-preview-tab">
<div className="landing-tab-icon" />
ResolutionFlow
<span className="landing-tab-close">&times;</span>
</div>
<div className="landing-preview-url-bar">
<div className="landing-preview-url">
<span className="landing-lock-icon">&#128274;</span>
app.resolutionflow.com/pilot
</div>
</div>
<div className="landing-preview-window-controls">
<div className="landing-win-btn">
<svg viewBox="0 0 12 12"><line x1="2" y1="6" x2="10" y2="6"/></svg>
</div>
<div className="landing-win-btn">
<svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8" rx="0.5"/></svg>
</div>
<div className="landing-win-btn close">
<svg viewBox="0 0 12 12"><line x1="2" y1="2" x2="10" y2="10"/><line x1="10" y1="2" x2="2" y2="10"/></svg>
</div>
</div>
</div>
<div className="landing-preview-body">
<div className="landing-preview-sidebar">
<div className="landing-preview-sidebar-item active">
<div className="dot" style={{ background: '#60a5fa' }} />
FlowPilot
</div>
<div className="landing-preview-sidebar-item">
<div className="dot" style={{ background: '#34d399' }} />
Session History
</div>
<div className="landing-preview-sidebar-item">
<div className="dot" style={{ background: '#a78bfa' }} />
Guided Flows
</div>
<div className="landing-preview-sidebar-item">
<div className="dot" style={{ background: '#2dd4bf' }} />
Scripts
</div>
<div className="landing-preview-sidebar-item">
<div className="dot" style={{ background: '#38bdf8' }} />
Analytics
</div>
</div>
<div className="landing-preview-canvas">
<div className="landing-mock-session">
<div className="landing-mock-chat-line">
<span className="label">You:</span>
<span className="text">User can&apos;t access shared drive after password reset</span>
</div>
<div className="landing-mock-chat-line">
<span className="label" style={{ color: '#60a5fa' }}>FlowPilot:</span>
<span className="text">This is likely a cached credential issue. Let&apos;s check a few things:</span>
</div>
<div className="landing-mock-chat-line">
<span className="label" style={{ color: '#60a5fa' }}>FlowPilot:</span>
<span className="text">1. Run <code>klist purge</code> to clear Kerberos tickets</span>
</div>
<div className="landing-mock-chat-line">
<span className="label" style={{ color: '#60a5fa' }}>FlowPilot:</span>
<span className="text">2. Open Credential Manager &rarr; remove saved entries for the share</span>
</div>
<div className="landing-mock-chat-line doc">
<span className="label">Auto-doc:</span>
<span className="text">3 steps captured &#10003;</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="landing-section-divider" />
{/* Problem Section */}
<section id="problem" className="landing-reveal">
{/* Problem — asymmetric: headline left, cards right */}
<section id="problem" className="landing-section landing-section-alt landing-reveal">
<div className="landing-section-inner">
<div className="landing-section-label">The Problem</div>
<h2 className="landing-section-title">Documentation is broken.<br />Everyone knows it.</h2>
<div className="landing-section-desc">
Engineers don&apos;t want to write it. Managers hate chasing it. Clients never see it. The same issues get solved from scratch every time.
</div>
<div className="landing-problem-grid">
<ProblemCard icon="&#9201;" color="red" title="1525 min lost per ticket" description="Engineers spend more time documenting what they did than actually doing it. After a complex issue, writing notes is the last thing anyone wants to do." />
<ProblemCard icon="&#128203;" color="amber" title="Vague, useless notes" description={`"Fixed Outlook" tells you nothing. Documentation written under pressure tends toward generalities that help nobody the second time around.`} />
<ProblemCard icon="&#128260;" color="slate" title="Knowledge walks out the door" description="When a senior engineer leaves, years of tribal knowledge disappear overnight. New hires spend months building up what was never captured." />
<ProblemCard icon="&#129504;" color="violet" title="Context switching kills speed" description="Jumping between the issue, documentation tools, PSA tickets, and knowledge bases fragments focus and slows resolution." />
<div className="landing-problem-layout">
<div className="landing-problem-headline">
<div className="landing-section-label">The Problem</div>
<h2>Documentation is broken.<br />Everyone knows it.</h2>
<p>Engineers don&apos;t want to write it. Managers hate chasing it. Clients never see it. The same issues get solved from scratch &mdash; every time.</p>
</div>
<div className="landing-problem-grid">
<ProblemCard icon="&#9201;" color="red" title="15&ndash;25 min lost per ticket" description="More time documenting than resolving. After a complex issue, writing notes is the last thing anyone does." />
<ProblemCard icon="&#128203;" color="amber" title="Vague, useless notes" description={`"Fixed Outlook" tells no one anything. Notes under pressure are always too vague to help next time.`} />
<ProblemCard icon="&#128260;" color="slate" title="Knowledge walks out the door" description="When a senior engineer leaves, years of tribal knowledge vanish overnight." />
<ProblemCard icon="&#129504;" color="violet" title="Context switching kills speed" description="Jumping between the issue, docs, PSA tickets, and knowledge bases fragments focus." />
</div>
</div>
</div>
</section>
<div className="landing-section-divider" />
{/* Brand Equation */}
{/* Equation */}
<div className="landing-equation-section landing-reveal">
<div className="landing-section-label">The Answer</div>
<div className="landing-brand-equation">
<span className="landing-eq-item">Resolution</span>
<span className="landing-eq-operator">+</span>
<span className="landing-eq-item">Documentation</span>
<span className="landing-eq-operator">&minus;</span>
<span className="landing-eq-item">Time</span>
<span className="landing-eq-operator">=</span>
<span className="landing-eq-result">ResolutionFlow</span>
<div className="landing-equation-inner">
<div className="landing-section-label">The Answer</div>
<div className="landing-brand-equation">
<span className="landing-eq-item">Resolution</span>
<span className="landing-eq-operator">+</span>
<span className="landing-eq-item">Documentation</span>
<span className="landing-eq-operator">&minus;</span>
<span className="landing-eq-item">Time</span>
<span className="landing-eq-operator">=</span>
<span className="landing-eq-result">ResolutionFlow</span>
</div>
<p className="landing-equation-desc">
What if documentation was a <em>byproduct</em> of solving the issue &mdash; not a separate task?
</p>
</div>
<p className="landing-equation-desc">
What if documentation was a <em>byproduct</em> of solving the issue not a separate task? What if every ticket your team touched had clean, detailed notes without anyone writing them?
</p>
</div>
<div className="landing-section-divider" />
{/* How It Works */}
<section id="how-it-works" className="landing-reveal">
{/* How It Works — zigzag */}
<section id="how-it-works" className="landing-section landing-reveal">
<div className="landing-section-inner">
<div className="landing-section-label">How It Works</div>
<h2 className="landing-section-title">Three steps. Zero note-writing.</h2>
<div className="landing-section-desc">
Just describe the issue. FlowPilot handles the rest.
</div>
<div className="landing-steps-container">
<div className="landing-step-card">
</div>
<div className="landing-zigzag">
<div className="landing-zigzag-step">
<div className="landing-zigzag-text">
<div className="landing-zigzag-number">01</div>
<h3>Describe the Issue</h3>
<p>Type what&apos;s happening, paste an error message, or drop a screenshot. FlowPilot understands MSP environments AD, Exchange, networking, VPN, you name it.</p>
<div className="landing-step-visual">
<div className="landing-mock-editor">
<div className="landing-mock-node step" style={{ fontSize: '0.7rem', padding: '8px 12px' }}>&#128172; &ldquo;User can&apos;t access shared drive after password reset, getting Access Denied in Event Viewer&rdquo;</div>
</div>
<p>Type what&apos;s happening, paste an error, or drop a screenshot. FlowPilot understands MSP environments &mdash; AD, Exchange, networking, VPN, you name it.</p>
</div>
<div className="landing-zigzag-visual">
<div className="landing-mock-input">
<span className="landing-mock-input-icon">&#128172;</span>
<span>User can&apos;t access shared drive after password reset, getting Access Denied in Event Viewer</span>
</div>
</div>
<div className="landing-step-card">
</div>
<div className="landing-zigzag-step reverse">
<div className="landing-zigzag-text">
<div className="landing-zigzag-number">02</div>
<h3>Troubleshoot Together</h3>
<p>FlowPilot acts like a senior engineer on the call with you. It suggests next steps, provides commands to run, and captures every action documentation builds itself as you work.</p>
<div className="landing-step-visual">
<div className="landing-mock-session">
<div className="landing-mock-chat-line">
<span className="label">FlowPilot:</span>
<span className="text">Is the user on VPN?</span>
</div>
<div className="landing-mock-chat-line">
<span className="label" style={{ color: '#848b9b' }}>Engineer:</span>
<span className="text">Yes, Cisco AnyConnect</span>
</div>
<div className="landing-mock-chat-line">
<span className="label">FlowPilot:</span>
<span className="text">Check split tunnel config &rarr;</span>
</div>
<div className="landing-mock-chat-line doc">
<span className="label">Auto-doc:</span>
<span className="text">Step captured &#10003;</span>
</div>
<p>FlowPilot acts like a senior engineer on the call. It suggests next steps, provides commands, and captures every action &mdash; documentation builds itself as you work.</p>
</div>
<div className="landing-zigzag-visual">
<div className="landing-mock-session compact">
<div className="landing-mock-chat-line ai">
<span className="label">FlowPilot</span>
<span className="text">Is the user on VPN?</span>
</div>
<div className="landing-mock-chat-line user">
<span className="label">You</span>
<span className="text">Yes, Cisco AnyConnect</span>
</div>
<div className="landing-mock-chat-line ai">
<span className="label">FlowPilot</span>
<span className="text">Check split tunnel config &rarr;</span>
</div>
<div className="landing-mock-chat-line doc">
<span className="label">Auto-doc</span>
<span className="text">Step captured &#10003;</span>
</div>
</div>
</div>
<div className="landing-step-card">
</div>
<div className="landing-zigzag-step">
<div className="landing-zigzag-text">
<div className="landing-zigzag-number">03</div>
<h3>Resolve &amp; Document</h3>
<p>Hit resolve and get clean, timestamped ticket notes ready to paste into ConnectWise, Atera, or Syncro. Every step you took, every command you ran, documented automatically.</p>
<div className="landing-step-visual">
<div className="landing-mock-ticket">
<div className="landing-mock-ticket-header">ConnectWise Ticket #48291</div>
<div className="landing-mock-ticket-line"><span className="time">10:04</span><span className="check">&#10003;</span><span>Verified VPN connection active</span></div>
<div className="landing-mock-ticket-line"><span className="time">10:06</span><span className="check">&#10003;</span><span>Split tunnel misconfigured fixed</span></div>
<div className="landing-mock-ticket-line"><span className="time">10:08</span><span className="check">&#10003;</span><span>Confirmed Outlook sync restored</span></div>
<div className="landing-mock-ticket-line"><span className="time">10:09</span><span className="check">&#10003;</span><span>Resolution: VPN split tunnel updated</span></div>
</div>
<p>Hit resolve and get clean, timestamped ticket notes &mdash; ready to paste into ConnectWise, Atera, or Syncro. Every step documented automatically.</p>
</div>
<div className="landing-zigzag-visual">
<div className="landing-mock-ticket">
<div className="landing-mock-ticket-header">ConnectWise Ticket #48291</div>
<div className="landing-mock-ticket-line"><span className="time">10:04</span><span className="check">&#10003;</span><span>Verified VPN connection active</span></div>
<div className="landing-mock-ticket-line"><span className="time">10:06</span><span className="check">&#10003;</span><span>Split tunnel misconfigured &mdash; fixed</span></div>
<div className="landing-mock-ticket-line"><span className="time">10:08</span><span className="check">&#10003;</span><span>Confirmed Outlook sync restored</span></div>
<div className="landing-mock-ticket-line"><span className="time">10:09</span><span className="check">&#10003;</span><span>Resolution: VPN split tunnel updated</span></div>
</div>
</div>
</div>
</div>
</section>
<div className="landing-section-divider" />
{/* Features */}
<section id="features" className="landing-reveal">
<section id="features" className="landing-section landing-section-alt landing-reveal">
<div className="landing-section-inner">
<div className="landing-section-label">Features</div>
<h2 className="landing-section-title">Troubleshoot faster.<br />Document everything. Automatically.</h2>
<h2 className="landing-section-title">Everything you need to troubleshoot faster.</h2>
<div className="landing-feature-highlight">
<div className="landing-feature-highlight-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3" /><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" /></svg>
</div>
<div className="landing-feature-highlight-content">
<h3>FlowPilot &mdash; Your AI Copilot</h3>
<p>Like having a senior engineer on every call. Describe the issue, get expert troubleshooting guidance, and documentation writes itself &mdash; as a byproduct of solving the problem.</p>
</div>
</div>
<div className="landing-features-grid">
<FeatureCard
highlight
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>}
title="FlowPilot — Your AI Copilot"
description="Like having a senior engineer on every call. Describe the issue, get expert troubleshooting guidance, and documentation writes itself — as a byproduct of solving the problem."
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><line x1="9" y1="3" x2="9" y2="21" /></svg>}
title="Guided Flows"
description="Build step-by-step troubleshooting paths your team can follow. Great for onboarding and consistency."
/>
<FeatureCard
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>}
title="Guided Troubleshooting Flows"
description="Build step-by-step troubleshooting paths your team can follow. Great for standard procedures, onboarding new engineers, or ensuring consistency."
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /></svg>}
title="Zero Empty Tickets"
description="Every session generates timestamped notes, formatted for your PSA. No more empty ticket closures."
/>
<FeatureCard
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>}
title="Zero Empty Ticket Notes"
description="Every troubleshooting session generates timestamped, detailed notes — formatted for your PSA. Your team will never close a ticket with empty notes again."
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></svg>}
title="Team Knowledge"
description="Solutions are saved and surfaced when the next engineer hits a similar issue."
/>
<FeatureCard
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>}
title="Team Knowledge That Grows"
description="Every resolved session makes your team smarter. Solutions are saved and surfaced automatically when the next engineer hits a similar issue."
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12" /></svg>}
title="Session Analytics"
description="Track resolution times, identify recurring issues, and measure team performance."
/>
<FeatureCard
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>}
title="Session History & Analytics"
description="See every troubleshooting session your team has run. Track resolution times, identify common issues, and measure team performance."
/>
<FeatureCard
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>}
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" /></svg>}
title="PSA Integration"
description="Connect directly to ConnectWise, Atera, and Syncro. Export session docs straight to tickets — no copy-paste needed."
description="Connect to ConnectWise, Atera, and Syncro. Push session docs straight to tickets."
/>
</div>
</div>
</section>
<div className="landing-section-divider" />
{/* Pricing */}
<section id="pricing" className="landing-reveal">
<section id="pricing" className="landing-section landing-reveal">
<div className="landing-section-inner">
<div className="landing-section-label">Pricing</div>
<h2 className="landing-section-title">Simple pricing. No surprises.</h2>
<div className="landing-section-desc">Start free. Upgrade when your team is ready.</div>
<p className="landing-section-desc">Start free. Upgrade when your team is ready.</p>
<div className="landing-pricing-grid">
<PricingCard
name="Free"
target="For individual techs evaluating"
target="Individual techs evaluating"
amount="$0"
note="Free forever"
features={['3 decision trees', '20 sessions per month', 'Auto-documentation export', 'Session history (30 days)', 'Community support']}
features={['3 guided flows', '20 sessions per month', 'Auto-documentation export', '30-day session history']}
btnLabel="Get Started"
btnStyle="outline"
plan="free"
/>
<PricingCard
featured
name="Pro"
target="For small MSPs with 15 techs"
target="Small MSPs &middot; 1&ndash;5 techs"
amount="$15"
period="/user/mo"
note="Billed monthly or annually"
features={['Unlimited decision trees', 'Unlimited sessions', 'FlowPilot AI copilot', 'Auto-documentation export', 'Full session history', 'Flow templates library', 'Priority support']}
features={['Unlimited flows & sessions', 'FlowPilot AI copilot', 'Full session history', 'Flow templates library', 'Priority support']}
btnLabel="Start Free Trial"
btnStyle="filled"
plan="pro"
/>
<PricingCard
name="Team"
target="For growing MSPs with 525 techs"
target="Growing MSPs &middot; 5&ndash;25 techs"
amount="$25"
period="/user/mo"
note="Billed monthly or annually"
features={['Everything in Pro', 'PSA integration (ConnectWise, Atera, Syncro)', 'Team analytics dashboard', 'Session sharing & collaboration', 'Client context system', 'Role-based permissions', 'Dedicated support']}
features={['Everything in Pro', 'PSA integration', 'Team analytics dashboard', 'Session sharing', 'Role-based permissions', 'Dedicated support']}
btnLabel="Start Free Trial"
btnStyle="outline"
plan="team"
/>
</div>
<p className="landing-pricing-session-note">One session = one troubleshooting conversation, regardless of length.</p>
<p className="landing-pricing-enterprise">
Need Enterprise (25+ techs, SSO, custom branding)?{' '}
<a href="mailto:hello@resolutionflow.com">Contact us</a>
Enterprise (25+ techs, SSO, custom branding)?{' '}
<a href="mailto:hello@resolutionflow.com">Let&apos;s talk</a>
</p>
</div>
</section>
<div className="landing-section-divider" />
{/* Testimonial */}
<div className="landing-testimonial-section landing-reveal">
<div className="landing-testimonial-quote">
We used to spend more time writing ticket notes than solving the actual issue. Now it just&hellip; happens. The documentation writes itself while we work.
{/* FAQ */}
<section id="faq" className="landing-section landing-section-alt landing-reveal">
<div className="landing-section-inner">
<div className="landing-section-label">FAQ</div>
<h2 className="landing-section-title">Common questions</h2>
<div className="landing-faq-list">
{FAQ_ITEMS.map((item, i) => (
<div key={i} className={`landing-faq-item ${openFaq === i ? 'open' : ''}`}>
<button
className="landing-faq-trigger"
onClick={() => toggleFaq(i)}
aria-expanded={openFaq === i}
>
<span>{item.q}</span>
<span className="landing-faq-icon" aria-hidden="true">{openFaq === i ? '\u2212' : '+'}</span>
</button>
<div className="landing-faq-answer" role="region">
<p>{item.a}</p>
</div>
</div>
))}
</div>
</div>
<div className="landing-testimonial-author">
<strong>Beta Tester</strong> MSP Engineer, Southeast US
</section>
{/* Founder — replaces anonymous testimonial */}
<div className="landing-founder-section landing-reveal">
<div className="landing-founder-inner">
<div className="landing-section-label">Why We Built This</div>
<blockquote>
After 15 years in the MSP trenches, I got tired of the same cycle: solve the issue in 10 minutes, spend 20 minutes writing notes about it. Or worse &mdash; close the ticket with &ldquo;Fixed issue&rdquo; because there&apos;s no time. ResolutionFlow is the tool I wanted on every call.
</blockquote>
<div className="landing-founder-name">&mdash; Michael, Founder</div>
</div>
</div>
<div className="landing-section-divider" />
{/* CTA */}
<section className="landing-cta-section landing-reveal">
<h2>Ready to never write ticket notes again?</h2>
<p>Join the beta. Troubleshoot your next ticket with FlowPilot and see the documentation write itself.</p>
<form className="landing-cta-email-form" onSubmit={handleBetaSubmit}>
<input
type="email"
className="landing-cta-email-input"
placeholder="you@yourmsp.com"
value={betaEmail}
onChange={e => setBetaEmail(e.target.value)}
required
/>
<button type="submit" className="landing-btn-hero-primary" style={{ whiteSpace: 'nowrap' }} disabled={betaStatus === 'sending'}>
{betaStatus === 'sending' ? 'Joining...' : betaStatus === 'sent' ? 'Joined!' : 'Join Beta'}
</button>
</form>
{betaStatus === 'sent' && (
<p className="landing-cta-success">Thanks! We&apos;ll be in touch with beta access details.</p>
)}
{betaStatus === 'error' && (
<p className="landing-cta-error">Something went wrong. Please try again.</p>
)}
<p className="landing-cta-fine-print">Free to start. No credit card required.</p>
<div className="landing-cta-inner">
<h2>Ready to stop writing ticket notes?</h2>
<p>Join the beta. Troubleshoot your next ticket with FlowPilot.</p>
<form className="landing-cta-email-form" onSubmit={handleBetaSubmit} noValidate>
<div className="landing-cta-input-wrap">
<input
type="email"
className="landing-cta-email-input"
placeholder="you@yourmsp.com"
value={betaEmail}
onChange={e => {
setBetaEmail(e.target.value)
if (betaStatus === 'error') { setBetaStatus('idle'); setBetaError('') }
}}
required
aria-describedby="beta-status"
/>
<button type="submit" className="landing-btn-hero-primary" disabled={betaStatus === 'sending'}>
{betaStatus === 'sending' ? 'Joining\u2026' : betaStatus === 'sent' ? 'Joined!' : 'Join Beta'}
</button>
</div>
<div id="beta-status" aria-live="polite" className="landing-cta-status">
{betaStatus === 'sent' && (
<p className="landing-cta-success">You&apos;re in. We&apos;ll send beta access details soon.</p>
)}
{betaStatus === 'error' && betaError && (
<p className="landing-cta-error">{betaError}</p>
)}
</div>
</form>
<p className="landing-cta-fine-print">Free to start. No credit card required.</p>
</div>
</section>
{/* Footer */}
<footer className="landing-footer">
<div className="landing-footer-inner">
<div className="landing-footer-left">
<div className="landing-nav-logo-icon" style={{ width: 28, height: 28, borderRadius: 8 }}>
<svg viewBox="0 0 24 24" fill="none" stroke="#000" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ width: 16, height: 16 }}>
<circle cx="12" cy="5" r="2"/>
<line x1="12" y1="7" x2="12" y2="11"/>
<circle cx="6" cy="15" r="2"/>
<circle cx="18" cy="15" r="2"/>
<line x1="12" y1="11" x2="6" y2="13"/>
<line x1="12" y1="11" x2="18" y2="13"/>
<div className="landing-nav-logo-icon" style={{ width: 24, height: 24, borderRadius: 6 }}>
<svg viewBox="0 0 24 24" fill="none" stroke="#000" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ width: 14, height: 14 }}>
<circle cx="12" cy="5" r="2" />
<line x1="12" y1="7" x2="12" y2="11" />
<circle cx="6" cy="15" r="2" />
<circle cx="18" cy="15" r="2" />
<line x1="12" y1="11" x2="6" y2="13" />
<line x1="12" y1="11" x2="18" y2="13" />
</svg>
</div>
<span className="landing-footer-copy">&copy; 2026 ResolutionFlow. All rights reserved.</span>
<span className="landing-footer-copy">&copy; 2026 ResolutionFlow</span>
</div>
<ul className="landing-footer-links">
<li><Link to="/privacy">Privacy</Link></li>
@@ -496,7 +477,7 @@ export default function LandingPage() {
</ul>
</div>
</footer>
</div>
</main>
</div>
</>
)
@@ -517,11 +498,11 @@ function ProblemCard({ icon, color, title, description }: {
)
}
function FeatureCard({ icon, title, description, highlight }: {
icon: React.ReactNode; title: string; description: string; highlight?: boolean
function FeatureCard({ icon, title, description }: {
icon: React.ReactNode; title: string; description: string
}) {
return (
<div className={`landing-feature-card ${highlight ? 'highlight' : ''}`}>
<div className="landing-feature-card">
<div className="landing-feature-icon">{icon}</div>
<h3>{title}</h3>
<p>{description}</p>
@@ -529,12 +510,13 @@ function FeatureCard({ icon, title, description, highlight }: {
)
}
function PricingCard({ name, target, amount, period, note, features, btnLabel, btnStyle, featured }: {
function PricingCard({ name, target, amount, period, note, features, btnLabel, btnStyle, featured, plan }: {
name: string; target: string; amount: string; period?: string; note: string
features: string[]; btnLabel: string; btnStyle: 'outline' | 'filled'; featured?: boolean
features: string[]; btnLabel: string; btnStyle: 'outline' | 'filled'; featured?: boolean; plan: string
}) {
return (
<div className={`landing-pricing-card ${featured ? 'featured' : ''}`}>
{featured && <div className="landing-pricing-badge">Most Popular</div>}
<div className="landing-pricing-plan-name">{name}</div>
<div className="landing-pricing-target">{target}</div>
<div className="landing-pricing-price">
@@ -545,7 +527,7 @@ function PricingCard({ name, target, amount, period, note, features, btnLabel, b
<ul className="landing-pricing-features">
{features.map(f => <li key={f}>{f}</li>)}
</ul>
<Link to="/register" className={`landing-pricing-btn ${btnStyle}`}>{btnLabel}</Link>
<Link to={`/register?plan=${plan}`} className={`landing-pricing-btn ${btnStyle}`}>{btnLabel}</Link>
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree, Plus, ListOrdered, ChevronDown, Wrench } from 'lucide-react'
import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree, Plus, ListOrdered, Wrench } from 'lucide-react'
import { PageMeta } from '@/components/common/PageMeta'
import { StaggerList } from '@/components/common/StaggerList'
import { Button } from '@/components/ui/Button'
@@ -16,6 +16,7 @@ import { useAuthStore } from '@/store/authStore'
import { usePermissions } from '@/hooks/usePermissions'
import { toast } from '@/lib/toast'
import { ForkModal } from '@/components/library/ForkModal'
import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown'
interface TreeWithStats extends TreeListItem {
lastUsed?: string
@@ -35,7 +36,6 @@ export function MyTreesPage() {
const [isDeleting, setIsDeleting] = useState(false)
const [treeToShare, setTreeToShare] = useState<TreeWithStats | null>(null)
const [showShareModal, setShowShareModal] = useState(false)
const [showCreateMenu, setShowCreateMenu] = useState(false)
const [forkTarget, setForkTarget] = useState<TreeWithStats | null>(null)
useEffect(() => {
@@ -129,55 +129,7 @@ export function MyTreesPage() {
</p>
</div>
{canCreateTrees && (
<div className="relative">
<Button
onClick={() => setShowCreateMenu(!showCreateMenu)}
>
<Plus className="h-4 w-4" />
Create New
<ChevronDown className="h-3.5 w-3.5" />
</Button>
{showCreateMenu && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowCreateMenu(false)} />
<div className="absolute right-0 z-20 mt-1 w-56 rounded-lg border border-border bg-card p-1 shadow-xl">
<Link
to="/trees/new"
onClick={() => setShowCreateMenu(false)}
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
>
<FolderTree className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">Troubleshooting Tree</div>
<div className="text-xs text-muted-foreground">Branching decision flow</div>
</div>
</Link>
<Link
to="/flows/new"
onClick={() => setShowCreateMenu(false)}
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
>
<ListOrdered className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">Procedural Flow</div>
<div className="text-xs text-muted-foreground">Step-by-step procedure</div>
</div>
</Link>
<Link
to="/flows/new?type=maintenance"
onClick={() => setShowCreateMenu(false)}
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
>
<Wrench className="h-4 w-4 text-amber-400" />
<div>
<div className="font-medium">Maintenance Flow</div>
<div className="text-xs text-muted-foreground">Scheduled multi-target tasks</div>
</div>
</Link>
</div>
</>
)}
</div>
<CreateFlowDropdown label="Create New" />
)}
</div>

View File

@@ -241,9 +241,9 @@ export function ProceduralEditorPage() {
// Summary strings for collapsed sections
const detailsSummary = [
name ? `"${name}"` : '"Untitled"',
tags.length > 0 ? `${tags.length} tag${tags.length !== 1 ? 's' : ''}` : 'No tags',
isPublic ? 'Public' : 'Private',
description ? `${description.slice(0, 40)}${description.length > 40 ? '\u2026' : ''}` : 'No description',
].join(' \u00b7 ')
const scheduleSummary = getScheduleSummary(schedule, scheduleTargetList)
@@ -265,37 +265,42 @@ export function ProceduralEditorPage() {
{/* Main content column */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* Toolbar — sticky */}
<div className="flex shrink-0 items-center justify-between border-b border-border bg-card px-4 py-2">
<div className="flex items-center gap-3">
<button
onClick={() => navigate('/trees')}
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ArrowLeft className="h-5 w-5" />
</button>
<div className="flex items-center gap-2">
{isMaintenance
? <Wrench className="h-5 w-5 text-amber-400" />
: <ListOrdered className="h-5 w-5 text-muted-foreground" />}
<h1 className="text-lg font-bold text-foreground">
{isEditMode ? `Edit ${flowLabel}` : `New ${flowLabel}`}
{name && <span className="ml-2 font-normal text-muted-foreground"> {name}</span>}
</h1>
</div>
<div className="flex shrink-0 items-center gap-3 border-b border-border bg-sidebar px-4 py-2.5">
<button
onClick={() => navigate('/trees')}
className="shrink-0 rounded p-1.5 text-muted-foreground transition-colors hover:bg-white/[0.08] hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex min-w-0 flex-1 items-center gap-2">
{isMaintenance
? <Wrench className="h-4 w-4 shrink-0 text-amber-400" />
: <ListOrdered className="h-4 w-4 shrink-0 text-muted-foreground" />}
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={`Untitled ${flowLabel}`}
className="min-w-0 flex-1 bg-transparent text-sm font-semibold text-heading placeholder:text-muted-foreground focus:outline-none"
/>
{isDirty && (
<span
title="Unsaved changes"
className="h-1.5 w-1.5 shrink-0 rounded-full bg-amber-400"
/>
)}
</div>
<div className="flex items-center gap-2">
{isDirty && (
<span className="text-xs text-muted-foreground">Unsaved changes</span>
)}
<div className="flex shrink-0 items-center gap-2">
<button
onClick={() => editorAI.isOpen ? editorAI.closePanel() : editorAI.openPanel()}
title="Toggle AI Assist panel"
className={cn(
'flex items-center gap-1.5 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors',
'flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium transition-colors',
editorAI.isOpen
? 'bg-accent-dim text-primary border-primary/30'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
? 'border-primary/30 bg-accent-dim text-primary'
: 'text-muted-foreground hover:bg-white/[0.08] hover:text-foreground'
)}
>
<Sparkles className="h-4 w-4" />
@@ -318,8 +323,8 @@ export function ProceduralEditorPage() {
</div>
</div>
{/* Collapsible sections */}
<div className="shrink-0">
{/* Config zone */}
<div className="shrink-0 border-b border-border bg-card">
<CollapsibleEditorSection
title="Details"
icon={<Settings className="h-4 w-4" />}
@@ -328,17 +333,6 @@ export function ProceduralEditorPage() {
onToggle={() => toggleSection('details')}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-muted-foreground">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Domain Controller Build"
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-muted-foreground">Description</label>
<textarea
@@ -397,25 +391,27 @@ export function ProceduralEditorPage() {
)}
</div>
{/* Step List */}
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{validationErrors.length > 0 && (
<div className="px-4 py-3">
<ValidationSummary
errors={validationErrors.map((e): ValidationError => ({
nodeId: e.stepId,
field: e.field,
message: e.message,
severity: e.severity,
}))}
onSelectNode={handleSelectStep}
onFixWithAI={handleFixWithAI}
isFixing={isFixing}
itemLabel="step"
/>
</div>
)}
<StepList onStepContextMenu={editorAI.openContextMenu} />
{/* Step canvas */}
<div className="min-h-0 flex-1 overflow-y-auto bg-page">
<div className="px-5 py-5">
{validationErrors.length > 0 && (
<div className="mb-4">
<ValidationSummary
errors={validationErrors.map((e): ValidationError => ({
nodeId: e.stepId,
field: e.field,
message: e.message,
severity: e.severity,
}))}
onSelectNode={handleSelectStep}
onFixWithAI={handleFixWithAI}
isFixing={isFixing}
itemLabel="step"
/>
</div>
)}
<StepList onStepContextMenu={editorAI.openContextMenu} />
</div>
</div>
{editorAI.contextMenu && (

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Terminal } from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -21,6 +21,11 @@ export default function ScriptBuilderPage() {
const [messages, setMessages] = useState<ScriptBuilderMessage[]>([])
const [language, setLanguage] = useState('powershell')
const [isLoading, setIsLoading] = useState(false)
// Ref-based lock: prevents two concurrent handleSend calls (e.g. FlowPilot
// handoff useEffect + user keystroke) from each calling createSession() and
// creating two orphaned sessions. React state updates are async so isLoading
// alone can't guard across two calls in the same render cycle.
const creatingSessionRef = useRef(false)
const [previewScript, setPreviewScript] = useState<{ script: string; filename: string | null } | null>(null)
const [showSaveDialog, setShowSaveDialog] = useState(false)
const [handoffProcessed, setHandoffProcessed] = useState(false)
@@ -75,8 +80,19 @@ export default function ScriptBuilderPage() {
// Create session if needed
let currentSession = session
if (!currentSession) {
currentSession = await scriptBuilderApi.createSession(effectiveLanguage)
setSession(currentSession)
if (creatingSessionRef.current) {
// Another concurrent call is already creating the session; drop this send.
setIsLoading(false)
setMessages((prev) => prev.slice(0, -1))
return
}
creatingSessionRef.current = true
try {
currentSession = await scriptBuilderApi.createSession(effectiveLanguage)
setSession(currentSession)
} finally {
creatingSessionRef.current = false
}
}
// Send message
@@ -189,6 +205,7 @@ export default function ScriptBuilderPage() {
<ScriptBuilderInput
onSend={(content) => handleSend(content)}
disabled={isLoading}
showSuggestions={messages.length === 0}
/>
</div>

View File

@@ -11,13 +11,13 @@ import { ParameterizeAndSavePanel } from '@/components/scripts/ParameterizeAndSa
import { scriptsApi } from '@/api'
import type { ScriptParameter } from '@/types'
type LibraryTab = 'mine' | 'team'
type LibraryTab = 'all' | 'mine' | 'team'
export default function ScriptLibraryPage() {
const [paneMode, setPaneMode] = useState<'browse' | 'configure'>('browse')
// inputValue owned here so it survives Configure ↔ Browse transitions
const [inputValue, setInputValue] = useState('')
const [activeTab, setActiveTab] = useState<LibraryTab>('mine')
const [activeTab, setActiveTab] = useState<LibraryTab>('all')
const loadCategories = useScriptGeneratorStore(s => s.loadCategories)
const loadTemplates = useScriptGeneratorStore(s => s.loadTemplates)
@@ -52,13 +52,19 @@ export default function ScriptLibraryPage() {
parameters_schema: payload.parameters_schema,
})
setShowImportPanel(false)
const filters = activeTab === 'mine' ? { mine: true } : { shared: true }
const filters =
activeTab === 'mine' ? { mine: true } :
activeTab === 'team' ? { shared: true } :
{}
loadTemplates(filters)
}
useEffect(() => {
loadCategories().then(() => {
const filters = activeTab === 'mine' ? { mine: true } : { shared: true }
const filters =
activeTab === 'mine' ? { mine: true } :
activeTab === 'team' ? { shared: true } :
{}
loadTemplates(filters)
})
}, [loadCategories, loadTemplates, activeTab])
@@ -97,39 +103,48 @@ export default function ScriptLibraryPage() {
<div>
<h1 className="text-2xl font-heading font-bold text-foreground">Script Library</h1>
<p className="text-sm text-muted-foreground mt-1">
Browse PowerShell templates, fill in parameters, and generate ready-to-run scripts.
Browse templates, fill in parameters, and generate ready-to-run scripts.
</p>
</div>
<div className="flex items-center gap-2">
{isEngineer && (
<div className="flex items-center gap-2 mt-2">
<>
<Link
to="/scripts/manage"
className="inline-flex items-center gap-1.5 text-xs text-primary bg-accent-dim hover:bg-primary/15 px-2.5 py-1 rounded-full transition-colors group"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2.5 py-1.5 rounded-lg transition-colors"
>
<Settings size={12} className="group-hover:rotate-90 transition-transform duration-300" />
Manage Templates
<Settings size={12} />
Manage
</Link>
<button
type="button"
onClick={() => setShowImportPanel(true)}
className="inline-flex items-center gap-1.5 text-xs text-primary bg-accent-dim hover:bg-primary/15 px-2.5 py-1 rounded-full transition-colors"
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:border-[var(--color-border-hover)] transition-colors"
>
<FileUp size={12} />
New from Script
<FileUp size={14} />
Import Script
</button>
</div>
</>
)}
<Link
to="/script-builder"
className="inline-flex items-center gap-2 bg-primary text-white font-semibold rounded-lg px-4 py-2 text-sm hover:brightness-110 active:scale-[0.98] transition-all"
>
<Wand2 size={14} />
Build New Script
</Link>
</div>
<Link
to="/script-builder"
className="inline-flex items-center gap-2 bg-primary text-white font-semibold rounded-lg px-4 py-2 hover:brightness-110 active:scale-[0.98] transition-all"
>
<Wand2 size={16} />
Build a New Script
</Link>
</div>
{/* Tab bar */}
<div className="flex gap-6 border-b border-border">
<button
type="button"
onClick={() => onTabChange('all')}
className={`pb-2 text-sm font-medium transition-colors ${tabClass('all')}`}
>
All Scripts
</button>
<button
type="button"
onClick={() => onTabChange('mine')}

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,13 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { X, RotateCcw, Play, FileUp } from 'lucide-react'
import { X, FileUp } from 'lucide-react'
import { PageMeta } from '@/components/common/PageMeta'
import { Button } from '@/components/ui/Button'
import { FlowIllustration } from '@/components/common/EmptyStateIllustrations'
import { treesApi } from '@/api/trees'
import { categoriesApi } from '@/api/categories'
import { foldersApi } from '@/api/folders'
import { sessionsApi } from '@/api/sessions'
import type { TreeListItem, CategoryListItem, FolderListItem, Session, IntakeFormField } from '@/types'
import type { TreeListItem, CategoryListItem, FolderListItem, IntakeFormField } from '@/types'
import { FolderEditModal } from '@/components/library/FolderEditModal'
import { ForkModal } from '@/components/library/ForkModal'
import { ExportFlowModal } from '@/components/library/ExportFlowModal'
@@ -21,11 +20,10 @@ import { TreeListView } from '@/components/library/TreeListView'
import { TreeTableView } from '@/components/library/TreeTableView'
import { ViewToggle } from '@/components/library/ViewToggle'
import { SortDropdown } from '@/components/library/SortDropdown'
import { cn, safeGetItem } from '@/lib/utils'
import { getSessionResumePath, getTreeNavigatePath } from '@/lib/routing'
import { cn } from '@/lib/utils'
import { getTreeNavigatePath } from '@/lib/routing'
import { usePermissions } from '@/hooks/usePermissions'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { useCachedQuota } from '@/hooks/useCachedQuota'
import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown'
import { Spinner } from '@/components/common/Spinner'
import { EmptyState } from '@/components/common/EmptyState'
@@ -94,25 +92,6 @@ export function TreeLibraryPage() {
// AI builder state
const { aiEnabled } = useCachedQuota()
// Repeat Last Session
const lastSessionData = (() => {
const raw = safeGetItem('last-session')
if (!raw) return null
try { return JSON.parse(raw) as { tree_id: string; tree_name: string; client_name: string; ticket_number: string; tree_type?: string } }
catch { return null }
})()
// Incomplete sessions for auto-recovery
const [incompleteSessions, setIncompleteSessions] = useState<Session[]>([])
const [dismissedSessionIds, setDismissedSessionIds] = useState<Set<string>>(() => {
try {
const raw = sessionStorage.getItem('dismissed-sessions')
return raw ? new Set(JSON.parse(raw) as string[]) : new Set()
} catch { return new Set() }
})
const loadFolders = useCallback(async () => {
try {
const foldersData = await foldersApi.list()
@@ -122,30 +101,6 @@ export function TreeLibraryPage() {
}
}, [])
// Load incomplete sessions on mount
useEffect(() => {
sessionsApi.list({ completed: false, size: 5 })
.then(setIncompleteSessions)
.catch((err) => console.error('Failed to load incomplete sessions:', err))
}, [])
const dismissSession = (sessionId: string) => {
const next = new Set(dismissedSessionIds)
next.add(sessionId)
setDismissedSessionIds(next)
try { sessionStorage.setItem('dismissed-sessions', JSON.stringify([...next])) } catch { /* */ }
}
const visibleIncompleteSessions = incompleteSessions.filter(s => !dismissedSessionIds.has(s.id))
const formatTimeAgo = (dateString: string) => {
const diff = Math.floor((Date.now() - new Date(dateString).getTime()) / 1000)
if (diff < 60) return 'just now'
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`
if (diff < 86400) return `${Math.floor(diff / 3600)} hr ago`
return `${Math.floor(diff / 86400)} days ago`
}
// Load categories once on mount (they rarely change)
useEffect(() => {
categoriesApi.list()
@@ -196,15 +151,18 @@ export function TreeLibraryPage() {
loadTrees()
return
}
const requestId = ++loadTreesRequestId.current
setIsLoading(true)
try {
const results = await treesApi.search(searchQuery)
if (requestId !== loadTreesRequestId.current) return
setTrees(results)
} catch (err) {
if (requestId !== loadTreesRequestId.current) return
toast.error('Failed to search flows')
console.error(err)
} finally {
setIsLoading(false)
if (requestId === loadTreesRequestId.current) setIsLoading(false)
}
}
@@ -311,11 +269,7 @@ export function TreeLibraryPage() {
<FileUp className="h-4 w-4" />
Import
</Button>
<CreateFlowDropdown
aiEnabled={aiEnabled}
label="Create New"
/>
<CreateFlowDropdown label="Create New" />
</div>
)}
</div>
@@ -436,59 +390,6 @@ export function TreeLibraryPage() {
</div>
)}
{/* Incomplete Session Recovery */}
{visibleIncompleteSessions.length > 0 && (
<div className="mb-6 space-y-2">
{visibleIncompleteSessions.map(s => (
<div key={s.id} className="bg-card border border-border flex items-center justify-between rounded-xl p-4">
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">
{s.tree_snapshot?.name || 'Unknown tree'}
</p>
<p className="text-sm text-muted-foreground">
{s.client_name && `${s.client_name} · `}
{s.started_at ? `Started ${formatTimeAgo(s.started_at)}` : 'Not started'}
</p>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => navigate(getSessionResumePath(s.tree_id, s.tree_snapshot?.tree_type), { state: { sessionId: s.id } })}
>
<Play className="h-3.5 w-3.5" />
Resume
</Button>
<button
onClick={() => dismissSession(s.id)}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
)}
{/* Repeat Last Session */}
{lastSessionData && (
<div className="mb-6">
<button
onClick={() => navigate(getSessionResumePath(lastSessionData.tree_id, lastSessionData.tree_type), {
state: { prefillClientName: lastSessionData.client_name, prefillTicketNumber: lastSessionData.ticket_number },
})}
className={cn(
'flex items-center gap-2 rounded-lg border border-border px-4 py-2.5 text-sm text-muted-foreground',
'hover:border-border hover:bg-accent hover:text-foreground'
)}
>
<RotateCcw className="h-4 w-4" />
Repeat: {lastSessionData.tree_name}
{lastSessionData.client_name && ` (${lastSessionData.client_name})`}
</button>
</div>
)}
{/* Loading State */}
{isLoading ? (
<div className="flex justify-center py-12">
@@ -512,7 +413,7 @@ export function TreeLibraryPage() {
description="Flows guide your team through proven resolution paths, capturing every decision along the way."
action={
canCreateTrees ? (
<CreateFlowDropdown aiEnabled={aiEnabled} label="Create a Flow" />
<CreateFlowDropdown label="Create a Flow" />
) : undefined
}
learnMoreLink="/guides/creating-flows"

View File

@@ -687,7 +687,7 @@ export function TreeNavigationPage() {
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold font-heading text-foreground">{tree.name}</h1>
{timerDisplay && (
<span className="flex items-center gap-1.5 rounded-full bg-accent px-2 py-0.5 text-[0.6875rem] font-sans text-xs text-muted-foreground">
<span className="flex items-center gap-1.5 rounded-full bg-accent px-2 py-0.5 text-[0.6875rem] font-sans text-xs text-[#0e1016]">
<Clock className="h-4 w-4" />
{timerDisplay}
</span>
@@ -874,7 +874,7 @@ export function TreeNavigationPage() {
<Spinner size="sm" className="h-4 w-4 border-t-foreground" />
</span>
) : (
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-accent text-xs font-medium text-muted-foreground">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-accent text-xs font-medium text-[#0e1016]">
{index + 1}
</span>
)

View File

@@ -14,6 +14,7 @@ interface ScriptGeneratorState {
selectedTemplate: ScriptTemplateDetail | null
searchQuery: string
activeCategoryId: string | null // null = "All"
tabFilters: { mine?: boolean; shared?: boolean } // current tab's ownership filter
isLoadingTemplates: boolean // drives skeleton in ScriptTemplateList
isLoadingDetail: boolean // drives spinner in ScriptConfigurePane
@@ -48,6 +49,7 @@ export const useScriptGeneratorStore = create<ScriptGeneratorState>()((set, get)
selectedTemplate: null,
searchQuery: '',
activeCategoryId: null,
tabFilters: {},
isLoadingTemplates: false,
isLoadingDetail: false,
paramValues: {},
@@ -68,6 +70,10 @@ export const useScriptGeneratorStore = create<ScriptGeneratorState>()((set, get)
},
loadTemplates: async (filters) => {
// When filters are provided (e.g. tab change), persist them so that
// subsequent setCategory/setSearch calls reuse the same ownership filter.
const resolvedFilters = filters !== undefined ? filters : get().tabFilters
if (filters !== undefined) set({ tabFilters: filters })
set({ isLoadingTemplates: true })
try {
const { activeCategoryId, categories, searchQuery } = get()
@@ -75,8 +81,8 @@ export const useScriptGeneratorStore = create<ScriptGeneratorState>()((set, get)
const params: { category_slug?: string; search?: string; mine?: boolean; shared?: boolean } = {}
if (category) params.category_slug = category.slug
if (searchQuery) params.search = searchQuery
if (filters?.mine) params.mine = true
if (filters?.shared) params.shared = true
if (resolvedFilters.mine) params.mine = true
if (resolvedFilters.shared) params.shared = true
const templates = await scriptsApi.getTemplates(params)
set({ templates, isLoadingTemplates: false })
} catch {

File diff suppressed because it is too large Load Diff

View File

@@ -117,6 +117,7 @@ export interface SessionDocumentation {
diagnostic_steps: DocumentationStep[]
resolution_summary: string | null
escalation_reason: string | null
follow_up_recommendations: string[]
total_steps: number
duration_display: string | null
generated_at: string
@@ -131,7 +132,7 @@ export interface SessionCloseResponse {
member_mapping_warning: string | null
}
export type StatusUpdateAudience = 'ticket_notes' | 'client_update' | 'email_draft'
export type StatusUpdateAudience = 'ticket_notes' | 'client_update' | 'email_draft' | 'request_info'
export type StatusUpdateLength = 'quick' | 'detailed'
export type StatusUpdateContext = 'status' | 'resolution' | 'escalation'
@@ -195,7 +196,7 @@ export interface AISessionDetail extends AISessionSummary {
ticket_data: Record<string, unknown> | null
steps: AISessionStepResponse[]
conversation_messages: Array<{ role: string; content: string }>
pending_task_lane: { questions: QuestionItem[]; actions: ActionItem[] } | null
pending_task_lane: { questions: QuestionItem[]; actions: ActionItem[]; responses?: Array<Record<string, unknown>> } | null
is_branching: boolean
active_branch_id: string | null
}