feat: flexible intake — deferred variables + prepared sessions (#103)

* feat: flexible intake — deferred variables + prepared sessions

Remove blocking intake form modal. Variables are now filled inline during
flow execution or pre-filled via prepared sessions. Adds PATCH /sessions/{id}/variables
endpoint, POST /sessions/prepare for session pre-staging, inline variable prompts
in StepDetail, editable Session Variables panel, and "Prepared for You" dashboard section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: pass treeData directly to startSession to avoid stale state

setTree(treeData) hasn't committed when startSession runs immediately
after, so tree is still null and getStepsFromTree returns []. This
caused the step detail area to render empty on new session start.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: wire PrepareSessionModal entry point in Flow Library

Add "Prepare session" button (clipboard icon) to grid, list, and table
views for procedural/maintenance flows. Clicking fetches tree intake
fields and account members, then opens PrepareSessionModal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #103.
This commit is contained in:
chihlasm
2026-03-10 09:49:51 -04:00
committed by GitHub
parent 4727106141
commit ccd06c9ed4
25 changed files with 1214 additions and 102 deletions

View File

@@ -0,0 +1,83 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { ClipboardList, ArrowRight, Clock } from 'lucide-react'
import { sessionsApi } from '@/api/sessions'
import type { Session } from '@/types/session'
import { getTreeNavigatePath } from '@/lib/routing'
import { cn } from '@/lib/utils'
export function PreparedSessions() {
const navigate = useNavigate()
const [sessions, setSessions] = useState<Session[]>([])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
sessionsApi.list({ status: 'prepared', size: 10 })
.then(setSessions)
.catch(() => setSessions([]))
.finally(() => setIsLoading(false))
}, [])
if (isLoading || sessions.length === 0) return null
const handleStart = (session: Session) => {
const treeType = (session.tree_snapshot as unknown as Record<string, unknown>)?.tree_type as string | undefined
navigate(getTreeNavigatePath(session.tree_id, treeType), {
state: { sessionId: session.id },
})
}
return (
<div className="glass-card-static p-5 fade-in" style={{ animationDelay: '200ms' }}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-cyan-400" />
<h3 className="font-heading text-sm font-semibold text-foreground">Prepared for You</h3>
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-cyan-400/20 text-[0.625rem] font-bold text-cyan-400">
{sessions.length}
</span>
</div>
</div>
<div className="space-y-2">
{sessions.map(session => {
const snapshot = session.tree_snapshot as unknown as Record<string, unknown>
const flowName = (snapshot?.name as string) || 'Unknown Flow'
const filledVars = Object.values(session.session_variables || {}).filter(v => v?.trim()).length
const treeType = snapshot?.tree_type as string | undefined
return (
<button
key={session.id}
onClick={() => handleStart(session)}
className={cn(
'group flex w-full items-center justify-between gap-3 rounded-lg border border-border px-4 py-3',
'text-left transition-all hover:border-cyan-500/30 hover:bg-cyan-500/5'
)}
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground truncate">{flowName}</p>
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
{session.ticket_number && (
<span>{session.ticket_number}</span>
)}
{session.client_name && (
<span>{session.client_name}</span>
)}
{filledVars > 0 && (
<span>{filledVars} variable{filledVars > 1 ? 's' : ''} pre-filled</span>
)}
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{treeType === 'procedural' ? 'Project' : treeType === 'maintenance' ? 'Maintenance' : 'Flow'}
</span>
</div>
</div>
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</button>
)
})}
</div>
</div>
)
}

View File

@@ -24,7 +24,7 @@ export function NotificationsPanel() {
setSessions(data)
// Mark as "new" if any session was updated in the last hour
const oneHourAgo = Date.now() - 3600000
setHasNew(data.some(s => new Date(s.started_at).getTime() > oneHourAgo))
setHasNew(data.some(s => s.started_at && new Date(s.started_at).getTime() > oneHourAgo))
})
.catch(() => {})
}, [])
@@ -90,7 +90,7 @@ export function NotificationsPanel() {
<p className="text-[0.6875rem] text-muted-foreground">
{session.completed_at
? `Completed ${timeAgo(session.completed_at)}`
: `Started ${timeAgo(session.started_at)}`}
: session.started_at ? `Started ${timeAgo(session.started_at)}` : 'Not started'}
{session.client_name && ` · ${session.client_name}`}
</p>
</div>

