@@ -12,7 +12,6 @@ import {
1212} from "../../services/http/session.service.ts" ;
1313import {
1414 activateWorkspace as activateWorkspaceRequest ,
15- attachWorkspaceRuntime ,
1615 heartbeatWorkspaceController ,
1716 releaseWorkspaceControllerKeepalive ,
1817 requestWorkspaceTakeover ,
@@ -49,6 +48,7 @@ import {
4948 getOrCreateClientId as getWorkspaceClientId ,
5049 getOrCreateDeviceId as getWorkspaceDeviceId ,
5150} from "../workspace/workspace-controller" ;
51+ import { attachWorkspaceRuntimeWithRetry } from "../workspace/runtime-attach" ;
5252import { createWorkspaceSessionActions } from "../workspace/session-actions" ;
5353import { useWorkspaceTransportSync } from "../workspace/workspace-sync-hooks" ;
5454import {
@@ -61,6 +61,13 @@ const withServiceFallback = async <T,>(
6161 fallback : T ,
6262) : Promise < T > => withFallback ( operation , fallback ) ;
6363
64+ const CONTROLLER_HEARTBEAT_INTERVAL_MS = 10_000 ;
65+ const HIDDEN_CONTROLLER_HEARTBEAT_INTERVAL_MS = 20_000 ;
66+ const CONTROLLER_RECOVERY_INTERVAL_MS = 1_000 ;
67+ const HIDDEN_CONTROLLER_RECOVERY_INTERVAL_MS = 5_000 ;
68+ const TAKEOVER_POLL_INTERVAL_MS = 2_000 ;
69+ const HIDDEN_TAKEOVER_POLL_INTERVAL_MS = 5_000 ;
70+
6471type WorkbenchRuntimeCoordinatorProps = {
6572 appSettings : AppSettings ;
6673 locale : Locale ;
@@ -73,6 +80,9 @@ export const WorkbenchRuntimeCoordinator = ({
7380 const navigate = useNavigate ( ) ;
7481 const [ state , setState ] = useRelaxState ( workbenchState ) ;
7582 const stateRef = useRef ( state ) ;
83+ const [ isOnline , setIsOnline ] = useState ( ( ) => (
84+ typeof navigator === "undefined" ? true : navigator . onLine !== false
85+ ) ) ;
7686 const [ isWindowFocused , setIsWindowFocused ] = useState ( ( ) => (
7787 typeof document === "undefined" ? true : document . hasFocus ( )
7888 ) ) ;
@@ -104,6 +114,9 @@ export const WorkbenchRuntimeCoordinator = ({
104114 exited : boolean ;
105115 } > ( ) ) ;
106116 const agentStartupTokenRef = useRef ( 0 ) ;
117+ const runtimeAttachInflightRef = useRef ( new Map < string , Promise < void > > ( ) ) ;
118+ const heartbeatInflightRef = useRef ( new Set < string > ( ) ) ;
119+ const takeoverPollingInflightRef = useRef ( new Set < string > ( ) ) ;
107120 const t = useMemo ( ( ) => createTranslator ( locale ) , [ locale ] ) ;
108121 const deviceId = useMemo ( ( ) => getWorkspaceDeviceId ( ) , [ ] ) ;
109122 const clientId = useMemo ( ( ) => getWorkspaceClientId ( ) , [ ] ) ;
@@ -144,7 +157,6 @@ export const WorkbenchRuntimeCoordinator = ({
144157 setIsDocumentVisible ( document . visibilityState === "visible" ) ;
145158 } ;
146159
147- syncVisibility ( ) ;
148160 window . addEventListener ( "focus" , syncVisibility ) ;
149161 window . addEventListener ( "blur" , syncVisibility ) ;
150162 document . addEventListener ( "visibilitychange" , syncVisibility ) ;
@@ -155,6 +167,24 @@ export const WorkbenchRuntimeCoordinator = ({
155167 } ;
156168 } , [ ] ) ;
157169
170+ useEffect ( ( ) => {
171+ if ( typeof window === "undefined" ) {
172+ return ;
173+ }
174+
175+ const syncOnlineState = ( ) => {
176+ setIsOnline ( typeof navigator === "undefined" ? true : navigator . onLine !== false ) ;
177+ } ;
178+
179+ syncOnlineState ( ) ;
180+ window . addEventListener ( "online" , syncOnlineState ) ;
181+ window . addEventListener ( "offline" , syncOnlineState ) ;
182+ return ( ) => {
183+ window . removeEventListener ( "online" , syncOnlineState ) ;
184+ window . removeEventListener ( "offline" , syncOnlineState ) ;
185+ } ;
186+ } , [ ] ) ;
187+
158188 useEffect ( ( ) => {
159189 if ( typeof window === "undefined" ) {
160190 return ;
@@ -352,7 +382,6 @@ export const WorkbenchRuntimeCoordinator = ({
352382
353383 const {
354384 markSessionIdle,
355- refreshTabFromBackend,
356385 settleSessionAfterExit,
357386 } = createWorkspaceSessionActions ( {
358387 appSettings,
@@ -365,13 +394,46 @@ export const WorkbenchRuntimeCoordinator = ({
365394 onCompletionReminder,
366395 } ) ;
367396
397+ const reattachWorkspaceRuntime = useCallback ( async ( workspaceId : string ) => {
398+ const inflight = runtimeAttachInflightRef . current . get ( workspaceId ) ;
399+ if ( inflight ) {
400+ await inflight ;
401+ return ;
402+ }
403+
404+ const task = ( async ( ) => {
405+ const runtimeSnapshot = await attachWorkspaceRuntimeWithRetry (
406+ workspaceId ,
407+ deviceId ,
408+ clientId ,
409+ withServiceFallback ,
410+ ) ;
411+ if ( ! runtimeSnapshot ) {
412+ return ;
413+ }
414+ updateState ( ( current ) => applyWorkspaceRuntimeSnapshot (
415+ current ,
416+ runtimeSnapshot ,
417+ locale ,
418+ appSettings ,
419+ deviceId ,
420+ clientId ,
421+ ) ) ;
422+ } ) ( ) . finally ( ( ) => {
423+ runtimeAttachInflightRef . current . delete ( workspaceId ) ;
424+ } ) ;
425+
426+ runtimeAttachInflightRef . current . set ( workspaceId , task ) ;
427+ await task ;
428+ } , [ appSettings , clientId , deviceId , locale , updateState ] ) ;
429+
368430 useWorkspaceTransportSync ( {
369431 agentRuntimeRefs,
370432 bootstrapReady : state . tabs . length > 0 ,
371433 clientId,
372434 deviceId,
373- refreshTabFromBackend,
374435 markSessionIdle,
436+ reattachWorkspaceRuntime,
375437 settleSessionAfterExit,
376438 syncSessionPatch,
377439 stateRef,
@@ -392,31 +454,14 @@ export const WorkbenchRuntimeCoordinator = ({
392454 return ;
393455 }
394456
395- let cancelled = false ;
396457 const workspaceIds = state . tabs
397458 . filter ( ( tab ) => tab . status === "ready" )
398459 . map ( ( tab ) => tab . id ) ;
399460
400461 void Promise . all ( workspaceIds . map ( async ( workspaceId ) => {
401- const runtimeSnapshot = await withServiceFallback (
402- ( ) => attachWorkspaceRuntime ( workspaceId , deviceId , clientId ) ,
403- null ,
404- ) ;
405- if ( ! runtimeSnapshot || cancelled ) return ;
406- updateState ( ( current ) => applyWorkspaceRuntimeSnapshot (
407- current ,
408- runtimeSnapshot ,
409- locale ,
410- appSettings ,
411- deviceId ,
412- clientId ,
413- ) ) ;
462+ await reattachWorkspaceRuntime ( workspaceId ) ;
414463 } ) ) ;
415-
416- return ( ) => {
417- cancelled = true ;
418- } ;
419- } , [ appSettings , attachedWorkspaceFingerprint , clientId , deviceId , locale , updateState ] ) ;
464+ } , [ attachedWorkspaceFingerprint , reattachWorkspaceRuntime ] ) ;
420465
421466 const controllerHeartbeatFingerprint = useMemo (
422467 ( ) => state . tabs
@@ -427,7 +472,7 @@ export const WorkbenchRuntimeCoordinator = ({
427472 ) ;
428473
429474 useEffect ( ( ) => {
430- if ( ! controllerHeartbeatFingerprint ) {
475+ if ( ! controllerHeartbeatFingerprint || ! isOnline ) {
431476 return ;
432477 }
433478
@@ -437,6 +482,10 @@ export const WorkbenchRuntimeCoordinator = ({
437482
438483 const sendHeartbeat = ( ) => {
439484 controllerWorkspaceIds ( ) . forEach ( ( workspaceId ) => {
485+ if ( heartbeatInflightRef . current . has ( workspaceId ) ) {
486+ return ;
487+ }
488+ heartbeatInflightRef . current . add ( workspaceId ) ;
440489 void heartbeatWorkspaceController ( workspaceId , deviceId , clientId )
441490 . then ( ( controller ) => {
442491 updateState ( ( current ) => applyWorkspaceControllerEvent ( current , {
@@ -446,16 +495,22 @@ export const WorkbenchRuntimeCoordinator = ({
446495 } )
447496 . catch ( ( ) => {
448497 // The WS controller stream or next attach will converge the UI.
498+ } )
499+ . finally ( ( ) => {
500+ heartbeatInflightRef . current . delete ( workspaceId ) ;
449501 } ) ;
450502 } ) ;
451503 } ;
452504
453505 sendHeartbeat ( ) ;
454- const timer = window . setInterval ( sendHeartbeat , 10000 ) ;
506+ const timer = window . setInterval (
507+ sendHeartbeat ,
508+ isDocumentVisible ? CONTROLLER_HEARTBEAT_INTERVAL_MS : HIDDEN_CONTROLLER_HEARTBEAT_INTERVAL_MS ,
509+ ) ;
455510 return ( ) => {
456511 window . clearInterval ( timer ) ;
457512 } ;
458- } , [ clientId , controllerHeartbeatFingerprint , deviceId , updateState ] ) ;
513+ } , [ clientId , controllerHeartbeatFingerprint , deviceId , isDocumentVisible , isOnline , updateState ] ) ;
459514
460515 const controllerRecoveryFingerprint = useMemo (
461516 ( ) => state . tabs
@@ -472,11 +527,10 @@ export const WorkbenchRuntimeCoordinator = ({
472527 ) ;
473528
474529 useEffect ( ( ) => {
475- if ( ! controllerRecoveryFingerprint ) {
530+ if ( ! controllerRecoveryFingerprint || ! isOnline ) {
476531 return ;
477532 }
478533
479- let cancelled = false ;
480534 const recoverableWorkspaceIds = ( ) => stateRef . current . tabs
481535 . filter ( ( tab ) =>
482536 tab . status === "ready"
@@ -489,30 +543,20 @@ export const WorkbenchRuntimeCoordinator = ({
489543
490544 const recoverControllers = ( ) => {
491545 void Promise . all ( recoverableWorkspaceIds ( ) . map ( async ( workspaceId ) => {
492- const runtimeSnapshot = await withServiceFallback (
493- ( ) => attachWorkspaceRuntime ( workspaceId , deviceId , clientId ) ,
494- null ,
495- ) ;
496- if ( ! runtimeSnapshot || cancelled ) return ;
497- updateState ( ( current ) => applyWorkspaceRuntimeSnapshot (
498- current ,
499- runtimeSnapshot ,
500- locale ,
501- appSettings ,
502- deviceId ,
503- clientId ,
504- ) ) ;
546+ await reattachWorkspaceRuntime ( workspaceId ) ;
505547 } ) ) ;
506548 } ;
507549
508550 recoverControllers ( ) ;
509- const timer = window . setInterval ( recoverControllers , 1000 ) ;
551+ const timer = window . setInterval (
552+ recoverControllers ,
553+ isDocumentVisible ? CONTROLLER_RECOVERY_INTERVAL_MS : HIDDEN_CONTROLLER_RECOVERY_INTERVAL_MS ,
554+ ) ;
510555
511556 return ( ) => {
512- cancelled = true ;
513557 window . clearInterval ( timer ) ;
514558 } ;
515- } , [ appSettings , clientId , controllerRecoveryFingerprint , deviceId , locale , updateState ] ) ;
559+ } , [ controllerRecoveryFingerprint , isDocumentVisible , isOnline , reattachWorkspaceRuntime ] ) ;
516560
517561 const takeoverPollingFingerprint = useMemo (
518562 ( ) => state . tabs
@@ -528,7 +572,7 @@ export const WorkbenchRuntimeCoordinator = ({
528572 ) ;
529573
530574 useEffect ( ( ) => {
531- if ( ! takeoverPollingFingerprint ) {
575+ if ( ! takeoverPollingFingerprint || ! isOnline ) {
532576 return ;
533577 }
534578
@@ -543,6 +587,10 @@ export const WorkbenchRuntimeCoordinator = ({
543587
544588 const pollTakeover = ( ) => {
545589 takeoverWorkspaceIds ( ) . forEach ( ( workspaceId ) => {
590+ if ( takeoverPollingInflightRef . current . has ( workspaceId ) ) {
591+ return ;
592+ }
593+ takeoverPollingInflightRef . current . add ( workspaceId ) ;
546594 void requestWorkspaceTakeover ( workspaceId , deviceId , clientId )
547595 . then ( ( controller ) => {
548596 updateState ( ( current ) => applyWorkspaceControllerEvent ( current , {
@@ -552,16 +600,22 @@ export const WorkbenchRuntimeCoordinator = ({
552600 } )
553601 . catch ( ( ) => {
554602 // Keep waiting; the controller stream is the primary source of truth.
603+ } )
604+ . finally ( ( ) => {
605+ takeoverPollingInflightRef . current . delete ( workspaceId ) ;
555606 } ) ;
556607 } ) ;
557608 } ;
558609
559610 pollTakeover ( ) ;
560- const timer = window . setInterval ( pollTakeover , 2000 ) ;
611+ const timer = window . setInterval (
612+ pollTakeover ,
613+ isDocumentVisible ? TAKEOVER_POLL_INTERVAL_MS : HIDDEN_TAKEOVER_POLL_INTERVAL_MS ,
614+ ) ;
561615 return ( ) => {
562616 window . clearInterval ( timer ) ;
563617 } ;
564- } , [ clientId , deviceId , takeoverPollingFingerprint , updateState ] ) ;
618+ } , [ clientId , deviceId , isDocumentVisible , isOnline , takeoverPollingFingerprint , updateState ] ) ;
565619
566620 const settingsSyncFingerprint = useMemo ( ( ) => state . tabs . map ( ( tab ) => [
567621 tab . id ,
0 commit comments