fix(l1): repair Tasks 14-15 frontend — restore real component contracts

Tasks 14 (df7150f) and 15 (f483196) were committed with broken TypeScript (I
misread eslint EXIT=0 as 'tsc clean'). Corrections:
- L1Dashboard: revert the speculative rewrite (it imported a non-existent
  StartWalkPanel and dropped the real PageMeta/greeting/inputs layout). Re-apply
  outcome dispatch as a MINIMAL edit on the real page — handleStart branches on
  outcome (matched/build -> walker; suggest -> use-flow/build-new; out_of_scope ->
  escalate-without-walk), preserving the original structure.
- L1WalkTreeVariant: revert the rewrite (it imported a non-existent WalkModals and
  changed the props contract, breaking L1WalkPage). Re-apply on the real component:
  keep {session,onSessionUpdate,onDone} + ResolveModal/EscalateModal + header +
  transcript sidebar; add an ai_build branch that walks nodes via /next-node (passing
  node_text), a disclaimer banner, and terminal -> existing resolve/escalate modals.
  flow/proposal keep the Phase-1 synthetic path.

Verified: tsc -b EXIT=0 + eslint EXIT=0 (whole-project typecheck). L1WalkPage
unchanged (already routes ai_build -> tree variant).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 20:18:45 -04:00
parent 3e23a837d4
commit ad9c4c8cd6
2 changed files with 340 additions and 284 deletions

View File

