@@ -36,6 +36,18 @@ import {
3636 mergeChannelsStatus ,
3737} from "./channelsSettings.ts" ;
3838import { createRequestGenerationGuard , parseApiResponse } from "./apiReliability.ts" ;
39+ import {
40+ attemptTargetedTaskRefresh ,
41+ getTaskResponseUiState ,
42+ markTaskResponseSubmitted ,
43+ mergeTargetedTaskDetail ,
44+ prepareTaskResponse ,
45+ reconcileTasksWithSubmittedAnswers ,
46+ selectTickAfterRefresh ,
47+ startHeartbeatTickPolling ,
48+ taskNeedsResponse ,
49+ type TaskResponseRefreshResult ,
50+ } from "./operatorUi.ts" ;
3951import { buildExecutionSteps } from "./traceSteps.ts" ;
4052
4153const API = "http://127.0.0.1:9712/api" ;
@@ -830,6 +842,13 @@ async function fetchTasks() {
830842 return res . json ( ) ;
831843}
832844
845+ async function fetchTask ( id ) {
846+ const res = await fetch ( `${ API } /tasks/${ id } ` ) ;
847+ const payload = await res . json ( ) . catch ( ( ) => ( { } ) ) ;
848+ if ( ! res . ok ) throw new Error ( payload . error || `HTTP ${ res . status } ` ) ;
849+ return payload ;
850+ }
851+
833852async function fetchHeartbeats ( ) {
834853 const res = await fetch ( `${ API } /heartbeats` ) ;
835854 if ( ! res . ok ) throw new Error ( `HTTP ${ res . status } ` ) ;
@@ -2212,9 +2231,12 @@ function HeartbeatDetailPanel({ heartbeat, ticks, onClose }) {
22122231 const [ tickOutput , setTickOutput ] = useState ( "" ) ;
22132232 const [ tickRunning , setTickRunning ] = useState ( false ) ;
22142233 const outputRef = useRef < any > ( null ) ;
2234+ const previousHeartbeatIdRef = useRef ( heartbeat . id ) ;
22152235
22162236 useEffect ( ( ) => {
2217- setSelectedTickId ( ticks [ 0 ] ?. id || null ) ;
2237+ const heartbeatChanged = previousHeartbeatIdRef . current !== heartbeat . id ;
2238+ previousHeartbeatIdRef . current = heartbeat . id ;
2239+ setSelectedTickId ( ( current ) => selectTickAfterRefresh ( current , ticks , heartbeatChanged ) ) ;
22182240 } , [ heartbeat . id , ticks ] ) ;
22192241
22202242 useEffect ( ( ) => {
@@ -2224,12 +2246,16 @@ function HeartbeatDetailPanel({ heartbeat, ticks, onClose }) {
22242246 return ;
22252247 }
22262248 let cancelled = false ;
2249+ let timeout ;
22272250 const load = async ( ) => {
22282251 try {
22292252 const data = await fetchHeartbeatTickOutput ( heartbeat . id , selectedTickId ) ;
22302253 if ( cancelled ) return ;
22312254 setTickOutput ( data . output || "" ) ;
22322255 setTickRunning ( ! ! data . is_running ) ;
2256+ if ( data . is_running ) {
2257+ timeout = setTimeout ( load , 1000 ) ;
2258+ }
22332259 } catch {
22342260 if ( ! cancelled ) {
22352261 setTickOutput ( "" ) ;
@@ -2238,10 +2264,9 @@ function HeartbeatDetailPanel({ heartbeat, ticks, onClose }) {
22382264 }
22392265 } ;
22402266 load ( ) ;
2241- const interval = setInterval ( load , 1000 ) ;
22422267 return ( ) => {
22432268 cancelled = true ;
2244- clearInterval ( interval ) ;
2269+ if ( timeout ) clearTimeout ( timeout ) ;
22452270 } ;
22462271 } , [ heartbeat . id , selectedTickId ] ) ;
22472272
@@ -3015,14 +3040,18 @@ function NewTaskModal({ onClose, onSubmit, initialData, mode = "create" }) {
30153040 ) ;
30163041}
30173042
3018- function DetailPanel ( { task, onClose, onResume } : any ) {
3043+ function DetailPanel ( { task, onClose, onRespond , onResume } : any ) {
30193044 // `task` is always truthy here — the only caller renders this inside
30203045 // `{detail && <DetailPanel task={... || detail} />}`. Hooks must stay
30213046 // unconditional, so do not early-return before them.
30223047 const [ liveOutput , setLiveOutput ] = useState ( "" ) ;
30233048 const [ resumeText , setResumeText ] = useState ( "" ) ;
30243049 const [ resumeError , setResumeError ] = useState ( "" ) ;
30253050 const [ resumeSent , setResumeSent ] = useState ( false ) ;
3051+ const [ answerText , setAnswerText ] = useState ( "" ) ;
3052+ const [ responseLoading , setResponseLoading ] = useState ( false ) ;
3053+ const [ responseError , setResponseError ] = useState ( "" ) ;
3054+ const [ responseResult , setResponseResult ] = useState < TaskResponseRefreshResult | null > ( null ) ;
30263055 const [ messages , setMessages ] = useState < any [ ] > ( [ ] ) ;
30273056 const [ events , setEvents ] = useState < any [ ] > ( [ ] ) ;
30283057 const [ showMessages , setShowMessages ] = useState ( false ) ;
@@ -3100,6 +3129,34 @@ function DetailPanel({ task, onClose, onResume }: any) {
31003129 }
31013130 } , [ messages ] ) ;
31023131
3132+ useEffect ( ( ) => {
3133+ setAnswerText ( "" ) ;
3134+ setResponseError ( "" ) ;
3135+ setResponseLoading ( false ) ;
3136+ setResponseResult ( null ) ;
3137+ } , [ task . id , task . question , task . answer ] ) ;
3138+
3139+ const handleResponse = async ( ) => {
3140+ const prepared = prepareTaskResponse ( answerText ) ;
3141+ if ( prepared . error ) {
3142+ setResponseError ( prepared . error ) ;
3143+ return ;
3144+ }
3145+
3146+ setResponseError ( "" ) ;
3147+ setResponseLoading ( true ) ;
3148+ try {
3149+ const result = await onRespond ( task . id , prepared . answer ) ;
3150+ setAnswerText ( "" ) ;
3151+ setResponseResult ( { refreshed : result ?. refreshed !== false } ) ;
3152+ } catch ( error ) {
3153+ setResponseError ( error instanceof Error ? error . message : "Failed to submit response." ) ;
3154+ } finally {
3155+ setResponseLoading ( false ) ;
3156+ }
3157+ } ;
3158+ const responseUiState = getTaskResponseUiState ( task . question , task . answer , responseResult ) ;
3159+
31033160 const handleResume = async ( ) => {
31043161 if ( ! resumeText . trim ( ) ) return ;
31053162 setResumeError ( "" ) ;
@@ -3227,6 +3284,89 @@ function DetailPanel({ task, onClose, onResume }: any) {
32273284 ) }
32283285 </ Section >
32293286
3287+ { responseUiState !== "hidden" && (
3288+ < Section title = "Agent Question" >
3289+ < div
3290+ style = { {
3291+ background : theme . orangeBg ,
3292+ border : `1px solid ${ theme . orange } ` ,
3293+ borderRadius : 8 ,
3294+ padding : 14 ,
3295+ fontSize : 12 ,
3296+ color : theme . text ,
3297+ whiteSpace : "pre-wrap" ,
3298+ wordBreak : "break-word" ,
3299+ lineHeight : 1.6 ,
3300+ marginBottom : 12 ,
3301+ } }
3302+ >
3303+ { task . question }
3304+ </ div >
3305+ { responseUiState === "form" ? (
3306+ < >
3307+ < textarea
3308+ value = { answerText }
3309+ onChange = { ( event ) => {
3310+ setAnswerText ( event . target . value ) ;
3311+ if ( responseError ) setResponseError ( "" ) ;
3312+ } }
3313+ placeholder = "Enter your answer..."
3314+ disabled = { responseLoading }
3315+ rows = { 4 }
3316+ style = { {
3317+ width : "100%" ,
3318+ boxSizing : "border-box" ,
3319+ background : theme . field ,
3320+ color : theme . text ,
3321+ border : `1px solid ${ responseError ? theme . red : theme . border } ` ,
3322+ borderRadius : 8 ,
3323+ padding : 12 ,
3324+ fontSize : 12 ,
3325+ fontFamily : "'JetBrains Mono', monospace" ,
3326+ lineHeight : 1.6 ,
3327+ resize : "vertical" ,
3328+ outline : "none" ,
3329+ } }
3330+ />
3331+ { responseError && (
3332+ < div style = { { color : theme . red , fontSize : 11 , marginTop : 8 } } > { responseError } </ div >
3333+ ) }
3334+ < div style = { { display : "flex" , justifyContent : "flex-end" , marginTop : 10 } } >
3335+ < button
3336+ onClick = { handleResponse }
3337+ disabled = { responseLoading }
3338+ style = { {
3339+ ...primaryButton ( ) ,
3340+ cursor : responseLoading ? "wait" : "pointer" ,
3341+ opacity : responseLoading ? 0.65 : 1 ,
3342+ } }
3343+ >
3344+ { responseLoading ? "Submitting..." : "Submit Answer" }
3345+ </ button >
3346+ </ div >
3347+ </ >
3348+ ) : (
3349+ < div
3350+ style = { {
3351+ background : responseUiState === "submitted-stale" ? theme . orangeBg : theme . greenBg ,
3352+ border : `1px solid ${
3353+ responseUiState === "submitted-stale" ? theme . orange : theme . green
3354+ } `,
3355+ borderRadius : 8 ,
3356+ padding : 12 ,
3357+ color : theme . text ,
3358+ fontSize : 12 ,
3359+ lineHeight : 1.6 ,
3360+ } }
3361+ >
3362+ { responseUiState === "submitted-stale"
3363+ ? "Answer submitted, but the task refresh failed. This view may be stale; automatic refresh will retry."
3364+ : "Answer submitted. Refreshing task status..." }
3365+ </ div >
3366+ ) }
3367+ </ Section >
3368+ ) }
3369+
32303370 < Section title = "Configuration" >
32313371 < InfoRow label = "Working Dir" value = { task . working_dir } />
32323372 < InfoRow label = "Agent" value = { task . agent || DEFAULT_AGENT } />
@@ -5822,6 +5962,8 @@ export default function App() {
58225962 const [ forkingTask , setForkingTask ] = useState < any > ( null ) ;
58235963 const [ editingHeartbeat , setEditingHeartbeat ] = useState < any > ( null ) ;
58245964 const pollGuardRef = useRef ( createRequestGenerationGuard ( ) ) ;
5965+ const heartbeatDetailId = heartbeatDetail ?. id ;
5966+ const submittedTaskAnswersRef = useRef < Record < string , string > > ( { } ) ;
58255967
58265968 // ─── Color mode ───
58275969 const [ colorMode , setColorMode ] = useState ( ( ) => localStorage . getItem ( "colorMode" ) || "system" ) ;
@@ -5879,16 +6021,25 @@ export default function App() {
58796021 fetchSkills ( ) ,
58806022 ] ) ;
58816023 if ( ! pollGuardRef . current . isCurrent ( generation ) ) return ;
5882- setTasks ( taskData ) ;
6024+ const reconciled = reconcileTasksWithSubmittedAnswers (
6025+ taskData ,
6026+ submittedTaskAnswersRef . current ,
6027+ ) ;
6028+ submittedTaskAnswersRef . current = Object . fromEntries (
6029+ reconciled . pendingSubmissionIds . map ( ( id ) => [ id , submittedTaskAnswersRef . current [ id ] ] ) ,
6030+ ) ;
6031+ setTasks ( reconciled . tasks ) ;
58836032 setHeartbeats ( heartbeatData ) ;
58846033 setSkillData ( skillRes ) ;
58856034 setSkills ( skillsRes . skills || [ ] ) ;
58866035 setConnected ( true ) ;
58876036 setApiError ( null ) ;
6037+ return true ;
58886038 } catch ( err ) {
58896039 if ( ! pollGuardRef . current . isCurrent ( generation ) ) return ;
58906040 setConnected ( false ) ;
58916041 setApiError ( `Failed to fetch tasks: ${ err . message } ` ) ;
6042+ return false ;
58926043 }
58936044 } , [ ] ) ;
58946045
@@ -5903,6 +6054,22 @@ export default function App() {
59036054 } ;
59046055 } , [ poll , backendReady ] ) ;
59056056
6057+ useEffect ( ( ) => {
6058+ if ( ! heartbeatDetailId ) return ;
6059+
6060+ return startHeartbeatTickPolling ( {
6061+ heartbeatId : heartbeatDetailId ,
6062+ load : fetchHeartbeatTicks ,
6063+ onTicks : setHeartbeatTicks ,
6064+ onError : ( error ) =>
6065+ setApiError (
6066+ `Failed to fetch heartbeat ticks: ${
6067+ error instanceof Error ? error . message : String ( error )
6068+ } `,
6069+ ) ,
6070+ } ) ;
6071+ } , [ heartbeatDetailId ] ) ;
6072+
59066073 useEffect ( ( ) => {
59076074 if ( ! backendReady ) return ;
59086075 fetchSettings ( ) . then ( ( s ) => {
@@ -5955,14 +6122,6 @@ export default function App() {
59556122 return ;
59566123 }
59576124 poll ( ) ;
5958- if ( heartbeatDetail ?. id === id && action !== "delete" ) {
5959- const [ updatedHeartbeat , ticks ] = await Promise . all ( [
5960- fetch ( `${ API } /heartbeats/${ id } ` ) . then ( ( r ) => r . json ( ) ) ,
5961- fetchHeartbeatTicks ( id ) ,
5962- ] ) ;
5963- setHeartbeatDetail ( updatedHeartbeat ) ;
5964- setHeartbeatTicks ( ticks ) ;
5965- }
59666125 } catch ( e ) {
59676126 setApiError ( `Heartbeat ${ action } failed: ${ e . message } ` ) ;
59686127 }
@@ -6057,10 +6216,40 @@ export default function App() {
60576216 const handleRespond = async ( id , answer ) => {
60586217 try {
60596218 await respondToTask ( id , answer ) ;
6060- poll ( ) ;
60616219 } catch ( e ) {
60626220 setApiError ( `Respond failed: ${ e . message } ` ) ;
6221+ throw e ;
60636222 }
6223+
6224+ submittedTaskAnswersRef . current [ String ( id ) ] = answer ;
6225+ setTasks ( ( current ) => current . map ( ( task ) => markTaskResponseSubmitted ( task , id , answer ) ) ) ;
6226+ setDetail ( ( current ) =>
6227+ current ?. id === id ? markTaskResponseSubmitted ( current , id , answer ) : current ,
6228+ ) ;
6229+
6230+ const refreshed = await attemptTargetedTaskRefresh ( {
6231+ load : ( ) => fetchTask ( id ) ,
6232+ onTask : ( refreshedTask ) => {
6233+ const responseStillPending = taskNeedsResponse (
6234+ refreshedTask . question ,
6235+ refreshedTask . answer ,
6236+ ) ;
6237+ if ( responseStillPending ) {
6238+ submittedTaskAnswersRef . current [ String ( id ) ] = answer ;
6239+ } else {
6240+ delete submittedTaskAnswersRef . current [ String ( id ) ] ;
6241+ }
6242+ const safeTask = responseStillPending
6243+ ? markTaskResponseSubmitted ( refreshedTask , id , answer )
6244+ : refreshedTask ;
6245+ setTasks ( ( current ) => current . map ( ( task ) => ( task . id === id ? safeTask : task ) ) ) ;
6246+ setDetail ( ( current ) => mergeTargetedTaskDetail ( current , id , safeTask ) ) ;
6247+ } ,
6248+ onError : ( ) => {
6249+ setApiError ( "Answer submitted, but task details have not refreshed yet." ) ;
6250+ } ,
6251+ } ) ;
6252+ return { refreshed } ;
60646253 } ;
60656254
60666255 const handleResume = ( ) => {
@@ -6087,15 +6276,9 @@ export default function App() {
60876276 }
60886277 } ;
60896278
6090- const openHeartbeatDetail = async ( heartbeat ) => {
6279+ const openHeartbeatDetail = ( heartbeat ) => {
6280+ if ( heartbeatDetail ?. id !== heartbeat . id ) setHeartbeatTicks ( [ ] ) ;
60916281 setHeartbeatDetail ( heartbeat ) ;
6092- try {
6093- const ticks = await fetchHeartbeatTicks ( heartbeat . id ) ;
6094- setHeartbeatTicks ( ticks ) ;
6095- } catch ( e ) {
6096- setApiError ( `Failed to fetch heartbeat ticks: ${ e . message } ` ) ;
6097- setHeartbeatTicks ( [ ] ) ;
6098- }
60996282 } ;
61006283
61016284 const filter =
0 commit comments