L1WalkTreeVariant drives ai_build sessions node-by-node through POST /next-node: fetch first node on mount, render question (yes/no) / instruction (acknowledge), pass node_text on each advance; terminal nodes (resolved/escalate/needs_review) hand off to the existing Resolve/Escalate modals. Standing AI disclaimer banner on ai_build walks. L1WalkPage routes ai_build to the tree variant. Published flow/ proposal keep the Phase-1 stub. tsc -b + eslint clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
184 lines
5.9 KiB
TypeScript
184 lines
5.9 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
||
import type { TreeNode, WalkSession } from '@/types/l1'
|
||
import { l1Api } from '@/api/l1'
|
||
import { WalkModals } from './WalkModals'
|
||
|
||
interface Props {
|
||
session: WalkSession
|
||
}
|
||
|
||
/**
|
||
* 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 [showEscalate, setShowEscalate] = useState(false)
|
||
const [node, setNode] = useState<TreeNode | null>(null)
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
// Fetch the first node on mount (ai_build only).
|
||
useEffect(() => {
|
||
if (!isAiBuild) return
|
||
let cancelled = false
|
||
setLoading(true)
|
||
l1Api
|
||
.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(
|
||
async (body: { answer?: 'yes' | 'no'; acknowledged?: boolean }) => {
|
||
if (!node) return
|
||
setLoading(true)
|
||
setError(null)
|
||
try {
|
||
const r = await l1Api.nextNode(session.id, {
|
||
node_id: node.id,
|
||
node_text: node.text,
|
||
...body,
|
||
})
|
||
setNode(r.node)
|
||
} catch {
|
||
setError('Could not generate the next step.')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
},
|
||
[node, session.id],
|
||
)
|
||
|
||
const isTerminal =
|
||
node?.node_type === 'resolved' ||
|
||
node?.node_type === 'escalate' ||
|
||
node?.node_type === 'needs_review'
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{isAiBuild && (
|
||
<div className="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
|
||
your organization’s knowledge base — review them before acting. When in
|
||
doubt, escalate early.
|
||
</div>
|
||
)}
|
||
|
||
{!isAiBuild && (
|
||
<div className="rounded-lg border border-default bg-card p-6">
|
||
<p className="text-sm text-muted-foreground">
|
||
Walking flow <span className="font-mono">{session.flow_id}</span>
|
||
</p>
|
||
<p className="mt-2 text-heading">Synthetic step rendering (Phase 1 stub).</p>
|
||
</div>
|
||
)}
|
||
|
||
{isAiBuild && (
|
||
<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>
|
||
)}
|
||
{error && <p className="text-sm text-danger">{error}</p>}
|
||
|
||
{!loading && node?.node_type === 'question' && (
|
||
<>
|
||
<p className="text-lg text-heading">{node.text}</p>
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={() => advance({ answer: 'yes' })}
|
||
className="rounded-md bg-accent px-5 py-2 text-sm text-white"
|
||
>
|
||
Yes
|
||
</button>
|
||
<button
|
||
onClick={() => advance({ answer: 'no' })}
|
||
className="rounded-md border border-default px-5 py-2 text-sm"
|
||
>
|
||
No
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{!loading && node?.node_type === 'instruction' && (
|
||
<>
|
||
<p className="text-lg text-heading">{node.text}</p>
|
||
<button
|
||
onClick={() => advance({ acknowledged: true })}
|
||
className="rounded-md bg-accent px-5 py-2 text-sm text-white"
|
||
>
|
||
Done — next step
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
{!loading && isTerminal && node && (
|
||
<>
|
||
<p className="text-lg text-heading">{node.text}</p>
|
||
<div className="flex gap-3">
|
||
{node.node_type === 'resolved' ? (
|
||
<button
|
||
onClick={() => setShowResolve(true)}
|
||
className="rounded-md bg-accent px-5 py-2 text-sm text-white"
|
||
>
|
||
Mark resolved
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={() => setShowEscalate(true)}
|
||
className="rounded-md bg-accent px-5 py-2 text-sm text-white"
|
||
>
|
||
Escalate to engineering
|
||
</button>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Resolve / Escalate are always reachable during a walk. */}
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={() => setShowResolve(true)}
|
||
className="rounded-md border border-default px-4 py-2 text-sm"
|
||
>
|
||
Resolve
|
||
</button>
|
||
<button
|
||
onClick={() => setShowEscalate(true)}
|
||
className="rounded-md border border-default px-4 py-2 text-sm"
|
||
>
|
||
Escalate
|
||
</button>
|
||
</div>
|
||
|
||
<WalkModals
|
||
session={session}
|
||
showResolve={showResolve}
|
||
showEscalate={showEscalate}
|
||
onCloseResolve={() => setShowResolve(false)}
|
||
onCloseEscalate={() => setShowEscalate(false)}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|