@@ -1,116 +1,124 @@
import { useCallback, useEffect, useState } from 'react' import { useState } from 'react'
import type { TreeNode, WalkSession } from '@/types/l1' import { ChevronLeft } from 'lucide-react'
import { Link } from 'react-router-dom'
import { l1Api } from '@/api/l1' import { l1Api } from '@/api/l1'
import { WalkModals } from './WalkModals' import type { WalkSession } from '@/types/l1'
import { EscalateModal, ResolveModal } from '@/components/l1/WalkModals'
interface Props { interface Props {
session: WalkSession session: WalkSession
onSessionUpdate: (s: WalkSession) => void
onDone: () => void
} }
/** export function L1WalkTreeVariant({ session, onSessionUpdate, onDone }: Props) {
* L1WalkTreeVariant — renders a decision-tree walk.
*
* For `ai_build` sessions it drives real, node-by-node generation via
* POST /l1/sessions/{id}/next-node: fetch the first node on mount, then on each
* answer/acknowledge POST the current node id (+ its text, so the walked path and
* captured tree stay legible) and render the returned node. Terminal nodes
* (resolved / escalate / needs_review) hand off to the existing Resolve/Escalate
* modals. Published `flow` / `proposal` walks keep the Phase-1 stub for now.
*/
export function L1WalkTreeVariant({ session }: Props) {
const isAiBuild = session.session_kind === 'ai_build'
const [showResolve, setShowResolve] = useState(false) const [showResolve, setShowResolve] = useState(false)
const [showEscalate, setShowEscalate] = useState(false) const [showEscalate, setShowEscalate] = useState(false)
const [node, setNode] = useState<TreeNode | null>(null) const [note, setNote] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Fetch the first node on mount (ai_build only). // Phase 1: we don't have the live flow-tree fetch wired up here yet
useEffect(() => { // (the tree-navigation pages have their own loader). The walker shows the
if (!isAiBuild) return // walked-path side panel, advance buttons stubbed for now — Phase 2 wires
let cancelled = false // the actual flow tree fetching + node advancement against tree data.
setLoading(true) // The "Yes/No" buttons record a synthetic step so the walked_path JSONB
l1Api // grows; this gives us a functional roundtrip until Phase 2 wires the tree.
.nextNode(session.id, {})
.then((r) => {
if (!cancelled) setNode(r.node)
})
.catch(() => {
if (!cancelled) setError('Could not generate the next step.')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [isAiBuild, session.id])
const advance = useCallback( const handleAnswer = async (answer: 'yes' | 'no') => {
async (body: { answer?: 'yes' | 'no'; acknowledged?: boolean }) => { const nodeId = session.current_node_id || `step-${session.walked_path.length + 1}`
if (!node) return
setLoading(true)
setError(null)
try { try {
const r = await l1Api.nextNode(session.id, { const updated = await l1Api.step(session.id, {
node_id: node.id, node_id: nodeId,
node_text: node.text, question: `Step ${session.walked_path.length + 1}`,
...body, answer,
note: note || null,
}) })
setNode(r.node) onSessionUpdate(updated)
} catch { setNote('')
setError('Could not generate the next step.') } catch (err) {
} finally { // Keep silent for v1 — Phase 2 wires real error UI
setLoading(false) console.error('step failed', err)
}
} }
},
[node, session.id],
)
const isTerminal = const lastError = (err: unknown): string => {
node?.node_type === 'resolved' || if (typeof err === 'object' && err && 'response' in err) {
node?.node_type === 'escalate' || const detail = (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
node?.node_type === 'needs_review' if (typeof detail === 'string') return detail
}
return 'Unexpected error'
}
return ( return (
<div className="space-y-4"> <div className="flex flex-col h-full">
{/* Header */}
<header className="border-b border-default px-6 py-4 flex items-center justify-between bg-sidebar">
<Link to="/l1" className="flex items-center gap-2 text-muted-foreground hover:text-heading transition-colors">
<ChevronLeft className="w-4 h-4" />
<span className="font-mono text-xs">#{session.id.slice(0, 8)}</span>
{(session.session_kind === 'proposal' || session.session_kind === 'ai_build') && (
<span className="ml-2 text-xs bg-accent/10 text-accent px-2 py-0.5 rounded">AI-built</span>
)}
</Link>
<div className="flex gap-2">
<button
onClick={() => setShowEscalate(true)}
className="rounded-md border border-default px-3 py-1.5 text-sm hover:bg-elevated transition-colors"
disabled={session.status !== 'active'}
>
Escalate
</button>
<button
onClick={() => setShowResolve(true)}
className="rounded-md bg-accent text-white px-3 py-1.5 text-sm hover:bg-accent/90 transition-colors disabled:opacity-50"
disabled={session.status !== 'active'}
>
Resolve
</button>
</div>
</header>
{/* Two-pane body */}
<div className="flex-1 flex min-h-0">
<main className="flex-1 p-6 overflow-y-auto min-h-0">
{isAiBuild && ( {isAiBuild && (
<div className="rounded-md border border-warning/30 bg-warning/10 px-4 py-2 text-xs text-warning"> <div className="mb-4 max-w-2xl rounded-md border border-warning/30 bg-warning/10 px-4 py-2 text-xs text-warning">
These are high-confidence troubleshooting steps, but they come from outside These are high-confidence troubleshooting steps, but they come from
your organizations knowledge base review them before acting. When in outside your organizations knowledge base review them before acting.
doubt, escalate early. When in doubt, escalate early.
</div> </div>
)} )}
<p className="font-sans text-[0.625rem] uppercase tracking-[0.12em] font-semibold text-muted-foreground mb-2">
{!isAiBuild && ( Step {session.walked_path.length + 1}
</p>
{session.status !== 'active' ? (
<div className="rounded-lg border border-default bg-card p-6"> <div className="rounded-lg border border-default bg-card p-6">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Walking flow <span className="font-mono">{session.flow_id}</span> This session is <span className="font-semibold">{session.status}</span>.
</p> </p>
<p className="mt-2 text-heading">Synthetic step rendering (Phase 1 stub).</p> <button onClick={onDone} className="mt-3 rounded-md bg-accent text-white px-3 py-1.5 text-sm">
Back to workspace
</button>
</div> </div>
)} ) : isAiBuild ? (
<div className="rounded-lg border border-default bg-card p-6 max-w-2xl space-y-4">
{isAiBuild && ( {nodeLoading && (
<div className="rounded-lg border border-default bg-card p-6 space-y-4">
{loading && (
<p className="text-sm text-muted-foreground">Thinking through the next step</p> <p className="text-sm text-muted-foreground">Thinking through the next step</p>
)} )}
{error && <p className="text-sm text-danger">{error}</p>} {nodeError && <p className="text-sm text-danger">{nodeError}</p>}
{!loading && node?.node_type === 'question' && ( {!nodeLoading && node?.node_type === 'question' && (
<> <>
<p className="text-lg text-heading">{node.text}</p> <p className="text-lg">{node.text}</p>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={() => advance({ answer: 'yes' })} onClick={() => advanceNode({ answer: 'yes' })}
className="rounded-md bg-accent px-5 py-2 text-sm text-white" className="flex-1 rounded-md bg-accent text-white py-3 text-base font-medium hover:bg-accent/90 min-h-[44px] transition-colors"
> >
Yes Yes
</button> </button>
<button <button
onClick={() => advance({ answer: 'no' })} onClick={() => advanceNode({ answer: 'no' })}
className="rounded-md border border-default px-5 py-2 text-sm" className="flex-1 rounded-md border border-default py-3 text-base font-medium hover:bg-elevated min-h-[44px] transition-colors"
> >
No No
</button> </button>
@@ -118,66 +126,115 @@ export function L1WalkTreeVariant({ session }: Props) {
</> </>
)} )}
{!loading && node?.node_type === 'instruction' && ( {!nodeLoading && node?.node_type === 'instruction' && (
<> <>
<p className="text-lg text-heading">{node.text}</p> <p className="text-lg">{node.text}</p>
<button <button
onClick={() => advance({ acknowledged: true })} onClick={() => advanceNode({ acknowledged: true })}
className="rounded-md bg-accent px-5 py-2 text-sm text-white" className="rounded-md bg-accent text-white px-5 py-3 text-base font-medium hover:bg-accent/90 min-h-[44px] transition-colors"
> >
Done next step Done next step
</button> </button>
</> </>
)} )}
{!loading && isTerminal && node && ( {!nodeLoading && isTerminalNode && node && (
<> <>
<p className="text-lg text-heading">{node.text}</p> <p className="text-lg">{node.text}</p>
<div className="flex gap-3">
{node.node_type === 'resolved' ? ( {node.node_type === 'resolved' ? (
<button <button
onClick={() => setShowResolve(true)} onClick={() => setShowResolve(true)}
className="rounded-md bg-accent px-5 py-2 text-sm text-white" className="rounded-md bg-accent text-white px-5 py-3 text-base font-medium hover:bg-accent/90 min-h-[44px] transition-colors"
> >
Mark resolved Mark resolved
</button> </button>
) : ( ) : (
<button <button
onClick={() => setShowEscalate(true)} onClick={() => setShowEscalate(true)}
className="rounded-md bg-accent px-5 py-2 text-sm text-white" className="rounded-md bg-warning text-white px-5 py-3 text-base font-medium hover:bg-warning/90 min-h-[44px] transition-colors"
> >
Escalate to engineering Escalate to engineering
</button> </button>
)} )}
</div>
</> </>
)} )}
</div> </div>
)} ) : (
<div className="rounded-lg border border-default bg-card p-6 max-w-2xl">
{/* Resolve / Escalate are always reachable during a walk. */} <p className="text-lg mb-6">Continue the walk:</p>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={() => setShowResolve(true)} onClick={() => handleAnswer('yes')}
className="rounded-md border border-default px-4 py-2 text-sm" className="flex-1 rounded-md bg-accent text-white py-3 text-base font-medium hover:bg-accent/90 min-h-[44px] transition-colors"
> >
Resolve Yes
</button> </button>
<button <button
onClick={() => setShowEscalate(true)} onClick={() => handleAnswer('no')}
className="rounded-md border border-default px-4 py-2 text-sm" className="flex-1 rounded-md border border-default py-3 text-base font-medium hover:bg-elevated min-h-[44px] transition-colors"
> >
Escalate No
</button> </button>
</div> </div>
<textarea
<WalkModals value={note}
session={session} onChange={(e) => setNote(e.target.value)}
showResolve={showResolve} placeholder="Optional note for this step…"
showEscalate={showEscalate} rows={2}
onCloseResolve={() => setShowResolve(false)} className="mt-4 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"
onCloseEscalate={() => setShowEscalate(false)}
/> />
</div> </div>
)}
</main>
{/* Right pane: transcript */}
<aside className="w-80 border-l border-default bg-page p-4 overflow-y-auto">
<p className="font-sans text-[0.625rem] uppercase tracking-[0.12em] font-semibold text-muted-foreground mb-3">
Walked so far
</p>
{session.walked_path.length === 0 ? (
<p className="text-xs text-muted-foreground">No steps yet.</p>
) : (
<ol className="space-y-3 text-sm">
{session.walked_path.map((step, i) => (
<li key={i} className="flex flex-col">
<span className="text-muted-foreground text-xs">{step.question}</span>
<span className="font-medium"> {step.answer}</span>
{step.l1_note && <span className="text-muted-foreground text-xs italic mt-0.5">{step.l1_note}</span>}
</li>
))}
</ol>
)}
</aside>
</div>
{/* Modals */}
{showResolve && (
<ResolveModal
onClose={() => setShowResolve(false)}
onConfirm={async (helpful, resolutionNotes) => {
try {
await l1Api.resolve(session.id, { helpful, resolution_notes: resolutionNotes })
onDone()
} catch (err) {
console.error('resolve failed:', lastError(err))
}
}}
/>
)}
{showEscalate && (
<EscalateModal
onClose={() => setShowEscalate(false)}
onConfirm={async (category, reason) => {
try {
await l1Api.escalate(session.id, { reason, reason_category: category })
onDone()
} catch (err) {
console.error('escalate failed:', lastError(err))
}
}}
/>
)}
</div>
) )
} }

View File

@@ -1,169 +1,168 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { PageMeta } from '@/components/common/PageMeta'
import { useAuthStore } from '@/store/authStore'
import { l1Api } from '@/api/l1' import { l1Api } from '@/api/l1'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
import { StartWalkPanel } from '@/components/l1/EmptyStateCard' import { EmptyStateCard } from '@/components/l1/EmptyStateCard'
import { ResumeInProgress } from '@/components/l1/ResumeInProgress' import { ResumeInProgress } from '@/components/l1/ResumeInProgress'
import { L1CoverageBanner } from '@/components/l1/L1CoverageBanner' import type { QueueRow } from '@/types/l1'
import type { NearMiss } from '@/types/l1'
export default function L1Dashboard() { export default function L1Dashboard() {
const user = useAuthStore((s) => s.user)
const navigate = useNavigate() const navigate = useNavigate()
const [problem, setProblem] = useState('') const [problem, setProblem] = useState('')
const [customerName, setCustomerName] = useState('') const [customerName, setCustomerName] = useState('')
const [customerContact, setCustomerContact] = useState('')
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [suggestion, setSuggestion] = useState<NearMiss | null>(null) const [queue, setQueue] = useState<QueueRow[]>([])
const [outOfScope, setOutOfScope] = useState<string | null>(null) const [isEmpty, setIsEmpty] = useState(false)
const reset = () => { useEffect(() => {
setSuggestion(null) l1Api.queue('open').then(setQueue).catch(() => setQueue([]))
setOutOfScope(null) // 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 handleStart = async () => { const handleStart = async () => {
if (!problem.trim()) return if (!problem.trim()) return
setSubmitting(true) setSubmitting(true)
reset()
try { try {
const res = await l1Api.intake({ const response = await l1Api.intake({
problem_statement: problem.trim(), problem_statement: problem.trim(),
customer_name: customerName.trim() || undefined, customer_name: customerName.trim() || undefined,
customer_contact: customerContact.trim() || undefined,
}) })
if (res.outcome === 'matched' || res.outcome === 'build') { navigate(`/l1/walk/${response.session_id}`)
navigate(`/l1/walk/${res.session_id}`) } catch (err) {
} else if (res.outcome === 'suggest') { const detail = (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
setSuggestion(res.near_miss ?? null) const msg =
} else if (res.outcome === 'out_of_scope') { typeof detail === 'string' ? detail : 'Failed to start walk. Try again.'
setOutOfScope(res.category ?? 'unknown') toast.error(msg)
}
} catch {
toast.error('Failed to start session. Please try again.')
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
} }
// "Use this flow" — re-run intake with the same text; it matches again and const now = new Date()
// returns a `matched` outcome with a started flow session (Phase 2A approach). const greeting =
const useSuggestedFlow = async () => { now.getHours() < 12 ? 'morning' : now.getHours() < 18 ? 'afternoon' : 'evening'
setSubmitting(true) const firstName = user?.name?.split(' ')[0] || 'there'
try {
const res = await l1Api.intake({ problem_statement: problem.trim() })
if (res.session_id) navigate(`/l1/walk/${res.session_id}`)
else reset()
} catch {
toast.error('Could not start the matched flow. Please try again.')
} finally {
setSubmitting(false)
}
}
// "Build new" — skip the match pass; still gated by enabled categories.
const buildNew = async () => {
setSubmitting(true)
reset()
try {
const res = await l1Api.intake({
problem_statement: problem.trim(),
customer_name: customerName.trim() || undefined,
force_build: true,
})
if (res.outcome === 'build' && res.session_id) {
navigate(`/l1/walk/${res.session_id}`)
} else if (res.outcome === 'out_of_scope') {
setOutOfScope(res.category ?? 'unknown')
}
} catch {
toast.error('Failed to start session. Please 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,
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. Please try again.')
} finally {
setSubmitting(false)
}
}
return ( return (
<div className="space-y-6"> <div className="overflow-y-auto h-full">
<L1CoverageBanner /> <PageMeta title="L1 Workspace" />
<StartWalkPanel <div className="max-w-4xl mx-auto px-6 pt-12 pb-12 space-y-8">
problem={problem} {/* Greeting */}
onProblemChange={setProblem} <div>
customerName={customerName} <p className="font-sans text-xs uppercase tracking-[0.12em] text-muted-foreground mb-1">
onCustomerNameChange={setCustomerName} {now.toLocaleDateString('en-US', {
onStart={handleStart} weekday: 'long',
submitting={submitting} 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>
{suggestion && ( {/* Open tickets */}
<div className="space-y-3 rounded-lg border border-default bg-card p-4"> {queue.length > 0 && (
<p className="text-sm text-primary"> <section>
Found a similar flow: <strong>{suggestion.flow_name}</strong>. Use it, or <div className="flex items-center gap-3 mb-3">
build a new troubleshooting tree for this problem? <span className="font-sans text-[0.625rem] uppercase tracking-[0.12em] font-semibold text-muted-foreground">
</p> Open tickets · {queue.length}
<div className="flex gap-2"> </span>
<button <div className="flex-1 h-px bg-border" />
onClick={useSuggestedFlow} </div>
disabled={submitting} <div className="rounded-lg border border-default bg-card overflow-hidden">
className="rounded-md bg-accent px-4 py-2 text-sm text-white disabled:opacity-50" {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"
> >
Use this flow <div className="flex items-center justify-between">
</button> <div>
<button <span className="font-mono text-xs text-muted-foreground mr-2">
onClick={buildNew} #{row.ticket_id.slice(0, 8)}
disabled={submitting} </span>
className="rounded-md border border-default px-4 py-2 text-sm disabled:opacity-50" <span className="text-sm">{row.problem_statement}</span>
> </div>
Build new <span className="text-xs px-2 py-0.5 rounded bg-elevated text-muted-foreground">
</button> {row.ticket_kind === 'psa' ? 'PSA' : 'Internal'}
</div> </span>
</div>
)}
{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
onClick={escalateOutOfScope}
disabled={submitting}
className="rounded-md bg-accent px-4 py-2 text-sm text-white disabled:opacity-50"
>
Escalate to engineering
</button>
<button
onClick={reset}
disabled={submitting}
className="rounded-md border border-default px-4 py-2 text-sm disabled:opacity-50"
>
Cancel
</button>
</div> </div>
</div> </div>
))}
</div>
</section>
)} )}
{/* Resume in progress */}
<ResumeInProgress /> <ResumeInProgress />
</div> </div>
</div>
) )
} }