@molecule/app-ide-react 1.0.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.
Files changed (250) hide show
  1. package/LICENSE +115 -0
  2. package/dist/chatTimelineOrdering.d.ts +50 -0
  3. package/dist/chatTimelineOrdering.d.ts.map +1 -0
  4. package/dist/chatTimelineOrdering.js +46 -0
  5. package/dist/chatTimelineOrdering.js.map +1 -0
  6. package/dist/command-metadata.d.ts +78 -0
  7. package/dist/command-metadata.d.ts.map +1 -0
  8. package/dist/command-metadata.js +214 -0
  9. package/dist/command-metadata.js.map +1 -0
  10. package/dist/components/ActivityCard.d.ts +28 -0
  11. package/dist/components/ActivityCard.d.ts.map +1 -0
  12. package/dist/components/ActivityCard.js +62 -0
  13. package/dist/components/ActivityCard.js.map +1 -0
  14. package/dist/components/AutoCommitBadge.d.ts +58 -0
  15. package/dist/components/AutoCommitBadge.d.ts.map +1 -0
  16. package/dist/components/AutoCommitBadge.js +78 -0
  17. package/dist/components/AutoCommitBadge.js.map +1 -0
  18. package/dist/components/ChatPanel.d.ts +184 -0
  19. package/dist/components/ChatPanel.d.ts.map +1 -0
  20. package/dist/components/ChatPanel.js +6691 -0
  21. package/dist/components/ChatPanel.js.map +1 -0
  22. package/dist/components/CommandPalette.d.ts +18 -0
  23. package/dist/components/CommandPalette.d.ts.map +1 -0
  24. package/dist/components/CommandPalette.js +33 -0
  25. package/dist/components/CommandPalette.js.map +1 -0
  26. package/dist/components/DeviceFrameSelector.d.ts +60 -0
  27. package/dist/components/DeviceFrameSelector.d.ts.map +1 -0
  28. package/dist/components/DeviceFrameSelector.js +230 -0
  29. package/dist/components/DeviceFrameSelector.js.map +1 -0
  30. package/dist/components/EditorPanel.d.ts +17 -0
  31. package/dist/components/EditorPanel.d.ts.map +1 -0
  32. package/dist/components/EditorPanel.js +241 -0
  33. package/dist/components/EditorPanel.js.map +1 -0
  34. package/dist/components/FileExplorer.d.ts +64 -0
  35. package/dist/components/FileExplorer.d.ts.map +1 -0
  36. package/dist/components/FileExplorer.js +1111 -0
  37. package/dist/components/FileExplorer.js.map +1 -0
  38. package/dist/components/FileExplorerContextMenu.d.ts +37 -0
  39. package/dist/components/FileExplorerContextMenu.d.ts.map +1 -0
  40. package/dist/components/FileExplorerContextMenu.js +241 -0
  41. package/dist/components/FileExplorerContextMenu.js.map +1 -0
  42. package/dist/components/HelpCard.d.ts +34 -0
  43. package/dist/components/HelpCard.d.ts.map +1 -0
  44. package/dist/components/HelpCard.js +28 -0
  45. package/dist/components/HelpCard.js.map +1 -0
  46. package/dist/components/Icon.d.ts +50 -0
  47. package/dist/components/Icon.d.ts.map +1 -0
  48. package/dist/components/Icon.js +25 -0
  49. package/dist/components/Icon.js.map +1 -0
  50. package/dist/components/KeyboardShortcutsPanel.d.ts +19 -0
  51. package/dist/components/KeyboardShortcutsPanel.d.ts.map +1 -0
  52. package/dist/components/KeyboardShortcutsPanel.js +187 -0
  53. package/dist/components/KeyboardShortcutsPanel.js.map +1 -0
  54. package/dist/components/MarkdownContent.d.ts +52 -0
  55. package/dist/components/MarkdownContent.d.ts.map +1 -0
  56. package/dist/components/MarkdownContent.js +303 -0
  57. package/dist/components/MarkdownContent.js.map +1 -0
  58. package/dist/components/ModelsTable.d.ts +38 -0
  59. package/dist/components/ModelsTable.d.ts.map +1 -0
  60. package/dist/components/ModelsTable.js +96 -0
  61. package/dist/components/ModelsTable.js.map +1 -0
  62. package/dist/components/PreviewPanel.d.ts +66 -0
  63. package/dist/components/PreviewPanel.d.ts.map +1 -0
  64. package/dist/components/PreviewPanel.js +1752 -0
  65. package/dist/components/PreviewPanel.js.map +1 -0
  66. package/dist/components/QuickOpen.d.ts +20 -0
  67. package/dist/components/QuickOpen.d.ts.map +1 -0
  68. package/dist/components/QuickOpen.js +72 -0
  69. package/dist/components/QuickOpen.js.map +1 -0
  70. package/dist/components/QuickPicker.d.ts +21 -0
  71. package/dist/components/QuickPicker.d.ts.map +1 -0
  72. package/dist/components/QuickPicker.js +174 -0
  73. package/dist/components/QuickPicker.js.map +1 -0
  74. package/dist/components/RelevantSkillSuggestion.d.ts +41 -0
  75. package/dist/components/RelevantSkillSuggestion.d.ts.map +1 -0
  76. package/dist/components/RelevantSkillSuggestion.js +85 -0
  77. package/dist/components/RelevantSkillSuggestion.js.map +1 -0
  78. package/dist/components/ReportModal.d.ts +35 -0
  79. package/dist/components/ReportModal.d.ts.map +1 -0
  80. package/dist/components/ReportModal.js +112 -0
  81. package/dist/components/ReportModal.js.map +1 -0
  82. package/dist/components/ResizeHandle.d.ts +21 -0
  83. package/dist/components/ResizeHandle.d.ts.map +1 -0
  84. package/dist/components/ResizeHandle.js +109 -0
  85. package/dist/components/ResizeHandle.js.map +1 -0
  86. package/dist/components/ScriptsCard.d.ts +37 -0
  87. package/dist/components/ScriptsCard.d.ts.map +1 -0
  88. package/dist/components/ScriptsCard.js +177 -0
  89. package/dist/components/ScriptsCard.js.map +1 -0
  90. package/dist/components/SearchPanel.d.ts +18 -0
  91. package/dist/components/SearchPanel.d.ts.map +1 -0
  92. package/dist/components/SearchPanel.js +449 -0
  93. package/dist/components/SearchPanel.js.map +1 -0
  94. package/dist/components/SettingsCard.d.ts +49 -0
  95. package/dist/components/SettingsCard.d.ts.map +1 -0
  96. package/dist/components/SettingsCard.js +59 -0
  97. package/dist/components/SettingsCard.js.map +1 -0
  98. package/dist/components/ShareModal.d.ts +32 -0
  99. package/dist/components/ShareModal.d.ts.map +1 -0
  100. package/dist/components/ShareModal.js +126 -0
  101. package/dist/components/ShareModal.js.map +1 -0
  102. package/dist/components/SidebarTabs.d.ts +18 -0
  103. package/dist/components/SidebarTabs.d.ts.map +1 -0
  104. package/dist/components/SidebarTabs.js +20 -0
  105. package/dist/components/SidebarTabs.js.map +1 -0
  106. package/dist/components/SkillsCard.d.ts +86 -0
  107. package/dist/components/SkillsCard.d.ts.map +1 -0
  108. package/dist/components/SkillsCard.js +227 -0
  109. package/dist/components/SkillsCard.js.map +1 -0
  110. package/dist/components/StreamingIndicator.d.ts +50 -0
  111. package/dist/components/StreamingIndicator.d.ts.map +1 -0
  112. package/dist/components/StreamingIndicator.js +142 -0
  113. package/dist/components/StreamingIndicator.js.map +1 -0
  114. package/dist/components/TabBar.d.ts +37 -0
  115. package/dist/components/TabBar.d.ts.map +1 -0
  116. package/dist/components/TabBar.js +187 -0
  117. package/dist/components/TabBar.js.map +1 -0
  118. package/dist/components/TipCard.d.ts +22 -0
  119. package/dist/components/TipCard.d.ts.map +1 -0
  120. package/dist/components/TipCard.js +73 -0
  121. package/dist/components/TipCard.js.map +1 -0
  122. package/dist/components/ToolCallCard.d.ts +18 -0
  123. package/dist/components/ToolCallCard.d.ts.map +1 -0
  124. package/dist/components/ToolCallCard.js +716 -0
  125. package/dist/components/ToolCallCard.js.map +1 -0
  126. package/dist/components/UserAvatar.d.ts +45 -0
  127. package/dist/components/UserAvatar.d.ts.map +1 -0
  128. package/dist/components/UserAvatar.js +80 -0
  129. package/dist/components/UserAvatar.js.map +1 -0
  130. package/dist/components/WorkspaceLayout.d.ts +19 -0
  131. package/dist/components/WorkspaceLayout.d.ts.map +1 -0
  132. package/dist/components/WorkspaceLayout.js +56 -0
  133. package/dist/components/WorkspaceLayout.js.map +1 -0
  134. package/dist/components/activity-utilities.d.ts +107 -0
  135. package/dist/components/activity-utilities.d.ts.map +1 -0
  136. package/dist/components/activity-utilities.js +154 -0
  137. package/dist/components/activity-utilities.js.map +1 -0
  138. package/dist/components/chat-autocommit-utilities.d.ts +172 -0
  139. package/dist/components/chat-autocommit-utilities.d.ts.map +1 -0
  140. package/dist/components/chat-autocommit-utilities.js +185 -0
  141. package/dist/components/chat-autocommit-utilities.js.map +1 -0
  142. package/dist/components/chat-card-style.d.ts +64 -0
  143. package/dist/components/chat-card-style.d.ts.map +1 -0
  144. package/dist/components/chat-card-style.js +74 -0
  145. package/dist/components/chat-card-style.js.map +1 -0
  146. package/dist/components/chat-commands.d.ts +17 -0
  147. package/dist/components/chat-commands.d.ts.map +1 -0
  148. package/dist/components/chat-commands.js +17 -0
  149. package/dist/components/chat-commands.js.map +1 -0
  150. package/dist/components/chat-commit-utilities.d.ts +29 -0
  151. package/dist/components/chat-commit-utilities.d.ts.map +1 -0
  152. package/dist/components/chat-commit-utilities.js +31 -0
  153. package/dist/components/chat-commit-utilities.js.map +1 -0
  154. package/dist/components/chat-cost-utilities.d.ts +62 -0
  155. package/dist/components/chat-cost-utilities.d.ts.map +1 -0
  156. package/dist/components/chat-cost-utilities.js +66 -0
  157. package/dist/components/chat-cost-utilities.js.map +1 -0
  158. package/dist/components/chat-effort-utilities.d.ts +96 -0
  159. package/dist/components/chat-effort-utilities.d.ts.map +1 -0
  160. package/dist/components/chat-effort-utilities.js +97 -0
  161. package/dist/components/chat-effort-utilities.js.map +1 -0
  162. package/dist/components/chat-help-utilities.d.ts +89 -0
  163. package/dist/components/chat-help-utilities.d.ts.map +1 -0
  164. package/dist/components/chat-help-utilities.js +131 -0
  165. package/dist/components/chat-help-utilities.js.map +1 -0
  166. package/dist/components/chat-model-mode-utilities.d.ts +143 -0
  167. package/dist/components/chat-model-mode-utilities.d.ts.map +1 -0
  168. package/dist/components/chat-model-mode-utilities.js +191 -0
  169. package/dist/components/chat-model-mode-utilities.js.map +1 -0
  170. package/dist/components/chat-models-utilities.d.ts +98 -0
  171. package/dist/components/chat-models-utilities.d.ts.map +1 -0
  172. package/dist/components/chat-models-utilities.js +136 -0
  173. package/dist/components/chat-models-utilities.js.map +1 -0
  174. package/dist/components/chat-report-utilities.d.ts +149 -0
  175. package/dist/components/chat-report-utilities.d.ts.map +1 -0
  176. package/dist/components/chat-report-utilities.js +151 -0
  177. package/dist/components/chat-report-utilities.js.map +1 -0
  178. package/dist/components/chat-scripts-utilities.d.ts +140 -0
  179. package/dist/components/chat-scripts-utilities.d.ts.map +1 -0
  180. package/dist/components/chat-scripts-utilities.js +158 -0
  181. package/dist/components/chat-scripts-utilities.js.map +1 -0
  182. package/dist/components/chat-settings-utilities.d.ts +72 -0
  183. package/dist/components/chat-settings-utilities.d.ts.map +1 -0
  184. package/dist/components/chat-settings-utilities.js +56 -0
  185. package/dist/components/chat-settings-utilities.js.map +1 -0
  186. package/dist/components/chat-share-utilities.d.ts +104 -0
  187. package/dist/components/chat-share-utilities.d.ts.map +1 -0
  188. package/dist/components/chat-share-utilities.js +90 -0
  189. package/dist/components/chat-share-utilities.js.map +1 -0
  190. package/dist/components/chat-skills-utilities.d.ts +242 -0
  191. package/dist/components/chat-skills-utilities.d.ts.map +1 -0
  192. package/dist/components/chat-skills-utilities.js +433 -0
  193. package/dist/components/chat-skills-utilities.js.map +1 -0
  194. package/dist/components/chat-stream-utilities.d.ts +74 -0
  195. package/dist/components/chat-stream-utilities.d.ts.map +1 -0
  196. package/dist/components/chat-stream-utilities.js +103 -0
  197. package/dist/components/chat-stream-utilities.js.map +1 -0
  198. package/dist/components/chat-tips-utilities.d.ts +108 -0
  199. package/dist/components/chat-tips-utilities.d.ts.map +1 -0
  200. package/dist/components/chat-tips-utilities.js +137 -0
  201. package/dist/components/chat-tips-utilities.js.map +1 -0
  202. package/dist/components/device-cycle.d.ts +84 -0
  203. package/dist/components/device-cycle.d.ts.map +1 -0
  204. package/dist/components/device-cycle.js +77 -0
  205. package/dist/components/device-cycle.js.map +1 -0
  206. package/dist/components/index.d.ts +36 -0
  207. package/dist/components/index.d.ts.map +1 -0
  208. package/dist/components/index.js +33 -0
  209. package/dist/components/index.js.map +1 -0
  210. package/dist/components/tool-call-utilities.d.ts +69 -0
  211. package/dist/components/tool-call-utilities.d.ts.map +1 -0
  212. package/dist/components/tool-call-utilities.js +368 -0
  213. package/dist/components/tool-call-utilities.js.map +1 -0
  214. package/dist/components/user-avatar-utilities.d.ts +41 -0
  215. package/dist/components/user-avatar-utilities.d.ts.map +1 -0
  216. package/dist/components/user-avatar-utilities.js +56 -0
  217. package/dist/components/user-avatar-utilities.js.map +1 -0
  218. package/dist/components/workspace-layout-utilities.d.ts +37 -0
  219. package/dist/components/workspace-layout-utilities.d.ts.map +1 -0
  220. package/dist/components/workspace-layout-utilities.js +58 -0
  221. package/dist/components/workspace-layout-utilities.js.map +1 -0
  222. package/dist/customEventCards.d.ts +117 -0
  223. package/dist/customEventCards.d.ts.map +1 -0
  224. package/dist/customEventCards.js +36 -0
  225. package/dist/customEventCards.js.map +1 -0
  226. package/dist/hooks/index.d.ts +8 -0
  227. package/dist/hooks/index.d.ts.map +1 -0
  228. package/dist/hooks/index.js +8 -0
  229. package/dist/hooks/index.js.map +1 -0
  230. package/dist/hooks/useKeyboardShortcuts.d.ts +48 -0
  231. package/dist/hooks/useKeyboardShortcuts.d.ts.map +1 -0
  232. package/dist/hooks/useKeyboardShortcuts.js +112 -0
  233. package/dist/hooks/useKeyboardShortcuts.js.map +1 -0
  234. package/dist/hooks/useViewport.d.ts +42 -0
  235. package/dist/hooks/useViewport.d.ts.map +1 -0
  236. package/dist/hooks/useViewport.js +61 -0
  237. package/dist/hooks/useViewport.js.map +1 -0
  238. package/dist/index.d.ts +75 -0
  239. package/dist/index.d.ts.map +1 -0
  240. package/dist/index.js +74 -0
  241. package/dist/index.js.map +1 -0
  242. package/dist/settings-metadata.d.ts +78 -0
  243. package/dist/settings-metadata.d.ts.map +1 -0
  244. package/dist/settings-metadata.js +131 -0
  245. package/dist/settings-metadata.js.map +1 -0
  246. package/dist/types.d.ts +751 -0
  247. package/dist/types.d.ts.map +1 -0
  248. package/dist/types.js +25 -0
  249. package/dist/types.js.map +1 -0
  250. package/package.json +90 -0
