@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.
- package/LICENSE +115 -0
- package/dist/chatTimelineOrdering.d.ts +50 -0
- package/dist/chatTimelineOrdering.d.ts.map +1 -0
- package/dist/chatTimelineOrdering.js +46 -0
- package/dist/chatTimelineOrdering.js.map +1 -0
- package/dist/command-metadata.d.ts +78 -0
- package/dist/command-metadata.d.ts.map +1 -0
- package/dist/command-metadata.js +214 -0
- package/dist/command-metadata.js.map +1 -0
- package/dist/components/ActivityCard.d.ts +28 -0
- package/dist/components/ActivityCard.d.ts.map +1 -0
- package/dist/components/ActivityCard.js +62 -0
- package/dist/components/ActivityCard.js.map +1 -0
- package/dist/components/AutoCommitBadge.d.ts +58 -0
- package/dist/components/AutoCommitBadge.d.ts.map +1 -0
- package/dist/components/AutoCommitBadge.js +78 -0
- package/dist/components/AutoCommitBadge.js.map +1 -0
- package/dist/components/ChatPanel.d.ts +184 -0
- package/dist/components/ChatPanel.d.ts.map +1 -0
- package/dist/components/ChatPanel.js +6691 -0
- package/dist/components/ChatPanel.js.map +1 -0
- package/dist/components/CommandPalette.d.ts +18 -0
- package/dist/components/CommandPalette.d.ts.map +1 -0
- package/dist/components/CommandPalette.js +33 -0
- package/dist/components/CommandPalette.js.map +1 -0
- package/dist/components/DeviceFrameSelector.d.ts +60 -0
- package/dist/components/DeviceFrameSelector.d.ts.map +1 -0
- package/dist/components/DeviceFrameSelector.js +230 -0
- package/dist/components/DeviceFrameSelector.js.map +1 -0
- package/dist/components/EditorPanel.d.ts +17 -0
- package/dist/components/EditorPanel.d.ts.map +1 -0
- package/dist/components/EditorPanel.js +241 -0
- package/dist/components/EditorPanel.js.map +1 -0
- package/dist/components/FileExplorer.d.ts +64 -0
- package/dist/components/FileExplorer.d.ts.map +1 -0
- package/dist/components/FileExplorer.js +1111 -0
- package/dist/components/FileExplorer.js.map +1 -0
- package/dist/components/FileExplorerContextMenu.d.ts +37 -0
- package/dist/components/FileExplorerContextMenu.d.ts.map +1 -0
- package/dist/components/FileExplorerContextMenu.js +241 -0
- package/dist/components/FileExplorerContextMenu.js.map +1 -0
- package/dist/components/HelpCard.d.ts +34 -0
- package/dist/components/HelpCard.d.ts.map +1 -0
- package/dist/components/HelpCard.js +28 -0
- package/dist/components/HelpCard.js.map +1 -0
- package/dist/components/Icon.d.ts +50 -0
- package/dist/components/Icon.d.ts.map +1 -0
- package/dist/components/Icon.js +25 -0
- package/dist/components/Icon.js.map +1 -0
- package/dist/components/KeyboardShortcutsPanel.d.ts +19 -0
- package/dist/components/KeyboardShortcutsPanel.d.ts.map +1 -0
- package/dist/components/KeyboardShortcutsPanel.js +187 -0
- package/dist/components/KeyboardShortcutsPanel.js.map +1 -0
- package/dist/components/MarkdownContent.d.ts +52 -0
- package/dist/components/MarkdownContent.d.ts.map +1 -0
- package/dist/components/MarkdownContent.js +303 -0
- package/dist/components/MarkdownContent.js.map +1 -0
- package/dist/components/ModelsTable.d.ts +38 -0
- package/dist/components/ModelsTable.d.ts.map +1 -0
- package/dist/components/ModelsTable.js +96 -0
- package/dist/components/ModelsTable.js.map +1 -0
- package/dist/components/PreviewPanel.d.ts +66 -0
- package/dist/components/PreviewPanel.d.ts.map +1 -0
- package/dist/components/PreviewPanel.js +1752 -0
- package/dist/components/PreviewPanel.js.map +1 -0
- package/dist/components/QuickOpen.d.ts +20 -0
- package/dist/components/QuickOpen.d.ts.map +1 -0
- package/dist/components/QuickOpen.js +72 -0
- package/dist/components/QuickOpen.js.map +1 -0
- package/dist/components/QuickPicker.d.ts +21 -0
- package/dist/components/QuickPicker.d.ts.map +1 -0
- package/dist/components/QuickPicker.js +174 -0
- package/dist/components/QuickPicker.js.map +1 -0
- package/dist/components/RelevantSkillSuggestion.d.ts +41 -0
- package/dist/components/RelevantSkillSuggestion.d.ts.map +1 -0
- package/dist/components/RelevantSkillSuggestion.js +85 -0
- package/dist/components/RelevantSkillSuggestion.js.map +1 -0
- package/dist/components/ReportModal.d.ts +35 -0
- package/dist/components/ReportModal.d.ts.map +1 -0
- package/dist/components/ReportModal.js +112 -0
- package/dist/components/ReportModal.js.map +1 -0
- package/dist/components/ResizeHandle.d.ts +21 -0
- package/dist/components/ResizeHandle.d.ts.map +1 -0
- package/dist/components/ResizeHandle.js +109 -0
- package/dist/components/ResizeHandle.js.map +1 -0
- package/dist/components/ScriptsCard.d.ts +37 -0
- package/dist/components/ScriptsCard.d.ts.map +1 -0
- package/dist/components/ScriptsCard.js +177 -0
- package/dist/components/ScriptsCard.js.map +1 -0
- package/dist/components/SearchPanel.d.ts +18 -0
- package/dist/components/SearchPanel.d.ts.map +1 -0
- package/dist/components/SearchPanel.js +449 -0
- package/dist/components/SearchPanel.js.map +1 -0
- package/dist/components/SettingsCard.d.ts +49 -0
- package/dist/components/SettingsCard.d.ts.map +1 -0
- package/dist/components/SettingsCard.js +59 -0
- package/dist/components/SettingsCard.js.map +1 -0
- package/dist/components/ShareModal.d.ts +32 -0
- package/dist/components/ShareModal.d.ts.map +1 -0
- package/dist/components/ShareModal.js +126 -0
- package/dist/components/ShareModal.js.map +1 -0
- package/dist/components/SidebarTabs.d.ts +18 -0
- package/dist/components/SidebarTabs.d.ts.map +1 -0
- package/dist/components/SidebarTabs.js +20 -0
- package/dist/components/SidebarTabs.js.map +1 -0
- package/dist/components/SkillsCard.d.ts +86 -0
- package/dist/components/SkillsCard.d.ts.map +1 -0
- package/dist/components/SkillsCard.js +227 -0
- package/dist/components/SkillsCard.js.map +1 -0
- package/dist/components/StreamingIndicator.d.ts +50 -0
- package/dist/components/StreamingIndicator.d.ts.map +1 -0
- package/dist/components/StreamingIndicator.js +142 -0
- package/dist/components/StreamingIndicator.js.map +1 -0
- package/dist/components/TabBar.d.ts +37 -0
- package/dist/components/TabBar.d.ts.map +1 -0
- package/dist/components/TabBar.js +187 -0
- package/dist/components/TabBar.js.map +1 -0
- package/dist/components/TipCard.d.ts +22 -0
- package/dist/components/TipCard.d.ts.map +1 -0
- package/dist/components/TipCard.js +73 -0
- package/dist/components/TipCard.js.map +1 -0
- package/dist/components/ToolCallCard.d.ts +18 -0
- package/dist/components/ToolCallCard.d.ts.map +1 -0
- package/dist/components/ToolCallCard.js +716 -0
- package/dist/components/ToolCallCard.js.map +1 -0
- package/dist/components/UserAvatar.d.ts +45 -0
- package/dist/components/UserAvatar.d.ts.map +1 -0
- package/dist/components/UserAvatar.js +80 -0
- package/dist/components/UserAvatar.js.map +1 -0
- package/dist/components/WorkspaceLayout.d.ts +19 -0
- package/dist/components/WorkspaceLayout.d.ts.map +1 -0
- package/dist/components/WorkspaceLayout.js +56 -0
- package/dist/components/WorkspaceLayout.js.map +1 -0
- package/dist/components/activity-utilities.d.ts +107 -0
- package/dist/components/activity-utilities.d.ts.map +1 -0
- package/dist/components/activity-utilities.js +154 -0
- package/dist/components/activity-utilities.js.map +1 -0
- package/dist/components/chat-autocommit-utilities.d.ts +172 -0
- package/dist/components/chat-autocommit-utilities.d.ts.map +1 -0
- package/dist/components/chat-autocommit-utilities.js +185 -0
- package/dist/components/chat-autocommit-utilities.js.map +1 -0
- package/dist/components/chat-card-style.d.ts +64 -0
- package/dist/components/chat-card-style.d.ts.map +1 -0
- package/dist/components/chat-card-style.js +74 -0
- package/dist/components/chat-card-style.js.map +1 -0
- package/dist/components/chat-commands.d.ts +17 -0
- package/dist/components/chat-commands.d.ts.map +1 -0
- package/dist/components/chat-commands.js +17 -0
- package/dist/components/chat-commands.js.map +1 -0
- package/dist/components/chat-commit-utilities.d.ts +29 -0
- package/dist/components/chat-commit-utilities.d.ts.map +1 -0
- package/dist/components/chat-commit-utilities.js +31 -0
- package/dist/components/chat-commit-utilities.js.map +1 -0
- package/dist/components/chat-cost-utilities.d.ts +62 -0
- package/dist/components/chat-cost-utilities.d.ts.map +1 -0
- package/dist/components/chat-cost-utilities.js +66 -0
- package/dist/components/chat-cost-utilities.js.map +1 -0
- package/dist/components/chat-effort-utilities.d.ts +96 -0
- package/dist/components/chat-effort-utilities.d.ts.map +1 -0
- package/dist/components/chat-effort-utilities.js +97 -0
- package/dist/components/chat-effort-utilities.js.map +1 -0
- package/dist/components/chat-help-utilities.d.ts +89 -0
- package/dist/components/chat-help-utilities.d.ts.map +1 -0
- package/dist/components/chat-help-utilities.js +131 -0
- package/dist/components/chat-help-utilities.js.map +1 -0
- package/dist/components/chat-model-mode-utilities.d.ts +143 -0
- package/dist/components/chat-model-mode-utilities.d.ts.map +1 -0
- package/dist/components/chat-model-mode-utilities.js +191 -0
- package/dist/components/chat-model-mode-utilities.js.map +1 -0
- package/dist/components/chat-models-utilities.d.ts +98 -0
- package/dist/components/chat-models-utilities.d.ts.map +1 -0
- package/dist/components/chat-models-utilities.js +136 -0
- package/dist/components/chat-models-utilities.js.map +1 -0
- package/dist/components/chat-report-utilities.d.ts +149 -0
- package/dist/components/chat-report-utilities.d.ts.map +1 -0
- package/dist/components/chat-report-utilities.js +151 -0
- package/dist/components/chat-report-utilities.js.map +1 -0
- package/dist/components/chat-scripts-utilities.d.ts +140 -0
- package/dist/components/chat-scripts-utilities.d.ts.map +1 -0
- package/dist/components/chat-scripts-utilities.js +158 -0
- package/dist/components/chat-scripts-utilities.js.map +1 -0
- package/dist/components/chat-settings-utilities.d.ts +72 -0
- package/dist/components/chat-settings-utilities.d.ts.map +1 -0
- package/dist/components/chat-settings-utilities.js +56 -0
- package/dist/components/chat-settings-utilities.js.map +1 -0
- package/dist/components/chat-share-utilities.d.ts +104 -0
- package/dist/components/chat-share-utilities.d.ts.map +1 -0
- package/dist/components/chat-share-utilities.js +90 -0
- package/dist/components/chat-share-utilities.js.map +1 -0
- package/dist/components/chat-skills-utilities.d.ts +242 -0
- package/dist/components/chat-skills-utilities.d.ts.map +1 -0
- package/dist/components/chat-skills-utilities.js +433 -0
- package/dist/components/chat-skills-utilities.js.map +1 -0
- package/dist/components/chat-stream-utilities.d.ts +74 -0
- package/dist/components/chat-stream-utilities.d.ts.map +1 -0
- package/dist/components/chat-stream-utilities.js +103 -0
- package/dist/components/chat-stream-utilities.js.map +1 -0
- package/dist/components/chat-tips-utilities.d.ts +108 -0
- package/dist/components/chat-tips-utilities.d.ts.map +1 -0
- package/dist/components/chat-tips-utilities.js +137 -0
- package/dist/components/chat-tips-utilities.js.map +1 -0
- package/dist/components/device-cycle.d.ts +84 -0
- package/dist/components/device-cycle.d.ts.map +1 -0
- package/dist/components/device-cycle.js +77 -0
- package/dist/components/device-cycle.js.map +1 -0
- package/dist/components/index.d.ts +36 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +33 -0
- package/dist/components/index.js.map +1 -0
- package/dist/components/tool-call-utilities.d.ts +69 -0
- package/dist/components/tool-call-utilities.d.ts.map +1 -0
- package/dist/components/tool-call-utilities.js +368 -0
- package/dist/components/tool-call-utilities.js.map +1 -0
- package/dist/components/user-avatar-utilities.d.ts +41 -0
- package/dist/components/user-avatar-utilities.d.ts.map +1 -0
- package/dist/components/user-avatar-utilities.js +56 -0
- package/dist/components/user-avatar-utilities.js.map +1 -0
- package/dist/components/workspace-layout-utilities.d.ts +37 -0
- package/dist/components/workspace-layout-utilities.d.ts.map +1 -0
- package/dist/components/workspace-layout-utilities.js +58 -0
- package/dist/components/workspace-layout-utilities.js.map +1 -0
- package/dist/customEventCards.d.ts +117 -0
- package/dist/customEventCards.d.ts.map +1 -0
- package/dist/customEventCards.js +36 -0
- package/dist/customEventCards.js.map +1 -0
- package/dist/hooks/index.d.ts +8 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +8 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/useKeyboardShortcuts.d.ts +48 -0
- package/dist/hooks/useKeyboardShortcuts.d.ts.map +1 -0
- package/dist/hooks/useKeyboardShortcuts.js +112 -0
- package/dist/hooks/useKeyboardShortcuts.js.map +1 -0
- package/dist/hooks/useViewport.d.ts +42 -0
- package/dist/hooks/useViewport.d.ts.map +1 -0
- package/dist/hooks/useViewport.js +61 -0
- package/dist/hooks/useViewport.js.map +1 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +74 -0
- package/dist/index.js.map +1 -0
- package/dist/settings-metadata.d.ts +78 -0
- package/dist/settings-metadata.d.ts.map +1 -0
- package/dist/settings-metadata.js +131 -0
- package/dist/settings-metadata.js.map +1 -0
- package/dist/types.d.ts +751 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +25 -0
- package/dist/types.js.map +1 -0
- 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
|