@@ -144,6 +144,60 @@ const QueueHint = ({
144144 ) ;
145145} ;
146146
147+ /**
148+ * Once the document has been READ and the run moves on, the big extraction reveal
149+ * (its moment is over) collapses into this one-line node at the top of the trace —
150+ * "Intake · INV-2042 · 3 lines · $730 · reconciled with PO" — expandable to re-show
151+ * the document + extracted fields. Keeps the AI-reads-the-doc proof one click away
152+ * while handing the pane to the workflow (the hero).
153+ */
154+ const CollapsedIntake = ( {
155+ pdfSrc,
156+ document,
157+ state,
158+ } : {
159+ pdfSrc : string ;
160+ document : Invoice ;
161+ state : ExtractionState ;
162+ } ) => {
163+ const [ open , setOpen ] = useState ( false ) ;
164+ return (
165+ < div className = "mb-3 rounded-lg ring-1 ring-inset ring-line" >
166+ < button
167+ type = "button"
168+ data-testid = "intake-collapsed"
169+ onClick = { ( ) => setOpen ( ( o ) => ! o ) }
170+ className = "flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] hover:bg-subtle/50"
171+ >
172+ < span aria-hidden className = "text-ok" >
173+ ✓
174+ </ span >
175+ < span className = "font-medium text-ink" > Intake</ span >
176+ < span className = "min-w-0 flex-1 truncate text-muted" >
177+ { document . vendor } · { document . lineItems . length } lines ·{ " " }
178+ { formatMoney ( document . total , document . currency ) }
179+ </ span >
180+ { state . matches && < Badge tone = "ok" > reconciled with PO</ Badge > }
181+ < span
182+ aria-hidden
183+ className = { `text-faint transition-transform ${ open ? "rotate-180" : "" } ` }
184+ >
185+ ▾
186+ </ span >
187+ </ button >
188+ { open && (
189+ < div className = "border-t border-line p-3" >
190+ < ExtractionReveal
191+ pdfSrc = { pdfSrc }
192+ state = { state }
193+ extractedInvoice = { document }
194+ />
195+ </ div >
196+ ) }
197+ </ div >
198+ ) ;
199+ } ;
200+
147201/** Small spinner shown while a run is in flight. */
148202const Spinner = ( ) => {
149203 return (
@@ -314,6 +368,20 @@ export const Dashboard = ({
314368 const graphToShow = runGraph ?. workflow ?? workflow ;
315369 const graphStatuses = runGraph ?. statuses ;
316370
371+ // Has the document been READ and the run moved on? The extraction reveal is a
372+ // MOMENT (the AI reading a real PDF): it owns the pane while it happens, then —
373+ // once matching/a later stage has started — it collapses to a one-line "Intake"
374+ // node at the top of the trace, handing the pane to the workflow (the hero).
375+ // `doneIntake` is the read document ONCE the run is past intake (else null), so
376+ // it both flags the phase and carries the data the collapsed node needs.
377+ const intake = readIntake ( state . trace ) ;
378+ const movedPastIntake = state . trace . some (
379+ ( e ) => e . stage !== "intake" && e . kind !== "run" ,
380+ ) ;
381+ const doneIntake =
382+ intake && intake . state . status === "done" && movedPastIntake ? intake : null ;
383+ const pastIntake = doneIntake !== null ;
384+
317385 // The gates the paused run is waiting on (joined: live status + the workflow's
318386 // people). Drives the inline per-node Approve/Reject and the submit affordance.
319387 const gates =
@@ -583,68 +651,81 @@ export const Dashboard = ({
583651 area — the cue that the trace continues below (esp. on macOS where the
584652 scrollbar is hidden). */ }
585653 < div className = "relative min-h-0 flex-1" >
654+ { /* Three phases, so the most important thing always owns the pane:
655+ • IDLE / READING — the extraction reveal (the AI reading the real PDF)
656+ is the MOMENT; it gets the full width, centered.
657+ • PAST INTAKE — the document's been read, so the reveal collapses to a
658+ one-line "Intake ✓" node and the pane becomes two columns: the
659+ WORKFLOW GRAPH (the hero) on the left, the trace on the right (with
660+ that collapsed intake as its first node).
661+ Mobile stacks everything into one scrolling column. */ }
586662 < div
587663 ref = { traceScrollRef }
588664 onScroll = { measureTrace }
589- className = "scrollbar-slim h-full overflow-y-auto px-5 py-4"
665+ className = { `scrollbar-slim h-full overflow-y-auto px-5 py-4 ${
666+ pastIntake
667+ ? "lg:grid lg:grid-cols-[1.35fr_minmax(300px,1fr)] lg:gap-5 lg:overflow-hidden"
668+ : ""
669+ } `}
590670 >
591- { /* The document + extraction panel is ALWAYS shown when a row is in
592- view (idle preview, or live during a run), so it never unmounts —
593- no flash between selecting and running. `intake` is null until the
594- run emits its first event; until then it's a static preview. */ }
595- { previewId && (
596- < div className = "mb-4" >
597- < ExtractionReveal
598- pdfSrc = { API_ROUTES . pdf ( previewId ) }
599- // Show the scanning state the instant Run is clicked — even
600- // before the first stream event lands — so the UI feels
601- // immediate. The real intake event takes over when it arrives.
602- state = {
603- readIntake ( state . trace ) ?. state ??
604- ( state . status === "running"
605- ? { status : "running" , extracted : null , matches : false }
606- : null )
607- }
608- extractedInvoice = { readIntake ( state . trace ) ?. document ?? null }
609- />
610- </ div >
671+ { /* Before we're past intake: the extraction reveal owns the pane (idle
672+ preview, or the live scan). Always mounted so there's no flash. */ }
673+ { ! pastIntake && previewId && (
674+ < ExtractionReveal
675+ pdfSrc = { API_ROUTES . pdf ( previewId ) }
676+ // Show the scanning state the instant Run is clicked — even before
677+ // the first stream event lands — so the UI feels immediate.
678+ state = {
679+ intake ?. state ??
680+ ( state . status === "running"
681+ ? { status : "running" , extracted : null , matches : false }
682+ : null )
683+ }
684+ extractedInvoice = { intake ?. document ?? null }
685+ />
611686 ) }
612- { /* The live workflow graph — the SAME canvas onboarding draws, lit up
613- by this invoice's path (a gate "In review" / "Approved" / "Skipped").
614- This is the loop made visible: the workflow you built on the left is
615- exactly what routes the invoice here. Shown once a run is underway, above
616- the detailed text timeline. */ }
617- { state . status !== "idle" && graphToShow && (
618- < div className = "mb-4" >
687+
688+ { /* Past intake: LEFT column — the workflow graph, the hero, given the
689+ larger share of the width. */ }
690+ { pastIntake && graphToShow && (
691+ < div className = "mb-4 lg:mb-0 lg:flex lg:min-h-0 lg:flex-col" >
619692 < p className = "mb-1.5 text-[11px] font-medium uppercase tracking-wider text-faint" >
620693 Routing through { graphToShow . name }
621694 </ p >
622695 < div
623696 data-testid = "live-graph"
624- className = "h-[440px] overflow-hidden rounded-xl bg-subtle/30 ring-1 ring-inset ring-line sm:h-64"
697+ className = "h-[440px] overflow-hidden rounded-xl bg-subtle/30 ring-1 ring-inset ring-line sm:h-64 lg:h-auto lg:min-h-[320px] lg:flex-1 "
625698 >
626699 < WorkflowGraph
627700 workflow = { graphToShow }
628701 statuses = { graphStatuses }
629- // When >1 gate pends in parallel, each pending node gets inline
630- // Approve/Reject (decide one, reject another). A single gate uses
631- // the header buttons, so the graph stays read-only there.
632702 decidableIds = { gates . length >= 2 ? pendingIds : undefined }
633703 decisions = { gates . length >= 2 ? gateChoices : undefined }
634704 onDecide = { gates . length >= 2 ? setGate : undefined }
635- // Pan to frame the waiting gate(s) the moment the run pauses.
636705 focusIds = { awaiting ? pendingIds : undefined }
637706 />
638707 </ div >
639708 </ div >
640709 ) }
641- { state . status !== "idle" && (
642- < TraceTimeline
643- state = { state }
644- invoiceLabel = { selected ?. invoiceNumber ?? null }
645- canRun = { ! ! selected }
646- onRun = { ( ) => selected && run ( selected . id ) }
647- />
710+
711+ { /* Past intake: RIGHT column — the trace, led by the collapsed Intake
712+ node (expandable to re-show the document + extracted fields). */ }
713+ { doneIntake && (
714+ < div className = "scrollbar-slim lg:h-full lg:overflow-y-auto lg:pl-1" >
715+ { previewId && (
716+ < CollapsedIntake
717+ pdfSrc = { API_ROUTES . pdf ( previewId ) }
718+ document = { doneIntake . document }
719+ state = { doneIntake . state }
720+ />
721+ ) }
722+ < TraceTimeline
723+ state = { state }
724+ invoiceLabel = { selected ?. invoiceNumber ?? null }
725+ canRun = { ! ! selected }
726+ onRun = { ( ) => selected && run ( selected . id ) }
727+ />
728+ </ div >
648729 ) }
649730 </ div >
650731 { /* "more ↓" is a trace affordance — only meaningful once a run is
0 commit comments