@molecule/app-ide-react 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -59,7 +59,7 @@ import type { PreviewPanelProps } from '../types.js';
59
59
  * @param props - Component props.
60
60
  * @returns The rendered preview panel element.
61
61
  */
62
- export declare function PreviewPanel({ loadingIndicator, restartingIndicator, className, onPreviewError, onPreviewStuck, onRenderState, uiCommand, onUiResult, fileChangeTick, buildingHint, isBuilding, wakeAt, }: PreviewPanelProps): JSX.Element;
62
+ export declare function PreviewPanel({ loadingIndicator, restartingIndicator, className, onPreviewError, onPreviewStuck, onRestartBackend, onRenderState, uiCommand, onUiResult, fileChangeTick, buildingHint, isBuilding, wakeAt, }: PreviewPanelProps): JSX.Element;
63
63
  export declare namespace PreviewPanel {
64
64
  var displayName: string;
65
65
  }
@@ -1 +1 @@
1
- {"version":3,"file":"PreviewPanel.d.ts","sourceRoot":"","sources":["../../src/components/PreviewPanel.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AAEH,OAAO,EAAE,KAAK,GAAG,EAA4C,MAAM,OAAO,CAAA;AAW1E,OAAO,KAAK,EAAE,iBAAiB,EAAuC,MAAM,aAAa,CAAA;AA8SzF;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,EAC3B,gBAAgB,EAChB,mBAAmB,EACnB,SAAS,EACT,cAAc,EACd,cAAc,EACd,aAAa,EACb,SAAS,EACT,UAAU,EACV,cAAc,EACd,YAAY,EACZ,UAAU,EACV,MAAM,GACP,EAAE,iBAAiB,GAAG,GAAG,CAAC,OAAO,CAwoDjC;yBArpDe,YAAY"}
1
+ {"version":3,"file":"PreviewPanel.d.ts","sourceRoot":"","sources":["../../src/components/PreviewPanel.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AAEH,OAAO,EAAE,KAAK,GAAG,EAA4C,MAAM,OAAO,CAAA;AAW1E,OAAO,KAAK,EAAE,iBAAiB,EAAuC,MAAM,aAAa,CAAA;AAoWzF;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,EAC3B,gBAAgB,EAChB,mBAAmB,EACnB,SAAS,EACT,cAAc,EACd,cAAc,EACd,gBAAgB,EAChB,aAAa,EACb,SAAS,EACT,UAAU,EACV,cAAc,EACd,YAAY,EACZ,UAAU,EACV,MAAM,GACP,EAAE,iBAAiB,GAAG,GAAG,CAAC,OAAO,CA81DjC;yBA52De,YAAY"}
@@ -73,6 +73,23 @@ const ORIENTATION_STORAGE_KEY = 'molecule.ide.preview.orientation';
73
73
  // --- Polling constants (exponential backoff, never gives up) ---
74
74
  /** Initial poll interval when waiting for the server. */
75
75
  const POLL_INITIAL_MS = 500;
76
+ /**
77
+ * How long the FIRST-mount server-up poll may run without the server answering
78
+ * before the panel surfaces the actionable "Preview can't load here" notice
79
+ * (ms). The poll itself keeps running underneath — the notice self-clears the
80
+ * moment a poll succeeds and mounts the iframe — so this is honesty, not a
81
+ * give-up: it hands the user Reload / Open-in-new-tab instead of an unbounded
82
+ * spinner. Before this existed the pre-mount poll was the one path with NO
83
+ * actionable escape at all: it "never gives up" by design, and the absolute
84
+ * ceiling that should have backstopped it fired once, stood down inside the
85
+ * wake window, and never re-checked — a viewer's fresh page load against a
86
+ * never-answering preview sat on "Loading preview…" indefinitely with no modal
87
+ * (observed 2026-08-31). Sized past a proxy cold-start + dev-server relaunch so
88
+ * a normal slow boot still resolves silently.
89
+ */
90
+ const PREMOUNT_GIVEUP_MS = 45_000;
91
+ /** Re-check gap for the absolute readiness ceiling when it stands down for a benign hold (ms). */
92
+ const ABSOLUTE_STUCK_RECHECK_MS = 10_000;
76
93
  /** Maximum poll interval after backoff. */
77
94
  const POLL_MAX_MS = 5000;
78
95
  /** Backoff multiplier applied after each failed poll. */
@@ -102,6 +119,38 @@ const STUCK_BACKOFF_FACTOR = 1.6;
102
119
  * instead of remounting indefinitely — the old `longRetry()` looped forever.
103
120
  */
104
121
  const MAX_RECOVERY_CYCLES = 3;
122
+ /**
123
+ * Max probe-driven automatic recoveries per dead-end episode. Every actionable
124
+ * notice ("Preview can't load here", "the preview is blank") used to be a true
125
+ * dead end: all in-budget retries had already run, and only a human clicking
126
+ * Reload could try again. But the server those retries ran against may simply
127
+ * not be up YET — an E2B resume of a long-paused sandbox pulls its snapshot
128
+ * from cold storage and can take minutes, outliving wake patience AND every
129
+ * retry budget (observed 2026-08-31: a 24h-asleep sandbox woke fine, but the
130
+ * panel had given up and a manual "Reload preview" was needed — which worked,
131
+ * because by then the server was serving). So while a dead-end notice is up
132
+ * over a DEAD document, the panel keeps probing the preview URL and, the
133
+ * moment the server answers, runs exactly the manual-retry path itself. This
134
+ * cap bounds that per episode so a server that answers probes but serves a
135
+ * broken app cannot ping-pong give-up → auto-retry forever; a confirmed render
136
+ * (or a human retry) resets the budget.
137
+ */
138
+ const MAX_DEAD_END_AUTO_RECOVERS = 3;
139
+ // --- Backend-restart escalation (see PreviewPanelProps.onRestartBackend) ---
140
+ /**
141
+ * How long the app may sit with NO confirmed render before the panel may escalate
142
+ * to a backend dev-server restart (ms). Deliberately generous: it must outlast a
143
+ * heavy cold compile, wake patience, and multiple dead-end reload attempts, so a
144
+ * restart is only ever the answer when reloading has already been tried against a
145
+ * SERVING host and provably changed nothing (the poisoned-module-cache class —
146
+ * a document reload reuses the dev server's immutable `?v=` module URLs, so only
147
+ * a server restart, which mints a new version hash, can clear it).
148
+ */
149
+ const BACKEND_RESTART_AFTER_MS = 180_000;
150
+ /** Cooldown between backend-restart escalations (ms) — one restart gets one full recovery cycle. */
151
+ const BACKEND_RESTART_COOLDOWN_MS = 600_000;
152
+ /** Cadence of the escalation watchdog's cheap pre-checks (ms). */
153
+ const BACKEND_RESTART_CHECK_MS = 10_000;
105
154
  /**
106
155
  * Absolute upper bound (ms) on how long the preview may sit on the loading overlay
107
156
  * without ever CONFIRMING a render (`molecule:ready`), while not mid-build. A pure
@@ -346,7 +395,7 @@ async function isServerUp(url, externalSignal) {
346
395
  * @param props - Component props.
347
396
  * @returns The rendered preview panel element.
348
397
  */
349
- export function PreviewPanel({ loadingIndicator, restartingIndicator, className, onPreviewError, onPreviewStuck, onRenderState, uiCommand, onUiResult, fileChangeTick, buildingHint, isBuilding, wakeAt, }) {
398
+ export function PreviewPanel({ loadingIndicator, restartingIndicator, className, onPreviewError, onPreviewStuck, onRestartBackend, onRenderState, uiCommand, onUiResult, fileChangeTick, buildingHint, isBuilding, wakeAt, }) {
350
399
  const cm = getClassMap();
351
400
  const { state, setUrl, refresh, setDevice, openExternal, recordNavigation, back, forward } = usePreview();
352
401
  const iframeRef = useRef(null);
@@ -476,6 +525,9 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
476
525
  const pollEpochRef = useRef(0);
477
526
  // AbortController for the in-flight server-up probe, so cleanup can cancel it.
478
527
  const pollAbortRef = useRef(null);
528
+ // Deadline for the first-mount poll to surface the actionable notice while it
529
+ // keeps polling (see PREMOUNT_GIVEUP_MS). Cleared with the poll chain.
530
+ const pollGiveUpTimerRef = useRef(null);
479
531
  const urlRef = useRef(state.url);
480
532
  urlRef.current = state.url;
481
533
  // The preview's live location (client-side route included), read by the stuck/freeze
@@ -499,6 +551,17 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
499
551
  // True once the cap of recovery cycles is hit — stops the loop and shows the
500
552
  // themed "Preview can't load here" panel (loop breaker) instead of thrashing.
501
553
  const [previewGaveUp, setPreviewGaveUp] = useState(false);
554
+ // Probe-driven automatic recoveries fired this dead-end episode (see
555
+ // MAX_DEAD_END_AUTO_RECOVERS) — reset on a confirmed render or a manual retry.
556
+ const deadEndAutoRecoversRef = useRef(0);
557
+ // --- Backend-restart escalation state (see the escalation watchdog effect) ---
558
+ // When the current "no confirmed render" stretch began. Pushed forward by any
559
+ // confirm-state change, a new load target, and a wake — so the escalation clock
560
+ // only measures genuinely-stuck time, never healthy time or granted patience.
561
+ const notConfirmedSinceRef = useRef(Date.now());
562
+ // One escalation per broken episode; re-armed only by a confirmed render.
563
+ const backendRestartUsedRef = useRef(false);
564
+ const lastBackendRestartAtRef = useRef(0);
502
565
  // Mirror iframeReady to a ref so timer callbacks read current value
503
566
  const iframeReadyRef = useRef(iframeReady);
504
567
  iframeReadyRef.current = iframeReady;
@@ -620,6 +683,9 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
620
683
  setBlankPostBuild(false);
621
684
  setPreviewGaveUp(false);
622
685
  setStuckRetryCount(0);
686
+ // A wake grants a fresh escalation clock too — the relaunching server owes
687
+ // no restart while its own cold path plays out.
688
+ notConfirmedSinceRef.current = Date.now();
623
689
  if (neverRendered && urlRef.current) {
624
690
  setIframeSrc(withCacheBuster(urlRef.current));
625
691
  }
@@ -659,6 +725,10 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
659
725
  pollAbortRef.current.abort();
660
726
  pollAbortRef.current = null;
661
727
  }
728
+ if (pollGiveUpTimerRef.current) {
729
+ clearTimeout(pollGiveUpTimerRef.current);
730
+ pollGiveUpTimerRef.current = null;
731
+ }
662
732
  }, []);
663
733
  // --- Clear stuck timer ---
664
734
  const clearStuckTimer = useCallback(() => {
@@ -668,7 +738,10 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
668
738
  }
669
739
  }, []);