@@ -0,0 +1,1752 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * Live preview panel with iframe and device frame selection.
4
+ *
5
+ * Two states:
6
+ * 1. "Loading preview" — polling until the first successful fetch
7
+ * 2. "Loading preview" — server was up before but fetch is now failing
8
+ *
9
+ * The iframe is always mounted (behind the overlay) once we have a URL. The overlay REVEAL is
10
+ * driven by TWO regimes, keyed on whether this load target has rendered before this session
11
+ * (`hasEverRendered`):
12
+ * - FIRST cold boot (never rendered yet): reveal ONLY on the `molecule:ready` handshake. A fresh
13
+ * project's cold Vite server can take many seconds to pre-bundle deps + compile the app before
14
+ * React mounts; `onLoad` fires on the shell HTML long BEFORE that, so revealing on `onLoad`
15
+ * here would flash a white, still-compiling iframe and then let a naive timer falsely accuse it
16
+ * of being "blank"/"unable to load" (with an "Open in new tab" button) — the exact reason a
17
+ * fresh preview "wouldn't load until I opened it in a new tab and refreshed the IDE". Instead we
18
+ * keep an honest "Starting your app…" overlay until the scaffold's inline bridge posts
19
+ * `molecule:ready` the instant #root mounts (reliable — verified to fire even while the overlay
20
+ * occludes the cross-origin iframe; the old "rAF frozen under occlusion" deadlock was actually
21
+ * the overlay's `backdrop-filter` freezing the whole renderer, since removed).
22
+ * - RELOAD of an app that already rendered (`hasEverRendered`): reveal on `onLoad` (+ a short
23
+ * grace) as a flash-free fast path, with `molecule:ready` faster still.
24
+ * A genuinely blank/broken app is surfaced by the app's OWN signals — an explicit `molecule:blank`,
25
+ * a crash, a dead (no-heartbeat) document, or an alive-but-never-mounts backstop — never by a
26
+ * timer that can't tell "still cold-compiling" from "rendered nothing".
27
+ *
28
+ * Recovery features:
29
+ * - Never-give-up polling with exponential backoff, single-chain (epoch-guarded +
30
+ * AbortController) so leaked poll chains can't starve the preview's connections
31
+ * - onLoad grace fallback: clears the overlay when the document loaded but the
32
+ * ready handshake never came (and no crash fired)
33
+ * - Stuck-load detection: gated on the document NOT having loaded (so it never
34
+ * interrupts a progressing load), backs off, and is CAPPED — after the cap it
35
+ * stops thrashing and shows a themed "Preview can't load here" loop-breaker
36
+ * panel (Reload + Open in new tab) instead of remounting forever
37
+ * - Last-good-frame: stores the sandbox's `molecule:snapshot` data-URL and shows
38
+ * it blurred behind the overlay while the app is rebuilding / blank
39
+ * - Pre-render error forwarding: module/import errors sent to the agent for auto-fix
40
+ * - Freeze watchdog: the scaffold heartbeats every ~3s; if its thread locks up the
41
+ * beats stop, so we surface a reload banner (the IDE stays responsive because the
42
+ * preview origin is isolated via Origin-Agent-Cluster)
43
+ * - Blank page detection: catches pages that render but show nothing
44
+ * - Wake patience: when the preview server restarts (detected by the load-target URL
45
+ * changing — a wake always reassigns the sandbox's docker port — or the host's `wakeAt`
46
+ * hint), the fast blank accusations stand down and the patient cold-boot regime governs
47
+ * for WAKE_PATIENCE_MS: a post-wake reload that renders nothing for a while is expected,
48
+ * not blank; only the generous never-rendered ceiling may accuse
49
+ * - Trust boundary: inbound `molecule:*` postMessages are accepted only from the
50
+ * preview iframe's own window (`event.source === iframe.contentWindow`) — a window-
51
+ * identity check, robust to origin-string divergence (Origin-Agent-Cluster, IP host,
52
+ * cache-buster, redirect) and unforgeable by any other window
53
+ *
54
+ * @module
55
+ */
56
+ import { useCallback, useEffect, useRef, useState } from 'react';
57
+ import { t } from '@molecule/app-i18n';
58
+ import { withCacheBuster } from '@molecule/app-live-preview';
59
+ import { usePreview } from '@molecule/app-react';
60
+ import { get as storageGet, set as storageSet } from '@molecule/app-storage';
61
+ import { getClassMap } from '@molecule/app-ui';
62
+ import { Tooltip } from '@molecule/app-ui-react/components/Tooltip.js';
63
+ import { useCoarsePointer, useNarrowViewport } from '../hooks/useViewport.js';
64
+ import { isDeviceRotatable, resolveDeviceSize } from './device-cycle.js';
65
+ import { DeviceFrameSelector } from './DeviceFrameSelector.js';
66
+ import { Icon } from './Icon.js';
67
+ /**
68
+ * Storage key for the persisted preview orientation. The orientation is a global,
69
+ * cross-device preview preference (it applies to every rotatable device and
70
+ * survives reloads), persisted via the bonded `@molecule/app-storage` provider.
71
+ */
72
+ const ORIENTATION_STORAGE_KEY = 'molecule.ide.preview.orientation';
73
+ // --- Polling constants (exponential backoff, never gives up) ---
74
+ /** Initial poll interval when waiting for the server. */
75
+ const POLL_INITIAL_MS = 500;
76
+ /** Maximum poll interval after backoff. */
77
+ const POLL_MAX_MS = 5000;
78
+ /** Backoff multiplier applied after each failed poll. */
79
+ const POLL_BACKOFF_FACTOR = 2;
80
+ // --- Stuck-load detection constants ---
81
+ /**
82
+ * Base time to wait for the load to make progress after the iframe src is set
83
+ * before attempting recovery (ms). Raised from the old 5s — a cold/slow first
84
+ * Vite load after an idle (or a deps-cache wipe) routinely takes longer than 5s,
85
+ * and remounting mid-load (with a cache-buster that defeats Vite's transform
86
+ * cache) only made the next load slower. Recovery now also requires that the
87
+ * document has NOT loaded (see the stuck-detection effect), so a progressing-but-
88
+ * slow load is never interrupted.
89
+ */
90
+ const STUCK_DETECT_MS = 8_000;
91
+ /** Exponential back-off applied to STUCK_DETECT_MS between recovery attempts. */
92
+ const STUCK_BACKOFF_FACTOR = 1.6;
93
+ /**
94
+ * Max recovery cycles (reload + remount) before giving up. After this cap the
95
+ * panel STOPS retrying and shows a themed loop-breaker (Reload + Open in new tab)
96
+ * instead of remounting indefinitely — the old `longRetry()` looped forever.
97
+ */
98
+ const MAX_RECOVERY_CYCLES = 3;
99
+ /**
100
+ * Absolute upper bound (ms) on how long the preview may sit on the loading overlay
101
+ * without ever CONFIRMING a render (`molecule:ready`), while not mid-build. A pure
102
+ * BACKSTOP: the targeted paths (onLoad grace, stuck-detection, blank-post-build) each
103
+ * resolve a specific failure faster, but a *combination* can still leave the overlay up
104
+ * forever — e.g. the document fired `onLoad` (so stuck-detection stands down) yet a
105
+ * crash/HMR-error storm keeps suppressing the onLoad grace, so `iframeReady` never flips
106
+ * and blank-post-build (which requires it) never fires. Past this ceiling the panel gives
107
+ * up to the actionable loop-breaker (Reload / open in tab) so the preview can NEVER be
108
+ * stuck on a spinner indefinitely. Self-correcting: a real render that lands later clears
109
+ * the loop-breaker (see the confirm effect), so an over-eager trip is harmless.
110
+ */
111
+ const ABSOLUTE_STUCK_MS = 30_000;
112
+ /**
113
+ * How long a document may stay LOADED (onLoad fired) without confirming a render before
114
+ * we attempt a single auto-reload (ms). Targets the stale-document case: a Vite "server
115
+ * restarted" (or any dev-server bounce) drops every client's HMR connection mid-load, so
116
+ * an iframe that loaded just before the restart is left disconnected and never renders —
117
+ * recovered today only by a MANUAL reload. The post-ready health check never runs here
118
+ * (it requires a render first), and stuck-detection stands down once onLoad fired.
119
+ */
120
+ const LOAD_RECOVER_AFTER_MS = 12_000;
121
+ /** Max auto-reloads of a loaded-but-unrendered document per load (then defer to the ceiling). */
122
+ const MAX_LOAD_RECOVERS = 2;
123
+ // --- onLoad grace fallback ---
124
+ /**
125
+ * After the iframe document fires `onLoad`, how long to wait for `molecule:ready`
126
+ * before treating the load as good anyway (ms). The handshake is the fast path;
127
+ * this is the safety net for a working app whose ready was dropped/suppressed.
128
+ */
129
+ const ONLOAD_GRACE_MS = 2_500;
130
+ /**
131
+ * When a new document fires `onLoad`, how recently a `molecule:ready` must have arrived for the
132
+ * load to count as already-confirmed-rendered (ms). A working full-reload mounts React (→ ready)
133
+ * just before the load event, so its ready lands inside this window and confirmedContent is kept
134
+ * (no overlay flash). A reload that produced a blank app sends no ready, so the last ready is
135
+ * older than this → confirmedContent drops and the overlay re-covers the white iframe.
136
+ */
137
+ const READY_FRESH_MS = 1_500;
138
+ // --- Last-good-frame snapshot (molecule:snapshot trust boundary) ---
139
+ /**
140
+ * Max accepted size of an inbound `molecule:snapshot` data-URL (chars). The
141
+ * sandbox sender downscales + JPEG-compresses, so a legitimate frame is well
142
+ * under this; the cap defends the postMessage trust boundary against an oversized
143
+ * payload bloating React state.
144
+ */
145
+ const MAX_SNAPSHOT_DATA_URL_LENGTH = 4_000_000;
146
+ // --- Freeze watchdog constants ---
147
+ /**
148
+ * Heartbeat gap beyond which the preview's main thread is treated as frozen (ms).
149
+ * The scaffold posts `molecule:heartbeat` every ~3s; this tolerates two missed
150
+ * beats plus jitter before declaring a freeze, so brief synchronous work (a heavy
151
+ * one-off render) doesn't trip it.
152
+ */
153
+ const FREEZE_THRESHOLD_MS = 8_000;
154
+ /** How often the watchdog compares `now` against the last heartbeat (ms). */
155
+ const FREEZE_CHECK_INTERVAL_MS = 2_000;
156
+ /**
157
+ * Window within which an identical runtime-error signature (message+source+line+column)
158
+ * is treated as a duplicate and dropped before forwarding (ms). Both the centralized
159
+ * runtime bridge AND a template's baked sender can report the SAME uncaught error, and
160
+ * a render-looping app re-throws it continuously — de-duping by signature keeps one
161
+ * fault from flooding the agent with the same message over and over.
162
+ */
163
+ const ERROR_DEDUP_WINDOW_MS = 4_000;
164
+ /**
165
+ * How long an ALREADY-rendered app that RELOADED to blank may stay loaded-but-unconfirmed
166
+ * (document loaded, no fresh `molecule:ready`) before the overlay's copy switches to the
167
+ * actionable "preview is blank — reload" notice (ms). This short window is ONLY used once the
168
+ * app has rendered at least once this session (`hasEverRendered`) — i.e. it WAS working and an
169
+ * edit reloaded it to blank — so a real regression surfaces quickly. It is NEVER used to accuse
170
+ * a first-cold-boot app that simply hasn't finished starting (see COLD_BOOT_PATIENCE_MS).
171
+ */
172
+ const BLANK_CONFIRM_MS = 2_500;
173
+ /**
174
+ * For a NEVER-yet-rendered document, how long with NO liveness (no `molecule:heartbeat` for
175
+ * FREEZE_THRESHOLD_MS) before surfacing the actionable notice (ms). The scaffold's preview
176
+ * bridge is an INLINE script in `index.html`, so it runs (and starts heartbeating) the moment
177
+ * the HTML parses — even before the app's modules compile. So a loaded document that is NOT
178
+ * heartbeating at all means its inline bridge never ran: a broken/error page (or JS disabled),
179
+ * i.e. a genuine failure, not a cold boot in progress. The settle lets the first ~3s heartbeat
180
+ * arrive before we judge liveness.
181
+ */
182
+ const BLANK_DEAD_MS = 4_000;
183
+ /**
184
+ * Absolute patience (ms) for a first-cold-boot app that IS alive (heartbeating) but has not yet
185
+ * confirmed a render (`molecule:ready`). A cold Vite dev server on a fresh project legitimately
186
+ * takes many seconds to pre-bundle deps + compile the app before React mounts; during that whole
187
+ * window the honest "Starting your app…" overlay stays up and we must NOT accuse it of being
188
+ * blank or "unable to load" — that false accusation (with its "Open in new tab" button) was the
189
+ * whole "fresh project preview won't load until I open it in a new tab and refresh the IDE" bug.
190
+ * Only after this generous ceiling — alive the entire time yet still never mounted — do we treat
191
+ * it as genuinely stuck and surface the reload/open-in-tab notice. The scaffold's `molecule:ready`
192
+ * (reliable, fired the instant #root mounts) clears everything long before this on any real boot.
193
+ */
194
+ const COLD_BOOT_PATIENCE_MS = 60_000;
195
+ /**
196
+ * How long after a detected wake (the preview's backing server/sandbox was just restarted) the
197
+ * panel stays in WAKE PATIENCE (ms). A wake is detected when the load target changes to a new
198
+ * preview URL — a wake always reassigns the sandbox's docker port, so a woken preview arrives on
199
+ * a fresh URL (the reliable signal) — or from the host's explicit `wakeAt` prop (a hint). A wake
200
+ * means the dev server behind the preview is restarting and cold-recompiling: the (re)loaded
201
+ * document legitimately shows nothing for a while — and behind a preview proxy the reload can even
202
+ * land on a transient error page that never runs the inline bridge. Both signatures are
203
+ * EXACTLY what the fast accusation windows (BLANK_CONFIRM_MS for an already-rendered app,
204
+ * BLANK_DEAD_MS for a bridge-less document) treat as a genuine failure — so right after a
205
+ * wake they false-fired a "preview is blank" notice over an app that rendered fine seconds
206
+ * later. During this window the panel re-enters the patient cold-boot regime (reveal on
207
+ * ready, honest starting status) and only the generous never-rendered ceiling may accuse.
208
+ * Sized to cover a fallback wake end-to-end: dev-server relaunch (~seconds) + the reload +
209
+ * a cold Vite recompile (up to tens of seconds). A real `molecule:ready` clears everything
210
+ * long before this on any healthy wake.
211
+ */
212
+ const WAKE_PATIENCE_MS = 45_000;
213
+ /**
214
+ * Duration of the overlay fade-out (ms) — kept in sync with the overlay's `transition:
215
+ * opacity` below. `fadingOut` is cleared on `onTransitionEnd`, but a timer matched to this
216
+ * is the robust fallback for when that event never fires (the overlay unmounts mid-fade,
217
+ * `prefers-reduced-motion` disables the transition, or a non-rendering env): a stuck
218
+ * `fadingOut` would pin the overlay at opacity 0 and block the post-build blank re-cover.
219
+ */
220
+ const OVERLAY_FADE_MS = 500;
221
+ // --- Rate-limiting constants ---
222
+ /** Minimum interval between acting on ready/error messages (ms). */
223
+ const MSG_RATE_LIMIT_MS = 300;
224
+ /** Max ready↔error transitions before suppressing (per window). */
225
+ const MAX_TRANSITIONS = 10;
226
+ /** Window duration for transition counting (ms). */
227
+ const TRANSITION_WINDOW_MS = 5000;
228
+ /**
229
+ * Check whether the server at `url` is accepting connections.
230
+ * Uses `no-cors` so CORS errors aren't mistaken for network failures.
231
+ * Aborts after 500ms to avoid holding browser connections — hanging polls
232
+ * exhaust the per-origin connection limit and starve the iframe of
233
+ * connections for script/asset requests. An optional `externalSignal` lets the
234
+ * caller abort the in-flight request on cleanup (e.g. when a poll chain is
235
+ * superseded), so a unmounting/restarting panel never leaves a fetch dangling.
236
+ * @param url - The URL to check.
237
+ * @param externalSignal - Optional signal; aborting it aborts this probe.
238
+ * @returns Whether the server responded within the timeout.
239
+ */
240
+ async function isServerUp(url, externalSignal) {
241
+ const controller = new AbortController();
242
+ const timeout = setTimeout(() => controller.abort(), 500);
243
+ const onExternalAbort = () => controller.abort();
244
+ if (externalSignal) {
245
+ if (externalSignal.aborted)
246
+ controller.abort();
247
+ else
248
+ externalSignal.addEventListener('abort', onExternalAbort);
249
+ }
250
+ try {
251
+ await fetch(url, { method: 'HEAD', mode: 'no-cors', signal: controller.signal });
252
+ return true;
253
+ }
254
+ catch (_error) {
255
+ // Fetch failure (or an abort) means the server is not usable right now —
256
+ // expected during polling / on cancellation. `false` is the correct result.
257
+ return false;
258
+ }
259
+ finally {
260
+ clearTimeout(timeout);
261
+ if (externalSignal)
262
+ externalSignal.removeEventListener('abort', onExternalAbort);
263
+ }
264
+ }
265
+ /**
266
+ * Live preview panel with iframe, device frame selector, and URL bar.
267
+ * @param props - Component props.
268
+ * @returns The rendered preview panel element.
269
+ */
270
+ export function PreviewPanel({ loadingIndicator, restartingIndicator, className, onPreviewError, onPreviewStuck, onRenderState, uiCommand, onUiResult, fileChangeTick, buildingHint, isBuilding, wakeAt, }) {
271
+ const cm = getClassMap();
272
+ const { state, setUrl, refresh, setDevice, openExternal, recordNavigation, back, forward } = usePreview();
273
+ const iframeRef = useRef(null);
274
+ // Phone-width viewport: the device-frame selector is hidden (the phone IS the
275
+ // device frame — the host resets the device to 'none' on mobile entry) and the
276
+ // URL field keeps a sane minimum width so the nav buttons can never crush it.
277
+ const isNarrow = useNarrowViewport();
278
+ const isCoarse = useCoarsePointer();
279
+ // 16px input type on phones/touch devices — below that iOS Safari zooms the
280
+ // page when the URL field receives focus.
281
+ const bigUrlType = isNarrow || isCoarse;
282
+ // AI-driven live preview control: when the host sets a NEW `uiCommand` (keyed on `id`), relay
283
+ // it to the iframe's interaction bridge (`molecule:ui-command`); the bridge replies
284
+ // `molecule:ui-result`, handled in the message listener below → `onUiResult`. Lets Synthase
285
+ // drive + verify the app end-to-end in the preview the user is watching (no headless browser).
286
+ const lastUiCommandIdRef = useRef(null);
287
+ // Ids the bridge has already answered — flipped by the message listener below so the retry
288
+ // loop stops. A ref (not state) so the interval sees the latest value without re-subscribing.
289
+ const uiResolvedRef = useRef(new Set());
290
+ useEffect(() => {
291
+ if (!uiCommand || uiCommand.id === lastUiCommandIdRef.current)
292
+ return;
293
+ lastUiCommandIdRef.current = uiCommand.id;
294
+ const cmdId = uiCommand.id;
295
+ const payload = {
296
+ type: 'molecule:ui-command',
297
+ id: cmdId,
298
+ action: uiCommand.action,
299
+ molId: uiCommand.molId,
300
+ selector: uiCommand.selector,
301
+ text: uiCommand.text,
302
+ value: uiCommand.value,
303
+ // Navigation only: only a document that loaded at or after this may answer, so the
304
+ // OUTGOING page can't reply with a pre-navigation snapshot.
305
+ minLoadedAt: uiCommand.minLoadedAt,
306
+ };
307
+ // Post repeatedly until the bridge replies (or ~40s, under the navigate tool's 45s wait). A
308
+ // single post races iframe/bridge readiness: the preview may be mid-mount (the view just
309
+ // switched to it) or mid-reload (navigate_preview reloads the iframe), so the first postMessage
310
+ // is silently dropped. The bridge dedups by command id, so re-sending a click/fill still
311
+ // executes it once. The window must outlast a slow dev server's COLD ROUTE COMPILE — a first
312
+ // hit to a Next.js/Nuxt/SvelteKit route commonly takes 10-30s (and an auth redirect compiles a
313
+ // SECOND route), so a 12s window timed out navigate_preview on every imported meta-framework app.
314
+ const post = () => {
315
+ iframeRef.current?.contentWindow?.postMessage(payload, '*');
316
+ };
317
+ post();
318
+ const deadline = Date.now() + 40_000;
319
+ const interval = window.setInterval(() => {
320
+ if (uiResolvedRef.current.has(cmdId) || Date.now() > deadline) {
321
+ window.clearInterval(interval);
322
+ uiResolvedRef.current.delete(cmdId);
323
+ return;
324
+ }
325
+ post();
326
+ }, 350);
327
+ return () => window.clearInterval(interval);
328
+ }, [uiCommand]);
329
+ // Preview-only iframe orientation (portrait ⇄ landscape) for fixed-frame
330
+ // devices. A purely visual preview concern, so it lives here rather than in the
331
+ // live-preview core state — but it is GLOBAL (one orientation for every
332
+ // rotatable device, never reset on a device switch) and PERSISTED across reloads
333
+ // via the bonded storage provider, so a chosen landscape carries everywhere.
334
+ const [orientation, setOrientation] = useState('portrait');
335
+ // Restore the persisted orientation once on mount. Best-effort: if no storage
336
+ // provider is bonded (tests / an app that didn't wire one) it just won't persist.
337
+ useEffect(() => {
338
+ let mounted = true;
339
+ void (async () => {
340
+ try {
341
+ const stored = await storageGet(ORIENTATION_STORAGE_KEY);
342
+ if (mounted && (stored === 'portrait' || stored === 'landscape')) {
343
+ setOrientation(stored);
344
+ }
345
+ }
346
+ catch (_error) {
347
+ // No storage provider bonded / read failed — orientation simply won't persist.
348
+ }
349
+ })();
350
+ return () => {
351
+ mounted = false;
352
+ };
353
+ }, []);
354
+ // Device switching does NOT reset the orientation — it's a global preference, so
355
+ // a landscape choice applies to whichever rotatable device is selected next.
356
+ const handleDeviceChange = useCallback((device) => {
357
+ setDevice(device);
358
+ }, [setDevice]);
359
+ const handleRotate = useCallback(() => {
360
+ const next = orientation === 'portrait' ? 'landscape' : 'portrait';
361
+ setOrientation(next);
362
+ void storageSet(ORIENTATION_STORAGE_KEY, next).catch((_error) => {
363
+ // Best-effort persist — no storage provider bonded; the in-memory value still applies.
364
+ });
365
+ }, [orientation]);
366
+ const canRotate = isDeviceRotatable(state.device);
367
+ // The preview's actual current location (updated by the scaffold's
368
+ // `molecule:navigate` message), falling back to the load target before the
369
+ // preview has reported a location.
370
+ const currentLocation = state.currentUrl || state.url;
371
+ // Whether the current location is served over a secure (https) origin. Drives
372
+ // the address bar's leading site-info glyph — a lock when secure, a globe
373
+ // otherwise — exactly like a real browser omnibox.
374
+ const isSecureLocation = /^https:/i.test(currentLocation);
375
+ // Has the server ever responded successfully?
376
+ const [everLoaded, setEverLoaded] = useState(false);
377
+ // Mirror everLoaded to a ref so the message handler (which is NOT re-subscribed
378
+ // when everLoaded changes) reads the live value, not a stale closure.
379
+ const everLoadedRef = useRef(everLoaded);
380
+ everLoadedRef.current = everLoaded;
381
+ // Is the iframe content ready to show?
382
+ const [iframeReady, setIframeReady] = useState(false);
383
+ // Fade-out transition in progress
384
+ const [fadingOut, setFadingOut] = useState(false);
385
+ const pollRef = useRef(null);
386
+ // Generation token: bumped on every clearPoll so a superseded poll() chain bails
387
+ // on its next guard instead of re-arming — guarantees only ONE live poll chain.
388
+ const pollEpochRef = useRef(0);
389
+ // AbortController for the in-flight server-up probe, so cleanup can cancel it.
390
+ const pollAbortRef = useRef(null);
391
+ const urlRef = useRef(state.url);
392
+ urlRef.current = state.url;
393
+ // The preview's live location (client-side route included), read by the stuck/freeze
394
+ // callbacks so a failure report names the route the user is actually on, not the
395
+ // initial load target.
396
+ const currentLocationRef = useRef(currentLocation);
397
+ currentLocationRef.current = currentLocation;
398
+ // De-dup ledger for forwarded runtime errors: signature → last-forwarded timestamp.
399
+ // Persists across message-handler re-subscriptions so a duplicate from the baked +
400
+ // centralized senders (or a re-throwing render loop) is dropped within the window.
401
+ const recentErrorSigRef = useRef(new Map());
402
+ // Tracks the iframe src to force reload when server recovers
403
+ const [iframeSrc, setIframeSrc] = useState('');
404
+ // --- Stuck-load detection state ---
405
+ const [stuckRetryCount, setStuckRetryCount] = useState(0);
406
+ const stuckTimerRef = useRef(null);
407
+ const [iframeMountKey, setIframeMountKey] = useState(0);
408
+ // Auto-reloads attempted for a loaded-but-unrendered document THIS load (stale-document
409
+ // recovery, e.g. after a Vite server restart) — bounded by MAX_LOAD_RECOVERS, reset per load.
410
+ const loadRecoverCountRef = useRef(0);
411
+ // True once the cap of recovery cycles is hit — stops the loop and shows the
412
+ // themed "Preview can't load here" panel (loop breaker) instead of thrashing.
413
+ const [previewGaveUp, setPreviewGaveUp] = useState(false);
414
+ // Mirror iframeReady to a ref so timer callbacks read current value
415
+ const iframeReadyRef = useRef(iframeReady);
416
+ iframeReadyRef.current = iframeReady;
417
+ // Whether the CURRENT iframe document has fired `onLoad`. When it has, the load
418
+ // reached the document and only the handshake is missing — the onLoad grace
419
+ // fallback (not a remount) clears the overlay, so the stuck-detector stands down.
420
+ const iframeLoadedRef = useRef(false);
421
+ // Pending onLoad grace timer (cleared on a new load / unmount).
422
+ const onLoadGraceRef = useRef(null);
423
+ // Timestamp of the last crash message — the onLoad fallback refuses to mask a
424
+ // load that just crashed (a crash within the grace window suppresses it).
425
+ const lastCrashAtRef = useRef(0);
426
+ // --- Last-good-frame (molecule:snapshot) ---
427
+ // The most recent rasterized frame the sandbox app posted while it was rendering
428
+ // fine. Shown BLURRED behind the overlay while the app is rebuilding or blank,
429
+ // so the user sees the last working UI instead of a white iframe.
430
+ const [lastGoodFrame, setLastGoodFrame] = useState(null);
431
+ // --- Content confirmation ---
432
+ // `iframeReady` only means the iframe was REVEALED (handshake OR the onLoad grace
433
+ // fallback) — it is true even for a blank/white app the grace path optimistically
434
+ // unmasked. `confirmedContent` is the stronger signal: the app actually reported it
435
+ // rendered visible content (molecule:ready, which a healthy app re-sends ~every 4s).
436
+ // The overlay keeps a status up until content is confirmed, so a half-built / blank /
437
+ // white iframe never shows as a bare white screen. Reset on a crash/blank/new load.
438
+ const [confirmedContent, setConfirmedContent] = useState(false);
439
+ const confirmedContentRef = useRef(false);
440
+ confirmedContentRef.current = confirmedContent;
441
+ // Timestamp of the last molecule:ready. When a NEW document loads (initial load, or a Vite
442
+ // full-reload that an edit triggers), we keep confirmedContent only if a ready landed in the
443
+ // moment before — otherwise the (re)loaded document has NOT shown content for THIS load and
444
+ // confirmedContent is dropped so the overlay re-covers a blank reload. This is what catches
445
+ // "the app rendered, then a reload left it blank."
446
+ const lastReadyAtRef = useRef(0);
447
+ // Settle-gated post-build blank: the build is not running, the document loaded, yet no content
448
+ // was ever confirmed — the app rendered nothing. Switches the overlay's copy to an ACTIONABLE
449
+ // notice (reload / open in tab) instead of the generic spinner.
450
+ const [blankPostBuild, setBlankPostBuild] = useState(false);
451
+ // Mirror blankPostBuild to a ref so the absolute-ceiling timer can tell "still on a
452
+ // bare spinner" (fire) from "already showing the actionable blank notice" (a way out
453
+ // exists — stand down).
454
+ const blankPostBuildRef = useRef(blankPostBuild);
455
+ blankPostBuildRef.current = blankPostBuild;
456
+ // Mirror isBuilding into a ref so timers/handlers read the current value.
457
+ const isBuildingRef = useRef(isBuilding);
458
+ isBuildingRef.current = isBuilding;
459
+ // Has the app at the CURRENT load target confirmed a render (`molecule:ready`) at least once
460
+ // this session? Set on the first ready, reset only on a genuinely NEW load target (url /
461
+ // loadNonce change), NOT on a plain Vite HMR full-reload of the same src. It is the switch
462
+ // between two regimes: a NEVER-yet-rendered app is treated as a patient cold boot (reveal only
463
+ // on ready, honest "Starting…" status, no false "blank"/"can't load"); an app that HAS rendered
464
+ // and then reloaded blank is a real regression (short blank-confirm window, fast onLoad-grace
465
+ // reveal). Conflating the two is what surfaced a false "blank" over a still-compiling fresh app.
466
+ const hasEverRenderedRef = useRef(false);
467
+ // Timestamp of the current document's last `onLoad` (0 = not loaded this target). Drives the
468
+ // elapsed-since-load math in the cold-boot evaluator without racing a stale closure.
469
+ const lastLoadAtRef = useRef(0);
470
+ // Bumped on every iframe `onLoad` so the cold-boot evaluator effect re-runs when a fresh
471
+ // document loads (the reveal no longer flips `iframeReady` on the grace during a cold boot, so
472
+ // that flag can't be the trigger anymore).
473
+ const [docLoadedTick, setDocLoadedTick] = useState(0);
474
+ // --- Wake patience (see WAKE_PATIENCE_MS) ---
475
+ // Mirror the wakeAt prop into a ref so interval/timeout callbacks (the blank evaluator,
476
+ // the absolute ceiling) read the live value without re-subscribing.
477
+ const wakeAtRef = useRef(wakeAt ?? 0);
478
+ wakeAtRef.current = wakeAt ?? 0;
479
+ // THE reliable, self-contained wake signal: the load target (`state.url`) changed to a
480
+ // DIFFERENT non-empty preview URL. The only thing that repoints the preview at a new URL is the
481
+ // sandbox's dev server being (re)started — a wake reassigns the container's docker port EVERY
482
+ // time (the port binding uses an empty HostPort, so Docker hands out a fresh ephemeral port on
483
+ // each start), so a woken preview always arrives on a new `http://127.0.0.1:<newport>` URL.
484
+ // In-app navigation never changes `state.url` (it moves `currentUrl` via recordNavigation), so
485
+ // a change here is unambiguously "the preview server just restarted." This is far more reliable
486
+ // than the host's sandbox-status signal (stop/hibernate broadcasts are easily missed and the
487
+ // status can desync), so it — not the `wakeAt` prop — is the primary trigger for wake patience.
488
+ const prevUrlRef = useRef('');
489
+ const urlWakeAtRef = useRef(0);
490
+ if (state.url && prevUrlRef.current && state.url !== prevUrlRef.current) {
491
+ urlWakeAtRef.current = Date.now();
492
+ }
493
+ if (state.url)
494
+ prevUrlRef.current = state.url;
495
+ /**
496
+ * Whether the backing preview server was woken/restarted recently enough to owe patience —
497
+ * true within WAKE_PATIENCE_MS of EITHER a load-target change (the reliable signal) OR the
498
+ * host's explicit `wakeAt` prop (a secondary hint).
499
+ */
500
+ const inWakeWindow = useCallback(() => {
501
+ const wokeAt = Math.max(wakeAtRef.current, urlWakeAtRef.current);
502
+ return wokeAt > 0 && Date.now() - wokeAt < WAKE_PATIENCE_MS;
503
+ }, []);
504
+ // A fresh wake re-enters the patient cold-boot regime for the CURRENT load target: the
505
+ // restarting dev server is about to reload the document (or already did) into a state
506
+ // that legitimately renders nothing for a while, so the "already rendered → any reload
507
+ // that doesn't re-confirm fast is a regression" contract no longer holds. Dropping
508
+ // hasEverRendered routes everything through the proven patient paths (reveal only on
509
+ // `molecule:ready`, no grace-unmasking of an unconfirmed reload, no BLANK_CONFIRM_MS
510
+ // fast accusation); the first ready after the wake restores the normal regime. Also
511
+ // withdraw any already-showing accusation (it described the pre-wake world) and grant a
512
+ // fresh stale-document recovery budget — those auto-reloads are the recovery engine for
513
+ // a wake that landed on a transient bridge-less error page. Runs on mount too, so a
514
+ // panel that remounts right after a wake (the sleeping-screen → running flow) inherits
515
+ // the same patience.
516
+ useEffect(() => {
517
+ if (!wakeAt || Date.now() - wakeAt >= WAKE_PATIENCE_MS)
518
+ return;
519
+ // FIRST-BOOT RELOAD. The load-target-change wake signal can never fire on
520
+ // a first boot: the URL is set once — before the server answers — and
521
+ // never changes, so the iframe keeps the stale pre-wake document (behind
522
+ // the preview proxy that is its "target unavailable" error page, which has
523
+ // no bridge and can never self-heal). If nothing has EVER rendered on this
524
+ // load target, a wake means "the app is up now — the current document is
525
+ // provably stale": reload it cache-busted immediately. Patience alone left
526
+ // that error page up until the slow stuck-retry cycle (observed live:
527
+ // served at 13:17:36, painted at 13:19:57). An already-rendered app keeps
528
+ // the old behavior — patience without a disruptive reload.
529
+ const neverRendered = !hasEverRenderedRef.current;
530
+ hasEverRenderedRef.current = false;
531
+ loadRecoverCountRef.current = 0;
532
+ setBlankPostBuild(false);
533
+ setPreviewGaveUp(false);
534
+ setStuckRetryCount(0);
535
+ if (neverRendered && urlRef.current) {
536
+ setIframeSrc(withCacheBuster(urlRef.current));
537
+ }
538
+ }, [wakeAt]);
539
+ // --- Heartbeat tracking ---
540
+ const lastHeartbeatRef = useRef(0);
541
+ // True when heartbeats have stopped for FREEZE_THRESHOLD_MS while the app is up.
542
+ const [previewFrozen, setPreviewFrozen] = useState(false);
543
+ // Guards the freeze report so a single freeze episode notifies the host ONCE, not on
544
+ // every watchdog tick; reset when heartbeats resume so a later freeze re-reports.
545
+ const frozenReportedRef = useRef(false);
546
+ // --- Cycle detection: catch rapid ready↔error oscillations ---
547
+ const transitionTimesRef = useRef([]);
548
+ const suppressedRef = useRef(false);
549
+ // --- URL bar (browser-style: reflects the preview's CURRENT location) ---
550
+ // A local draft so the user can type freely; it's committed on Enter (a real
551
+ // load) and resynced to the live location whenever the preview navigates and
552
+ // the user isn't mid-edit. Editing never reloads per-keystroke.
553
+ const [urlDraft, setUrlDraft] = useState(currentLocation);
554
+ const [urlEditing, setUrlEditing] = useState(false);
555
+ useEffect(() => {
556
+ if (!urlEditing)
557
+ setUrlDraft(currentLocation);
558
+ }, [currentLocation, urlEditing]);
559
+ // --- Clear poll timer ---
560
+ // Also invalidates any in-flight poll chain: bumping the epoch makes a poll()
561
+ // that is mid-await bail on its next guard instead of re-arming a new timer, and
562
+ // aborting the controller releases the in-flight HEAD probe so it stops holding a
563
+ // per-origin connection (the leak the file's own comment warns about).
564
+ const clearPoll = useCallback(() => {
565
+ if (pollRef.current) {
566
+ clearTimeout(pollRef.current);
567
+ pollRef.current = null;
568
+ }
569
+ pollEpochRef.current += 1;
570
+ if (pollAbortRef.current) {
571
+ pollAbortRef.current.abort();
572
+ pollAbortRef.current = null;
573
+ }
574
+ }, []);
575
+ // --- Clear stuck timer ---
576
+ const clearStuckTimer = useCallback(() => {
577
+ if (stuckTimerRef.current) {
578
+ clearTimeout(stuckTimerRef.current);
579
+ stuckTimerRef.current = null;
580
+ }
581
+ }, []);
582
+ // --- Poll until server is up, then mount the iframe ---
583
+ // Uses exponential backoff: POLL_INITIAL_MS → POLL_MAX_MS. Never gives up.
584
+ const startPolling = useCallback((url) => {
585
+ // clearPoll() bumps the epoch + aborts the prior chain; capture the fresh
586
+ // epoch + a new AbortController so THIS chain is the only live one. Any older
587
+ // poll() awaiting a fetch now sees a stale epoch and bails.
588
+ clearPoll();
589
+ const epoch = pollEpochRef.current;
590
+ const abort = new AbortController();
591
+ pollAbortRef.current = abort;
592
+ setIframeReady(false);
593
+ setFadingOut(false);
594
+ setPreviewGaveUp(false);
595
+ // A (re)load hasn't confirmed content yet — the overlay governs until it does.
596
+ setConfirmedContent(false);
597
+ setBlankPostBuild(false);
598
+ let interval = POLL_INITIAL_MS;
599
+ const poll = async () => {
600
+ if (pollEpochRef.current !== epoch || urlRef.current !== url)
601
+ return;
602
+ const up = await isServerUp(url, abort.signal);
603
+ if (pollEpochRef.current !== epoch || urlRef.current !== url)
604
+ return;
605
+ if (up) {
606
+ setEverLoaded(true);
607
+ // Atomic remount (fresh <iframe> element + src in one commit), like the
608
+ // proven manual-reload/navigation paths: if `setUrl` was called more than
609
+ // once for this load target before the server came up (e.g. a sandbox-boot
610
+ // host reporting its preview URL from more than one code path), an earlier
611
+ // poll chain may already have raced partway through mounting an iframe for
612
+ // this exact url before being superseded — bumping the mount key here
613
+ // guarantees this success always creates a genuinely fresh OOPIF rather
614
+ // than reusing/navigating one a superseded chain may have touched.
615
+ setIframeMountKey((k) => k + 1);
616
+ setIframeSrc(url);
617
+ pollRef.current = null;
618
+ }
619
+ else {
620
+ pollRef.current = setTimeout(poll, interval);
621
+ interval = Math.min(interval * POLL_BACKOFF_FACTOR, POLL_MAX_MS);
622
+ }
623
+ };
624
+ void poll();
625
+ }, [clearPoll]);
626
+ // --- When URL changes, reset and start polling ---
627
+ useEffect(() => {
628
+ if (!state.url) {
629
+ clearPoll();
630
+ clearStuckTimer();
631
+ setEverLoaded(false);
632
+ setIframeReady(false);
633
+ setFadingOut(false);
634
+ setIframeSrc('');
635
+ setStuckRetryCount(0);
636
+ setPreviewGaveUp(false);
637
+ setLastGoodFrame(null);
638
+ setConfirmedContent(false);
639
+ setBlankPostBuild(false);
640
+ hasEverRenderedRef.current = false;
641
+ lastLoadAtRef.current = 0;
642
+ return;
643
+ }
644
+ setStuckRetryCount(0);
645
+ setPreviewGaveUp(false);
646
+ loadRecoverCountRef.current = 0; // fresh stale-document recovery budget for this load
647
+ // A brand-new load target starts a fresh cold-boot regime: it has not rendered yet, so treat
648
+ // it patiently (reveal on ready, no premature blank/give-up) until ITS first molecule:ready.
649
+ hasEverRenderedRef.current = false;
650
+ lastLoadAtRef.current = 0;
651
+ // A new load target hasn't confirmed content yet.
652
+ setConfirmedContent(false);
653
+ setBlankPostBuild(false);
654
+ // A brand-new load target is a different app — drop the previous app's frame
655
+ // so a blank in the NEW app never flashes the OLD app's last-good frame.
656
+ setLastGoodFrame(null);
657
+ if (everLoadedRef.current) {
658
+ // NAVIGATION / refresh of an already-running preview (a chat-link route, the URL
659
+ // bar, Back/Forward, or a loadNonce refresh). The dev server is already up, so do an
660
+ // ATOMIC remount to the new target — bump the mount key (fresh <iframe> element) and
661
+ // set the src in ONE commit — EXACTLY like the manual-reload path (handleManualRetry),
662
+ // which is known to work. The OLD path blanked the live iframe (setIframeSrc('')) then
663
+ // re-added it after an async probe; that two-phase teardown→rebuild wedged the live
664
+ // sandboxed cross-origin (OOPIF) frame so the route "navigated but never loaded".
665
+ //
666
+ // CRUCIALLY use the cache-buster, like refresh does: navigating a cross-origin iframe
667
+ // to a URL it already treats as its CURRENT location (the app's own client-side router
668
+ // may have moved the iframe's location to this route already, or a bfcache entry
669
+ // exists) is a same-document no-op that never reloads — which is why a chat-link nav
670
+ // hung while the refresh button (cache-busted) worked. The `_r=<ts>` makes every nav a
671
+ // novel URL that forces a real document load; recordNavigation strips `_r` so the URL
672
+ // bar stays clean.
673
+ clearPoll();
674
+ setIframeMountKey((k) => k + 1);
675
+ setIframeSrc(withCacheBuster(state.url));
676
+ }
677
+ else {
678
+ // COLD / first load: the dev server may still be starting, so keep the empty state
679
+ // and poll until it answers, then mount.
680
+ setEverLoaded(false);
681
+ setIframeSrc('');
682
+ startPolling(state.url);
683
+ }
684
+ return () => {
685
+ clearPoll();
686
+ clearStuckTimer();
687
+ };
688
+ // state.loadNonce forces a reload even when the URL string is unchanged
689
+ // (refresh, or back/forward to the same load target). recordNavigation does
690
+ // NOT bump loadNonce, so an in-app navigation updates the URL bar without
691
+ // reloading here.
692
+ }, [state.url, state.loadNonce, startPolling, clearPoll, clearStuckTimer]);
693
+ // --- A new document is loading → reset per-load onLoad tracking ---
694
+ // Whenever the iframe gets a fresh src or is remounted, the onLoad flag and any
695
+ // pending grace timer belong to the PREVIOUS load — clear them so the new load's
696
+ // own onLoad drives the fallback.
697
+ useEffect(() => {
698
+ iframeLoadedRef.current = false;
699
+ if (onLoadGraceRef.current) {
700
+ clearTimeout(onLoadGraceRef.current);
701
+ onLoadGraceRef.current = null;
702
+ }
703
+ return () => {
704
+ if (onLoadGraceRef.current) {
705
+ clearTimeout(onLoadGraceRef.current);
706
+ onLoadGraceRef.current = null;
707
+ }
708
+ };
709
+ }, [iframeSrc, iframeMountKey]);
710
+ // --- When the preview is ready to SHOW, trigger fade-out ---
711
+ // Reveal is driven by `iframeReady`, set by the `molecule:ready` handshake (always) OR the
712
+ // `onLoad` grace — but the grace flips it ONLY for a RELOAD of an app that already rendered
713
+ // (`hasEverRendered`), NOT on a first cold boot (see handleIframeLoad). So:
714
+ // • first cold boot → reveal only on `molecule:ready` (the honest "Starting…" overlay stays
715
+ // up over the still-compiling app; no white flash, no false "blank"). The bridge reliably
716
+ // posts ready the instant #root mounts — verified to fire even under occlusion, so this
717
+ // does NOT deadlock (the old "overlay covers iframe → rAF frozen → ready never posts" stall
718
+ // was actually the overlay's `backdrop-filter` freezing the whole renderer, since removed).
719
+ // • reload of an already-rendered app → reveal on the onLoad grace (fast, flash-free), with
720
+ // ready faster still.
721
+ // A genuinely blank/crashed app is RE-COVERED by the explicit `molecule:blank`/crash handlers
722
+ // (which lower `iframeReady`). `confirmedContent` stays the diagnostic render-verdict signal.
723
+ useEffect(() => {
724
+ if (iframeReady) {
725
+ setFadingOut(true);
726
+ // Content is showing again — tear down the loop-breaker panel so a later good render
727
+ // always clears it.
728
+ setPreviewGaveUp(false);
729
+ // Clear fadingOut when the fade finishes. The overlay's onTransitionEnd is the primary
730
+ // trigger, but it never fires if the overlay unmounts mid-fade, the transition is disabled
731
+ // (prefers-reduced-motion), or in a non-rendering env — so a timer matched to the fade is
732
+ // the robust fallback. A stuck fadingOut pins the overlay at opacity 0 AND blocks the
733
+ // post-build blank re-cover (which is gated on !fadingOut).
734
+ const t = setTimeout(() => setFadingOut(false), OVERLAY_FADE_MS + 50);
735
+ return () => clearTimeout(t);
736
+ }
737
+ return undefined;
738
+ }, [iframeReady]);
739
+ // --- Stuck-load detection: recover ONLY when the load is genuinely stuck ---
740
+ // "Stuck" now means: we have a src, the iframe hasn't reported ready, AND we've
741
+ // given up (panel shown) is not the case. Critically, recovery is gated on the
742
+ // document NOT having fired `onLoad` — if the document loaded, the handshake (not
743
+ // the load) is what's missing and the onLoad grace fallback handles it, so we do
744
+ // NOT remount (remounting would interrupt a progressing load and bust Vite's
745
+ // cache). Delays back off, and after MAX_RECOVERY_CYCLES we STOP and show the
746
+ // themed loop-breaker panel instead of remounting forever.
747
+ useEffect(() => {
748
+ if (!iframeSrc || iframeReady || previewGaveUp) {
749
+ clearStuckTimer();
750
+ return;
751
+ }
752
+ let cycleCount = 0;
753
+ const scheduleNext = () => {
754
+ const delay = STUCK_DETECT_MS * Math.pow(STUCK_BACKOFF_FACTOR, cycleCount);
755
+ stuckTimerRef.current = setTimeout(() => {
756
+ stuckTimerRef.current = null;
757
+ // Read refs, not stale closures.
758
+ if (iframeReadyRef.current)
759
+ return;
760
+ // The document loaded but ready never came → defer to the onLoad grace
761
+ // fallback; do not interrupt a progressing load with a remount.
762
+ if (iframeLoadedRef.current)
763
+ return;
764
+ cycleCount += 1;
765
+ setStuckRetryCount(cycleCount);
766
+ if (cycleCount <= MAX_RECOVERY_CYCLES) {
767
+ if (cycleCount % 2 === 1) {
768
+ // Odd cycles: force-reload iframe with cache buster
769
+ setIframeSrc(withCacheBuster(urlRef.current));
770
+ }
771
+ else {
772
+ // Even cycles: full iframe remount (unmount + remount)
773
+ setIframeMountKey((k) => k + 1);
774
+ }
775
+ scheduleNext();
776
+ }
777
+ else {
778
+ // Cap reached — HARD STOP. Never loop forever (the old longRetry()
779
+ // remounted every 10s indefinitely, compounding the thrash). Notify the
780
+ // host once, then surface the loop-breaker panel (Reload + open in tab).
781
+ onPreviewStuck?.({ reason: 'load-failed', url: currentLocationRef.current });
782
+ setPreviewGaveUp(true);
783
+ }
784
+ }, delay);
785
+ };
786
+ scheduleNext();
787
+ return () => clearStuckTimer();
788
+ }, [iframeSrc, iframeReady, previewGaveUp, onPreviewStuck, clearStuckTimer]);
789
+ // --- Absolute readiness ceiling (backstop: never stuck on the overlay forever) ---
790
+ // Independent of every intermediate flag (onLoad / iframeReady / stuck cycles): anchored
791
+ // to the load, it fires ONCE after ABSOLUTE_STUCK_MS and — if the app still hasn't
792
+ // confirmed a render and a build isn't running — surfaces the actionable loop-breaker
793
+ // panel. This is the guarantee that no combination of failed sub-paths can leave the
794
+ // preview spinning forever. Read live refs (not stale closures); a `molecule:ready` that
795
+ // lands later clears `previewGaveUp` via the confirm effect, so an early trip is harmless.
796
+ useEffect(() => {
797
+ if (!state.url)
798
+ return;
799
+ const timer = setTimeout(() => {
800
+ // Fire ONLY when still on a bare spinner. iframeReady is the key addition: the onLoad
801
+ // grace already revealed a loaded document, so the give-up panel must never cover it —
802
+ // the panel is reserved for a preview that genuinely never loaded (onLoad never fired).
803
+ // A confirmed render, an active build, or the actionable blank notice also mean not-stuck.
804
+ if (confirmedContentRef.current ||
805
+ iframeReadyRef.current ||
806
+ isBuildingRef.current ||
807
+ blankPostBuildRef.current ||
808
+ // The app is ALIVE (heartbeating) — a cold boot in progress, not a stuck load. Never show
809
+ // "Preview can't load here" over an app that's still starting; the cold-boot evaluator
810
+ // surfaces an honest notice only if it stays alive-but-unmounted past COLD_BOOT_PATIENCE_MS.
811
+ Date.now() - lastHeartbeatRef.current < FREEZE_THRESHOLD_MS ||
812
+ // A just-woken server is expected to serve a dead/transient document for a while —
813
+ // stand down; the cold-boot evaluator's ceiling still guarantees an eventual way out.
814
+ inWakeWindow())
815
+ return;
816
+ onPreviewStuck?.({ reason: 'load-timeout', url: currentLocationRef.current });
817
+ setPreviewGaveUp(true);
818
+ }, ABSOLUTE_STUCK_MS);
819
+ return () => clearTimeout(timer);
820
+ // Re-armed per load (url change or refresh/back-forward via loadNonce); a successful
821
+ // render before the ceiling is honored by the live-ref check in the callback.
822
+ }, [state.url, state.loadNonce, onPreviewStuck, inWakeWindow]);
823
+ // --- Stale-document auto-recovery (loaded but never rendered → reload once) ---
824
+ // The post-ready health check (below) only recovers a server drop AFTER a render; a Vite
825
+ // "server restarted" mid-load drops the HMR connection and leaves the just-loaded iframe
826
+ // disconnected, so it never renders and nothing reloads it (today only a MANUAL reload
827
+ // recovers — the "loads in a new tab, then works on reload" symptom). Here: once the
828
+ // document has fired onLoad (so it's a stale/blank LOADED doc, NOT a still-optimizing
829
+ // pre-onLoad load we'd interrupt) and still hasn't confirmed a render, do a SINGLE
830
+ // server-up check (not continuous polling — that competes for the load's connections) and,
831
+ // if the server is up, reload ONCE. Bounded by MAX_LOAD_RECOVERS; defers to the ceiling.
832
+ useEffect(() => {
833
+ if (!iframeSrc || iframeReady || confirmedContent || previewGaveUp)
834
+ return;
835
+ if (loadRecoverCountRef.current >= MAX_LOAD_RECOVERS)
836
+ return;
837
+ const timer = setTimeout(() => {
838
+ void (async () => {
839
+ // Re-check live state — a render/build/give-up since scheduling cancels recovery.
840
+ if (iframeReadyRef.current || confirmedContentRef.current || isBuildingRef.current)
841
+ return;
842
+ // Only act on a LOADED document; a still-loading one is progressing (slow optimize).
843
+ if (!iframeLoadedRef.current)
844
+ return;
845
+ // A heartbeating document is ALIVE and still coming up on a cold boot — reloading it only
846
+ // restarts the load and thrashes Vite's cold optimize (making the fresh-project first load
847
+ // WORSE). Only recover a genuinely STALE doc — no heartbeat for FREEZE_THRESHOLD_MS, i.e.
848
+ // its bridge is dead (a Vite "server restarted" dropped it mid-load) — the exact case this
849
+ // recovery exists for.
850
+ if (Date.now() - lastHeartbeatRef.current < FREEZE_THRESHOLD_MS)
851
+ return;
852
+ const up = await isServerUp(urlRef.current);
853
+ if (!up || iframeReadyRef.current || confirmedContentRef.current)
854
+ return;
855
+ loadRecoverCountRef.current += 1;
856
+ setIframeSrc(withCacheBuster(urlRef.current));
857
+ })();
858
+ }, LOAD_RECOVER_AFTER_MS);
859
+ return () => clearTimeout(timer);
860
+ }, [iframeSrc, iframeReady, confirmedContent, previewGaveUp, iframeMountKey]);
861
+ // --- Listen for postMessage from scaffold template ---
862
+ // molecule:ready = #root got children (app rendered) → hide overlay
863
+ // molecule:error = pre-render error OR crash → forward/show overlay
864
+ // molecule:runtime-error = JS runtime error in the iframe → forward to chat
865
+ // molecule:heartbeat = scaffold alive signal
866
+ // molecule:blank = page rendered but appears empty
867
+ // molecule:navigate = preview's client-side location changed → update URL bar
868
+ useEffect(() => {
869
+ // Debounce runtime errors — HMR triggers rapid error/recovery cycles
870
+ let errorBatch = [];
871
+ let debounceTimer = null;
872
+ const MAX_ERRORS_PER_BATCH = 5;
873
+ // Rate-limit ready/error to prevent rapid state oscillation
874
+ let lastMsgTime = 0;
875
+ let lastMsgType = '';
876
+ let pendingMsg = null;
877
+ const flushErrors = () => {
878
+ if (errorBatch.length > 0 && onPreviewError) {
879
+ onPreviewError(errorBatch);
880
+ errorBatch = [];
881
+ }
882
+ debounceTimer = null;
883
+ };
884
+ const queueError = (err) => {
885
+ // Drop a signature already forwarded within the window — the baked sender and the
886
+ // centralized runtime bridge can each report the SAME uncaught error, and a
887
+ // render-looping app re-throws it endlessly; either way the agent needs it once.
888
+ const signature = `${err.message}|${err.source ?? ''}|${err.line ?? ''}|${err.column ?? ''}`;
889
+ const now = Date.now();
890
+ const ledger = recentErrorSigRef.current;
891
+ const lastSeen = ledger.get(signature);
892
+ if (lastSeen !== undefined && now - lastSeen < ERROR_DEDUP_WINDOW_MS)
893
+ return;
894
+ ledger.set(signature, now);
895
+ // Bound the ledger: evict entries older than the window so it can't grow unbounded
896
+ // over a long session (a steady stream of distinct errors would otherwise leak).
897
+ for (const [sig, ts] of ledger) {
898
+ if (now - ts >= ERROR_DEDUP_WINDOW_MS)
899
+ ledger.delete(sig);
900
+ }
901
+ if (errorBatch.length < MAX_ERRORS_PER_BATCH) {
902
+ errorBatch.push(err);
903
+ }
904
+ if (debounceTimer)
905
+ clearTimeout(debounceTimer);
906
+ debounceTimer = setTimeout(flushErrors, 2000);
907
+ };
908
+ const handler = (event) => {
909
+ // Trust boundary: accept molecule:* messages ONLY from the preview iframe's OWN window.
910
+ // We compare the SOURCE WINDOW IDENTITY (event.source === the iframe's contentWindow), NOT
911
+ // a fragile origin STRING match. event.origin can legitimately fail to byte-equal
912
+ // new URL(state.url).origin — Origin-Agent-Cluster isolation, a host:port vs 127.0.0.1 vs
913
+ // IP-literal preview URL, the load's cache-buster, or a redirect-on-load all diverge the
914
+ // strings — and an origin-only gate then silently DROPS the real handshake (the
915
+ // molecule:ready that lifts the overlay) and every nav/error report. Window identity is
916
+ // both more robust AND strictly more secure: no other window (a malicious sub-iframe the
917
+ // preview embeds, another tab/opener) can forge the source reference, and it needs no
918
+ // cross-origin read. The iframe is always mounted before it can post, so contentWindow is
919
+ // set by the time any message arrives.
920
+ const previewWindow = iframeRef.current?.contentWindow;
921
+ if (!previewWindow || event.source !== previewWindow)
922
+ return;
923
+ if (event.data?.type === 'molecule:ready') {
924
+ const now = Date.now();
925
+ // Track transitions for cycle detection
926
+ const times = transitionTimesRef.current;
927
+ times.push(now);
928
+ // Trim old entries
929
+ while (times.length > 0 && now - times[0] > TRANSITION_WINDOW_MS)
930
+ times.shift();
931
+ if (times.length >= MAX_TRANSITIONS && !suppressedRef.current) {
932
+ suppressedRef.current = true;
933
+ console.warn('[PreviewPanel] Rapid ready/error cycle detected (%d transitions in %dms) — suppressing further toggles. This indicates a render loop in the preview iframe.', times.length, TRANSITION_WINDOW_MS);
934
+ // Auto-recover after the storm passes
935
+ setTimeout(() => {
936
+ suppressedRef.current = false;
937
+ transitionTimesRef.current = [];
938
+ }, TRANSITION_WINDOW_MS);
939
+ return;
940
+ }
941
+ if (suppressedRef.current)
942
+ return;
943
+ // Rate-limit: ignore duplicate ready within MSG_RATE_LIMIT_MS
944
+ if (lastMsgType === 'molecule:ready' && now - lastMsgTime < MSG_RATE_LIMIT_MS)
945
+ return;
946
+ lastMsgTime = now;
947
+ lastMsgType = 'molecule:ready';
948
+ if (pendingMsg)
949
+ clearTimeout(pendingMsg);
950
+ clearPoll();
951
+ setEverLoaded(true);
952
+ setIframeReady(true);
953
+ setStuckRetryCount(0);
954
+ // The app CONFIRMED it rendered visible content — the strong signal that clears the
955
+ // status overlay (vs. the onLoad grace which only reveals the iframe). Also tears down
956
+ // any post-build "blank" notice: a real render means it isn't blank anymore.
957
+ setConfirmedContent(true);
958
+ // This load target has now rendered at least once — leave cold-boot patience mode. A
959
+ // subsequent reload that goes blank is a real regression handled by the short window.
960
+ hasEverRenderedRef.current = true;
961
+ lastReadyAtRef.current = now;
962
+ setBlankPostBuild(false);
963
+ // Handshake arrived (the fast path) — cancel any pending onLoad grace
964
+ // fallback and clear the crash marker so a future onLoad isn't suppressed.
965
+ if (onLoadGraceRef.current) {
966
+ clearTimeout(onLoadGraceRef.current);
967
+ onLoadGraceRef.current = null;
968
+ }
969
+ lastCrashAtRef.current = 0;
970
+ }
971
+ else if (event.data?.type === 'molecule:error' && event.data.crash) {
972
+ const now = Date.now();
973
+ // Track transitions
974
+ const times = transitionTimesRef.current;
975
+ times.push(now);
976
+ while (times.length > 0 && now - times[0] > TRANSITION_WINDOW_MS)
977
+ times.shift();
978
+ if (times.length >= MAX_TRANSITIONS && !suppressedRef.current) {
979
+ suppressedRef.current = true;
980
+ console.warn('[PreviewPanel] Rapid ready/error cycle detected (%d transitions in %dms) — suppressing further toggles.', times.length, TRANSITION_WINDOW_MS);
981
+ setTimeout(() => {
982
+ suppressedRef.current = false;
983
+ transitionTimesRef.current = [];
984
+ }, TRANSITION_WINDOW_MS);
985
+ return;
986
+ }
987
+ if (suppressedRef.current)
988
+ return;
989
+ // Rate-limit: debounce crash to let HMR settle
990
+ if (lastMsgType === 'molecule:error' && now - lastMsgTime < MSG_RATE_LIMIT_MS)
991
+ return;
992
+ lastMsgTime = now;
993
+ lastMsgType = 'molecule:error';
994
+ if (pendingMsg)
995
+ clearTimeout(pendingMsg);
996
+ // Debounce crash — if a ready arrives within 300ms, skip the crash entirely
997
+ pendingMsg = setTimeout(() => {
998
+ pendingMsg = null;
999
+ setIframeReady(false);
1000
+ setFadingOut(false);
1001
+ // The app crashed — it is no longer showing confirmed content, so the status
1002
+ // overlay must come back.
1003
+ setConfirmedContent(false);
1004
+ // Mark the crash so the onLoad grace fallback won't mask it as a good
1005
+ // load (a crash within the grace window suppresses the fallback).
1006
+ lastCrashAtRef.current = Date.now();
1007
+ }, MSG_RATE_LIMIT_MS);
1008
+ }
1009
+ else if (event.data?.type === 'molecule:error' && !event.data.crash && event.data.message) {
1010
+ // Pre-render error — app failed to mount (e.g., module export missing, import error).
1011
+ // Forward to the agent so it can fix the issue.
1012
+ queueError({
1013
+ message: String(event.data.message),
1014
+ source: event.data.source ? String(event.data.source) : 'pre-render',
1015
+ line: event.data.line ?? undefined,
1016
+ column: event.data.column ?? undefined,
1017
+ });
1018
+ }
1019
+ else if (event.data?.type === 'molecule:runtime-error') {
1020
+ queueError({
1021
+ message: String(event.data.message ?? 'Unknown error'),
1022
+ source: event.data.source ?? undefined,
1023
+ line: event.data.line ?? undefined,
1024
+ column: event.data.column ?? undefined,
1025
+ });
1026
+ }
1027
+ else if (event.data?.type === 'molecule:heartbeat') {
1028
+ lastHeartbeatRef.current = Date.now();
1029
+ }
1030
+ else if (event.data?.type === 'molecule:ui-result') {
1031
+ // AI-driven preview interaction result — forward to the host, keyed by the command `id`
1032
+ // it round-trips on (see the uiCommand effect / onUiResult). The panel only relays it.
1033
+ if (typeof event.data.id === 'string') {
1034
+ // Mark resolved so the uiCommand retry loop stops re-sending this command.
1035
+ uiResolvedRef.current.add(event.data.id);
1036
+ if (onUiResult)
1037
+ onUiResult(event.data.id, event.data);
1038
+ }
1039
+ }
1040
+ else if (event.data?.type === 'molecule:navigate') {
1041
+ // The preview reported a client-side navigation (see the scaffold-injected
1042
+ // sender). recordNavigation validates the URL and updates the URL bar
1043
+ // (state.currentUrl) without reloading the iframe. `isReplace` (set by the
1044
+ // sender for replaceState) preserves the Forward stack on a redirect-on-load
1045
+ // instead of truncating it; a missing/garbage value coerces to false (push).
1046
+ if (typeof event.data.url === 'string')
1047
+ recordNavigation(event.data.url, event.data.isReplace === true);
1048
+ }
1049
+ else if (event.data?.type === 'molecule:blank') {
1050
+ // Page rendered but appears blank — re-cover it. Reveal is gated on `iframeReady`, so
1051
+ // dropping `confirmedContent` alone no longer re-covers; lower `iframeReady` too. The
1052
+ // onLoad grace already fired and won't re-arm, so the overlay stays (showing the
1053
+ // last-good frame) until a real `molecule:ready` re-reveals a non-blank render.
1054
+ setConfirmedContent(false);
1055
+ setIframeReady(false);
1056
+ setFadingOut(false);
1057
+ // Notify the agent
1058
+ if (onPreviewError) {
1059
+ onPreviewError([
1060
+ {
1061
+ message: String(event.data.message ?? 'Page appears blank — rendered but no visible content'),
1062
+ source: 'molecule:blank',
1063
+ },
1064
+ ]);
1065
+ }
1066
+ // Marking content unconfirmed (above) already raises the overlay, and the blurred
1067
+ // last-good frame stands in behind it (it renders whenever a frame was captured) — so
1068
+ // a blanked app shows the last working UI instead of an empty white iframe.
1069
+ }
1070
+ else if (event.data?.type === 'molecule:snapshot') {
1071
+ // The sandbox app self-rasterized a stable render and posted it. Validate
1072
+ // defensively (postMessage trust boundary): a data:image/ URL within a sane
1073
+ // size cap. Stored as the blurred last-good placeholder shown during a
1074
+ // rebuild/blank window.
1075
+ const dataUrl = event.data.dataUrl;
1076
+ if (typeof dataUrl === 'string' &&
1077
+ dataUrl.startsWith('data:image/') &&
1078
+ dataUrl.length <= MAX_SNAPSHOT_DATA_URL_LENGTH) {
1079
+ setLastGoodFrame(dataUrl);
1080
+ }
1081
+ }
1082
+ };
1083
+ window.addEventListener('message', handler);
1084
+ return () => {
1085
+ window.removeEventListener('message', handler);
1086
+ if (debounceTimer)
1087
+ clearTimeout(debounceTimer);
1088
+ if (pendingMsg)
1089
+ clearTimeout(pendingMsg);
1090
+ };
1091
+ }, [clearPoll, onPreviewError, recordNavigation, onUiResult]);
1092
+ // --- iframe onError: server unreachable, poll and reload when ready ---
1093
+ const handleIframeError = useCallback(() => {
1094
+ if (!urlRef.current)
1095
+ return;
1096
+ setIframeReady(false);
1097
+ setFadingOut(false);
1098
+ startPolling(urlRef.current);
1099
+ }, [startPolling]);
1100
+ // --- iframe onLoad: grace fallback when molecule:ready never arrives ---
1101
+ // The document finished loading. `molecule:ready` is the fast path that clears
1102
+ // the overlay immediately; but if that handshake is dropped/suppressed/starved,
1103
+ // a perfectly good app would otherwise stay hidden behind the overlay forever
1104
+ // (the single point of failure that made the preview get permanently stuck). So
1105
+ // after a short grace, if no ready arrived AND no crash just fired, treat the
1106
+ // load as good and clear the overlay anyway.
1107
+ const handleIframeLoad = useCallback(() => {
1108
+ if (!urlRef.current)
1109
+ return;
1110
+ iframeLoadedRef.current = true;
1111
+ lastLoadAtRef.current = Date.now();
1112
+ // Re-trigger the cold-boot evaluator effect: during a first cold boot the reveal no longer
1113
+ // flips `iframeReady` on the grace, so this document-load tick is the effect's trigger.
1114
+ setDocLoadedTick((k) => k + 1);
1115
+ // A NEW document is now displayed — the initial load OR a Vite full-reload that an edit
1116
+ // (Synthase's or the user's) triggered, which reloads the SAME iframe src without remounting
1117
+ // it or changing state.url, so no other reset path runs. If this load did NOT just confirm a
1118
+ // render (no molecule:ready landed in the moment before — i.e. the reloaded app rendered
1119
+ // nothing), drop confirmedContent so the overlay re-covers the now-blank iframe instead of
1120
+ // leaving the stale "it rendered" state masking a white screen. A working full-reload mounts
1121
+ // React (→ molecule:ready) just before the load event, so its ready is fresh and
1122
+ // confirmedContent is KEPT (no flash); a slow/async render re-confirms via the ready that
1123
+ // follows (the bridge re-sends ready ~every 4s while content is visible).
1124
+ if (Date.now() - lastReadyAtRef.current > READY_FRESH_MS) {
1125
+ setConfirmedContent(false);
1126
+ // Re-cover the freshly (re)loaded document until ITS OWN onLoad grace re-reveals it (or a
1127
+ // fresh molecule:ready confirms sooner). Reveal is gated on `iframeReady`, so a full-reload
1128
+ // that an edit triggers must lower it — otherwise the previous load's reveal leaks through
1129
+ // and flashes the new, possibly-blank document uncovered. The grace timer armed just below
1130
+ // re-reveals it.
1131
+ setIframeReady(false);
1132
+ // Bring the overlay back SOLID over the blank reload — not mid-fade from the previous
1133
+ // app's confirmation (whose fade-out may still be in flight).
1134
+ setFadingOut(false);
1135
+ }
1136
+ if (onLoadGraceRef.current)
1137
+ clearTimeout(onLoadGraceRef.current);
1138
+ onLoadGraceRef.current = setTimeout(() => {
1139
+ onLoadGraceRef.current = null;
1140
+ if (iframeReadyRef.current)
1141
+ return;
1142
+ // A crash within the grace window means the load is NOT good — don't mask it.
1143
+ if (Date.now() - lastCrashAtRef.current < ONLOAD_GRACE_MS)
1144
+ return;
1145
+ // FIRST cold boot (this target has NEVER rendered): do NOT reveal on the grace. The
1146
+ // scaffold's inline bridge posts `molecule:ready` the instant #root mounts (reliable), so
1147
+ // we keep the honest "Starting your app…" overlay up until that REAL signal instead of
1148
+ // flashing a white, still-compiling iframe and then falsely accusing it of being blank —
1149
+ // the fresh-project "won't load until I open a new tab" bug. The grace reveal is purely a
1150
+ // flash-avoidance shortcut for RELOADS of an app that already rendered once; a genuinely
1151
+ // never-mounting app is surfaced honestly by the cold-boot evaluator (dead / 60s ceiling).
1152
+ //
1153
+ // A fresh wake is the SAME cold-boot regime even when the app already rendered pre-wake
1154
+ // (hasEverRendered stays true): the woken Vite mounts once then full-reloads mid-optimize,
1155
+ // and revealing that blank reload on the grace would flash a bare white iframe instead of
1156
+ // the honest "Starting…" spinner. Keep the overlay up until a REAL molecule:ready (which a
1157
+ // healthy wake posts within seconds); only that reveals during the wake window.
1158
+ if (!hasEverRenderedRef.current || inWakeWindow())
1159
+ return;
1160
+ setEverLoaded(true);
1161
+ setIframeReady(true);
1162
+ setStuckRetryCount(0);
1163
+ }, ONLOAD_GRACE_MS);
1164
+ }, [inWakeWindow]);
1165
+ // --- Health check: periodically verify server is still up ---
1166
+ useEffect(() => {
1167
+ // Only run health checks after overlay is fully gone
1168
+ if (!iframeReady || fadingOut || !state.url)
1169
+ return;
1170
+ const url = state.url;
1171
+ const healthRef = { current: null };
1172
+ // Cancellation flag — prevents orphaned poll() closures from running
1173
+ // after this effect cleans up.
1174
+ let cancelled = false;
1175
+ healthRef.current = setInterval(async () => {
1176
+ if (cancelled)
1177
+ return;
1178
+ const up = await isServerUp(url);
1179
+ if (cancelled)
1180
+ return;
1181
+ if (!up && urlRef.current === url) {
1182
+ // Server went down — show overlay, poll, reload when back
1183
+ if (healthRef.current)
1184
+ clearInterval(healthRef.current);
1185
+ setIframeReady(false);
1186
+ setFadingOut(false);
1187
+ let interval = POLL_INITIAL_MS;
1188
+ const poll = async () => {
1189
+ if (cancelled || urlRef.current !== url)
1190
+ return;
1191
+ const back = await isServerUp(url);
1192
+ if (cancelled || urlRef.current !== url)
1193
+ return;
1194
+ if (back) {
1195
+ // Force-reload iframe with cache buster
1196
+ setIframeSrc(withCacheBuster(url));
1197
+ }
1198
+ else {
1199
+ setTimeout(poll, interval);
1200
+ interval = Math.min(interval * POLL_BACKOFF_FACTOR, POLL_MAX_MS);
1201
+ }
1202
+ };
1203
+ void poll();
1204
+ }
1205
+ }, 3000);
1206
+ return () => {
1207
+ cancelled = true;
1208
+ if (healthRef.current)
1209
+ clearInterval(healthRef.current);
1210
+ };
1211
+ }, [iframeReady, fadingOut, state.url]);
1212
+ // --- Freeze watchdog ---
1213
+ // The scaffold's main thread posts `molecule:heartbeat` every ~3s. If that thread
1214
+ // locks up (an infinite loop or runaway render in the in-progress app), the beats
1215
+ // stop. Origin-Agent-Cluster isolation keeps that freeze off the IDE's thread, so
1216
+ // the IDE can notice the silence and offer a reload — a frozen frame can't recover
1217
+ // on its own. Auto-clears if heartbeats resume (transient jank, not a hard lock).
1218
+ useEffect(() => {
1219
+ if (!iframeReady || fadingOut || !state.url) {
1220
+ setPreviewFrozen(false);
1221
+ return;
1222
+ }
1223
+ // Baseline so a stale value from a prior load can't trip us immediately.
1224
+ lastHeartbeatRef.current = Date.now();
1225
+ const timer = setInterval(() => {
1226
+ setPreviewFrozen(Date.now() - lastHeartbeatRef.current > FREEZE_THRESHOLD_MS);
1227
+ }, FREEZE_CHECK_INTERVAL_MS);
1228
+ return () => clearInterval(timer);
1229
+ }, [iframeReady, fadingOut, state.url]);
1230
+ // --- Freeze → host report ---
1231
+ // A frozen preview is the worst case the iframe CANNOT self-report (its thread is
1232
+ // locked, so no molecule:error/runtime-error can ever be posted) — the banner alone
1233
+ // gives the user a reload but never tells the agent. Bridge that gap: on the
1234
+ // false→true freeze transition, hand the host a structured `frozen` report (with the
1235
+ // route) so it can, gated on the autofix setting, ask the agent to find and fix the
1236
+ // infinite loop / runaway render. Fires once per episode; re-arms when beats resume.
1237
+ useEffect(() => {
1238
+ if (previewFrozen && !frozenReportedRef.current) {
1239
+ frozenReportedRef.current = true;
1240
+ onPreviewStuck?.({ reason: 'frozen', url: currentLocationRef.current });
1241
+ }
1242
+ else if (!previewFrozen) {
1243
+ frozenReportedRef.current = false;
1244
+ }
1245
+ }, [previewFrozen, onPreviewStuck]);
1246
+ // --- Render verdict → host (→ server) ---
1247
+ // Collapse the panel's internal flags into the single render verdict the server needs:
1248
+ // did the app actually draw content, or is it blank/frozen/still loading? The host
1249
+ // forwards it so Synthase's post-loop verification can confirm a REAL render (not just
1250
+ // that the bundle compiled + served). Emit only on change, with the live route.
1251
+ const renderState = previewFrozen
1252
+ ? 'frozen'
1253
+ : previewGaveUp || blankPostBuild
1254
+ ? 'blank'
1255
+ : confirmedContent
1256
+ ? 'rendered'
1257
+ : 'loading';
1258
+ const lastRenderStateRef = useRef(null);
1259
+ useEffect(() => {
1260
+ if (!state.url)
1261
+ return;
1262
+ if (lastRenderStateRef.current === renderState)
1263
+ return;
1264
+ lastRenderStateRef.current = renderState;
1265
+ onRenderState?.(renderState, currentLocationRef.current);
1266
+ }, [renderState, state.url, onRenderState]);
1267
+ // --- "Preview is in trouble" detection (signal-driven, NOT a naive timer) ---
1268
+ // Decides whether to swap the honest "Starting/Building…" status for the ACTIONABLE
1269
+ // "preview is blank — reload / open in new tab" notice. Driven by the scaffold bridge's OWN
1270
+ // signals — `molecule:ready` (rendered), `molecule:heartbeat` (alive) — instead of the old
1271
+ // "loaded + 2.5s + no ready ⇒ blank" guess, which FALSE-fired over a fresh project whose cold
1272
+ // Vite server was still pre-bundling/compiling the just-written app (the app WAS coming up and
1273
+ // would render seconds later). The regimes:
1274
+ // • confirmed / actively building / mid-fade / no document yet → never a problem.
1275
+ // • already rendered once, then reloaded blank (`hasEverRendered`) → real regression, surface
1276
+ // after the short BLANK_CONFIRM_MS.
1277
+ // • never rendered yet AND not alive (no heartbeat for FREEZE_THRESHOLD_MS after a settle) →
1278
+ // the inline bridge never ran ⇒ broken/error page ⇒ surface after BLANK_DEAD_MS.
1279
+ // • never rendered yet BUT alive (heartbeating) → a cold boot in progress → keep the honest
1280
+ // "Starting…" overlay; only surface after the generous COLD_BOOT_PATIENCE_MS ceiling
1281
+ // (alive the whole time yet never mounted ⇒ genuinely stuck).
1282
+ // Re-evaluated on an interval because liveness + elapsed-since-load change over time. An
1283
+ // explicit `molecule:blank` also raises the notice directly via its handler (setBlankPostBuild).
1284
+ useEffect(() => {
1285
+ if (!state.url || !iframeLoadedRef.current || confirmedContent || isBuilding || fadingOut) {
1286
+ setBlankPostBuild(false);
1287
+ return;
1288
+ }
1289
+ const evaluate = () => {
1290
+ if (confirmedContentRef.current || isBuildingRef.current) {
1291
+ setBlankPostBuild(false);
1292
+ return true;
1293
+ }
1294
+ const now = Date.now();
1295
+ const sinceLoad = now - (lastLoadAtRef.current || now);
1296
+ const aliveRecently = now - lastHeartbeatRef.current < FREEZE_THRESHOLD_MS;
1297
+ // Right after a wake, NEITHER fast accusation window may fire — only the generous
1298
+ // never-rendered ceiling. A wake relaunches the dev server, and cold Vite routinely
1299
+ // mounts the app once (→ molecule:ready, so hasEverRendered flips true) and THEN does a
1300
+ // full HMR reload while it finishes pre-bundling deps; that reload renders nothing for a
1301
+ // beat and would trip the already-rendered BLANK_CONFIRM_MS path. It can equally land on
1302
+ // a transient bridge-less error page the restarting proxy serves (the never-rendered
1303
+ // BLANK_DEAD_MS path). Both are EXPECTED during a restart, so the wake window overrides
1304
+ // both — matching the intent: accuse only when we're certain nothing ever rendered
1305
+ // (COLD_BOOT_PATIENCE_MS elapsed while alive-but-unmounted). The stale-document
1306
+ // auto-reloads still recover a genuinely dead doc in the meantime.
1307
+ // Inside the wake window: NEVER accuse. The old in-window ceiling used
1308
+ // `sinceLoad >= COLD_BOOT_PATIENCE_MS`, but on a wake `lastLoadAt` is the
1309
+ // PRE-HIBERNATION load — minutes-to-hours old — so the "generous ceiling"
1310
+ // tripped the instant the sleeping screen lifted and only cleared when the
1311
+ // wake reload reset the anchor (the observed blank-notice flash over a
1312
+ // 13-second relaunch). The window itself (WAKE_PATIENCE_MS) is shorter
1313
+ // than the ceiling anyway, so an in-window `sinceLoad` accusation could
1314
+ // ONLY ever fire off that stale anchor; a wake that genuinely never comes
1315
+ // up is accused by the normal branches the moment the window expires.
1316
+ const introuble = inWakeWindow()
1317
+ ? false
1318
+ : hasEverRenderedRef.current
1319
+ ? sinceLoad >= BLANK_CONFIRM_MS
1320
+ : (!aliveRecently && sinceLoad >= BLANK_DEAD_MS) || sinceLoad >= COLD_BOOT_PATIENCE_MS;
1321
+ setBlankPostBuild(introuble);
1322
+ return introuble;
1323
+ };
1324
+ if (evaluate())
1325
+ return;
1326
+ const id = setInterval(() => {
1327
+ if (evaluate())
1328
+ clearInterval(id);
1329
+ }, 500);
1330
+ return () => clearInterval(id);
1331
+ // docLoadedTick re-arms this when a fresh document loads (iframeReady no longer flips on the
1332
+ // cold-boot grace, so it can't be the trigger). iframeReady is still a dep so a real reveal
1333
+ // re-evaluates (and clears) immediately.
1334
+ }, [state.url, docLoadedTick, iframeReady, confirmedContent, isBuilding, fadingOut, inWakeWindow]);
1335
+ // --- Auto-reload when AI edits files — ONLY while the preview is broken ---
1336
+ // A healthy preview needs nothing from us: Vite HMR applies every edit live,
1337
+ // and Vite itself broadcasts a full reload when a change can't hot-apply. The
1338
+ // old "safety net" (force-reload 3s after EVERY edit even when healthy) made
1339
+ // each HMR-applied change get wiped by a redundant full reload moments later —
1340
+ // the "theme updated, then the whole preview reloaded anyway" flash. When the
1341
+ // preview IS broken (crashed / failed to mount), the document may be an error
1342
+ // page without a live Vite client, so HMR can't deliver the fix — there a
1343
+ // debounced reload after the next edit is the genuine recovery path.
1344
+ const fileChangTickRef = useRef(fileChangeTick);
1345
+ useEffect(() => {
1346
+ if (fileChangTickRef.current === fileChangeTick)
1347
+ return;
1348
+ fileChangTickRef.current = fileChangeTick;
1349
+ if (!everLoaded || !state.url)
1350
+ return;
1351
+ if (iframeReady)
1352
+ return; // healthy — trust HMR / Vite's own full-reload
1353
+ const timer = setTimeout(() => {
1354
+ setIframeSrc(withCacheBuster(state.url));
1355
+ }, 1500);
1356
+ return () => clearTimeout(timer);
1357
+ }, [fileChangeTick, iframeReady, everLoaded, state.url]);
1358
+ // --- Manual retry handler ---
1359
+ const handleManualRetry = useCallback(() => {
1360
+ // Clear the loop-breaker so a fresh attempt starts clean.
1361
+ setPreviewGaveUp(false);
1362
+ setIframeMountKey((k) => k + 1);
1363
+ setStuckRetryCount(0);
1364
+ if (state.url) {
1365
+ setIframeSrc(withCacheBuster(state.url));
1366
+ }
1367
+ }, [state.url]);
1368
+ // --- Reload a frozen preview ---
1369
+ // Remounts the iframe (fresh load re-runs the app, clearing the locked thread).
1370
+ const handleReloadFrozen = useCallback(() => {
1371
+ setPreviewFrozen(false);
1372
+ lastHeartbeatRef.current = Date.now();
1373
+ handleManualRetry();
1374
+ }, [handleManualRetry]);
1375
+ // --- Back / Forward: client-side history, NOT a cold reload ---
1376
+ // Browser-grade Back/Forward must preserve scroll position + SPA state, so we
1377
+ // do NOT bump loadNonce (which would re-mount the iframe and re-run the app).
1378
+ // Instead we (1) move the host's history cursor via the provider — updating the
1379
+ // URL bar + button gating with NO reload — and (2) post a `molecule:nav-command`
1380
+ // to the iframe, whose scaffold-injected receiver runs `history.back()`/
1381
+ // `forward()` inside the preview. The resulting `molecule:navigate` dedupes
1382
+ // against the cursor we just moved, so the forward stack survives.
1383
+ //
1384
+ // Order matters: move the cursor FIRST (synchronously sets currentUrl) so the
1385
+ // reported navigation dedupes; posting first would let recordNavigation push a
1386
+ // fresh entry and truncate the forward stack. Back/Forward are only enabled once
1387
+ // the preview has reported navigations (history is built from them), so the
1388
+ // scaffold nav-bridge receiver is always present when these fire.
1389
+ const postNavCommand = useCallback((action) => {
1390
+ iframeRef.current?.contentWindow?.postMessage({ type: 'molecule:nav-command', action }, '*');
1391
+ }, []);
1392
+ const handleBack = useCallback(() => {
1393
+ back();
1394
+ postNavCommand('back');
1395
+ }, [back, postNavCommand]);
1396
+ const handleForward = useCallback(() => {
1397
+ forward();
1398
+ postNavCommand('forward');
1399
+ }, [forward, postNavCommand]);
1400
+ // --- Rendering ---
1401
+ // Device frame sizes the iframe: fluid frames (responsive/desktop) fill the
1402
+ // area; fixed frames (tablet/mobile) get an explicit pixel width AND height,
1403
+ // swapped in landscape so rotation visibly re-proportions the preview.
1404
+ const { width: iframeWidth, height: iframeHeight } = resolveDeviceSize(state.device, orientation);
1405
+ // `building` selects the overlay CONTENT ("Updating `X`…") while the overlay
1406
+ // is shown for a BROKEN preview during edits — it no longer forces the overlay
1407
+ // over a healthy iframe. It existed to mask the white flash of the old
1408
+ // reload-after-every-edit safety net; with that gone, a healthy preview stays
1409
+ // visible and HMR updates are seen live, uncovered.
1410
+ const building = buildingHint != null;
1411
+ // The agent is actively building AND the app has not confirmed it rendered content (no
1412
+ // molecule:ready). The iframe may have "loaded" into a half-built/blank/white state — keep
1413
+ // the reassuring "Building your app…" overlay up over it instead of a bare white screen.
1414
+ // A confirmed render clears it, so a working preview stays visible (HMR updates uncovered).
1415
+ // THE invariant: the overlay shows until the preview is READY TO SHOW. On a first cold boot that
1416
+ // means until `molecule:ready` confirms a render (the honest "Starting…" status stays up over
1417
+ // the still-compiling app — no white flash, no false "blank"); on a reload of an already-rendered
1418
+ // app the onLoad grace reveals it fast. `iframeReady` is that reveal flag (set by ready always,
1419
+ // by the grace only when `hasEverRendered` — see handleIframeLoad + the fade-out effect). Explicit
1420
+ // `molecule:blank`/crash signals RE-COVER a blank/broken app by lowering `iframeReady`, so a
1421
+ // working preview always surfaces and the user is never left staring at a bare iframe OR stuck
1422
+ // behind a status that can't lift. `fadingOut` keeps the overlay through its fade-out;
1423
+ // `blankPostBuild` re-asserts it (as the actionable notice) for a real blank/dead/stuck app.
1424
+ // An active build keeps the reassuring status overlay up ONLY while the preview is
1425
+ // unconfirmed — a confirmed-healthy preview stays uncovered so the user sees HMR edits live
1426
+ // (the "theme updated, no reload flash" behavior). So `(isBuilding || building) &&
1427
+ // !confirmedContent` re-asserts the overlay during a build, but never over a working app.
1428
+ const buildingUnconfirmed = (isBuilding || building) && !confirmedContent;
1429
+ const showOverlay = Boolean(state.url) &&
1430
+ !previewGaveUp &&
1431
+ (!iframeReady || fadingOut || blankPostBuild || buildingUnconfirmed);
1432
+ // Fully opaque + interactive whenever it is standing in for missing content (only the
1433
+ // ready-then-fading-out state animates to transparent).
1434
+ const overlaySolid = !iframeReady || blankPostBuild || buildingUnconfirmed;
1435
+ const overlayContent = blankPostBuild ? (_jsx(PreviewBlankNotice, { onReload: handleManualRetry, onOpenExternal: openExternal })) : isBuilding || building ? (_jsx(DefaultLoadingIndicator, { hint: buildingHint, retryCount: stuckRetryCount, onManualRetry: handleManualRetry })) : everLoaded ? ((restartingIndicator ??
1436
+ loadingIndicator ?? (_jsx(DefaultLoadingIndicator, { retryCount: stuckRetryCount, onManualRetry: handleManualRetry })))) : ((loadingIndicator ?? (_jsx(DefaultLoadingIndicator, { retryCount: stuckRetryCount, onManualRetry: handleManualRetry }))));
1437
+ 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: {
1438
+ flex: 1,
1439
+ // On phone-width viewports the field keeps an 80px floor (the
1440
+ // address ellipsizes) so the nav cluster can never squeeze it to
1441
+ // zero; elsewhere it may shrink freely.
1442
+ minWidth: isNarrow ? 80 : 0,
1443
+ // Full height (P4-12): the address field stretches to fill the
1444
+ // toolbar row instead of sitting as a short centered box.
1445
+ alignSelf: 'stretch',
1446
+ borderRadius: '6px',
1447
+ // Borderless (P4-12): no resting border. The focus indicator is a
1448
+ // primary-token ring shown only while editing \u2014 it preserves the
1449
+ // WCAG 2.4.7 focus indicator the removed border used to carry,
1450
+ // without a boxed border at rest.
1451
+ boxShadow: urlEditing ? '0 0 0 2px var(--mol-color-primary, #6366f1)' : 'none',
1452
+ transition: 'box-shadow 120ms',
1453
+ }, children: [_jsx(Tooltip, { content: isSecureLocation
1454
+ ? t('ide.preview.secure', {}, { defaultValue: 'Secure (HTTPS)' })
1455
+ : t('ide.preview.address', {}, { defaultValue: 'Preview address' }), placement: "bottom", children: _jsx(Icon, { name: isSecureLocation ? 'lock' : 'globe', size: 16, "data-mol-id": "preview-site-info", className: cm.cn(cm.textMuted, cm.sp('mr', 1)), "aria-hidden": "true" }) }), _jsx("input", { type: "text", "data-mol-id": "preview-url", value: urlDraft, onChange: (e) => setUrlDraft(e.target.value), onFocus: (e) => {
1456
+ setUrlEditing(true);
1457
+ // Select-all on focus so one click readies the whole address to
1458
+ // overtype or copy — standard browser omnibox behavior.
1459
+ e.currentTarget.select();
1460
+ }, onBlur: () => setUrlEditing(false), onKeyDown: (e) => {
1461
+ if (e.key === 'Enter') {
1462
+ const next = urlDraft.trim();
1463
+ if (next)
1464
+ setUrl(next);
1465
+ e.currentTarget.blur();
1466
+ }
1467
+ else if (e.key === 'Escape') {
1468
+ setUrlDraft(currentLocation);
1469
+ e.currentTarget.blur();
1470
+ }
1471
+ }, "aria-label": t('ide.preview.urlBar', {}, { defaultValue: 'Preview URL' }), className: cm.cn(!bigUrlType && cm.textSize('sm')), style: {
1472
+ flex: 1,
1473
+ minWidth: 0,
1474
+ background: 'transparent',
1475
+ border: 'none',
1476
+ color: 'inherit',
1477
+ // Normal (non-monospace) UI font at 14px (cm.textSize('sm')),
1478
+ // vertically centered by the field's align-center row (P4-12).
1479
+ // On phones/touch devices the class is dropped and 16px set
1480
+ // inline instead (never both — inline would silently override
1481
+ // the ClassMap class): sub-16px focused inputs make iOS Safari
1482
+ // zoom the page. Ellipsize the (unfocused) address overflow.
1483
+ fontSize: bigUrlType ? 16 : undefined,
1484
+ textOverflow: 'ellipsis',
1485
+ fontFamily: 'inherit',
1486
+ // The wrapping address field carries the visible focus indicator
1487
+ // (a primary-token ring while focused), so the input's own outline
1488
+ // is intentionally suppressed here — matching the chat input.
1489
+ outline: 'none',
1490
+ } })] }), _jsx("div", { "aria-hidden": "true", "data-mol-id": "preview-toolbar-separator", style: {
1491
+ alignSelf: 'stretch',
1492
+ width: '1px',
1493
+ flexShrink: 0,
1494
+ margin: '0 4px',
1495
+ background: 'var(--mol-color-border, rgba(128,128,128,0.2))',
1496
+ } }), _jsx(BarButton, { icon: "arrow-left", molId: "preview-back", onClick: handleBack, disabled: !state.canGoBack, title: t('ide.preview.back', {}, { defaultValue: 'Back' }) }), _jsx(BarButton, { icon: "arrow-right", molId: "preview-forward", onClick: handleForward, disabled: !state.canGoForward, title: t('ide.preview.forward', {}, { defaultValue: 'Forward' }) }), _jsx(BarButton, { icon: "sync", molId: "preview-refresh", onClick: refresh, title: t('ide.preview.refresh', {}, { defaultValue: 'Reload' }) }), !isNarrow && (_jsx(DeviceFrameSelector, { current: state.device, onChange: handleDeviceChange, canRotate: canRotate, rotated: orientation === 'landscape', onRotate: handleRotate, onOpenExternal: openExternal }))] }) }), _jsxs("div", { className: cm.cn(cm.flex({ direction: 'row', justify: 'center', align: 'center' }), cm.surfaceSecondary), style: {
1497
+ flex: 1,
1498
+ minHeight: 0,
1499
+ overflow: 'auto',
1500
+ position: 'relative',
1501
+ }, children: [iframeSrc && (_jsx("iframe", { ref: iframeRef, src: iframeSrc, sandbox: "allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin", title: t('ide.preview.livePreview'), className: cm.cn(state.device !== 'none' && state.device !== 'desktop' && cm.borderAll), style: {
1502
+ width: iframeWidth,
1503
+ height: iframeHeight,
1504
+ borderRadius: state.device === 'mobile' ? '16px' : '0',
1505
+ background: '#fff',
1506
+ }, onLoad: handleIframeLoad, onError: handleIframeError }, iframeMountKey)), showOverlay && (_jsxs("div", { "data-mol-id": "preview-overlay", style: {
1507
+ position: 'absolute',
1508
+ inset: 0,
1509
+ display: 'flex',
1510
+ flexDirection: 'column',
1511
+ alignItems: 'center',
1512
+ justifyContent: 'center',
1513
+ zIndex: 1,
1514
+ // Once the preview has loaded at least once, a restart overlay is
1515
+ // semi-transparent so the last rendered UI stays dimly visible behind
1516
+ // the rotating status — the user keeps "something to look at" instead
1517
+ // of a blank wall on every build-triggered reload. The very first load
1518
+ // (nothing rendered yet) stays fully opaque.
1519
+ // Semi-transparent (so the dim last-good UI shows through) ONLY when there is
1520
+ // actually a last-good frame to reveal — a restart of an app that rendered before.
1521
+ // A first cold boot has no frame yet, so stay fully opaque instead of bleeding the
1522
+ // still-white, mid-compile iframe through a 78% tint.
1523
+ background: everLoaded && lastGoodFrame
1524
+ ? 'color-mix(in srgb, var(--mol-color-surface-secondary, #f5f5f5) 78%, transparent)'
1525
+ : 'var(--mol-color-surface-secondary, #f5f5f5)',
1526
+ // DO NOT add `backdrop-filter` here. This overlay sits ON TOP of the live
1527
+ // cross-origin preview <iframe> (an out-of-process iframe). backdrop-filter must
1528
+ // SAMPLE its backdrop — i.e. read the OOPIF's compositor surface across processes
1529
+ // every frame. Under software / GPU-less compositing (this VM runs
1530
+ // --disable-gpu-compositing) that cross-process surface read DEADLOCKS the host
1531
+ // renderer's main thread → the whole IDE tab freezes on every reload of a
1532
+ // once-loaded preview. A `backdrop-filter: blur(2px)` here was the CONFIRMED cause
1533
+ // of exactly that freeze (verified by elimination: removing only the backdrop-filter,
1534
+ // overlay still opaque + occluding, un-froze the foregrounded tab). The blurred
1535
+ // "last working UI" is provided SAFELY by the last-good-frame <img> below — it
1536
+ // blurs its OWN pixels (a same-origin snapshot), never the live OOPIF backdrop.
1537
+ // Anything standing in for missing content (a build, a blank fallback, an
1538
+ // unconfirmed building app, or a post-build blank) forces the overlay fully
1539
+ // visible (it may have already faded out from a prior ready state); otherwise
1540
+ // honor the fade-out.
1541
+ opacity: overlaySolid ? 1 : fadingOut ? 0 : 1,
1542
+ transition: 'opacity 0.5s ease-out',
1543
+ pointerEvents: overlaySolid || !fadingOut ? 'auto' : 'none',
1544
+ }, onTransitionEnd: () => {
1545
+ if (fadingOut)
1546
+ setFadingOut(false);
1547
+ }, children: [everLoaded && lastGoodFrame && (_jsxs(_Fragment, { children: [_jsx("img", { src: lastGoodFrame, "data-mol-id": "preview-last-frame", alt: t('ide.preview.lastWorkingFrame', {}, { defaultValue: 'Last working preview' }), style: {
1548
+ position: 'absolute',
1549
+ inset: 0,
1550
+ width: '100%',
1551
+ height: '100%',
1552
+ objectFit: 'cover',
1553
+ objectPosition: 'top center',
1554
+ filter: 'blur(8px)',
1555
+ zIndex: 0,
1556
+ pointerEvents: 'none',
1557
+ } }), _jsx("div", { "aria-hidden": "true", style: {
1558
+ position: 'absolute',
1559
+ inset: 0,
1560
+ zIndex: 0,
1561
+ background: 'color-mix(in srgb, var(--mol-color-surface-secondary, #f5f5f5) 55%, transparent)',
1562
+ pointerEvents: 'none',
1563
+ } })] })), _jsx("div", { style: { position: 'relative', zIndex: 1 }, children: overlayContent })] })), previewGaveUp && (_jsxs("div", { "data-mol-id": "preview-load-failed", className: cm.surface, style: {
1564
+ position: 'absolute',
1565
+ inset: 0,
1566
+ display: 'flex',
1567
+ flexDirection: 'column',
1568
+ alignItems: 'center',
1569
+ justifyContent: 'center',
1570
+ gap: '12px',
1571
+ zIndex: 2,
1572
+ padding: '24px',
1573
+ textAlign: 'center',
1574
+ }, 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: {
1575
+ display: 'flex',
1576
+ alignItems: 'center',
1577
+ justifyContent: 'center',
1578
+ width: '100%',
1579
+ }, children: state.isLoading
1580
+ ? t('ide.preview.starting', {}, { defaultValue: 'Loading preview...' })
1581
+ : t('ide.preview.noPreview', {}, { defaultValue: 'No preview available' }) })), previewFrozen && confirmedContent && !fadingOut && (_jsxs("div", { className: cm.cn(cm.textSize('xs'), cm.bgErrorSubtle, cm.textError), style: {
1582
+ position: 'absolute',
1583
+ top: 0,
1584
+ left: 0,
1585
+ right: 0,
1586
+ zIndex: 3,
1587
+ display: 'flex',
1588
+ alignItems: 'center',
1589
+ justifyContent: 'space-between',
1590
+ gap: '12px',
1591
+ padding: '8px 12px',
1592
+ }, children: [_jsx("span", { children: t('ide.preview.frozen', {}, {
1593
+ defaultValue: 'This app stopped responding — an infinite loop or runaway render froze the preview. The IDE is unaffected.',
1594
+ }) }), _jsx("button", { type: "button", onClick: handleReloadFrozen,
1595
+ // touchTarget grows the hit-area to ≥44px on coarse-pointer
1596
+ // devices only; desktop keeps the compact banner button.
1597
+ className: cm.touchTarget, style: {
1598
+ flexShrink: 0,
1599
+ padding: '4px 12px',
1600
+ fontSize: '12px',
1601
+ border: '1px solid currentColor',
1602
+ borderRadius: '4px',
1603
+ background: 'transparent',
1604
+ color: 'inherit',
1605
+ cursor: 'pointer',
1606
+ }, children: t('ide.preview.frozenReload', {}, { defaultValue: 'Reload app' }) })] })), state.error && (_jsx("div", { className: cm.cn(cm.textSize('sm'), cm.sp('p', 3), cm.bgErrorSubtle, cm.textError), style: {
1607
+ position: 'absolute',
1608
+ bottom: 0,
1609
+ left: 0,
1610
+ right: 0,
1611
+ zIndex: 2,
1612
+ }, children: state.error }))] })] }));
1613
+ }
1614
+ PreviewPanel.displayName = 'PreviewPanel';
1615
+ /**
1616
+ * A single icon button inside the preview URL bar. Renders a ghost button with
1617
+ * an icon-set glyph wrapped in the framework's REAL styled {@link Tooltip} —
1618
+ * instant, themed via ClassMap tokens, and focus-aware — NOT the delayed,
1619
+ * unstyled, touch-blind native `title` attribute it replaces. `disabled` buttons
1620
+ * (e.g. Back/Forward when there's no history entry to go to) are dimmed and
1621
+ * non-interactive via the ClassMap button's built-in disabled styling.
1622
+ *
1623
+ * The Tooltip is imported via its dedicated subpath
1624
+ * (`@molecule/app-ui-react/components/Tooltip.js`) so the IDE pulls only the
1625
+ * Tooltip, never the package barrel (which would drag in `react-router-dom`).
1626
+ * This BarButton is the reference pattern other IDE affordances should follow
1627
+ * when replacing native `title` tooltips.
1628
+ *
1629
+ * The compact `xs` size is ~26px tall — fine for a mouse but below the WCAG
1630
+ * 2.5.5 minimum tap target. `cm.touchTarget` grows the hit-area to >=44x44px on
1631
+ * coarse-pointer (touch) devices ONLY, so the toolbar stays compact on desktop
1632
+ * but is comfortably tappable on phones/tablets.
1633
+ * @param props - Component props.
1634
+ * @returns The rendered button element.
1635
+ */
1636
+ function BarButton({ icon, title, molId, onClick, disabled, }) {
1637
+ const cm = getClassMap();
1638
+ return (_jsx(Tooltip, { content: title, placement: "bottom", children: _jsx("button", { type: "button", "data-mol-id": molId, "aria-label": title, onClick: onClick, disabled: disabled, className: cm.cn(cm.button({ variant: 'ghost', size: 'xs' }), cm.touchTarget), children: _jsx(Icon, { name: icon, size: 16, "aria-hidden": "true" }) }) }));
1639
+ }
1640
+ /**
1641
+ * Fallback loading indicator when no custom one is provided.
1642
+ * @param props - Component props.
1643
+ * @returns The rendered loading indicator element.
1644
+ */
1645
+ /**
1646
+ * Actionable notice shown when a build has FINISHED but the app rendered nothing — the
1647
+ * document loaded and the preview bridge is alive, yet no `molecule:ready` ever arrived
1648
+ * (the app's `#root` stayed empty). Unlike the reassuring building spinner, this gives the
1649
+ * user a clear way forward instead of a bare white screen: reload, or open the preview in a
1650
+ * new tab — and it honestly names the likely cause (an app error the agent can fix).
1651
+ * @param props - Component props.
1652
+ * @returns The rendered blank-preview notice card.
1653
+ */
1654
+ function PreviewBlankNotice({ onReload, onOpenExternal, }) {
1655
+ const cm = getClassMap();
1656
+ return (_jsxs("div", { "data-mol-id": "preview-blank-notice", className: cm.surface, style: {
1657
+ display: 'flex',
1658
+ flexDirection: 'column',
1659
+ alignItems: 'center',
1660
+ gap: '12px',
1661
+ border: '1px solid var(--mol-color-border, rgba(128,128,128,0.2))',
1662
+ borderRadius: '8px',
1663
+ padding: '20px 24px',
1664
+ maxWidth: '360px',
1665
+ textAlign: 'center',
1666
+ boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
1667
+ }, children: [_jsx(Icon, { name: "image", 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.blankTitle', {}, { defaultValue: 'The preview is blank' }) }), _jsx("div", { className: cm.cn(cm.textSize('xs'), cm.textMuted), style: { lineHeight: 1.5 }, children: t('ide.preview.blankHint', {}, {
1668
+ defaultValue: 'The app loaded but didn’t render anything — it may have an error. Synthase has been notified. You can reload, or open the preview in a new tab.',
1669
+ }) }), _jsxs("div", { style: { display: 'flex', gap: '8px' }, children: [_jsx("button", { type: "button", "data-mol-id": "preview-blank-reload", onClick: onReload, 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-blank-open", onClick: onOpenExternal, className: cm.cn(cm.button({ variant: 'ghost', size: 'sm' }), cm.touchTarget), children: t('ide.preview.openNewTab', {}, { defaultValue: 'Open in new tab' }) })] })] }));
1670
+ }
1671
+ /**
1672
+ * Molecule-themed status phrases rotated in the preview overlay. A build edits
1673
+ * files rapidly, so the preview can reload (or briefly blank) many times before it
1674
+ * settles — a static "Loading preview…" left users wondering what was happening.
1675
+ * Rotating phrases read as "the agent is actively working", not "broken".
1676
+ */
1677
+ const PREVIEW_MESSAGES = [
1678
+ { key: 'ide.preview.msg.synthesizing', defaultValue: 'Synthesizing components…' },
1679
+ { key: 'ide.preview.msg.bonding', defaultValue: 'Bonding the modules…' },
1680
+ { key: 'ide.preview.msg.assembling', defaultValue: 'Assembling the build…' },
1681
+ { key: 'ide.preview.msg.reacting', defaultValue: 'Reacting to your edits…' },
1682
+ { key: 'ide.preview.msg.composing', defaultValue: 'Composing the interface…' },
1683
+ { key: 'ide.preview.msg.catalyzing', defaultValue: 'Catalyzing your changes…' },
1684
+ { key: 'ide.preview.msg.stabilizing', defaultValue: 'Stabilizing the structure…' },
1685
+ { key: 'ide.preview.msg.crystallizing', defaultValue: 'Crystallizing the UI…' },
1686
+ { key: 'ide.preview.msg.workingOnIt', defaultValue: 'Working on it…' },
1687
+ { key: 'ide.preview.msg.almostThere', defaultValue: 'Almost there…' },
1688
+ ];
1689
+ /**
1690
+ * Default preview-overlay content: pulsing dots, a status message, and (after
1691
+ * repeated recovery cycles) a manual retry button. With a `hint` it shows
1692
+ * "Updating `<hint>`" (the file the build is currently editing); without one it
1693
+ * rotates molecule-themed phrases so the overlay never reads as frozen.
1694
+ * @param props - Component props.
1695
+ * @returns The rendered loading indicator.
1696
+ */
1697
+ function DefaultLoadingIndicator({ hint, retryCount, onManualRetry, }) {
1698
+ const cm = getClassMap();
1699
+ // Rotate the themed phrases (~2.4s each) so the overlay never reads as a frozen
1700
+ // "Loading preview…" while the build thrashes the preview with reloads. (When a
1701
+ // specific edit `hint` is present we show that instead — the changing filenames
1702
+ // are their own liveliness.)
1703
+ const [msgIdx, setMsgIdx] = useState(0);
1704
+ useEffect(() => {
1705
+ if (hint)
1706
+ return;
1707
+ const id = setInterval(() => setMsgIdx((i) => (i + 1) % PREVIEW_MESSAGES.length), 2400);
1708
+ return () => clearInterval(id);
1709
+ }, [hint]);
1710
+ const phrase = PREVIEW_MESSAGES[msgIdx];
1711
+ return (_jsxs("div", { style: {
1712
+ display: 'flex',
1713
+ flexDirection: 'column',
1714
+ alignItems: 'center',
1715
+ gap: '16px',
1716
+ background: 'var(--mol-color-surface, #fff)',
1717
+ border: '1px solid var(--mol-color-border, rgba(128,128,128,0.2))',
1718
+ borderRadius: '8px',
1719
+ padding: '16px 24px',
1720
+ boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
1721
+ }, children: [_jsx("div", { style: { display: 'flex', gap: '8px' }, children: [0, 1, 2].map((i) => (_jsx("span", { style: {
1722
+ width: '8px',
1723
+ height: '8px',
1724
+ borderRadius: '50%',
1725
+ background: 'var(--mol-color-primary, #4070e0)',
1726
+ animation: `mol-preview-pulse 1.4s ease-in-out ${i * 0.2}s infinite`,
1727
+ } }, i))) }), _jsx("span", { style: {
1728
+ fontSize: '13px',
1729
+ // Foreground token (not the low-contrast muted token) so "Updating
1730
+ // <file>" reads with full contrast in BOTH themes — the card backdrop
1731
+ // above guarantees a known surface behind it.
1732
+ color: 'var(--mol-color-text, #333)',
1733
+ textAlign: 'center',
1734
+ }, children: hint ? (_jsxs(_Fragment, { children: [t('ide.preview.updating', {}, { defaultValue: 'Updating' }), ' ', _jsx("code", { style: { fontFamily: 'monospace', fontSize: 'inherit', opacity: 0.9 }, children: hint })] })) : (t(phrase.key, {}, { defaultValue: phrase.defaultValue })) }), retryCount > 0 && (_jsx("span", { style: { fontSize: '11px', color: 'var(--mol-color-text-muted, #888)' }, children: t('ide.preview.retryCount', { count: retryCount }, { defaultValue: 'Retry attempt {{count}}' }) })), retryCount >= MAX_RECOVERY_CYCLES && onManualRetry && (_jsx("button", { type: "button", onClick: onManualRetry,
1735
+ // ≥44px hit-area on coarse-pointer devices only (compact on desktop).
1736
+ className: cm.touchTarget, style: {
1737
+ marginTop: '8px',
1738
+ padding: '6px 16px',
1739
+ fontSize: '12px',
1740
+ border: '1px solid var(--mol-color-border, #ddd)',
1741
+ borderRadius: '4px',
1742
+ background: 'transparent',
1743
+ color: 'inherit',
1744
+ cursor: 'pointer',
1745
+ }, children: t('ide.preview.retryButton', {}, { defaultValue: 'Retry now' }) })), _jsx("style", { children: `
1746
+ @keyframes mol-preview-pulse {
1747
+ 0%, 80%, 100% { transform: scale(0.6); opacity: 0.3; }
1748
+ 40% { transform: scale(1); opacity: 1; }
1749
+ }
1750
+ ` })] }));
1751
+ }
1752
+ //# sourceMappingURL=PreviewPanel.js.map