Files
resolutionflow/frontend/src/pages/l1/L1Dashboard.tsx
Michael Chihlas 076a9ec98d fix(l1): actually wire Tasks 14-15 (prior commit ad9c4c8 was committed broken)
ad9c4c8 committed with TSC_EXIT=2 (I batched the commit with its own failing
verification). Two regressions, now fixed and tsc -b + eslint verified (TSC=0,
ESLINT=0):
- L1WalkTreeVariant.tsx: the ai_build JSX branch referenced isAiBuild/node/
  nodeLoading/nodeError/advanceNode/isTerminalNode that were never declared (the
  import + state Edits had silently failed). Add the import (useEffect/useCallback,
  TreeNode) and the state/effect/advanceNode/isTerminalNode block.
- L1Dashboard.tsx: had reverted to the original (no dispatch). Re-add outcome
  dispatch as minimal edits on the real page (matched/build->walker; suggest->
  use-flow/build-new; out_of_scope->escalate-without-walk).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 20:24:44 -04:00

304 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { PageMeta } from '@/components/common/PageMeta'
import { useAuthStore } from '@/store/authStore'
import { l1Api } from '@/api/l1'
import { toast } from '@/lib/toast'
import { EmptyStateCard } from '@/components/l1/EmptyStateCard'
import { ResumeInProgress } from '@/components/l1/ResumeInProgress'
import type { NearMiss, QueueRow } from '@/types/l1'
export default function L1Dashboard() {
const user = useAuthStore((s) => s.user)
const navigate = useNavigate()
const [problem, setProblem] = useState('')
const [customerName, setCustomerName] = useState('')
const [customerContact, setCustomerContact] = useState('')
const [submitting, setSubmitting] = useState(false)
const [queue, setQueue] = useState<QueueRow[]>([])
const [isEmpty, setIsEmpty] = useState(false)
const [suggestion, setSuggestion] = useState<NearMiss | null>(null)
const [outOfScope, setOutOfScope] = useState<string | null>(null)
useEffect(() => {
l1Api.queue('open').then(setQueue).catch(() => setQueue([]))
// Phase 1: emptiness detection is just "is the queue empty AND no resumable sessions" —
// we conservatively show the empty-state card on accounts with literally no L1 activity yet.
// (A stricter KB-empty detection arrives in Phase 2 when the kb_documents table exists.)
}, [])
useEffect(() => {
// Show empty-state ONLY for first-run state — no queue items and no active sessions
if (queue.length === 0) {
l1Api
.listActiveSessions()
.then((active) => setIsEmpty(active.length === 0))
.catch(() => setIsEmpty(false))
} else {
setIsEmpty(false)
}
}, [queue])
const resetPrompts = () => {
setSuggestion(null)
setOutOfScope(null)
}
const handleStart = async () => {
if (!problem.trim()) return
setSubmitting(true)
resetPrompts()
try {
// Phase 2A: intake dispatches via match_or_build and returns an `outcome`.
const response = await l1Api.intake({
problem_statement: problem.trim(),
customer_name: customerName.trim() || undefined,
customer_contact: customerContact.trim() || undefined,
})
if (response.outcome === 'matched' || response.outcome === 'build') {
navigate(`/l1/walk/${response.session_id}`)
} else if (response.outcome === 'suggest') {
setSuggestion(response.near_miss ?? null)
} else if (response.outcome === 'out_of_scope') {
setOutOfScope(response.category ?? 'unknown')
}
} catch (err) {
const detail = (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
const msg =
typeof detail === 'string' ? detail : 'Failed to start walk. Try again.'
toast.error(msg)
} finally {
setSubmitting(false)
}
}
// "Use this flow" — re-run intake with the same text; it matches again and
// returns a `matched` outcome with a started flow session (acceptable Phase 2A).
const useSuggestedFlow = async () => {
setSubmitting(true)
try {
const response = await l1Api.intake({
problem_statement: problem.trim(),
customer_name: customerName.trim() || undefined,
customer_contact: customerContact.trim() || undefined,
})
if (response.session_id) navigate(`/l1/walk/${response.session_id}`)
else resetPrompts()
} catch {
toast.error('Could not start the matched flow. Try again.')
} finally {
setSubmitting(false)
}
}
// "Build new" — skip the match pass (force_build); still gated by enabled categories.
const buildNew = async () => {
setSubmitting(true)
resetPrompts()
try {
const response = await l1Api.intake({
problem_statement: problem.trim(),
customer_name: customerName.trim() || undefined,
customer_contact: customerContact.trim() || undefined,
force_build: true,
})
if (response.outcome === 'build' && response.session_id) {
navigate(`/l1/walk/${response.session_id}`)
} else if (response.outcome === 'out_of_scope') {
setOutOfScope(response.category ?? 'unknown')
}
} catch {
toast.error('Failed to start walk. Try again.')
} finally {
setSubmitting(false)
}
}
// out-of-scope fallback: escalate straight to engineering (no walk).
const escalateOutOfScope = async () => {
if (!problem.trim()) return
setSubmitting(true)
try {
const session = await l1Api.escalateWithoutWalk({
problem_statement: problem.trim(),
customer_name: customerName.trim() || undefined,
customer_contact: customerContact.trim() || undefined,
reason_category: 'out_of_scope',
reason: 'Problem is outside the enabled L1 AI-build categories.',
})
toast.success('Escalated to engineering.')
navigate(`/l1/walk/${session.id}`)
} catch {
toast.error('Could not escalate. Try again.')
} finally {
setSubmitting(false)
}
}
const now = new Date()
const greeting =
now.getHours() < 12 ? 'morning' : now.getHours() < 18 ? 'afternoon' : 'evening'
const firstName = user?.name?.split(' ')[0] || 'there'
return (
<div className="overflow-y-auto h-full">
<PageMeta title="L1 Workspace" />
<div className="max-w-4xl mx-auto px-6 pt-12 pb-12 space-y-8">
{/* Greeting */}
<div>
<p className="font-sans text-xs uppercase tracking-[0.12em] text-muted-foreground mb-1">
{now.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
})}
</p>
<h1 className="font-heading text-3xl sm:text-4xl font-extrabold tracking-tight text-heading leading-tight">
Good {greeting}, {firstName}.
</h1>
</div>
{/* Empty state (first-run) */}
{isEmpty && <EmptyStateCard />}
{/* Describe the problem */}
<section>
<div className="flex items-center gap-3 mb-3">
<span className="w-1 h-4 bg-accent rounded-sm" />
<span className="font-sans text-[0.625rem] uppercase tracking-[0.12em] font-semibold text-muted-foreground">
Describe the problem
</span>
</div>
<div className="rounded-lg border border-default bg-card p-4 space-y-3">
<textarea
value={problem}
onChange={(e) => setProblem(e.target.value)}
placeholder="What's the user calling about?"
autoFocus
rows={3}
className="w-full bg-page border border-default rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40"
/>
<div className="grid grid-cols-2 gap-3">
<input
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
placeholder="Customer name (optional)"
className="bg-page border border-default rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40"
/>
<input
value={customerContact}
onChange={(e) => setCustomerContact(e.target.value)}
placeholder="Email or phone (optional)"
className="bg-page border border-default rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40"
/>
</div>
<div className="flex justify-end">
<button
type="button"
onClick={handleStart}
disabled={!problem.trim() || submitting}
className="rounded-md bg-accent text-white px-5 py-2 text-sm font-medium hover:bg-accent/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? 'Starting…' : 'Start walk →'}
</button>
</div>
</div>
</section>
{/* Open tickets */}
{queue.length > 0 && (
<section>
<div className="flex items-center gap-3 mb-3">
<span className="font-sans text-[0.625rem] uppercase tracking-[0.12em] font-semibold text-muted-foreground">
Open tickets · {queue.length}
</span>
<div className="flex-1 h-px bg-border" />
</div>
<div className="rounded-lg border border-default bg-card overflow-hidden">
{queue.map((row) => (
/* Phase 1: display-only rows. Phase 2 makes them clickable to claim. */
<div
key={row.ticket_id}
className="px-4 py-3 border-b border-default last:border-b-0"
>
<div className="flex items-center justify-between">
<div>
<span className="font-mono text-xs text-muted-foreground mr-2">
#{row.ticket_id.slice(0, 8)}
</span>
<span className="text-sm">{row.problem_statement}</span>
</div>
<span className="text-xs px-2 py-0.5 rounded bg-elevated text-muted-foreground">
{row.ticket_kind === 'psa' ? 'PSA' : 'Internal'}
</span>
</div>
</div>
))}
</div>
</section>
)}
{/* Suggest: near-miss flow found */}
{suggestion && (
<div className="space-y-3 rounded-lg border border-default bg-card p-4">
<p className="text-sm text-primary">
Found a similar flow: <strong>{suggestion.flow_name}</strong>. Use it, or
build a new troubleshooting tree for this problem?
</p>
<div className="flex gap-2">
<button
type="button"
onClick={useSuggestedFlow}
disabled={submitting}
className="rounded-md bg-accent text-white px-4 py-2 text-sm font-medium hover:bg-accent/90 transition-colors disabled:opacity-50"
>
Use this flow
</button>
<button
type="button"
onClick={buildNew}
disabled={submitting}
className="rounded-md border border-default px-4 py-2 text-sm hover:bg-elevated transition-colors disabled:opacity-50"
>
Build new
</button>
</div>
</div>
)}
{/* Out of scope: category disabled/unknown */}
{outOfScope && (
<div className="space-y-3 rounded-lg border border-default bg-card p-4">
<p className="text-sm text-primary">
This problem isnt in your accounts enabled L1 categories
{outOfScope !== 'unknown' ? ` (${outOfScope.replace(/_/g, ' ')})` : ''}, so
theres no AI-built walk for it. You can escalate it to engineering.
</p>
<div className="flex gap-2">
<button
type="button"
onClick={escalateOutOfScope}
disabled={submitting}
className="rounded-md bg-accent text-white px-4 py-2 text-sm font-medium hover:bg-accent/90 transition-colors disabled:opacity-50"
>
Escalate to engineering
</button>
<button
type="button"
onClick={resetPrompts}
disabled={submitting}
className="rounded-md border border-default px-4 py-2 text-sm hover:bg-elevated transition-colors disabled:opacity-50"
>
Cancel
</button>
</div>
</div>
)}
{/* Resume in progress */}
<ResumeInProgress />
</div>
</div>
)
}