670
740
  // --- Poll until server is up, then mount the iframe ---
671
- // Uses exponential backoff: POLL_INITIAL_MS → POLL_MAX_MS. Never gives up.
741
+ // Uses exponential backoff: POLL_INITIAL_MS → POLL_MAX_MS. The poll itself never
742
+ // stops, but past PREMOUNT_GIVEUP_MS it surfaces the actionable notice while it
743
+ // keeps polling — the user always has a way out (Reload / Open in new tab), and a
744
+ // late success still mounts + withdraws the notice by itself.
672
745
  const startPolling = useCallback((url) => {
673
746
  // clearPoll() bumps the epoch + aborts the prior chain; capture the fresh
674
747
  // epoch + a new AbortController so THIS chain is the only live one. Any older
@@ -683,6 +756,21 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
683
756
  // A (re)load hasn't confirmed content yet — the overlay governs until it does.
684
757
  setConfirmedContent(false);
685
758
  setBlankPostBuild(false);
759
+ // The poll never stops on its own, so past this deadline it surfaces the
760
+ // actionable notice (Reload / Open in new tab) INSTEAD of an unbounded
761
+ // spinner — and keeps polling underneath; a later success mounts the
762
+ // iframe and withdraws the notice. This was the one preview path with no
763
+ // escape hatch at all (see PREMOUNT_GIVEUP_MS).
764
+ pollGiveUpTimerRef.current = setTimeout(() => {
765
+ pollGiveUpTimerRef.current = null;
766
+ if (pollEpochRef.current !== epoch)
767
+ return;
768
+ // Deliberate breadcrumb: this state means the preview host never answered a
769
+ // probe for the whole deadline — the console line is the forensic trail for
770
+ // the next live debug of a "stuck on Loading preview" report.
771
+ console.warn(`[preview] server at ${url} has not answered for ${Math.round(PREMOUNT_GIVEUP_MS / 1000)}s — showing the reload notice, still polling`);
772
+ setPreviewGaveUp(true);
773
+ }, PREMOUNT_GIVEUP_MS);
686
774
  let interval = POLL_INITIAL_MS;
687
775
  const poll = async () => {
688
776
  if (pollEpochRef.current !== epoch || urlRef.current !== url)
@@ -691,6 +779,11 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
691
779
  if (pollEpochRef.current !== epoch || urlRef.current !== url)
692
780
  return;
693
781
  if (up) {
782
+ if (pollGiveUpTimerRef.current) {
783
+ clearTimeout(pollGiveUpTimerRef.current);
784
+ pollGiveUpTimerRef.current = null;
785
+ }
786
+ setPreviewGaveUp(false);
694
787
  setEverLoaded(true);
695
788
  // Atomic remount (fresh <iframe> element + src in one commit), like the
696
789
  // proven manual-reload/navigation paths: if `setUrl` was called more than
@@ -736,6 +829,8 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
736
829
  // it patiently (reveal on ready, no premature blank/give-up) until ITS first molecule:ready.
737
830
  hasEverRenderedRef.current = false;
738
831
  lastLoadAtRef.current = 0;
832
+ // Fresh escalation clock for the new target.
833
+ notConfirmedSinceRef.current = Date.now();
739
834
  // A new load target hasn't confirmed content yet.
740
835
  setConfirmedContent(false);
741
836
  setBlankPostBuild(false);
@@ -812,8 +907,10 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
812
907
  if (iframeReady) {
813
908
  setFadingOut(true);
814
909
  // Content is showing again — tear down the loop-breaker panel so a later good render
815
- // always clears it.
910
+ // always clears it, and refund the dead-end auto-recovery budget (the episode ended
911
+ // in a working preview, so the next outage starts with a full budget).
816
912
  setPreviewGaveUp(false);
913
+ deadEndAutoRecoversRef.current = 0;
817
914
  // Clear fadingOut when the fade finishes. The overlay's onTransitionEnd is the primary
818
915
  // trigger, but it never fires if the overlay unmounts mid-fade, the transition is disabled
819
916
  // (prefers-reduced-motion), or in a non-rendering env — so a timer matched to the fade is
@@ -849,6 +946,19 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
849
946
  // fallback; do not interrupt a progressing load with a remount.
850
947
  if (iframeLoadedRef.current)
851
948
  return;
949
+ // A heartbeating document is ALIVE and MID-LOAD: the inline bridge runs
950
+ // from HTML parse, long before a large module graph finishes (a
951
+ // multi-repo import behind a microVM edge takes ~30s for ~250 modules —
952
+ // observed live 2026-08-31). Reloading it aborts every in-flight module
953
+ // fetch, and the next document races onto the aborted entries and
954
+ // memoizes spurious failures — the recovery loop MURDERS the loads it
955
+ // guards ("stuck on Loading preview" while a fresh tab loads fine).
956
+ // Keep watching instead; a dead doc (no heartbeat) still recovers, and
957
+ // window `load` never firing at all is bounded by the ceilings.
958
+ if (Date.now() - lastHeartbeatRef.current < FREEZE_THRESHOLD_MS) {
959
+ scheduleNext();
960
+ return;
961
+ }
852
962
  cycleCount += 1;
853
963
  setStuckRetryCount(cycleCount);
854
964
  if (cycleCount <= MAX_RECOVERY_CYCLES) {
@@ -876,35 +986,46 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
876
986
  }, [iframeSrc, iframeReady, previewGaveUp, onPreviewStuck, clearStuckTimer]);
877
987
  // --- Absolute readiness ceiling (backstop: never stuck on the overlay forever) ---
878
988
  // Independent of every intermediate flag (onLoad / iframeReady / stuck cycles): anchored
879
- // to the load, it fires ONCE after ABSOLUTE_STUCK_MS and — if the app still hasn't
880
- // confirmed a render and a build isn't running — surfaces the actionable loop-breaker
881
- // panel. This is the guarantee that no combination of failed sub-paths can leave the
882
- // preview spinning forever. Read live refs (not stale closures); a `molecule:ready` that
883
- // lands later clears `previewGaveUp` via the confirm effect, so an early trip is harmless.
989
+ // to the load, it first checks at ABSOLUTE_STUCK_MS and — if the app still hasn't
990
+ // confirmed a render and no benign hold applies — surfaces the actionable loop-breaker
991
+ // panel; a benign hold defers the CHECK, never disarms it. This is the guarantee that no
992
+ // combination of failed sub-paths can leave the preview spinning forever. Read live refs
993
+ // (not stale closures); a `molecule:ready` that lands later clears `previewGaveUp` via
994
+ // the confirm effect, so an early trip is harmless.
884
995
  useEffect(() => {
885
996
  if (!state.url)
886
997
  return;
887
- const timer = setTimeout(() => {
888
- // Fire ONLY when still on a bare spinner. iframeReady is the key addition: the onLoad
889
- // grace already revealed a loaded document, so the give-up panel must never cover it —
890
- // the panel is reserved for a preview that genuinely never loaded (onLoad never fired).
891
- // A confirmed render, an active build, or the actionable blank notice also mean not-stuck.
892
- if (confirmedContentRef.current ||
893
- iframeReadyRef.current ||
894
- isBuildingRef.current ||
895
- blankPostBuildRef.current ||
896
- // The app is ALIVE (heartbeating) a cold boot in progress, not a stuck load. Never show
897
- // "Preview can't load here" over an app that's still starting; the cold-boot evaluator
898
- // surfaces an honest notice only if it stays alive-but-unmounted past COLD_BOOT_PATIENCE_MS.
899
- Date.now() - lastHeartbeatRef.current < FREEZE_THRESHOLD_MS ||
900
- // A just-woken server is expected to serve a dead/transient document for a while —
901
- // stand down; the cold-boot evaluator's ceiling still guarantees an eventual way out.
902
- inWakeWindow())
903
- return;
904
- onPreviewStuck?.({ reason: 'load-timeout', url: currentLocationRef.current });
905
- setPreviewGaveUp(true);
906
- }, ABSOLUTE_STUCK_MS);
907
- return () => clearTimeout(timer);
998
+ let timer = null;
999
+ const arm = (delay) => {
1000
+ timer = setTimeout(() => {
1001
+ // A revealed or confirmed preview is healthy this load needs no ceiling.
1002
+ if (confirmedContentRef.current || iframeReadyRef.current)
1003
+ return;
1004
+ // Fire ONLY when still on a bare spinner. iframeReady above is the key gate: the
1005
+ // onLoad grace already revealed a loaded document, so the give-up panel must never
1006
+ // cover it — the panel is reserved for a preview that genuinely never loaded.
1007
+ // A benign holdan active build, the actionable blank notice already showing, an
1008
+ // ALIVE (heartbeating) cold boot, or a just-woken server's patience window does
1009
+ // NOT disarm the ceiling: it RE-CHECKS after a beat. The old one-shot timer stood
1010
+ // down permanently on any of these, so a wake window that swallowed the single
1011
+ // firing left a never-answering preview on the spinner forever with no notice
1012
+ // (the "no reload modal ever popped up" report, 2026-08-31).
1013
+ if (isBuildingRef.current ||
1014
+ blankPostBuildRef.current ||
1015
+ Date.now() - lastHeartbeatRef.current < FREEZE_THRESHOLD_MS ||
1016
+ inWakeWindow()) {
1017
+ arm(ABSOLUTE_STUCK_RECHECK_MS);
1018
+ return;
1019
+ }
1020
+ onPreviewStuck?.({ reason: 'load-timeout', url: currentLocationRef.current });
1021
+ setPreviewGaveUp(true);
1022
+ }, delay);
1023
+ };
1024
+ arm(ABSOLUTE_STUCK_MS);
1025
+ return () => {
1026
+ if (timer)
1027
+ clearTimeout(timer);
1028
+ };
908
1029
  // Re-armed per load (url change or refresh/back-forward via loadNonce); a successful
909
1030
  // render before the ceiling is honored by the live-ref check in the callback.
910
1031
  }, [state.url, state.loadNonce, onPreviewStuck, inWakeWindow]);
@@ -1330,6 +1451,16 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
1330
1451
  // stop. Origin-Agent-Cluster isolation keeps that freeze off the IDE's thread, so
1331
1452
  // the IDE can notice the silence and offer a reload — a frozen frame can't recover
1332
1453
  // on its own. Auto-clears if heartbeats resume (transient jank, not a hard lock).
1454
+ //
1455
+ // VISIBILITY-AWARE, deliberately: in a hidden tab the browser throttles BOTH
1456
+ // sides — the iframe's heartbeats stop being delivered AND this interval only
1457
+ // wakes about once a minute — so every throttle wake used to read as an
1458
+ // 18–58s "freeze": the banner popped and the frozen report (with its alert
1459
+ // tone) fired once per minute for as long as the tab stayed backgrounded
1460
+ // (observed live 2026-08-29; forensics showed heap flat + thread idle, i.e.
1461
+ // pure throttling, no real freeze). A hidden tab renders nothing, so there is
1462
+ // nothing to accuse: skip checks while hidden and re-baseline on return to
1463
+ // the foreground so the hidden gap can never count against the app.
1333
1464
  useEffect(() => {
1334
1465
  if (!iframeReady || fadingOut || !state.url) {
1335
1466
  setPreviewFrozen(false);
@@ -1338,9 +1469,23 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
1338
1469
  // Baseline so a stale value from a prior load can't trip us immediately.
1339
1470
  lastHeartbeatRef.current = Date.now();
1340
1471
  const timer = setInterval(() => {
1472
+ if (document.visibilityState === 'hidden')
1473
+ return;
1341
1474
  setPreviewFrozen(Date.now() - lastHeartbeatRef.current > FREEZE_THRESHOLD_MS);
1342
1475
  }, FREEZE_CHECK_INTERVAL_MS);
1343
- return () => clearInterval(timer);
1476
+ const onVisibility = () => {
1477
+ if (document.visibilityState !== 'visible')
1478
+ return;
1479
+ // Fresh grace window: heartbeats resume with the foreground; the gap that
1480
+ // accumulated while hidden was throttling, not the app.
1481
+ lastHeartbeatRef.current = Date.now();
1482
+ setPreviewFrozen(false);
1483
+ };
1484
+ document.addEventListener('visibilitychange', onVisibility);
1485
+ return () => {
1486
+ clearInterval(timer);
1487
+ document.removeEventListener('visibilitychange', onVisibility);
1488
+ };
1344
1489
  }, [iframeReady, fadingOut, state.url]);
1345
1490
  // --- Freeze → host report ---
1346
1491
  // A frozen preview is the worst case the iframe CANNOT self-report (its thread is
@@ -1480,6 +1625,139 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
1480
1625
  setIframeSrc(withCacheBuster(state.url));
1481
1626
  }
1482
1627
  }, [state.url]);
1628
+ // A HUMAN retry is a fresh episode — refund the probe-driven budget so the
1629
+ // panel keeps auto-recovering alongside them.
1630
+ const handleUserRetry = useCallback(() => {
1631
+ deadEndAutoRecoversRef.current = 0;
1632
+ handleManualRetry();
1633
+ }, [handleManualRetry]);
1634
+ // --- Dead-end auto-recovery: probe while an actionable notice is up, retry when the server answers ---
1635
+ // Both dead-end notices (the give-up loop-breaker and the blank notice) mean every
1636
+ // in-budget retry already ran — against a server that may simply not have been up YET
1637
+ // (the motivating case: an E2B resume pulling a long-paused snapshot from cold storage
1638
+ // takes minutes; see MAX_DEAD_END_AUTO_RECOVERS). So instead of waiting for a human to
1639
+ // click Reload, keep asking the preview host whether it is serving — sequentially, one
1640
+ // probe at a time, exactly like the steady-state health check — and when it finally
1641
+ // answers, run the manual-retry path automatically. Gated on the document being DEAD
1642
+ // (no recent heartbeat): a heartbeating app behind the blank notice is an app that runs
1643
+ // but renders nothing — an app bug a reload cannot fix, and auto-reloading it would
1644
+ // just flicker the actionable notice the user is reading. isServerUp prefers the
1645
+ // readable /__mol/preview-status (the mlcl.dev proxy reports the UPSTREAM truthfully,
1646
+ // so an edge answering FOR a down sandbox does not read as "up").
1647
+ useEffect(() => {
1648
+ if (!state.url || confirmedContent)
1649
+ return undefined;
1650
+ if (!previewGaveUp && !blankPostBuild)
1651
+ return undefined;
1652
+ const aborter = new AbortController();
1653
+ let timer = null;
1654
+ const tick = async () => {
1655
+ if (aborter.signal.aborted)
1656
+ return;
1657
+ if (deadEndAutoRecoversRef.current >= MAX_DEAD_END_AUTO_RECOVERS)
1658
+ return;
1659
+ if (Date.now() - lastHeartbeatRef.current < FREEZE_THRESHOLD_MS) {
1660
+ // Alive behind the notice — not our case; re-check later in case it dies.
1661
+ timer = setTimeout(() => void tick(), HEALTH_POLL_MS);
1662
+ return;
1663
+ }
1664
+ const up = await isServerUp(urlRef.current, aborter.signal);
1665
+ if (aborter.signal.aborted)
1666
+ return;
1667
+ if (up) {
1668
+ deadEndAutoRecoversRef.current += 1;
1669
+ handleManualRetry();
1670
+ return;
1671
+ }
1672
+ timer = setTimeout(() => void tick(), HEALTH_POLL_MS);
1673
+ };
1674
+ timer = setTimeout(() => void tick(), HEALTH_POLL_MS);
1675
+ return () => {
1676
+ aborter.abort();
1677
+ if (timer)
1678
+ clearTimeout(timer);
1679
+ };
1680
+ }, [previewGaveUp, blankPostBuild, confirmedContent, state.url, handleManualRetry]);
1681
+ // The escalation clock measures CONTINUOUS unconfirmed time: any change of the
1682
+ // confirm verdict (healthy→broken or broken→healthy) restarts it, so a stretch
1683
+ // of healthy rendering can never count toward a restart, and a confirmed render
1684
+ // re-arms the once-per-episode escalation budget.
1685
+ useEffect(() => {
1686
+ notConfirmedSinceRef.current = Date.now();
1687
+ if (confirmedContent)
1688
+ backendRestartUsedRef.current = false;
1689
+ }, [confirmedContent]);
1690
+ // --- Backend-restart escalation watchdog (see PreviewPanelProps.onRestartBackend) ---
1691
+ // The one recovery a document reload can NEVER perform: when the dev server's
1692
+ // module state is poisoned (observed live 2026-08-31: module failures memoized
1693
+ // against vite's immutable `?v=`-hashed URLs — every reload, manual and
1694
+ // automatic, rebuilt the same broken graph with zero errors anywhere), only a
1695
+ // SERVER restart mints new module URLs. Escalates at most once per broken
1696
+ // episode, and only when every cheaper explanation is exhausted:
1697
+ // • the app has confirmed nothing for BACKEND_RESTART_AFTER_MS straight
1698
+ // (clock reset by confirms, new targets, and wakes — patience is honored),
1699
+ // • at least one dead-end auto-reload already ran (so a plain cold boot,
1700
+ // which never engages the dead-end machinery, can never trip this),
1701
+ // • the server answers probes RIGHT NOW (a down server needs waking/time,
1702
+ // not a restart — the dead-end probe loop owns that case),
1703
+ // • no build is running (the executor owns the sandbox during a turn).
1704
+ // The host may DECLINE (return false: wrong role, hidden tab, sandbox not
1705
+ // running) — nothing is burned and the watchdog may ask again later.
1706
+ useEffect(() => {
1707
+ if (!state.url || !onRestartBackend)
1708
+ return undefined;
1709
+ const aborter = new AbortController();
1710
+ let inFlight = false;
1711
+ const interval = setInterval(() => {
1712
+ if (inFlight)
1713
+ return;
1714
+ inFlight = true;
1715
+ void (async () => {
1716
+ try {
1717
+ if (confirmedContentRef.current || isBuildingRef.current)
1718
+ return;
1719
+ if (deadEndAutoRecoversRef.current < 1)
1720
+ return;
1721
+ if (backendRestartUsedRef.current)
1722
+ return;
1723
+ const now = Date.now();
1724
+ if (now - notConfirmedSinceRef.current < BACKEND_RESTART_AFTER_MS)
1725
+ return;
1726
+ if (now - lastBackendRestartAtRef.current < BACKEND_RESTART_COOLDOWN_MS)
1727
+ return;
1728
+ const up = await isServerUp(urlRef.current, aborter.signal);
1729
+ if (aborter.signal.aborted || !up)
1730
+ return;
1731
+ // Re-check the fast-moving gates after the await.
1732
+ if (confirmedContentRef.current || isBuildingRef.current)
1733
+ return;
1734
+ const outcome = await onRestartBackend();
1735
+ if (aborter.signal.aborted || outcome === false)
1736
+ return;
1737
+ backendRestartUsedRef.current = true;
1738
+ lastBackendRestartAtRef.current = Date.now();
1739
+ notConfirmedSinceRef.current = Date.now();
1740
+ // The restarted server deserves a fresh reload budget so the dead-end
1741
+ // probe (or the wake reload) can bring the recovered app in.
1742
+ deadEndAutoRecoversRef.current = 0;
1743
+ // Deliberate breadcrumb — names the escalation in the console so a
1744
+ // "the preview restarted itself" report is traceable.
1745
+ console.warn('[preview] escalated to a backend dev-server restart — reloads could not produce a render against a serving host');
1746
+ }
1747
+ catch (_error) {
1748
+ // Escalation is strictly best-effort: a failed probe or a host error
1749
+ // must never break the panel; the next check re-evaluates.
1750
+ }
1751
+ finally {
1752
+ inFlight = false;
1753
+ }
1754
+ })();
1755
+ }, BACKEND_RESTART_CHECK_MS);
1756
+ return () => {
1757
+ aborter.abort();
1758
+ clearInterval(interval);
1759
+ };
1760
+ }, [state.url, onRestartBackend]);
1483
1761
  // --- Reload a frozen preview ---
1484
1762
  // Remounts the iframe (fresh load re-runs the app, clearing the locked thread).
1485
1763
  const handleReloadFrozen = useCallback(() => {
@@ -1547,8 +1825,8 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
1547
1825
  // Fully opaque + interactive whenever it is standing in for missing content (only the
1548
1826
  // ready-then-fading-out state animates to transparent).
1549
1827
  const overlaySolid = !iframeReady || blankPostBuild || buildingUnconfirmed;
1550
- const overlayContent = blankPostBuild ? (_jsx(PreviewBlankNotice, { onReload: handleManualRetry, onOpenExternal: openExternal })) : isBuilding || building ? (_jsx(DefaultLoadingIndicator, { hint: buildingHint, retryCount: stuckRetryCount, onManualRetry: handleManualRetry })) : everLoaded ? ((restartingIndicator ??
1551
- loadingIndicator ?? (_jsx(DefaultLoadingIndicator, { retryCount: stuckRetryCount, onManualRetry: handleManualRetry })))) : ((loadingIndicator ?? (_jsx(DefaultLoadingIndicator, { retryCount: stuckRetryCount, onManualRetry: handleManualRetry }))));
1828
+ const overlayContent = blankPostBuild ? (_jsx(PreviewBlankNotice, { onReload: handleUserRetry, onOpenExternal: openExternal })) : isBuilding || building ? (_jsx(DefaultLoadingIndicator, { hint: buildingHint, retryCount: stuckRetryCount, onManualRetry: handleUserRetry })) : everLoaded ? ((restartingIndicator ??
1829
+ loadingIndicator ?? (_jsx(DefaultLoadingIndicator, { retryCount: stuckRetryCount, onManualRetry: handleUserRetry })))) : ((loadingIndicator ?? (_jsx(DefaultLoadingIndicator, { retryCount: stuckRetryCount, onManualRetry: handleUserRetry }))));
1552
1830
  return (_jsxs("div", { className: cm.cn(cm.flex({ direction: 'col' }), cm.h('full'), cm.surface, className), children: [_jsx("div", { className: cm.cn(cm.sp('px', 3), cm.sp('py', 2), cm.shrink0, cm.borderB), children: _jsxs("div", { className: cm.cn(cm.flex({ direction: 'row', align: 'center', gap: 'xs' })), children: [_jsxs("div", { "data-mol-id": "preview-url-field", className: cm.cn(cm.flex({ direction: 'row', align: 'center', gap: 'xs' }), cm.sp('px', 2), cm.surface), style: {
1553
1831
  flex: 1,
1554
1832
  // On phone-width viewports the field keeps an 80px floor (the
@@ -1686,7 +1964,7 @@ export function PreviewPanel({ loadingIndicator, restartingIndicator, className,
1686
1964
  zIndex: 2,
1687
1965
  padding: '24px',
1688
1966
  textAlign: 'center',
1689
- }, children: [_jsx(Icon, { name: "x-circle", size: 28, className: cm.textMuted, "aria-hidden": "true" }), _jsx("div", { className: cm.cn(cm.textSize('sm')), style: { color: 'var(--mol-color-text, #333)', fontWeight: 600 }, children: t('ide.preview.loadFailed', {}, { defaultValue: "Preview can't load here" }) }), _jsx("div", { className: cm.cn(cm.textSize('xs'), cm.textMuted), style: { maxWidth: '320px' }, children: t('ide.preview.loadFailedHint', {}, { defaultValue: 'Try reloading, or open the preview in a new tab.' }) }), _jsxs("div", { style: { display: 'flex', gap: '8px' }, children: [_jsx("button", { type: "button", "data-mol-id": "preview-load-failed-reload", onClick: handleManualRetry, className: cm.cn(cm.button({ variant: 'solid', color: 'primary', size: 'sm' }), cm.touchTarget), children: t('ide.preview.reloadPreview', {}, { defaultValue: 'Reload preview' }) }), _jsx("button", { type: "button", "data-mol-id": "preview-load-failed-open", onClick: openExternal, className: cm.cn(cm.button({ variant: 'ghost', size: 'sm' }), cm.touchTarget), children: t('ide.preview.openNewTab', {}, { defaultValue: 'Open in new tab' }) })] })] })), !state.url && (_jsx("div", { className: cm.cn(cm.textMuted, cm.textSize('sm')), style: {
1967
+ }, children: [_jsx(Icon, { name: "x-circle", size: 28, className: cm.textMuted, "aria-hidden": "true" }), _jsx("div", { className: cm.cn(cm.textSize('sm')), style: { color: 'var(--mol-color-text, #333)', fontWeight: 600 }, children: t('ide.preview.loadFailed', {}, { defaultValue: "Preview can't load here" }) }), _jsx("div", { className: cm.cn(cm.textSize('xs'), cm.textMuted), style: { maxWidth: '320px' }, children: t('ide.preview.loadFailedHint', {}, { defaultValue: 'Try reloading, or open the preview in a new tab.' }) }), _jsxs("div", { style: { display: 'flex', gap: '8px' }, children: [_jsx("button", { type: "button", "data-mol-id": "preview-load-failed-reload", onClick: handleUserRetry, className: cm.cn(cm.button({ variant: 'solid', color: 'primary', size: 'sm' }), cm.touchTarget), children: t('ide.preview.reloadPreview', {}, { defaultValue: 'Reload preview' }) }), _jsx("button", { type: "button", "data-mol-id": "preview-load-failed-open", onClick: openExternal, className: cm.cn(cm.button({ variant: 'ghost', size: 'sm' }), cm.touchTarget), children: t('ide.preview.openNewTab', {}, { defaultValue: 'Open in new tab' }) })] })] })), !state.url && (_jsx("div", { className: cm.cn(cm.textMuted, cm.textSize('sm')), style: {
1690
1968
  display: 'flex',
1691
1969
  alignItems: 'center',
1692
1970
  justifyContent: 'center',