1- import { useEffect } from "react" ;
1+ import { useCallback , useEffect , useRef } from "react" ;
22import { useRunStore } from "./store/useRunStore" ;
33import { useWebSocket } from "./store/useWebSocket" ;
44import { listRuns , listEntrypoints , getRun } from "./api/client" ;
5+ import type { RunDetail } from "./types/run" ;
56import { useHashRoute } from "./hooks/useHashRoute" ;
67import Sidebar from "./components/layout/Sidebar" ;
78import NewRunPanel from "./components/runs/NewRunPanel" ;
@@ -41,79 +42,109 @@ export default function App() {
4142 . catch ( console . error ) ;
4243 } , [ setRuns , setEntrypoints ] ) ;
4344
45+ const selectedRun = selectedRunId ? runs [ selectedRunId ] : null ;
46+
47+ // Shared helper: apply a full run detail response to the store
48+ const applyRunDetail = useCallback ( ( runId : string , detail : RunDetail ) => {
49+ upsertRun ( detail ) ;
50+ setTraces ( runId , detail . traces ) ;
51+ setLogs ( runId , detail . logs ) ;
52+ // Convert messages to chat format (server uses camelCase aliases)
53+ const chatMsgs = ( detail . messages as unknown as Record < string , unknown > [ ] ) . map ( ( m : Record < string , unknown > ) => {
54+ const parts = ( ( m . contentParts ?? m . content_parts ) as Array < Record < string , unknown > > ) ?? [ ] ;
55+ const toolCalls = ( ( m . toolCalls ?? m . tool_calls ) as Array < Record < string , unknown > > ) ?? [ ] ;
56+ return {
57+ message_id : ( ( m . messageId ?? m . message_id ) as string ) ,
58+ role : ( m . role as string ) ?? "assistant" ,
59+ content :
60+ parts
61+ . filter ( ( p ) => {
62+ const mime = ( ( p . mimeType ?? p . mime_type ) as string ) ?? "" ;
63+ return mime . startsWith ( "text/" ) || mime === "application/json" ;
64+ } )
65+ . map ( ( p ) => {
66+ const data = p . data as Record < string , unknown > ;
67+ return ( data ?. inline as string ) ?? "" ;
68+ } )
69+ . join ( "\n" )
70+ . trim ( ) ?? "" ,
71+ tool_calls : toolCalls . length > 0
72+ ? toolCalls . map ( ( tc ) => ( {
73+ name : ( tc . name as string ) ?? "" ,
74+ has_result : ! ! tc . result ,
75+ } ) )
76+ : undefined ,
77+ } ;
78+ } ) ;
79+ setChatMessages ( runId , chatMsgs ) ;
80+ // Cache graph data per run (persists across reloads)
81+ if ( detail . graph && detail . graph . nodes . length > 0 ) {
82+ setGraphCache ( runId , detail . graph ) ;
83+ }
84+ // Load persisted state events
85+ if ( detail . states && detail . states . length > 0 ) {
86+ setStateEvents (
87+ runId ,
88+ detail . states . map ( ( s ) => ( {
89+ node_name : s . node_name ,
90+ qualified_node_name : s . qualified_node_name ,
91+ phase : s . phase ,
92+ timestamp : new Date ( s . timestamp ) . getTime ( ) ,
93+ payload : s . payload ,
94+ } ) ) ,
95+ ) ;
96+ }
97+ } , [ upsertRun , setTraces , setLogs , setChatMessages , setStateEvents , setGraphCache ] ) ;
98+
4499 // Subscribe to selected run
45100 useEffect ( ( ) => {
46101 if ( ! selectedRunId ) return ;
47102 ws . subscribe ( selectedRunId ) ;
48103
49- const applyRunDetail = ( detail : Awaited < ReturnType < typeof getRun > > ) => {
50- upsertRun ( detail ) ;
51- setTraces ( selectedRunId , detail . traces ) ;
52- setLogs ( selectedRunId , detail . logs ) ;
53- // Convert messages to chat format (server uses camelCase aliases)
54- const chatMsgs = ( detail . messages as unknown as Record < string , unknown > [ ] ) . map ( ( m : Record < string , unknown > ) => {
55- const parts = ( ( m . contentParts ?? m . content_parts ) as Array < Record < string , unknown > > ) ?? [ ] ;
56- const toolCalls = ( ( m . toolCalls ?? m . tool_calls ) as Array < Record < string , unknown > > ) ?? [ ] ;
57- return {
58- message_id : ( ( m . messageId ?? m . message_id ) as string ) ,
59- role : ( m . role as string ) ?? "assistant" ,
60- content :
61- parts
62- . filter ( ( p ) => {
63- const mime = ( ( p . mimeType ?? p . mime_type ) as string ) ?? "" ;
64- return mime . startsWith ( "text/" ) || mime === "application/json" ;
65- } )
66- . map ( ( p ) => {
67- const data = p . data as Record < string , unknown > ;
68- return ( data ?. inline as string ) ?? "" ;
69- } )
70- . join ( "\n" )
71- . trim ( ) ?? "" ,
72- tool_calls : toolCalls . length > 0
73- ? toolCalls . map ( ( tc ) => ( {
74- name : ( tc . name as string ) ?? "" ,
75- has_result : ! ! tc . result ,
76- } ) )
77- : undefined ,
78- } ;
79- } ) ;
80- setChatMessages ( selectedRunId , chatMsgs ) ;
81- // Cache graph data per run (persists across reloads)
82- if ( detail . graph && detail . graph . nodes . length > 0 ) {
83- setGraphCache ( selectedRunId , detail . graph ) ;
84- }
85- // Load persisted state events
86- if ( detail . states && detail . states . length > 0 ) {
87- setStateEvents (
88- selectedRunId ,
89- detail . states . map ( ( s ) => ( {
90- node_name : s . node_name ,
91- qualified_node_name : s . qualified_node_name ,
92- phase : s . phase ,
93- timestamp : new Date ( s . timestamp ) . getTime ( ) ,
94- payload : s . payload ,
95- } ) ) ,
96- ) ;
97- }
98- } ;
99-
100104 // Fetch full run details (includes fresh status in case we missed run.updated events)
101- getRun ( selectedRunId ) . then ( applyRunDetail ) . catch ( console . error ) ;
105+ getRun ( selectedRunId ) . then ( ( d ) => applyRunDetail ( selectedRunId , d ) ) . catch ( console . error ) ;
102106
103107 // Safety net: re-fetch if run is still in progress after WS subscribe + initial fetch.
104108 // Covers the race where the run completes before WS subscription is processed.
105109 const retryTimer = setTimeout ( ( ) => {
106110 const run = useRunStore . getState ( ) . runs [ selectedRunId ] ;
107111 if ( run && ( run . status === "pending" || run . status === "running" ) ) {
108- getRun ( selectedRunId ) . then ( applyRunDetail ) . catch ( console . error ) ;
112+ getRun ( selectedRunId ) . then ( ( d ) => applyRunDetail ( selectedRunId , d ) ) . catch ( console . error ) ;
109113 }
110114 } , 2000 ) ;
111115
112116 return ( ) => {
113117 clearTimeout ( retryTimer ) ;
114118 ws . unsubscribe ( selectedRunId ) ;
115119 } ;
116- } , [ selectedRunId , ws , upsertRun , setTraces , setLogs , setChatMessages , setStateEvents , setGraphCache ] ) ;
120+ } , [ selectedRunId , ws , applyRunDetail ] ) ;
121+
122+ // Refetch full details when run reaches terminal status, but only if WS events were missed
123+ const prevStatusRef = useRef < string | null > ( null ) ;
124+ useEffect ( ( ) => {
125+ if ( ! selectedRunId ) return ;
126+ const status = selectedRun ?. status ;
127+ const prev = prevStatusRef . current ;
128+ prevStatusRef . current = status ?? null ;
129+
130+ if (
131+ status &&
132+ ( status === "completed" || status === "failed" ) &&
133+ prev !== status
134+ ) {
135+ // Compare what we received via WS against the counts in the run summary.
136+ // Only refetch if something was missed — avoids unnecessary re-renders / flicker.
137+ const state = useRunStore . getState ( ) ;
138+ const haveTraces = state . traces [ selectedRunId ] ?. length ?? 0 ;
139+ const haveLogs = state . logs [ selectedRunId ] ?. length ?? 0 ;
140+ const expectedTraces = selectedRun ?. trace_count ?? 0 ;
141+ const expectedLogs = selectedRun ?. log_count ?? 0 ;
142+
143+ if ( haveTraces < expectedTraces || haveLogs < expectedLogs ) {
144+ getRun ( selectedRunId ) . then ( ( d ) => applyRunDetail ( selectedRunId , d ) ) . catch ( console . error ) ;
145+ }
146+ }
147+ } , [ selectedRunId , selectedRun ?. status , applyRunDetail ] ) ;
117148
118149 const handleRunCreated = ( runId : string ) => {
119150 navigate ( `#/runs/${ runId } /traces` ) ;
@@ -129,8 +160,6 @@ export default function App() {
129160 navigate ( "#/new" ) ;
130161 } ;
131162
132- const selectedRun = selectedRunId ? runs [ selectedRunId ] : null ;
133-
134163 return (
135164 < div className = "flex h-screen w-screen" >
136165 < Sidebar
0 commit comments