/** The answer desk — a question, the answer, and the sources behind it. */ import { useEffect, useRef, useState } from "react"; import { Text } from "@lotics/ui/text"; import { colors } from "@lotics/ui/colors"; import { Box } from "@lotics/ui/box"; import { PressableRow } from "@lotics/ui/pressable_row"; import { Divider } from "@lotics/ui/divider"; import { Callout, CalloutText, CalloutTitle } from "@lotics/ui/callout"; import { ScrollArea } from "@lotics/ui/scroll_area"; import { Drawer, DrawerScrollArea } from "@lotics/ui/drawer"; import { useContainerSize } from "@lotics/ui/size_boundary"; import { Stack } from "@lotics/ui/stack"; import { Composer } from "@lotics/ui/composer"; import { AgentRun } from "@lotics/ui/agent_run"; import { ConfidenceCallout, type ConfidenceLevel } from "@lotics/ui/confidence_callout"; import { Status } from "@lotics/ui/status"; import { Sources, type SourceRef } from "@lotics/ui/sources"; import type { UIMessagePart, UIDataTypes, UITools } from "ai"; type Part = UIMessagePart; // ───────────────────────────────────────────────────────────────────────────── // Template, Answer desk — describe the goods on the LEFT, the agent RANKS the // matching codes (nearest matches, the top one Recommended), you pick one and its // structured ANSWER pins on the RIGHT. The flow is input → matches → pick, not a // single confident verdict: classification is ambiguous, so the alternatives are // first-class (the prep/pack push the goods to a different code with a different // duty). The answer is a panel of scannable components — the code, the exact duty // breakdown (hand-composed spec rows), the policies, the sources. Refine the description ("what // if it's pre-cooked?") and the ranking updates. Same shape for any // look-up-and-explain: tariff/HS, fee lookup, policy Q&A, a spec/compliance desk. // ───────────────────────────────────────────────────────────────────────────── type Tone = "warning" | "info" | "success"; interface Policy { tone: Tone; title: string; text: string } interface Candidate { id: string; /** Short heading for the ranked list on the left. */ label: string; /** The agent's top pick — wears a "Recommended" badge, selected first. */ recommended?: boolean; code: string; heading: string; confidence: ConfidenceLevel; basis: string; duty: SpecRow[]; dutyTotal: SpecRow; specs: SpecRow[]; policies: Policy[]; sources: SourceRef[]; } // One query, several plausible HS codes — the agent RANKS them; the declarant // picks. The prep and the pack push the goods to a different code with a // different duty, which is exactly why a single confident verdict is wrong. const CANDIDATES: Candidate[] = [ { id: "c1", label: "Frozen shrimp, retail packs ≤ 1 kg", recommended: true, code: "0306.17.10", heading: "Frozen shrimp & prawns of the family Penaeidae — not in airtight containers", confidence: "high", basis: "Headless, shell-on, individually-quick-frozen Penaeus monodon → heading 0306.17 (frozen shrimp of the Penaeidae); national line .10 for consumer packs ≤ 1 kg.", duty: [ { label: "Import duty (MFN)", value: "0%" }, { label: "VAT", value: "8%" }, { label: "Anti-dumping", value: "0%" }, ], dutyTotal: { label: "Effective tax on CIF", value: "8%" }, specs: [ { label: "Chapter", value: "03 — Fish & crustaceans" }, { label: "Form", value: "Frozen (IQF)" }, { label: "Presentation", value: "Headless, shell-on" }, { label: "Net pack", value: "≤ 1 kg" }, ], policies: [ { tone: "warning", title: "Health certificate required", text: "Aquatic-product imports need a health certificate from the competent authority of the exporting country before clearance." }, { tone: "info", title: "Origin can lower the rate", text: "A valid Form D / RCEP certificate of origin keeps the preferential 0% where the MFN line is non-zero." }, ], sources: [ { id: "s1", label: "Tariff schedule 2026", kind: "knowledge", detail: "Ch. 03" }, { id: "s2", label: "Heading 0306.17", kind: "knowledge" }, { id: "s3", label: "Circular 31/2022 — VAT", kind: "document", detail: "Art. 1" }, ], }, { id: "c2", label: "Frozen shrimp, other (bulk / catering)", code: "0306.17.90", heading: "Frozen shrimp & prawns of the family Penaeidae — other", confidence: "medium", basis: "Same heading 0306.17, but the residual national line .90 covers packs ABOVE 1 kg / bulk catering. Right only if the ≤ 1 kg retail-pack evidence is set aside — the duty is identical, the line is not.", duty: [ { label: "Import duty (MFN)", value: "0%" }, { label: "VAT", value: "8%" }, { label: "Anti-dumping", value: "0%" }, ], dutyTotal: { label: "Effective tax on CIF", value: "8%" }, specs: [ { label: "Chapter", value: "03 — Fish & crustaceans" }, { label: "Form", value: "Frozen (IQF)" }, { label: "Net pack", value: "> 1 kg / bulk" }, ], policies: [ { tone: "warning", title: "Health certificate required", text: "Aquatic-product imports need a health certificate from the competent authority of the exporting country before clearance." }, ], sources: [ { id: "s1", label: "Tariff schedule 2026", kind: "knowledge", detail: "Ch. 03" }, { id: "s2", label: "Heading 0306.17", kind: "knowledge" }, ], }, { id: "c3", label: "Shrimp, prepared or preserved", code: "1605.21.00", heading: "Shrimp & prawns, prepared or preserved — not in airtight containers", confidence: "low", basis: "Only if the product is COOKED / breaded / marinated → chapter 16 (prepared), heading 1605.21. Raw IQF shrimp is NOT prepared — the wrong line unless processing is confirmed, and it carries a much higher MFN rate.", duty: [ { label: "Import duty (MFN)", value: "20%" }, { label: "ATIGA (Form D)", value: "0%" }, { label: "VAT", value: "8%" }, ], dutyTotal: { label: "Effective tax on CIF", value: "8–28%" }, specs: [ { label: "Chapter", value: "16 — Prepared fish / crustaceans" }, { label: "Form", value: "Cooked / prepared" }, { label: "Presentation", value: "Not airtight" }, ], policies: [ { tone: "info", title: "Processing decides the chapter", text: "Raw frozen sits in Ch. 03; cooked / breaded / marinated moves to Ch. 16. The 20-point MFN gap makes the evidence of processing decisive." }, ], sources: [ { id: "s1", label: "Tariff schedule 2026", kind: "knowledge", detail: "Ch. 16" }, { id: "s2", label: "Heading 1605.21", kind: "knowledge" }, ], }, ]; type ScriptStep = { id: string; toolName: string; input?: unknown; output?: unknown }; const SCRIPT: ScriptStep[] = [ { id: "r1", toolName: "Reading the description", input: undefined }, { id: "r2", toolName: "tariff_schedule.search" }, { id: "r3", toolName: "Narrowing the heading", input: undefined }, { id: "r4", toolName: "duty_rates.read" }, { id: "r5", toolName: "Checking import policies", input: undefined }, ]; export function TplLookup() { const { small } = useContainerSize(); // NARROW: the answer is a DRAWER over the command column rather than a pane // beside it — two panes in 390 leave the answer 10px and every line in it // renders at nothing. `composition.md` § master-detail: the phone shape of a // pinned detail is the drawer, opened by the pick that chose it. const [answerOpen, setAnswerOpen] = useState(false); const [activeId, setActiveId] = useState(CANDIDATES.find((c) => c.recommended)?.id ?? CANDIDATES[0].id); const [phase, setPhase] = useState<"running" | "ready">("ready"); const [revealed, setRevealed] = useState(SCRIPT.length); const [prompt, setPrompt] = useState(""); const [runKey, setRunKey] = useState(0); const timer = useRef | null>(null); const active = CANDIDATES.find((c) => c.id === activeId) ?? CANDIDATES[0]; useEffect(() => { if (runKey === 0) return; setPhase("running"); setRevealed(1); let n = 1; timer.current = setInterval(() => { n += 1; setRevealed(n); if (n >= SCRIPT.length) { if (timer.current) clearInterval(timer.current); setPhase("ready"); } }, 700); return () => { if (timer.current) clearInterval(timer.current); }; }, [runKey]); const ask = (text: string) => { // Refining the description re-ranks: cooked → prepared (Ch. 16), bulk → the // residual frozen line, else the recommended retail-pack code. const q = text.toLowerCase(); const hit = /cook|prepar|breaded|marinat/.test(q) ? "c3" : /bulk|cater/.test(q) ? "c2" : "c1"; setActiveId(hit); setPrompt(""); setRunKey((k) => k + 1); }; const streaming = phase === "running" && revealed < SCRIPT.length; const items: Part[] = SCRIPT.slice(0, revealed).map((s, i): Part => { const running = streaming && i === revealed - 1; if (running) { return { type: "dynamic-tool", toolName: s.toolName, toolCallId: s.id, state: "input-available", input: s.input }; } return { type: "dynamic-tool", toolName: s.toolName, toolCallId: s.id, state: "output-available", input: s.input, output: s.output }; }); // One block, two chromes: a pane beside the command column, or a drawer over // it. Written once so the phone never reads a second, thinner answer. const answer = ( {/* verdict header — open, no card box: the panel IS the answer */} Suggested HS code {active.code} {active.heading} {/* ConfidenceCallout IS a callout — the level with its basis, never a floating meter. Ranked rows carry no meter at all: the rank and the Recommended badge already convey standing. */} {active.confidence === "high" ? "The description matches the heading notes and both classification criteria." : active.confidence === "medium" ? "The material matches, but the heading notes leave the use-case ambiguous — check the criteria below." : "Only a partial match against the heading notes — treat as a starting point and verify the criteria."} {active.basis} Duty & tax Classification basis {active.policies.length > 0 ? ( Policies that apply {active.policies.map((p, i) => ( {p.title} {p.text} ))} ) : null} {}} /> A suggested classification — the declarant confirms the code before filing. ); return ( <> {/* ── LEFT, command + session ───────────────────────────────── `min-height: 0` on the row and the pane both: a flex item's automatic minimum size is its CONTENT, so without it the pane's scroller grows the page instead of scrolling inside it. `View` carried that floor. */} HS classification Describe the goods — the agent ranks the matching codes; pick one to see its duty {/* current run */} {/* Matches surface only once the run settles — the agent's output, never a pre-filled list; they sit at the bottom, under the run. */} {phase === "ready" ? ( Nearest matches {/* A one-of-N card picker is `PressableRow`'s `card` variant — a real button that announces its `aria-pressed` and wears the kit's edge, where the hand-rolled card announced nothing and painted its own. */} {CANDIDATES.map((c) => ( { setActiveId(c.id); setPhase("ready"); setRevealed(SCRIPT.length); setAnswerOpen(true); }} selected={c.id === activeId} accessibilityLabel={`Show ${c.code} — ${c.label}`} style={{ padding: 10, gap: 4 }} > {c.code} {c.recommended ? : null} {c.label} ))} ) : null} {/* ── RIGHT, the pinned structured answer ───────────────────── */} {small ? null : ( {answer} )} {/* The overlay is RENDERED and its `open` toggles — only the BODY is conditional. Full width on a small screen by the Drawer's own rule. */} setAnswerOpen(open)} title={active.code}> {answerOpen ? {answer} : null} ); } // The structured label → value breakdown behind the answer — the duty table, // the classification basis. Hand-composed two-column rows: hierarchy from // weight and tabular figures; the total pins under a divider. interface SpecRow { label: string; value: string } function SpecRows({ rows, total, dense }: { rows: SpecRow[]; total?: SpecRow; dense?: boolean }) { const line = (row: SpecRow, isTotal?: boolean) => ( {row.label} {row.value} ); return ( {rows.map((r) => line(r))} {total ? ( <> {line(total, true)} ) : null} ); }