View File

@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom'
import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench, Star, Download } from 'lucide-react'
import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench, Star, Download, ClipboardList } from 'lucide-react'
import type { TreeListItem } from '@/types'
import { TagBadges } from '@/components/common/TagBadges'
import { StaggerList } from '@/components/common/StaggerList'
@@ -10,6 +10,7 @@ import { getTreeEditorPath } from '@/lib/routing'
interface TreeGridViewProps {
trees: TreeListItem[]
onStartSession: (treeId: string, treeType?: string) => void
onPrepareSession?: (tree: TreeListItem) => void
onTagClick: (tag: string) => void
onFolderCreated: (parentId?: string | null) => void
onDeleteTree: (tree: TreeListItem) => void
@@ -23,6 +24,7 @@ interface TreeGridViewProps {
export function TreeGridView({
trees,
onStartSession,
onPrepareSession,
onTagClick,
onDeleteTree,
onForkTree,
@@ -169,6 +171,20 @@ export function TreeGridView({
<Trash2 className="h-4 w-4" />
</button>
)}
{onPrepareSession && tree.tree_type !== 'troubleshooting' && (
<button
type="button"
onClick={() => onPrepareSession(tree)}
className={cn(
'rounded-md border border-border p-2 text-muted-foreground',
'hover:bg-cyan-500/10 hover:text-cyan-400 hover:border-cyan-500/30'
)}
title="Prepare session for engineer"
aria-label="Prepare session"
>
<ClipboardList className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={() => onStartSession(tree.id, tree.tree_type)}

View File

@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom'
import { Pencil, Globe, Lock, GitBranch, FileText, Trash2, Wrench, Star, Download } from 'lucide-react'
import { Pencil, Globe, Lock, GitBranch, FileText, Trash2, Wrench, Star, Download, ClipboardList } from 'lucide-react'
import type { TreeListItem } from '@/types'
import { TagBadges } from '@/components/common/TagBadges'
import { cn } from '@/lib/utils'
@@ -9,6 +9,7 @@ import { getTreeEditorPath } from '@/lib/routing'
interface TreeListViewProps {
trees: TreeListItem[]
onStartSession: (treeId: string, treeType?: string) => void
onPrepareSession?: (tree: TreeListItem) => void
onTagClick: (tag: string) => void
onFolderCreated: (parentId?: string | null) => void
onDeleteTree: (tree: TreeListItem) => void
@@ -22,6 +23,7 @@ interface TreeListViewProps {
export function TreeListView({
trees,
onStartSession,
onPrepareSession,
onTagClick,
onDeleteTree,
onForkTree,
@@ -172,6 +174,20 @@ export function TreeListView({
</button>
</>
)}
{onPrepareSession && tree.tree_type !== 'troubleshooting' && (
<button
type="button"
onClick={() => onPrepareSession(tree)}
className={cn(
'rounded-md border border-border p-1.5 text-muted-foreground',
'hover:bg-cyan-500/10 hover:text-cyan-400 hover:border-cyan-500/30'
)}
title="Prepare session for engineer"
aria-label="Prepare session"
>
<ClipboardList className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={() => onStartSession(tree.id, tree.tree_type)}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Pencil, Globe, Lock, ChevronUp, ChevronDown, GitBranch, FileText, Trash2, Wrench, Star, Download } from 'lucide-react'
import { Pencil, Globe, Lock, ChevronUp, ChevronDown, GitBranch, FileText, Trash2, Wrench, Star, Download, ClipboardList } from 'lucide-react'
import type { TreeListItem } from '@/types'
import { TagBadges } from '@/components/common/TagBadges'
import { cn } from '@/lib/utils'
@@ -10,6 +10,7 @@ import { getTreeEditorPath } from '@/lib/routing'
interface TreeTableViewProps {
trees: TreeListItem[]
onStartSession: (treeId: string, treeType?: string) => void
onPrepareSession?: (tree: TreeListItem) => void
onTagClick: (tag: string) => void
onFolderCreated: (parentId?: string | null) => void
onDeleteTree: (tree: TreeListItem) => void
@@ -26,6 +27,7 @@ type SortColumn = 'name' | 'category' | 'version' | 'usage' | 'updated'
export function TreeTableView({
trees,
onStartSession,
onPrepareSession,
onTagClick,
onDeleteTree,
onSortChange,
@@ -283,6 +285,20 @@ export function TreeTableView({
</button>
</>
)}
{onPrepareSession && tree.tree_type !== 'troubleshooting' && (
<button
type="button"
onClick={() => onPrepareSession(tree)}
className={cn(
'rounded-md border border-border p-1.5 text-muted-foreground',
'hover:bg-cyan-500/10 hover:text-cyan-400 hover:border-cyan-500/30'
)}
title="Prepare session for engineer"
aria-label="Prepare session"
>
<ClipboardList className="h-3.5 w-3.5" />
</button>
)}
<button
type="button"
onClick={() => onStartSession(tree.id, tree.tree_type)}

View File

@@ -0,0 +1,130 @@
import { useState, useRef, useEffect } from 'react'
import { Variable } from 'lucide-react'
import type { IntakeFormField } from '@/types'
import { cn } from '@/lib/utils'
interface InlineVariablePromptProps {
variableName: string
fieldMeta?: IntakeFormField
onSubmit: (variableName: string, value: string) => void
disabled?: boolean
}
export function InlineVariablePrompt({
variableName,
fieldMeta,
onSubmit,
disabled,
}: InlineVariablePromptProps) {
const [value, setValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
const inputRef = useRef<HTMLInputElement | HTMLSelectElement>(null)
const label = fieldMeta?.label || variableName
const placeholder = fieldMeta?.placeholder || `Enter ${label}...`
const helpText = fieldMeta?.help_text
const fieldType = fieldMeta?.field_type || 'text'
const options = fieldMeta?.options || []
const isRequired = fieldMeta?.required ?? false
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus()
}
}, [isEditing])
const handleSubmit = () => {
const trimmed = value.trim()
if (trimmed) {
onSubmit(variableName, trimmed)
setIsEditing(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
}
if (e.key === 'Escape') {
setIsEditing(false)
setValue('')
}
}
if (!isEditing) {
return (
<button
onClick={() => setIsEditing(true)}
disabled={disabled}
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-sm font-medium transition-all',
'border-cyan-500/40 bg-cyan-500/5 text-cyan-400',
'hover:border-cyan-400/60 hover:bg-cyan-500/10 hover:shadow-[0_0_12px_rgba(6,182,212,0.15)]',
'disabled:opacity-50 disabled:cursor-not-allowed',
isRequired && 'ring-1 ring-cyan-500/20'
)}
>
<Variable className="h-3.5 w-3.5" />
{label}
</button>
)
}
// Select field type
if (fieldType === 'select' && options.length > 0) {
return (
<span className="inline-flex items-center gap-1.5">
<select
ref={inputRef as React.RefObject<HTMLSelectElement>}
value={value}
onChange={(e) => {
setValue(e.target.value)
if (e.target.value) {
onSubmit(variableName, e.target.value)
setIsEditing(false)
}
}}
onBlur={() => {
if (!value) setIsEditing(false)
}}
className="rounded-md border border-cyan-500/40 bg-cyan-500/5 px-2.5 py-1 text-sm text-foreground shadow-[0_0_12px_rgba(6,182,212,0.15)] focus:border-cyan-400 focus:outline-hidden focus:ring-1 focus:ring-cyan-400/30"
>
<option value="">{placeholder}</option>
{options.map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</span>
)
}
// Text/other input types
const inputType = fieldType === 'email' ? 'email'
: fieldType === 'number' ? 'number'
: fieldType === 'url' ? 'url'
: fieldType === 'ip_address' ? 'text'
: 'text'
return (
<span className="inline-flex items-center gap-1.5">
<span className="relative inline-flex items-center">
<input
ref={inputRef as React.RefObject<HTMLInputElement>}
type={inputType}
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={handleSubmit}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="w-48 rounded-md border border-cyan-500/40 bg-cyan-500/5 px-2.5 py-1 text-sm text-foreground shadow-[0_0_12px_rgba(6,182,212,0.15)] placeholder:text-muted-foreground focus:border-cyan-400 focus:outline-hidden focus:ring-1 focus:ring-cyan-400/30"
/>
{helpText && (
<span className="absolute -bottom-5 left-0 text-[0.625rem] text-muted-foreground whitespace-nowrap">
{helpText}
</span>
)}
</span>
</span>
)
}

View File

@@ -0,0 +1,239 @@
import { useState, useEffect } from 'react'
import { X, UserPlus, FileText } from 'lucide-react'
import type { IntakeFormField } from '@/types'
import { sessionsApi } from '@/api/sessions'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
interface TeamMember {
id: string
name: string
email: string
}
interface PrepareSessionModalProps {
isOpen: boolean
onClose: () => void
treeId: string
treeName: string
intakeFields: IntakeFormField[]
teamMembers?: TeamMember[]
onPrepared?: () => void
}
export function PrepareSessionModal({
isOpen,
onClose,
treeId,
treeName,
intakeFields,
teamMembers = [],
onPrepared,
}: PrepareSessionModalProps) {
const [values, setValues] = useState<Record<string, string>>({})
const [assignedToId, setAssignedToId] = useState('')
const [ticketNumber, setTicketNumber] = useState('')
const [clientName, setClientName] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
// Reset on open
useEffect(() => {
if (isOpen) {
setValues({})
setAssignedToId('')
setTicketNumber('')
setClientName('')
}
}, [isOpen])
if (!isOpen) return null
const handleFieldChange = (variableName: string, value: string) => {
setValues(prev => ({ ...prev, [variableName]: value }))
}
const handleSubmit = async () => {
setIsSubmitting(true)
try {
// Clean empty values
const cleanedVars: Record<string, string> = {}
for (const [k, v] of Object.entries(values)) {
if (v.trim()) cleanedVars[k] = v.trim()
}
await sessionsApi.prepare({
tree_id: treeId,
session_variables: Object.keys(cleanedVars).length > 0 ? cleanedVars : undefined,
assigned_to_id: assignedToId || undefined,
ticket_number: ticketNumber.trim() || undefined,
client_name: clientName.trim() || undefined,
})
toast.success('Session prepared successfully')
onPrepared?.()
onClose()
} catch {
toast.error('Failed to prepare session')
} finally {
setIsSubmitting(false)
}
}
// Group fields by group_name
const grouped = new Map<string, IntakeFormField[]>()
for (const field of [...intakeFields].sort((a, b) => a.display_order - b.display_order)) {
const group = field.group_name || 'General'
if (!grouped.has(group)) grouped.set(group, [])
grouped.get(group)!.push(field)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-background/60 backdrop-blur-xs" onClick={onClose} />
<div className="relative w-full max-w-lg rounded-2xl border border-border bg-card shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-cyan-400" />
<h3 className="text-sm font-semibold text-foreground">Prepare Session</h3>
</div>
<button
onClick={onClose}
className="rounded-lg p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="max-h-[60vh] overflow-y-auto p-5 space-y-5">
{/* Flow name */}
<div className="rounded-lg bg-accent px-3 py-2">
<p className="text-xs text-muted-foreground">Flow</p>
<p className="text-sm font-medium text-foreground">{treeName}</p>
</div>
{/* Context fields */}
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">Ticket Number</label>
<input
type="text"
value={ticketNumber}
onChange={(e) => setTicketNumber(e.target.value)}
placeholder="e.g. TKT-12345"
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">Client Name</label>
<input
type="text"
value={clientName}
onChange={(e) => setClientName(e.target.value)}
placeholder="e.g. Acme Corp"
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/>
</div>
</div>
{/* Assignee */}
{teamMembers.length > 0 && (
<div>
<label className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<UserPlus className="h-3.5 w-3.5" />
Assign to Engineer
</label>
<select
value={assignedToId}
onChange={(e) => setAssignedToId(e.target.value)}
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
>
<option value="">Unassigned (visible to all)</option>
{teamMembers.map(m => (
<option key={m.id} value={m.id}>{m.name} ({m.email})</option>
))}
</select>
</div>
)}
{/* Intake form fields */}
{intakeFields.length > 0 && (
<div className="space-y-4">
<h4 className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Variables (optional can be filled later)
</h4>
{Array.from(grouped.entries()).map(([groupName, fields]) => (
<div key={groupName} className="space-y-3">
{grouped.size > 1 && (
<p className="text-xs font-medium text-muted-foreground">{groupName}</p>
)}
{fields.map(field => (
<div key={field.variable_name}>
<label className="mb-1 block text-xs font-medium text-muted-foreground">
{field.label}
{field.required && <span className="ml-1 text-amber-400">*</span>}
</label>
{field.field_type === 'select' && field.options?.length ? (
<select
value={values[field.variable_name] || ''}
onChange={(e) => handleFieldChange(field.variable_name, e.target.value)}
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
>
<option value="">{field.placeholder || 'Select...'}</option>
{field.options.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
) : field.field_type === 'textarea' ? (
<textarea
value={values[field.variable_name] || ''}
onChange={(e) => handleFieldChange(field.variable_name, e.target.value)}
placeholder={field.placeholder}
rows={3}
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/>
) : (
<input
type={field.field_type === 'number' ? 'number' : field.field_type === 'email' ? 'email' : 'text'}
value={values[field.variable_name] || ''}
onChange={(e) => handleFieldChange(field.variable_name, e.target.value)}
placeholder={field.placeholder}
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/>
)}
{field.help_text && (
<p className="mt-1 text-[0.625rem] text-muted-foreground">{field.help_text}</p>
)}
</div>
))}
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 border-t border-border px-5 py-4">
<button
onClick={onClose}
className="rounded-[10px] px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={isSubmitting}
className={cn(
'rounded-[10px] bg-gradient-brand px-4 py-2 text-sm font-semibold text-[#101114]',
'shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97]',
'disabled:opacity-40'
)}
>
{isSubmitting ? 'Preparing...' : 'Prepare Session'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,87 @@
import type { IntakeFormField } from '@/types'
import { InlineVariablePrompt } from './InlineVariablePrompt'
interface ResolvedTextProps {
text: string
variables: Record<string, string>
intakeFields?: IntakeFormField[]
onVariableSubmit?: (variableName: string, value: string) => void
className?: string
}
/**
* Renders text with resolved variables inline. Unresolved [VAR:x] tokens
* are replaced with interactive InlineVariablePrompt components.
*/
export function ResolvedText({
text,
variables,
intakeFields,
onVariableSubmit,
className,
}: ResolvedTextProps) {
if (!text) return null
// Split on variable tokens, keeping the tokens in the result
const tokenPattern = /(\[(?:VAR|USER_INPUT):([^\]]+)\])/g
const parts: Array<{ type: 'text'; value: string } | { type: 'var'; variableName: string; token: string }> = []
let lastIndex = 0
let match
// Remove [SAVE_AS:...] tokens first
const cleaned = text.replace(/\[SAVE_AS:[^\]]+\]/g, '')
while ((match = tokenPattern.exec(cleaned)) !== null) {
// Add text before the token
if (match.index > lastIndex) {
parts.push({ type: 'text', value: cleaned.slice(lastIndex, match.index) })
}
const variableName = match[2].trim()
const resolvedValue = variables[variableName]
if (resolvedValue && resolvedValue.trim()) {
// Variable is resolved — render as plain text
parts.push({ type: 'text', value: resolvedValue })
} else {
// Variable is unresolved — render as inline prompt
parts.push({ type: 'var', variableName, token: match[1] })
}
lastIndex = match.index + match[0].length
}
// Add remaining text
if (lastIndex < cleaned.length) {
parts.push({ type: 'text', value: cleaned.slice(lastIndex) })
}
// If no unresolved variables, just render plain text
const hasUnresolved = parts.some(p => p.type === 'var')
if (!hasUnresolved) {
const fullText = parts.map(p => p.type === 'text' ? p.value : '').join('')
return <span className={className}>{fullText}</span>
}
// Find field metadata helper
const getFieldMeta = (varName: string) =>
intakeFields?.find(f => f.variable_name === varName)
return (
<span className={className}>
{parts.map((part, i) => {
if (part.type === 'text') {
return <span key={i}>{part.value}</span>
}
return (
<InlineVariablePrompt
key={`${part.variableName}-${i}`}
variableName={part.variableName}
fieldMeta={getFieldMeta(part.variableName)}
onSubmit={onVariableSubmit || (() => {})}
/>
)
})}
</span>
)
}

View File

@@ -1,7 +1,8 @@
import { useState } from 'react'
import { AlertTriangle, CheckCircle2, Info, Zap, Copy, Check, ExternalLink } from 'lucide-react'
import type { RuntimeStep, StepContentType, CommandBlock } from '@/types'
import type { RuntimeStep, StepContentType, CommandBlock, IntakeFormField } from '@/types'
import { resolveVariables } from '@/lib/variableResolver'
import { ResolvedText } from './ResolvedText'
import { cn } from '@/lib/utils'
const contentTypeConfig: Record<StepContentType, { icon: typeof Zap; color: string; bg: string; label: string }> = {
@@ -23,6 +24,8 @@ interface StepDetailProps {
isCompleted: boolean
onMarkComplete: () => void
isLast: boolean
intakeFields?: IntakeFormField[]
onVariableSubmit?: (variableName: string, value: string) => void
}
export function StepDetail({
@@ -37,6 +40,8 @@ export function StepDetail({
isCompleted,
onMarkComplete,
isLast,
intakeFields,
onVariableSubmit,
}: StepDetailProps) {
const [copiedIndex, setCopiedIndex] = useState<number | null>(null)
const isCustom = 'isCustom' in step && step.isCustom
@@ -116,14 +121,28 @@ export function StepDetail({
{'warning_text' in step && step.warning_text && (
<div className="flex items-start gap-2 rounded-lg border border-yellow-400/20 bg-yellow-400/5 px-3 py-2.5">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-yellow-400" />
<p className="text-sm text-yellow-200">{resolve(step.warning_text)}</p>
<p className="text-sm text-yellow-200">
<ResolvedText
text={step.warning_text}
variables={variables}
intakeFields={intakeFields}
onVariableSubmit={onVariableSubmit}
/>
</p>
</div>
)}
{/* Description */}
{step.description && (
<div className="prose prose-invert prose-sm max-w-none text-muted-foreground">
<p className="whitespace-pre-wrap">{resolve(step.description)}</p>
<p className="whitespace-pre-wrap">
<ResolvedText
text={step.description}
variables={variables}
intakeFields={intakeFields}
onVariableSubmit={onVariableSubmit}
/>
</p>
</div>
)}
@@ -145,7 +164,12 @@ export function StepDetail({
</button>
</div>
<pre className="overflow-x-auto p-3 font-mono text-sm text-emerald-300">
{resolve(cmd.code)}
<ResolvedText
text={cmd.code}
variables={variables}
intakeFields={intakeFields}
onVariableSubmit={onVariableSubmit}
/>
</pre>
</div>
))}