@widgetic/creator 0.3.63 → 0.3.65

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.
@@ -70,6 +70,8 @@
70
70
  import { uploadChatFileToWidgeticApi } from './utils/widgeticChatUpload.js';
71
71
  import { persistWidgetThumbnail } from './utils/persistWidgetThumbnail.js';
72
72
  import { nativeScrollbarPadding } from './utils/nativeScrollbarPadding.js';
73
+ import { pinScrollToBottom } from './utils/pinScrollToBottom.js';
74
+ import { installFreezeWatchdog } from './utils/freezeWatchdog.js';
73
75
  import { streamOperationUpdates } from './utils/operationStream.js';
74
76
  import { generateEmbedCode, EMBED_FORMATS, EMBED_FORMAT_LABELS, type EmbedFormat } from './utils/embedCode.js';
75
77
 
@@ -494,7 +496,10 @@
494
496
  let WidgeticCanvas: any;
495
497
  let canvasRef: any;
496
498
  /** HTML iframe overlays over Fabric WidgetShapes (W3). */
497
- let widgetShapeLivePreviewsRef: { sync?: () => void } | null = null;
499
+ let widgetShapeLivePreviewsRef: {
500
+ sync?: () => void;
501
+ requestReload?: (widgetId: string) => void;
502
+ } | null = null;
498
503
  /** Selected WidgetShape id for chrome highlight (from Fabric selection). */
499
504
  let canvasSelectedWidgetShapeId: string | null = null;
500
505
  let widgetDetailsModulePromise: Promise<any> | null = null;
@@ -515,8 +520,40 @@
515
520
  const canvasModule = await canvasModulePromise;
516
521
  WidgeticCanvas = canvasModule.Canvas;
517
522
  }
523
+ installLongTaskFreezeReporter();
524
+ installFreezeWatchdog();
518
525
  });
519
526
 
527
+ /**
528
+ * Freeze attribution: while a code generation is tracked, any main-thread
529
+ * block ≥ LONG_TASK_FREEZE_MS is reported with its most likely culprit (the
530
+ * last status line + open panels). This gives a concrete answer to "why does
531
+ * the site freeze during generation" instead of a silent stuck progress bar.
532
+ */
533
+ const LONG_TASK_FREEZE_MS = 250;
534
+ let freezeObserver: PerformanceObserver | null = null;
535
+ function installLongTaskFreezeReporter(): void {
536
+ if (typeof window === 'undefined' || !('PerformanceObserver' in window)) return;
537
+ if (freezeObserver) return;
538
+ try {
539
+ freezeObserver = new PerformanceObserver((list) => {
540
+ if (!Object.keys(generatingWidgetIds).some((id) => generatingWidgetIds[id])) return;
541
+ for (const entry of list.getEntries()) {
542
+ if (entry.duration < LONG_TASK_FREEZE_MS) continue;
543
+ console.warn(
544
+ `[FreezeReport] Main thread blocked ${Math.round(entry.duration)}ms during generation.`
545
+ + ` Status: "${generateCodeStatus ?? '—'}".`
546
+ + ` Open panels: ${openWidgetPanelIds.length}.`
547
+ + ` Start: ${Math.round(entry.startTime)}ms after nav.`,
548
+ );
549
+ }
550
+ });
551
+ freezeObserver.observe({ entryTypes: ['longtask'] });
552
+ } catch (err) {
553
+ console.warn('[FreezeReport] longtask observer unavailable', err);
554
+ }
555
+ }
556
+
520
557
  // Trigger WidgetDetails load as soon as a panel is opened or requested
521
558
  $: if (openWidgetPanelIds.length > 0 && !WidgetDetails) {
522
559
  void loadWidgetDetailsModule();
@@ -794,6 +831,9 @@
794
831
  let codeGenerationPrompt = DEFAULT_PROMPT;
795
832
  let isGeneratingCode = false;
796
833
  let generatingWidgetIds: Record<string, boolean> = {};
834
+ function isAnyWidgetGenerating(): boolean {
835
+ return isGeneratingCode || Object.keys(generatingWidgetIds).some((id) => generatingWidgetIds[id]);
836
+ }
797
837
  const widgetAbortControllers = new Map<string, AbortController>();
798
838
  let currentGenerationOperationId: string | null = null;
799
839
  let generateCodeStatus: string | null = null;
@@ -1575,6 +1615,9 @@
1575
1615
  iframe: HTMLIFrameElement,
1576
1616
  timeoutMs = 6000,
1577
1617
  ): Promise<{ data: string | null; error: string | null }> {
1618
+ if (isAnyWidgetGenerating()) {
1619
+ return Promise.resolve({ data: null, error: 'generation in progress' });
1620
+ }
1578
1621
  return new Promise((resolve) => {
1579
1622
  if (!iframe.contentWindow) {
1580
1623
  resolve({ data: null, error: 'no-content-window' });
@@ -1654,19 +1697,20 @@
1654
1697
 
1655
1698
  /** Explicit Refresh only: capture the loaded frame without opening a panel or publishing. */
1656
1699
  async function handleWidgetShapeRefresh(widgetId, iframe, isCurrentFrame) {
1700
+ if (isAnyWidgetGenerating()) return;
1657
1701
  const capturedCanvasId = getEffectiveCanvasId();
1658
1702
  const capturedCanvas = canvasRef;
1659
1703
  const capturedSource = shapePreviewSourceByWidgetId[widgetId] || 'live';
1660
1704
  // Frame/intent identity owns this capture, not focused-panel HEAD hydration
1661
1705
  // or another widget's global generation operation.
1662
1706
  const isCurrent = () => isCurrentFrame() && !!getWidgetById(widgetId)
1663
- && !isDraftConvertId(widgetId) && !generatingWidgetIds[widgetId]
1707
+ && !isDraftConvertId(widgetId) && !generatingWidgetIds[widgetId] && !isAnyWidgetGenerating()
1664
1708
  && capturedCanvasId === getEffectiveCanvasId() && capturedCanvas === canvasRef
1665
1709
  && capturedSource === (shapePreviewSourceByWidgetId[widgetId] || 'live')
1666
1710
  && resolveLiveWidgetShapeIframe(widgetId) === iframe;
1667
1711
  if (!isCurrent()) return;
1668
1712
  try {
1669
- const screenshot = await handleChatScreenshot(widgetId, iframe);
1713
+ const screenshot = await handleChatScreenshot(widgetId, iframe, true);
1670
1714
  if (!screenshot || !isCurrent()) return;
1671
1715
  // Persist before Fabric/autosave side effects can resync the live overlay.
1672
1716
  await persistWidgetListThumbnail(widgetId, screenshot, isCurrent);
@@ -1680,13 +1724,34 @@
1680
1724
  }
1681
1725
 
1682
1726
  /** Screenshot: capture the live WidgetShape iframe (CDN or Worker), not Fabric wireframe. */
1683
- async function handleChatScreenshot(widgetId?: string | null, requiredIframe: HTMLIFrameElement | null = null): Promise<string | null> {
1727
+ async function handleChatScreenshot(widgetId?: string | null, requiredIframe: HTMLIFrameElement | null = null, quiet = false): Promise<string | null> {
1684
1728
  const targetId = widgetId || focusedPanelWidgetId || selectedWidgetId;
1685
1729
  if (!targetId) {
1686
1730
  console.warn('[Chat→Screenshot] No widget id');
1687
1731
  return null;
1688
1732
  }
1733
+ // Never run background / refresh screenshot captures while code generation is running —
1734
+ // html2canvas inside any same-origin iframe freezes the browser main thread.
1735
+ if ((quiet || requiredIframe != null) && isAnyWidgetGenerating()) {
1736
+ console.log('[Chat→Screenshot] Skip capture — generation in progress');
1737
+ return null;
1738
+ }
1739
+ // Same single-flight as handleRefreshScreenshot: both paths run html2canvas
1740
+ // inside preview iframes that share the page main thread. Overlapping
1741
+ // captures during generation were a major freeze source.
1742
+ if (screenshotCaptureInFlight.has(targetId)) {
1743
+ console.log('[Chat→Screenshot] Capture already in flight — skipping', targetId.substring(0, 8));
1744
+ return null;
1745
+ }
1746
+ screenshotCaptureInFlight.add(targetId);
1747
+ try {
1748
+ return await performChatScreenshot(targetId, requiredIframe, quiet);
1749
+ } finally {
1750
+ screenshotCaptureInFlight.delete(targetId);
1751
+ }
1752
+ }
1689
1753
 
1754
+ async function performChatScreenshot(targetId: string, requiredIframe: HTMLIFrameElement | null, quiet = false): Promise<string | null> {
1690
1755
  const liveIframe = requiredIframe ?? resolveLiveWidgetShapeIframe(targetId);
1691
1756
  if (requiredIframe && (requiredIframe !== resolveLiveWidgetShapeIframe(targetId) || !requiredIframe.isConnected)) return null;
1692
1757
  const liveSrc = liveIframe
@@ -1709,6 +1774,8 @@
1709
1774
 
1710
1775
  // Explicit refresh must not persist a different version or a stale Fabric image.
1711
1776
  if (requiredIframe) return null;
1777
+ // Quiet thumbnail sync must NEVER construct a temporary hidden iframe and compile worker preview.
1778
+ if (quiet) return null;
1712
1779
 
1713
1780
  // 2) Hidden Worker /preview (same capture script; wait for load)
1714
1781
  const fromWorker = await captureViaHiddenWorkerPreview(targetId);
@@ -1734,7 +1801,6 @@
1734
1801
  });
1735
1802
  return null;
1736
1803
  }
1737
-
1738
1804
  function normalizeConversationRecords(json: any): any[] {
1739
1805
  const raw = json?.data ?? json?.conversations ?? json;
1740
1806
  const list = Array.isArray(raw)
@@ -7616,12 +7682,12 @@
7616
7682
  canvasShapePreviewSyncTimers.clear();
7617
7683
  }
7618
7684
  function scheduleCanvasShapePreviewSync(widgetId: string, delayMs = 1800): void {
7619
- if (!widgetId) return;
7685
+ if (!widgetId || isAnyWidgetGenerating()) return;
7620
7686
  const existing = canvasShapePreviewSyncTimers.get(widgetId);
7621
7687
  if (existing) clearTimeout(existing);
7622
7688
  const timer = setTimeout(() => {
7623
7689
  canvasShapePreviewSyncTimers.delete(widgetId);
7624
- if (generatingWidgetIds[widgetId]) {
7690
+ if (isAnyWidgetGenerating() || generatingWidgetIds[widgetId]) {
7625
7691
  // console.log('[CreatorApp] Skip shape preview sync — codegen in progress:', widgetId.substring(0, 8));
7626
7692
  return;
7627
7693
  }
@@ -7640,6 +7706,24 @@
7640
7706
  const quiet = !!options.quiet;
7641
7707
  // console.log('[handleRefreshScreenshot] Capturing screenshot via postMessage for widget:', widgetId, quiet ? '(quiet)' : '');
7642
7708
 
7709
+ // Never run quiet background screenshot captures while ANY code generation is running —
7710
+ // html2canvas execution inside any preview iframe blocks the single shared main thread.
7711
+ if (quiet && isAnyWidgetGenerating()) {
7712
+ console.log('[handleRefreshScreenshot] Skip quiet capture — codegen in progress', widgetId?.substring(0, 8));
7713
+ return;
7714
+ }
7715
+
7716
+ // Single-flight per widget: overlapping captures stack html2canvas runs in
7717
+ // the preview iframe — same-origin iframes share this page's main thread,
7718
+ // and stacked captures blocked the whole site during generation (no
7719
+ // [FreezeReport] because the long task is attributed to the iframe).
7720
+ if (screenshotCaptureInFlight.has(widgetId)) {
7721
+ console.log('[handleRefreshScreenshot] Capture already in flight — skipping', widgetId?.substring(0, 8));
7722
+ return;
7723
+ }
7724
+ screenshotCaptureInFlight.add(widgetId);
7725
+
7726
+ try {
7643
7727
  if (isConvertOrDraftPanelOpen() && !isDraftConvertId(widgetId)) {
7644
7728
  // console.log('[handleRefreshScreenshot] Skip iframe capture — convert/draft panel is open:', widgetId.substring(0, 8));
7645
7729
  return;
@@ -7670,7 +7754,7 @@
7670
7754
  if (!shapeThumbnailCaptureInFlight.has(widgetId)) {
7671
7755
  shapeThumbnailCaptureInFlight.add(widgetId);
7672
7756
  try {
7673
- const freshCapture = await handleChatScreenshot(widgetId);
7757
+ const freshCapture = await handleChatScreenshot(widgetId, null, quiet);
7674
7758
  if (freshCapture) {
7675
7759
  canvasRef?.updateWidgetImage(widgetId, freshCapture);
7676
7760
  const freshJson = canvasRef?.saveCanvas?.();
@@ -7710,8 +7794,7 @@
7710
7794
  showToast('info', 'Capturing widget screenshot...', { duration: 3000 });
7711
7795
  }
7712
7796
 
7713
- try {
7714
- const result = await new Promise<{ data: string | null; error: string | null }>((resolve) => {
7797
+ const result = await new Promise<{ data: string | null; error: string | null }>((resolve) => {
7715
7798
  const timeout = setTimeout(() => {
7716
7799
  window.removeEventListener('message', handler);
7717
7800
  resolve({ data: null, error: 'timeout' });
@@ -7762,6 +7845,8 @@
7762
7845
  if (!quiet) {
7763
7846
  showToast('error', 'Screenshot capture failed', { duration: 3000 });
7764
7847
  }
7848
+ } finally {
7849
+ screenshotCaptureInFlight.delete(widgetId);
7765
7850
  }
7766
7851
  }
7767
7852
 
@@ -7968,6 +8053,7 @@ Requirements:
7968
8053
 
7969
8054
  const panelChat = getChatMethodsForWidget(generationWidgetId);
7970
8055
  const panelSnap = getPanelState(generationWidgetId);
8056
+ const hadCodeBeforeGeneration = !!(panelSnap?.hasGeneratedCode || panelSnap?.lastCommitId);
7971
8057
 
7972
8058
  // Per-widget abort: only abort if the SAME widget already has a running generation
7973
8059
  const existingController = widgetAbortControllers.get(generationWidgetId);
@@ -8230,6 +8316,11 @@ Requirements:
8230
8316
  const responseData = response?.data || response;
8231
8317
  const operationId = responseData?.operationId;
8232
8318
 
8319
+ // The Worker serves rebuilt code at the same preview URL, so the panel
8320
+ // must know when the current generation started — hasLiveWorkerPreview()
8321
+ // only counts frames loaded after this moment as "fresh".
8322
+ getWidgetDetailsRef(generationWidgetId)?.markCodegenStarted?.();
8323
+
8233
8324
  if (!operationId) {
8234
8325
  patchCodegenUiState(generationWidgetId, {
8235
8326
  generateCodeStatus: '❌ Code generation started, but no operation ID was returned.',
@@ -8343,18 +8434,25 @@ Requirements:
8343
8434
  // Refresh version history after every generation so the new commit shows
8344
8435
  // up in the version menu immediately. Publishing is deliberately not
8345
8436
  // automatic — the user previews and presses Publish themselves.
8437
+ // Deferred past the preview remount: running this synchronously against
8438
+ // `dynamic_worker_preview_ready` stacked three heavy re-renders on the
8439
+ // main thread in the same 800ms window — the "frozen at 80%" report.
8346
8440
  if (generationWidgetId) {
8347
8441
  const { [generationWidgetId]: _resolved, ...restResolved } = shapeHistoryResolvedByWidgetId;
8348
8442
  shapeHistoryResolvedByWidgetId = restResolved;
8349
- void loadPublishHistoryForPanel(generationWidgetId, { silent: true }).then(() => {
8350
- syncWidgetShapeLivePreviews();
8351
- if (isFocusedPanel) panelDisplayRevision += 1;
8352
- });
8443
+ setTimeout(() => {
8444
+ void loadPublishHistoryForPanel(generationWidgetId, { silent: true }).then(() => {
8445
+ syncWidgetShapeLivePreviews();
8446
+ if (isFocusedPanel) panelDisplayRevision += 1;
8447
+ });
8448
+ }, 1200);
8353
8449
  }
8354
8450
 
8355
8451
  // Always reconcile GitLab HEAD after codegen (metadata.commitId can be missing/stale).
8452
+ // Deferred — same window as the schema/history reloads above.
8356
8453
  if (generationWidgetId) {
8357
- void (async () => {
8454
+ setTimeout(() => {
8455
+ void (async () => {
8358
8456
  try {
8359
8457
  const fetchedHead = await fetchRepoHeadCommitSha(generationWidgetId);
8360
8458
  // Prefer the codegen commit from the operation. GitLab HEAD can still
@@ -8396,11 +8494,15 @@ Requirements:
8396
8494
  } catch (err) {
8397
8495
  console.warn('[Version] Failed to reconcile HEAD after codegen:', err);
8398
8496
  }
8399
- })();
8497
+ })();
8498
+ }, 1000);
8400
8499
  }
8401
8500
  if (generationWidgetId) {
8402
8501
  // console.log('[CompositionEditor] Reloading schemas after codegen', generationWidgetId.substring(0, 8));
8403
- void loadWidgetSchemas(generationWidgetId, { force: true });
8502
+ // Deferred with the publish-history reload — see comment above.
8503
+ setTimeout(() => {
8504
+ void loadWidgetSchemas(generationWidgetId, { force: true });
8505
+ }, 1600);
8404
8506
  }
8405
8507
 
8406
8508
  const completedRepoId = resolveRepositoryIdForWidget(generationWidgetId);
@@ -8498,12 +8600,19 @@ Requirements:
8498
8600
  await activePanelRef?.refreshPreview?.({ withRetry: true, skipIfAlreadyShowing: true });
8499
8601
  }
8500
8602
  activePanelRef?.setPreviewBuildError(null);
8603
+ // TODO(tomorrow): Dupa o generare noua, sincronizarea versiunii in dropdown si istoric se desincronizeaza.
8604
+ // Trebuie armonizata selectia automata a noii versiuni draft (vN) create in istoric,
8605
+ // actualizat trigger-ul pe canvas si in chat la noul numar de versiune si pastrata coerenta intre preview si dropdown.
8501
8606
  // Show Worker preview on the canvas shape (not the previous Live CDN artifact).
8502
8607
  shapePreviewSourceByWidgetId = {
8503
8608
  ...shapePreviewSourceByWidgetId,
8504
8609
  [generationWidgetId]: 'preview',
8505
8610
  };
8506
8611
  syncWidgetShapeLivePreviews();
8612
+ // The Worker serves the rebuilt code at the SAME /preview URL — sync()
8613
+ // only reloads when src changes, so force a cache-busted iframe reload
8614
+ // on the canvas shape overlay or it keeps showing the previous version.
8615
+ widgetShapeLivePreviewsRef?.requestReload?.(generationWidgetId);
8507
8616
  panelDisplayRevision += 1;
8508
8617
  if (!completedPreviewBuildErr) {
8509
8618
  await applyDesignSchemaDefaultsToPreview(generationWidgetId);
@@ -8519,10 +8628,13 @@ Requirements:
8519
8628
  panelChat?.markWidgetHeadAtLatestCommit?.();
8520
8629
  }, 2500);
8521
8630
  setTimeout(() => {
8522
- void (async () => {
8523
- await handleRefreshScreenshot(generationWidgetId, { quiet: true });
8524
- // List thumb only from live iframe capture — never Fabric shape icon.
8525
- })();
8631
+ // Only capture screenshot after the very first generation or at publish
8632
+ // Subsequent generations skip this heavy operation to keep the main thread smooth.
8633
+ if (!hadCodeBeforeGeneration) {
8634
+ void (async () => {
8635
+ await handleRefreshScreenshot(generationWidgetId, { quiet: true });
8636
+ })();
8637
+ }
8526
8638
  }, 3000);
8527
8639
  // Do NOT auto-publish — user previews first, then clicks Publish / Set live.
8528
8640
  } else {
@@ -8932,6 +9044,7 @@ Requirements:
8932
9044
  widgetPublishStatus,
8933
9045
  codeGenerationStatus,
8934
9046
  agentHarnessOutput,
9047
+ dynamicWorkerPreviewStatus,
8935
9048
  setAuthToken as setWsAuthToken,
8936
9049
  setWebsocketApiUrl
8937
9050
  } from './stores/websocketStore.js';
@@ -9318,10 +9431,46 @@ Requirements:
9318
9431
 
9319
9432
  function pushCodegenProgressToUi(op: OperationStatus, widgetId: string | null | undefined) {
9320
9433
  const line = formatCodegenProgressStatus(op);
9434
+ // Feed the freeze watchdog the latest status so a [FreezeReport] names the
9435
+ // stage the UI was showing when the main thread stopped responding.
9436
+ (window as any).__widgeticFreezeWatchdog?.setStatus?.(line);
9437
+ // SSE progress frames can arrive many times per second while the LLM
9438
+ // streams. Each one re-renders every open panel — coalesce them.
9321
9439
  if (widgetId) {
9322
- patchCodegenUiState(widgetId, { generateCodeStatus: line });
9323
- }
9324
- getChatMethodsForWidget(widgetId)?.updateLatestCodegenStatusMessage?.(line);
9440
+ scheduleCodegenStatusPush(widgetId, line);
9441
+ }
9442
+ scheduleCodegenChatStatusPush(widgetId, line);
9443
+ }
9444
+
9445
+ const CODEGEN_STATUS_THROTTLE_MS = 400;
9446
+ const codegenStatusTimers: Record<string, ReturnType<typeof setTimeout>> = {};
9447
+ const codegenStatusPendingLine: Record<string, string> = {};
9448
+ const codegenChatStatusTimers: Record<string, ReturnType<typeof setTimeout>> = {};
9449
+ const codegenChatStatusPendingLine: Record<string, string> = {};
9450
+
9451
+ /** At most one panel status write per widget per interval; trailing line always lands. */
9452
+ function scheduleCodegenStatusPush(widgetId: string, line: string): void {
9453
+ codegenStatusPendingLine[widgetId] = line;
9454
+ if (codegenStatusTimers[widgetId]) return;
9455
+ codegenStatusTimers[widgetId] = setTimeout(() => {
9456
+ delete codegenStatusTimers[widgetId];
9457
+ const pending = codegenStatusPendingLine[widgetId];
9458
+ delete codegenStatusPendingLine[widgetId];
9459
+ if (pending) patchCodegenUiState(widgetId, { generateCodeStatus: pending });
9460
+ }, CODEGEN_STATUS_THROTTLE_MS);
9461
+ }
9462
+
9463
+ /** Same throttle for the in-chat status bubble (updateLatestCodegenStatusMessage). */
9464
+ function scheduleCodegenChatStatusPush(widgetId: string | null | undefined, line: string): void {
9465
+ const key = widgetId || '_focused';
9466
+ codegenChatStatusPendingLine[key] = line;
9467
+ if (codegenChatStatusTimers[key]) return;
9468
+ codegenChatStatusTimers[key] = setTimeout(() => {
9469
+ delete codegenChatStatusTimers[key];
9470
+ const pending = codegenChatStatusPendingLine[key];
9471
+ delete codegenChatStatusPendingLine[key];
9472
+ if (pending) getChatMethodsForWidget(widgetId)?.updateLatestCodegenStatusMessage?.(pending);
9473
+ }, CODEGEN_STATUS_THROTTLE_MS);
9325
9474
  }
9326
9475
 
9327
9476
  function patchCodegenUiState(
@@ -9875,6 +10024,26 @@ Requirements:
9875
10024
 
9876
10025
  function getPanelEditPreviewUrl(widgetId: string): string | null {
9877
10026
  const ps = getPanelDisplayState(widgetId);
10027
+ const source = shapePreviewSourceByWidgetId[widgetId];
10028
+ if (source && source.startsWith('v:')) {
10029
+ const version = parseInt(source.slice(2), 10);
10030
+ if (Number.isFinite(version)) {
10031
+ const record = getShapePublishHistory(widgetId).find(
10032
+ (row) => Number(row.version) === version,
10033
+ );
10034
+ const isPublished = !!(record?.publishedAt ?? record?.published_at);
10035
+ const artifact =
10036
+ record?.artifactUrl ||
10037
+ record?.artifact_url ||
10038
+ (version === ps.lastPublishedVersion ? ps.selectedWidgetJsPath : null);
10039
+ if (isPublished && artifact) {
10040
+ const url = resolvePublishedWidgetUrl(widgetId, version, artifact);
10041
+ if (url) return url;
10042
+ }
10043
+ const previewState = resolveWidgetShapeVersionPreviewState(widgetId);
10044
+ if (previewState?.url) return previewState.url;
10045
+ }
10046
+ }
9878
10047
  const widget = getWidgetById(widgetId);
9879
10048
  const version = ps.lastPublishedVersion ?? resolvePublishedVersionFromWidget(widget);
9880
10049
  if (version == null) return null;
@@ -9977,6 +10146,15 @@ Requirements:
9977
10146
  return fromPanel?.length ? fromPanel : [];
9978
10147
  }
9979
10148
 
10149
+ /** Newest unpublished version — Worker `/preview` (repo HEAD) shows this version's code. */
10150
+ function getNewestDraftVersionId(widgetId: string): number | null {
10151
+ const draftVersions = getShapePublishHistory(widgetId)
10152
+ .filter((row) => !(row.publishedAt ?? row.published_at))
10153
+ .map((row) => Number(row.version))
10154
+ .filter((v) => Number.isFinite(v));
10155
+ return draftVersions.length > 0 ? Math.max(...draftVersions) : null;
10156
+ }
10157
+
9980
10158
  function resolveWidgetShapeLivePreviewUrl(widgetId: string): string | null {
9981
10159
  if (!widgetId) return null;
9982
10160
  const source = shapePreviewSourceByWidgetId[widgetId] || 'live';
@@ -10004,6 +10182,11 @@ Requirements:
10004
10182
  const url = resolvePublishedWidgetUrl(widgetId, version, artifact);
10005
10183
  if (url) return url;
10006
10184
  }
10185
+ // The newest draft IS repo HEAD — Worker `/preview/{widgetId}` already serves this code!
10186
+ const newestDraft = getNewestDraftVersionId(widgetId);
10187
+ if (!isPublished && newestDraft != null && version === newestDraft && workerUrl) {
10188
+ return workerUrl;
10189
+ }
10007
10190
  return resolveWidgetShapeVersionPreviewState(widgetId)?.url ?? null;
10008
10191
  }
10009
10192
  return null;
@@ -10029,6 +10212,13 @@ Requirements:
10029
10212
  if (!Number.isFinite(version)) return null;
10030
10213
  return `v${version}`;
10031
10214
  }
10215
+ // 'preview' = Worker HEAD = newest generated (draft) version. Showing the
10216
+ // Live version here hid every fresh generation behind the old artifact.
10217
+ if (source === 'preview') {
10218
+ const newestDraft = getNewestDraftVersionId(widgetId);
10219
+ if (newestDraft != null) return `v${newestDraft}`;
10220
+ return null;
10221
+ }
10032
10222
  return liveVersion != null ? `v${liveVersion}` : null;
10033
10223
  }
10034
10224
 
@@ -10115,6 +10305,13 @@ Requirements:
10115
10305
  function resolveWidgetShapeVersionSelectedId(widgetId: string): string | null {
10116
10306
  const source = shapePreviewSourceByWidgetId[widgetId];
10117
10307
  if (source?.startsWith('v:')) return source;
10308
+ if (source === 'preview') {
10309
+ // Worker HEAD preview = the newest draft — keep the dropdown selection
10310
+ // on it, otherwise the picker kept pointing at the stale Live version.
10311
+ const newestDraft = getNewestDraftVersionId(widgetId);
10312
+ if (newestDraft != null) return `v:${newestDraft}`;
10313
+ return null;
10314
+ }
10118
10315
  const ps = getPanelDisplayState(widgetId);
10119
10316
  const liveVersion =
10120
10317
  ps.lastPublishedVersion ?? resolvePublishedVersionFromWidget(getWidgetById(widgetId));
@@ -10146,9 +10343,12 @@ Requirements:
10146
10343
  // Published artifacts need no build. Drafts must never substitute HEAD.
10147
10344
  if (versionId.startsWith('v:')) {
10148
10345
  const version = Number(versionId.slice(2));
10149
- if (resolveWidgetShapeLivePreviewUrl(widgetId)) {
10346
+ const directUrl = resolveWidgetShapeLivePreviewUrl(widgetId);
10347
+ if (directUrl) {
10150
10348
  const record = getShapePublishHistory(widgetId).find((row) => Number(row.version) === version);
10151
10349
  console.log('[VersionPreview] Selection', { widgetId, version, commitSha: record?.commitSha ?? record?.commit_sha ?? null, source: 'published' });
10350
+ const detailsRef = getWidgetDetailsRef(widgetId);
10351
+ detailsRef?.showPublishedArtifact?.(directUrl);
10152
10352
  } else {
10153
10353
  void ensureWidgetShapeVersionPreview(widgetId, version);
10154
10354
  }
@@ -10199,6 +10399,8 @@ Requirements:
10199
10399
  panelDisplayRevision += 1;
10200
10400
  shapeVersionPreviewErrorsByKey = { ...shapeVersionPreviewErrorsByKey, [`${widgetId}:${versionId}`]: null };
10201
10401
  console.log('[VersionPreview] Selection', { widgetId, version, commitSha });
10402
+ const detailsRef = getWidgetDetailsRef(widgetId);
10403
+ detailsRef?.showPublishedArtifact?.(previewUrl);
10202
10404
  } catch (error: unknown) {
10203
10405
  if (
10204
10406
  shapePreviewSourceByWidgetId[widgetId] !== versionId
@@ -11684,6 +11886,17 @@ $: if ($agentHarnessOutput?.timestamp) {
11684
11886
  }
11685
11887
  }
11686
11888
 
11889
+ // Worker preview ready via WebSocket event — reload canvas shape preview immediately
11890
+ $: if ($dynamicWorkerPreviewStatus.widgetId && $dynamicWorkerPreviewStatus.previewUrl) {
11891
+ const wsReadyWidgetId = $dynamicWorkerPreviewStatus.widgetId;
11892
+ shapePreviewSourceByWidgetId = {
11893
+ ...shapePreviewSourceByWidgetId,
11894
+ [wsReadyWidgetId]: 'preview',
11895
+ };
11896
+ syncWidgetShapeLivePreviews();
11897
+ widgetShapeLivePreviewsRef?.requestReload?.(wsReadyWidgetId);
11898
+ }
11899
+
11687
11900
  // ===================== Publish History =====================
11688
11901
 
11689
11902
  /**
@@ -11848,7 +12061,9 @@ function togglePublishHistory(triggerEl?: HTMLElement | null) {
11848
12061
  const cluster = triggerEl.closest('.wc-version-history-ct') as HTMLElement | null;
11849
12062
  const rect = (cluster ?? triggerEl).getBoundingClientRect();
11850
12063
  liveHistoryPanelTop = Math.round(rect.bottom + 4);
11851
- liveHistoryPanelRight = Math.round(window.innerWidth - rect.right);
12064
+ // Rows inset their action button 12px from the panel edge (panel p-1 4px + row px-2 8px).
12065
+ // Shift right by 12px so the per-row action buttons line up under the header action button.
12066
+ liveHistoryPanelRight = Math.round(window.innerWidth - rect.right) - 12;
11852
12067
  }
11853
12068
  loadPublishHistory();
11854
12069
  }
@@ -12340,6 +12555,14 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
12340
12555
  */
12341
12556
  const shapeThumbnailCaptureInFlight = new Set<string>();
12342
12557
 
12558
+ /**
12559
+ * Whole-function single-flight guard for handleRefreshScreenshot.
12560
+ * Stacked html2canvas captures run inside the preview iframe, which shares
12561
+ * the page main thread — a capture storm froze the entire site with no
12562
+ * parent-page [FreezeReport] (long tasks attributed to the iframe).
12563
+ */
12564
+ const screenshotCaptureInFlight = new Set<string>();
12565
+
12343
12566
  /**
12344
12567
  * Called when a Widget Image shape is clicked on the canvas.
12345
12568
  * Selects the linked widget and opens WidgetDetails.
@@ -12418,7 +12641,6 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
12418
12641
  notifyHostWidgetName(widget);
12419
12642
  }
12420
12643
  await openWidgetDetailsPanel(widgetId);
12421
- scheduleCanvasShapePreviewSync(widgetId);
12422
12644
  }
12423
12645
 
12424
12646
  /**
@@ -14107,6 +14329,9 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14107
14329
  onTranslateEnd={(widgetId) => {
14108
14330
  canvasRef?.commitWidgetShapeTranslate?.(widgetId);
14109
14331
  }}
14332
+ onOverlayDragChange={(active) => {
14333
+ canvasRef?.setWidgetShapeOverlayDragActive?.(active);
14334
+ }}
14110
14335
  onSelectShape={(widgetId) => {
14111
14336
  canvasRef?.selectWidgetShapeById?.(widgetId);
14112
14337
  canvasSelectedWidgetShapeId = widgetId;
@@ -14116,6 +14341,7 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14116
14341
  void openWidgetDetailsPanel(widgetId);
14117
14342
  }}
14118
14343
  onRefresh={handleWidgetShapeRefresh}
14344
+ isGenerating={isAnyWidgetGenerating()}
14119
14345
  />
14120
14346
 
14121
14347
  <!-- ═══ PANEL 2: Widgets Panel — debug + showWidgetsList (site passes false) ═══ -->
@@ -14519,8 +14745,6 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14519
14745
  if (isPanelFocused && previewWidgetId === panelWidgetId) {
14520
14746
  chatMethods?.clearStalePreviewBuildFailures?.();
14521
14747
  }
14522
- // Keep canvas WidgetShape thumbnail aligned with the live Worker preview.
14523
- scheduleCanvasShapePreviewSync(previewWidgetId);
14524
14748
  if (detail?.step === 'create' || ps.widgetDetailsStep === 'create') {
14525
14749
  applyPanelCompositionToPreview(previewWidgetId);
14526
14750
  }
@@ -14755,7 +14979,13 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14755
14979
  {@const shapeSource = shapePreviewSourceByWidgetId[panelWidgetId] || defaultVersionSource}
14756
14980
  {@const viewingVersion = shapeSource && shapeSource.startsWith('v:')
14757
14981
  ? parseInt(shapeSource.slice(2), 10)
14758
- : panelPublishedVersion}
14982
+ : shapeSource === 'preview'
14983
+ // A 'preview' source (Worker HEAD, unpublished) is not a published
14984
+ // version — falling back to panelPublishedVersion produced `vnull`
14985
+ // right after generation AND showed the stale Live version when a
14986
+ // draft existed. The newest draft IS this HEAD's version.
14987
+ ? (newestDraft ? newestDraft.version : panelPublishedVersion)
14988
+ : panelPublishedVersion}
14759
14989
  {@const viewingIsLive = Number(viewingVersion) === Number(panelPublishedVersion)}
14760
14990
  <!--
14761
14991
  A version is a Draft until it is published. The history is one
@@ -14793,13 +15023,13 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14793
15023
  }}
14794
15024
  disabled={isPublishDisabledForPanel(panelWidgetId) || (isPublishing && publishingWidgetId === panelWidgetId)}
14795
15025
  title={getPublishDisabledReasonForPanel(panelWidgetId) ?? 'Publish the latest generated code as the Live version'}
14796
- class="wc-version-publish-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
15026
+ class="wc-version-publish-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] w-[4.75rem] text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
14797
15027
  >
14798
15028
  {isPublishing && publishingWidgetId === panelWidgetId ? '...' : 'Publish'}
14799
15029
  </button>
14800
15030
  {:else if viewingIsLive}
14801
15031
  <span
14802
- class="wc-version-live-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-600 opacity-80"
15032
+ class="wc-version-live-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] w-[4.75rem] text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-600 opacity-80"
14803
15033
  title="Live — generate new code to Publish a new version"
14804
15034
  >Live</span>
14805
15035
  {:else if viewingIsDraft}
@@ -14815,7 +15045,7 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14815
15045
  }}
14816
15046
  disabled={!canPublishDraftVersionForPanel(panelWidgetId, viewingVersion) || getPublishGateForPanel(panelWidgetId, Number(viewingVersion)).disabled}
14817
15047
  title="Build this draft version and make it the Live version"
14818
- class="wc-version-publish-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
15048
+ class="wc-version-publish-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] w-[4.75rem] text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
14819
15049
  >
14820
15050
  {isPublishing && publishingWidgetId === panelWidgetId ? '...' : 'Publish'}
14821
15051
  </button>
@@ -14832,7 +15062,7 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14832
15062
  }}
14833
15063
  disabled={isSettingLiveVersion === Number(viewingVersion)}
14834
15064
  title="Publish this version so visitors see it on your website"
14835
- class="wc-version-set-live-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded border border-emerald-300 text-emerald-700 hover:bg-emerald-100 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
15065
+ class="wc-version-set-live-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] w-[4.75rem] text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded border border-emerald-300 text-emerald-700 hover:bg-emerald-100 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
14836
15066
  >
14837
15067
  {isSettingLiveVersion === Number(viewingVersion) ? '...' : 'Set live'}
14838
15068
  </button>
@@ -14845,15 +15075,20 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14845
15075
  aria-label="Close version history"
14846
15076
  onclick={() => { isHistoryOpen = false; }}
14847
15077
  ></button>
15078
+ {@const hasChatScroll = ps.publishHistory.length > 5}
14848
15079
  <div
14849
- class="wc-version-history-panel flex flex-col gap-1 rounded-md border border-emerald-200 bg-white shadow-lg p-1.5 z-[9999] w-max"
14850
- style="position: fixed; top: {liveHistoryPanelTop}px; right: {liveHistoryPanelRight}px; max-width: min(22rem, calc(100vw - 16px));"
15080
+ class="wc-version-history-panel rounded-md border border-emerald-200 bg-white shadow-lg p-1 z-[9999] w-max"
15081
+ style="position: fixed; top: {liveHistoryPanelTop}px; right: {liveHistoryPanelRight}px;"
14851
15082
  >
14852
15083
  {#if isLoadingHistory}
14853
15084
  <p class="text-xs text-emerald-500 animate-pulse text-center py-2">Loading history...</p>
14854
15085
  {:else if ps.publishHistory.length === 0}
14855
15086
  <p class="text-xs text-emerald-400 text-center py-2">No history records yet. Generate or publish to start tracking.</p>
14856
15087
  {:else}
15088
+ <div
15089
+ class="wc-version-history-scroll flex flex-col gap-1 overflow-x-hidden {hasChatScroll ? 'overflow-y-auto' : 'overflow-y-visible'}"
15090
+ style="{hasChatScroll ? 'max-height: 185px; margin-right: -4px; padding-right: 4px;' : 'max-height: none;'} scrollbar-width: thin; scrollbar-color: rgba(0,0,0,0.2) transparent;"
15091
+ >
14857
15092
  {#each ps.publishHistory as record (record.id)}
14858
15093
  {@const rowVersion = Number(record.version)}
14859
15094
  {@const rowIsDraft = !(record.publishedAt ?? record.published_at)}
@@ -14874,6 +15109,7 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14874
15109
  publishDisabledReason={publishGate.disabled ? publishGate.reason : null}
14875
15110
  setLiveDisabled={isSettingLiveVersion === record.version}
14876
15111
  onselect={() => {
15112
+ isHistoryOpen = false;
14877
15113
  handleWidgetShapeVersionChange(panelWidgetId, `v:${record.version}`);
14878
15114
  }}
14879
15115
  onpublish={() => {
@@ -14885,6 +15121,7 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14885
15121
  }}
14886
15122
  />
14887
15123
  {/each}
15124
+ </div>
14888
15125
  {/if}
14889
15126
  </div>
14890
15127
  </Portal>
@@ -14973,6 +15210,7 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14973
15210
  class="generation-scroll-ct flex flex-col h-full min-h-0 overflow-y-auto border-t border-gray-200"
14974
15211
  style="padding: 0;"
14975
15212
  use:nativeScrollbarPadding={0}
15213
+ use:pinScrollToBottom
14976
15214
  onmousedown={() => { if (!isPanelFocused) focusWidgetPanel(panelWidgetId); }}
14977
15215
  >
14978
15216
  {#key panelInstanceKey}
@@ -15773,21 +16011,24 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
15773
16011
  outline: none;
15774
16012
  box-shadow: none;
15775
16013
  }
15776
- .wc-version-history-panel {
16014
+ .wc-version-history-scroll {
15777
16015
  scrollbar-width: thin;
15778
16016
  scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
15779
16017
  }
15780
- .wc-version-history-panel::-webkit-scrollbar {
15781
- width: 8px;
16018
+ .wc-version-history-scroll::-webkit-scrollbar {
16019
+ width: 4px;
15782
16020
  background: transparent;
15783
16021
  }
15784
- .wc-version-history-panel::-webkit-scrollbar-track {
16022
+ .wc-version-history-scroll::-webkit-scrollbar-track {
15785
16023
  background: transparent;
15786
16024
  }
15787
- .wc-version-history-panel::-webkit-scrollbar-thumb {
16025
+ .wc-version-history-scroll::-webkit-scrollbar-thumb {
15788
16026
  background: rgba(0, 0, 0, 0.2);
15789
16027
  border-radius: 4px;
15790
16028
  }
16029
+ .wc-version-history-scroll::-webkit-scrollbar-thumb:hover {
16030
+ background: rgba(0, 0, 0, 0.35);
16031
+ }
15791
16032
  .generation-scroll-ct {
15792
16033
  scrollbar-width: thin;
15793
16034
  scrollbar-color: rgba(0, 0, 0, 0.22) transparent;
@@ -42,14 +42,16 @@
42
42
  // One fixed width per column — identical on every row and in both dropdowns.
43
43
  // Explicit PIXEL widths that hug the content (vN, 7-char SHA, dd/mm/yyyy
44
44
  // hh:mm) — no rem sizing, no floating side padding.
45
- const COL_VERSION = 'w-[16px] justify-end';
45
+ const COL_VERSION = 'w-[20px] justify-end';
46
46
  const COL_COMMIT = 'w-[44px] justify-end';
47
47
  const COL_DATE = 'w-[88px]';
48
- const COL_ACTION = 'w-[60px]';
48
+ // Same width as the header Publish/Live button (min-w-[4.75rem] / w-[4.75rem]) so every
49
+ // action button lines up in one column across rows, header and overlay.
50
+ const COL_ACTION = 'w-[4.75rem]';
49
51
  </script>
50
52
 
51
53
  <div
52
- class="version-dropdown-row flex items-center gap-2 rounded px-2 py-1.5 w-max text-[10px] cursor-pointer {selected ? 'bg-emerald-50 text-emerald-800' : 'text-gray-700 hover:bg-gray-50'}"
54
+ class="version-dropdown-row flex items-center justify-between gap-2 rounded px-2 py-1.5 w-full text-[10px] cursor-pointer {selected ? 'bg-emerald-50 text-emerald-800' : 'text-gray-700 hover:bg-gray-50'}"
53
55
  role="option"
54
56
  aria-selected={selected}
55
57
  tabindex="0"
@@ -801,7 +801,20 @@
801
801
  // console.log('[WidgetDetails] Codegen preview marked fresh — skipping GitLab rebuilds for 30s');
802
802
  }
803
803
 
804
- /** True when the iframe is already showing this widget's Worker preview (not about:blank / error). */
804
+ /** Timestamp of generation start — set by the parent when the operation begins. */
805
+ let codegenStartedAt: number | null = null;
806
+ /** Parent calls this when a generation starts for THIS widget's panel. */
807
+ export function markCodegenStarted(): void {
808
+ codegenStartedAt = Date.now();
809
+ }
810
+
811
+ /**
812
+ * True when the iframe is already showing this widget's Worker preview AND was
813
+ * reloaded after the current generation started. The Worker serves new code at
814
+ * the same `/preview/{id}` URL, so URL equality alone cannot tell a stale frame
815
+ * (pre-codegen) from a fresh one — that mismatch left the old version on screen
816
+ * after generation.
817
+ */
805
818
  export function hasLiveWorkerPreview(): boolean {
806
819
  if (!selectedWidgetId) return false;
807
820
  if (!iframeSrc || iframeSrc === 'about:blank') return false;
@@ -809,7 +822,10 @@
809
822
  const workerUrl = dynamicWorkerPreviewUrl || buildWorkerPreviewUrl(selectedWidgetId);
810
823
  const currentBase = normalizePreviewBaseUrl(iframeSrc);
811
824
  const workerBase = normalizePreviewBaseUrl(toPreviewFetchUrl(workerUrl));
812
- return !!currentBase && !!workerBase && currentBase === workerBase;
825
+ if (!currentBase || !workerBase || currentBase !== workerBase) return false;
826
+ // During/after a generation, only a frame (re)loaded after it started counts.
827
+ if (codegenStartedAt !== null && lastPreviewIframeLoadAt < codegenStartedAt) return false;
828
+ return true;
813
829
  }
814
830
 
815
831
  // Preview resize
@@ -108,6 +108,7 @@ declare const WidgetDetails: $$__sveltets_2_IsomorphicComponent<{
108
108
  showPublishedArtifact?: (url: string | null) => void;
109
109
  finishCompositionHydration?: () => void;
110
110
  markCodegenPreviewFresh?: () => void;
111
+ markCodegenStarted?: () => void;
111
112
  hasLiveWorkerPreview?: () => boolean;
112
113
  getLatestRuntimeLogs?: (maxLines?: number) => string[];
113
114
  resumeDeferredPreviewLoad?: () => Promise<void>;
@@ -204,6 +205,7 @@ declare const WidgetDetails: $$__sveltets_2_IsomorphicComponent<{
204
205
  showPublishedArtifact: (url: string | null) => void;
205
206
  finishCompositionHydration: () => void;
206
207
  markCodegenPreviewFresh: () => void;
208
+ markCodegenStarted: () => void;
207
209
  hasLiveWorkerPreview: () => boolean;
208
210
  getLatestRuntimeLogs: (maxLines?: number) => string[];
209
211
  resumeDeferredPreviewLoad: () => Promise<void>;
@@ -49,6 +49,8 @@
49
49
  onTranslate?: ((widgetId: string, dxScreen: number, dyScreen: number) => void) | null;
50
50
  /** Fired once when a chrome drag ends (mark canvas dirty / history). */
51
51
  onTranslateEnd?: ((widgetId: string) => void) | null;
52
+ /** Tell the canvas a chrome drag started/ended (suppresses hover-select). */
53
+ onOverlayDragChange?: ((active: boolean) => void) | null;
52
54
  /** Select the Fabric WidgetShape (e.g. when pressing the drag bar). */
53
55
  onSelectShape?: ((widgetId: string) => void) | null;
54
56
  /** Open Widget Details / chat for this widget. */
@@ -86,6 +88,8 @@
86
88
  resolveIsPublishing?: ((widgetId: string) => boolean) | null;
87
89
  /** Publish-disabled reason for a specific Draft row (null = enabled). */
88
90
  resolvePublishDisabledReason?: ((widgetId: string, version: number) => string | null) | null;
91
+ /** Whether code generation is running (suppresses background captures / reloads). */
92
+ isGenerating?: boolean;
89
93
  }
90
94
 
91
95
  let {
@@ -103,6 +107,7 @@
103
107
  resolvePublishDisabled = null,
104
108
  onTranslate = null,
105
109
  onTranslateEnd = null,
110
+ onOverlayDragChange = null,
106
111
  onSelectShape = null,
107
112
  onOpenChat = null,
108
113
  resolveTitle = null,
@@ -121,6 +126,7 @@
121
126
  resolveIsPublishing = null,
122
127
  /** Per-widget publish-gate reason for a Draft row (tooltip on Publish). */
123
128
  resolvePublishDisabledReason = null,
129
+ isGenerating = false,
124
130
  }: Props = $props();
125
131
 
126
132
  const DRAG_BAR_H = 30;
@@ -130,7 +136,11 @@
130
136
  const MIN_EXPANDED_CHROME_WIDTH = 220;
131
137
  const IFRAME_INSET_PX = 2;
132
138
  /** ~5 version rows before the menu scrolls (matches chat history panel). */
133
- const VERSION_MENU_MAX_H = '12.5rem';
139
+ /** Row height ≈2.1rem (py-1.5 + text) + 0.6rem vertical padding for the menu box. */
140
+ const VERSION_MENU_ROW_REM = 2.1;
141
+ const VERSION_MENU_PAD_REM = 0.6;
142
+ /** Menu shows at most 5 rows before scrolling. */
143
+ const VERSION_MENU_MAX_ROWS = 5;
134
144
 
135
145
  type ShapeRect = {
136
146
  widgetId: string;
@@ -171,8 +181,33 @@
171
181
  }
172
182
  pendingRefreshTokens.delete(widgetId);
173
183
  }
184
+
185
+ /**
186
+ * Parent-driven forced reload for widgets whose Worker preview content changed
187
+ * at the same URL (codegen rebuild). `sync()` only reloads when `src` changes,
188
+ * so without a cache-bust token the overlay keeps showing pre-codegen content.
189
+ * Bumps the reload token → `{#key}` remounts the iframe with a fresh `_t`.
190
+ * Debounced by 1200ms to avoid double-refresh flicker when WS and completed SSE both fire.
191
+ */
192
+ let lastReloadAtByWidgetId: Record<string, number> = {};
193
+ export function requestReload(widgetId: string): void {
194
+ const now = Date.now();
195
+ if (now - (lastReloadAtByWidgetId[widgetId] || 0) < 1200) {
196
+ return;
197
+ }
198
+ lastReloadAtByWidgetId[widgetId] = now;
199
+ cancelPendingRefresh(widgetId);
200
+ const token = Math.max(now, (reloadTokens[widgetId] || 0) + 1);
201
+ pendingRefreshTokens.set(widgetId, token);
202
+ reloadTokens = { ...reloadTokens, [widgetId]: token };
203
+ iframeLoadedByWidgetId = { ...iframeLoadedByWidgetId, [widgetId]: false };
204
+ sync();
205
+ }
206
+
174
207
  /** Which shape chrome version menu is open. */
175
208
  let openVersionMenuWidgetId = $state<string | null>(null);
209
+ /** Local manual version selection override per widget (ensures trigger updates immediately). */
210
+ let localSelectedVersionByWidgetId = $state<Record<string, string>>({});
176
211
  /** Hide Fabric placeholder flash — show loader until iframe fires load. */
177
212
  let iframeLoadedByWidgetId = $state<Record<string, boolean>>({});
178
213
 
@@ -250,11 +285,13 @@
250
285
  document.body.style.removeProperty('user-select');
251
286
  document.body.style.removeProperty('cursor');
252
287
  onTranslateEnd?.(widgetId);
288
+ onOverlayDragChange?.(false);
253
289
  console.log('[WidgetShapeLivePreviews] Drag end', widgetId.substring(0, 8));
254
290
  }
255
291
 
256
292
  document.body.style.userSelect = 'none';
257
293
  document.body.style.cursor = 'grabbing';
294
+ onOverlayDragChange?.(true);
258
295
  document.addEventListener('pointermove', onMove);
259
296
  document.addEventListener('pointerup', endDrag);
260
297
  document.addEventListener('pointercancel', endDrag);
@@ -290,6 +327,11 @@
290
327
  // An old load event must not mark the replacement loaded or claim its timer.
291
328
  if (!matchesFrame()) return;
292
329
  iframeLoadedByWidgetId = { ...iframeLoadedByWidgetId, [widgetId]: true };
330
+ // Suppress auto-refresh / screenshot capture when code generation is running.
331
+ if (isGenerating) {
332
+ cancelPendingRefresh(widgetId);
333
+ return;
334
+ }
293
335
  const token = pendingRefreshTokens.get(widgetId);
294
336
  if (!token || refreshSettleTimers.has(widgetId)) return;
295
337
  const isCurrent = () => matchesFrame()
@@ -319,6 +361,8 @@
319
361
  }
320
362
 
321
363
  function resolveSelectedVersionId(widgetId: string, options: VersionOption[]): string {
364
+ const local = localSelectedVersionByWidgetId[widgetId];
365
+ if (local && options.some((o) => o.id === local)) return local;
322
366
  const fromParent = resolveVersionSelectedId?.(widgetId)?.trim();
323
367
  if (fromParent && options.some((o) => o.id === fromParent)) return fromParent;
324
368
  const liveOpt = options.find((o) => o.isLive);
@@ -391,6 +435,7 @@
391
435
  function selectVersionFromMenu(widgetId: string, versionId: string) {
392
436
  openVersionMenuWidgetId = null;
393
437
  if (!versionId) return;
438
+ localSelectedVersionByWidgetId = { ...localSelectedVersionByWidgetId, [widgetId]: versionId };
394
439
  onVersionChange?.(widgetId, versionId);
395
440
  }
396
441
 
@@ -425,8 +470,15 @@
425
470
  */
426
471
  function trackIframeSelection(iframe, widgetId) {
427
472
  let focusTimer;
473
+ let pointerIsDown = false;
474
+ const onWindowPointerDown = () => { pointerIsDown = true; };
475
+ const onWindowPointerUp = () => { pointerIsDown = false; };
428
476
  function select() {
429
477
  if (!enabled || draggingWidgetId || resizingWidgetId || !iframe.isConnected) return;
478
+ // A drag in progress anywhere (Fabric shape drag or overlay chrome drag)
479
+ // must not let hover over this iframe steal the selection — the pointer
480
+ // merely passes over other shapes while carrying the dragged one.
481
+ if (pointerIsDown) return;
430
482
  // Keep the menu owner selected while its dropdown is being used.
431
483
  if (openVersionMenuWidgetId && openVersionMenuWidgetId !== widgetId) return;
432
484
  if (selectedWidgetId !== widgetId) onSelectShape?.(widgetId);
@@ -437,6 +489,8 @@
437
489
  if (document.activeElement === iframe) select();
438
490
  }, 0);
439
491
  }
492
+ window.addEventListener('pointerdown', onWindowPointerDown, true);
493
+ window.addEventListener('pointerup', onWindowPointerUp, true);
440
494
  iframe.addEventListener('pointerenter', select);
441
495
  iframe.addEventListener('focus', select);
442
496
  window.addEventListener('blur', checkFocus);
@@ -444,6 +498,8 @@
444
498
  update(nextWidgetId) { widgetId = nextWidgetId; },
445
499
  destroy() {
446
500
  clearTimeout(focusTimer);
501
+ window.removeEventListener('pointerdown', onWindowPointerDown, true);
502
+ window.removeEventListener('pointerup', onWindowPointerUp, true);
447
503
  iframe.removeEventListener('pointerenter', select);
448
504
  iframe.removeEventListener('focus', select);
449
505
  window.removeEventListener('blur', checkFocus);
@@ -591,7 +647,7 @@
591
647
  >
592
648
  <button
593
649
  type="button"
594
- class="widget-shape-publish-btn shrink-0 inline-flex items-center justify-center min-w-[3.75rem] rounded border border-emerald-600 bg-emerald-600 px-1 py-0.5 text-[8px] font-semibold uppercase tracking-wide text-white hover:bg-emerald-700 disabled:opacity-50 cursor-pointer"
650
+ class="widget-shape-publish-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] w-[4.75rem] rounded border border-emerald-600 bg-emerald-600 px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-white hover:bg-emerald-700 disabled:opacity-50 cursor-pointer"
595
651
  disabled={isPublishing || selectedPreviewBlocked || !!resolvePublishDisabled?.(overlay.widgetId, selectedVersion ? Number(selectedVersion.id.slice(2)) : null)}
596
652
  onclick={(e) => {
597
653
  e.stopPropagation();
@@ -607,7 +663,7 @@
607
663
  class="inline-flex shrink-0"
608
664
  >
609
665
  <span
610
- class="widget-shape-live-btn shrink-0 inline-flex items-center justify-center min-w-[3.75rem] rounded bg-emerald-100 px-1 py-0.5 text-[8px] font-semibold uppercase tracking-wide text-emerald-600 opacity-80"
666
+ class="widget-shape-live-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] w-[4.75rem] rounded bg-emerald-100 px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-600 opacity-80"
611
667
  >Live</span>
612
668
  </HoverTooltip>
613
669
  {:else if selectedVersion && onSetLive && !isDraftVersion(selectedVersion)}
@@ -618,7 +674,7 @@
618
674
  >
619
675
  <button
620
676
  type="button"
621
- class="widget-shape-set-live-btn shrink-0 inline-flex items-center justify-center min-w-[3.75rem] rounded border border-emerald-300 px-1 py-0.5 text-[8px] font-semibold uppercase tracking-wide text-emerald-700 hover:bg-emerald-100 disabled:opacity-50 cursor-pointer"
677
+ class="widget-shape-set-live-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] w-[4.75rem] rounded border border-emerald-300 px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-700 hover:bg-emerald-100 disabled:opacity-50 cursor-pointer"
622
678
  disabled={settingLiveVersion === Number(selectedVersion.id.slice(2))}
623
679
  onclick={(e) => handleSetLiveClick(e, overlay.widgetId, selectedVersion.id)}
624
680
  onpointerdown={(e) => e.stopPropagation()}
@@ -633,7 +689,7 @@
633
689
  >
634
690
  <button
635
691
  type="button"
636
- class="widget-shape-publish-btn shrink-0 inline-flex items-center justify-center min-w-[3.75rem] rounded border border-emerald-600 bg-emerald-600 px-1 py-0.5 text-[8px] font-semibold uppercase tracking-wide text-white hover:bg-emerald-700 disabled:opacity-50 cursor-pointer"
692
+ class="widget-shape-publish-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] w-[4.75rem] rounded border border-emerald-600 bg-emerald-600 px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-white hover:bg-emerald-700 disabled:opacity-50 cursor-pointer"
637
693
  disabled={isPublishing || selectedPreviewBlocked || !resolveCanPublishDraft?.(overlay.widgetId, Number(selectedVersion.id.slice(2))) || !!resolvePublishDisabled?.(overlay.widgetId, Number(selectedVersion.id.slice(2)))}
638
694
  onclick={(e) => handlePublishDraftClick(e, overlay.widgetId, selectedVersion.id)}
639
695
  onpointerdown={(e) => e.stopPropagation()}
@@ -646,39 +702,45 @@
646
702
  class="inline-flex shrink-0"
647
703
  >
648
704
  <span
649
- class="widget-shape-draft-badge shrink-0 inline-flex items-center justify-center min-w-[3.75rem] rounded bg-amber-100 px-1 py-0.5 text-[8px] font-semibold uppercase tracking-wide text-amber-700"
705
+ class="widget-shape-draft-badge shrink-0 inline-flex items-center justify-center min-w-[4.75rem] w-[4.75rem] rounded bg-amber-100 px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-700"
650
706
  >Draft</span>
651
707
  </HoverTooltip>
652
708
  {/if}
653
709
  {/if}
654
710
  {#if versionMenuOpen}
711
+ {@const hasScroll = versionOptions.length > 5}
655
712
  <div
656
- class="widget-shape-version-menu absolute right-0 top-full z-30 mt-0.5 w-max max-w-[min(22rem,calc(100vw-16px))] overflow-y-auto overflow-x-hidden rounded-md border border-emerald-200 bg-white py-0.5 shadow-lg"
657
- style="max-height: {VERSION_MENU_MAX_H}; scrollbar-width: thin; scrollbar-color: rgba(0,0,0,0.2) transparent;"
713
+ class="widget-shape-version-menu absolute top-full z-30 mt-0.5 w-max rounded-md border border-emerald-200 bg-white p-1 shadow-lg"
714
+ style="right: -12px;"
658
715
  role="listbox"
659
716
  >
660
- {#each versionOptions as option (option.id)}
661
- {@const optionVersion = Number(option.id.slice(2))}
662
- {@const optionBusy = (void titleRevision, !!resolveIsPublishing?.(overlay.widgetId))}
663
- {@const optionPublishDisabled = !resolveCanPublishDraft?.(overlay.widgetId, optionVersion)
664
- || !!resolvePublishDisabled?.(overlay.widgetId, optionVersion)
665
- || (selectedVersion?.id === option.id && selectedPreviewBlocked)}
666
- {@const optionPublishReason = (void titleRevision, resolvePublishDisabledReason?.(overlay.widgetId, optionVersion))}
667
- <VersionDropdownRow
668
- versionLabel={option.shortLabel || option.label}
669
- dateLabel={option.dateLabel}
670
- isLive={option.isLive === true}
671
- isDraft={isDraftVersion(option)}
672
- selected={selectedVersion?.id === option.id}
673
- busy={optionBusy}
674
- publishDisabled={optionPublishDisabled}
675
- publishDisabledReason={optionPublishReason || 'Build this draft version and make it the Live version'}
676
- setLiveDisabled={settingLiveVersion === optionVersion}
677
- onselect={() => selectVersionFromMenu(overlay.widgetId, option.id)}
678
- onpublish={() => handlePublishDraftClickFromMenu(overlay.widgetId, option.id)}
679
- onsetlive={() => handleSetLiveClickFromMenu(overlay.widgetId, option.id)}
680
- />
681
- {/each}
717
+ <div
718
+ class="widget-shape-version-scroll flex flex-col gap-1 overflow-x-hidden {hasScroll ? 'overflow-y-auto' : 'overflow-y-visible'}"
719
+ style="{hasScroll ? 'max-height: 185px; margin-right: -4px; padding-right: 4px;' : 'max-height: none;'} scrollbar-width: thin; scrollbar-color: rgba(0,0,0,0.2) transparent;"
720
+ >
721
+ {#each versionOptions as option (option.id)}
722
+ {@const optionVersion = Number(option.id.slice(2))}
723
+ {@const optionBusy = (void titleRevision, !!resolveIsPublishing?.(overlay.widgetId))}
724
+ {@const optionPublishDisabled = !resolveCanPublishDraft?.(overlay.widgetId, optionVersion)
725
+ || !!resolvePublishDisabled?.(overlay.widgetId, optionVersion)
726
+ || (selectedVersion?.id === option.id && selectedPreviewBlocked)}
727
+ {@const optionPublishReason = (void titleRevision, resolvePublishDisabledReason?.(overlay.widgetId, optionVersion))}
728
+ <VersionDropdownRow
729
+ versionLabel={option.shortLabel || option.label}
730
+ dateLabel={option.dateLabel}
731
+ isLive={option.isLive === true}
732
+ isDraft={isDraftVersion(option)}
733
+ selected={selectedVersion?.id === option.id}
734
+ busy={optionBusy}
735
+ publishDisabled={optionPublishDisabled}
736
+ publishDisabledReason={optionPublishReason || 'Build this draft version and make it the Live version'}
737
+ setLiveDisabled={settingLiveVersion === optionVersion}
738
+ onselect={() => selectVersionFromMenu(overlay.widgetId, option.id)}
739
+ onpublish={() => handlePublishDraftClickFromMenu(overlay.widgetId, option.id)}
740
+ onsetlive={() => handleSetLiveClickFromMenu(overlay.widgetId, option.id)}
741
+ />
742
+ {/each}
743
+ </div>
682
744
  </div>
683
745
  {/if}
684
746
  </div>
@@ -799,16 +861,19 @@
799
861
  {/if}
800
862
 
801
863
  <style>
802
- .widget-shape-version-menu::-webkit-scrollbar {
803
- width: 8px;
804
- height: 8px;
864
+ .widget-shape-version-scroll::-webkit-scrollbar {
865
+ width: 4px;
866
+ height: 4px;
805
867
  background: transparent;
806
868
  }
807
- .widget-shape-version-menu::-webkit-scrollbar-track {
869
+ .widget-shape-version-scroll::-webkit-scrollbar-track {
808
870
  background: transparent;
809
871
  }
810
- .widget-shape-version-menu::-webkit-scrollbar-thumb {
872
+ .widget-shape-version-scroll::-webkit-scrollbar-thumb {
811
873
  background: rgba(0, 0, 0, 0.2);
812
874
  border-radius: 4px;
813
875
  }
876
+ .widget-shape-version-scroll::-webkit-scrollbar-thumb:hover {
877
+ background: rgba(0, 0, 0, 0.35);
878
+ }
814
879
  </style>
@@ -51,6 +51,8 @@ interface Props {
51
51
  onTranslate?: ((widgetId: string, dxScreen: number, dyScreen: number) => void) | null;
52
52
  /** Fired once when a chrome drag ends (mark canvas dirty / history). */
53
53
  onTranslateEnd?: ((widgetId: string) => void) | null;
54
+ /** Tell the canvas a chrome drag started/ended (suppresses hover-select). */
55
+ onOverlayDragChange?: ((active: boolean) => void) | null;
54
56
  /** Select the Fabric WidgetShape (e.g. when pressing the drag bar). */
55
57
  onSelectShape?: ((widgetId: string) => void) | null;
56
58
  /** Open Widget Details / chat for this widget. */
@@ -88,8 +90,11 @@ interface Props {
88
90
  resolveIsPublishing?: ((widgetId: string) => boolean) | null;
89
91
  /** Publish-disabled reason for a specific Draft row (null = enabled). */
90
92
  resolvePublishDisabledReason?: ((widgetId: string, version: number) => string | null) | null;
93
+ /** Whether code generation is running (suppresses background captures / reloads). */
94
+ isGenerating?: boolean;
91
95
  }
92
96
  declare const WidgetShapeLivePreviews: import("svelte").Component<Props, {
97
+ requestReload: (widgetId: string) => void;
93
98
  sync: () => void;
94
99
  }, "">;
95
100
  type WidgetShapeLivePreviews = ReturnType<typeof WidgetShapeLivePreviews>;
@@ -39,6 +39,30 @@ export const codeGenerationStatus = writable({
39
39
  timestamp: null
40
40
  });
41
41
  export const agentHarnessOutput = writable(null);
42
+ /**
43
+ * Throttle for high-frequency agent harness frames. The stream used to emit one
44
+ * WS event per LLM token (plus one SSE progress frame every 10 tokens), and each
45
+ * frame re-rendered every open panel — with `structuredClone` on schemas per
46
+ * render. That saturated the browser main thread and froze the whole site during
47
+ * generation. Frames now coalesce into one store write per interval; the final
48
+ * frame always flushes so the last line is never lost.
49
+ */
50
+ const AGENT_HARNESS_FLUSH_MS = 300;
51
+ let agentHarnessPending = null;
52
+ let agentHarnessFlushTimer = null;
53
+ function flushAgentHarnessOutput() {
54
+ agentHarnessFlushTimer = null;
55
+ if (!agentHarnessPending)
56
+ return;
57
+ agentHarnessOutput.set(agentHarnessPending);
58
+ agentHarnessPending = null;
59
+ }
60
+ function pushAgentHarnessOutput(output) {
61
+ agentHarnessPending = { output, timestamp: new Date().toISOString() };
62
+ if (!agentHarnessFlushTimer) {
63
+ agentHarnessFlushTimer = setTimeout(flushAgentHarnessOutput, AGENT_HARNESS_FLUSH_MS);
64
+ }
65
+ }
42
66
  // =============================================================================
43
67
  // Socket.IO Connection
44
68
  // =============================================================================
@@ -204,10 +228,8 @@ function setupSocketListeners(sock) {
204
228
  return;
205
229
  if (showLogs)
206
230
  console.log('[WsStore] Agent harness output:', output.slice(0, 120));
207
- agentHarnessOutput.set({
208
- output,
209
- timestamp: new Date().toISOString()
210
- });
231
+ // Coalesced write — see AGENT_HARNESS_FLUSH_MS above.
232
+ pushAgentHarnessOutput(output);
211
233
  });
212
234
  sock.on('dynamic_worker_preview_ready', (data) => {
213
235
  if (showLogs)
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Main-thread responsiveness watchdog.
3
+ *
4
+ * PerformanceObserver('longtask') only fires AFTER a blocking task completes —
5
+ * an infinite reactive loop, or heavy work running inside a same-origin iframe
6
+ * (html2canvas screenshot captures in the widget preview share the parent's main
7
+ * thread), is never attributed to the page. The result was a frozen site with no
8
+ * [FreezeReport] in the console.
9
+ *
10
+ * This watchdog flips the model: a Web Worker (own thread, unaffected by the
11
+ * blocked main thread) pings every 500ms and the main thread answers. When pongs
12
+ * stop arriving, the worker logs a [FreezeReport] straight to the page console
13
+ * (worker console output shows in DevTools) with the block duration and the last
14
+ * codegen status the main thread reported. It also reports recovery, so each
15
+ * freeze episode produces exactly two lines: block start + recovery.
16
+ */
17
+ export interface FreezeWatchdog {
18
+ /** Main thread reports the current codegen status; included in freeze reports. */
19
+ setStatus(status: string): void;
20
+ dispose(): void;
21
+ }
22
+ export declare function installFreezeWatchdog(): FreezeWatchdog | null;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Main-thread responsiveness watchdog.
3
+ *
4
+ * PerformanceObserver('longtask') only fires AFTER a blocking task completes —
5
+ * an infinite reactive loop, or heavy work running inside a same-origin iframe
6
+ * (html2canvas screenshot captures in the widget preview share the parent's main
7
+ * thread), is never attributed to the page. The result was a frozen site with no
8
+ * [FreezeReport] in the console.
9
+ *
10
+ * This watchdog flips the model: a Web Worker (own thread, unaffected by the
11
+ * blocked main thread) pings every 500ms and the main thread answers. When pongs
12
+ * stop arriving, the worker logs a [FreezeReport] straight to the page console
13
+ * (worker console output shows in DevTools) with the block duration and the last
14
+ * codegen status the main thread reported. It also reports recovery, so each
15
+ * freeze episode produces exactly two lines: block start + recovery.
16
+ */
17
+ const WATCHDOG_SOURCE = `
18
+ let lastPongAt = performance.now();
19
+ let freezeStartedAt = null;
20
+ let reported = false;
21
+ let lastStatus = '';
22
+ const PING_MS = 500;
23
+ const STALL_MS = 4000;
24
+ setInterval(() => {
25
+ postMessage({ type: 'ping' });
26
+ const now = performance.now();
27
+ if (now - lastPongAt >= STALL_MS) {
28
+ if (!freezeStartedAt) freezeStartedAt = now - STALL_MS;
29
+ if (!reported) {
30
+ reported = true;
31
+ console.warn('[FreezeReport] Main thread unresponsive ' + Math.round(now - freezeStartedAt) +
32
+ 'ms. Last status: "' + (lastStatus || '—') + '"');
33
+ }
34
+ } else if (freezeStartedAt) {
35
+ console.warn('[FreezeReport] Main thread recovered after ' + Math.round(now - freezeStartedAt) +
36
+ 'ms. Last status: "' + (lastStatus || '—') + '"');
37
+ freezeStartedAt = null;
38
+ reported = false;
39
+ }
40
+ }, PING_MS);
41
+ onmessage = (e) => {
42
+ if (e.data && e.data.type === 'pong') {
43
+ lastPongAt = performance.now();
44
+ if (e.data.status) lastStatus = e.data.status;
45
+ }
46
+ };
47
+ `;
48
+ export function installFreezeWatchdog() {
49
+ if (typeof window === 'undefined' || typeof Worker === 'undefined' || typeof Blob === 'undefined') {
50
+ return null;
51
+ }
52
+ const existing = window.__widgeticFreezeWatchdog;
53
+ if (existing)
54
+ return existing;
55
+ const blob = new Blob([WATCHDOG_SOURCE], { type: 'text/javascript' });
56
+ const url = URL.createObjectURL(blob);
57
+ const worker = new Worker(url);
58
+ URL.revokeObjectURL(url);
59
+ let lastStatus = '';
60
+ worker.onmessage = (event) => {
61
+ if (event.data?.type === 'ping') {
62
+ worker.postMessage({ type: 'pong', status: lastStatus });
63
+ }
64
+ };
65
+ const watchdog = {
66
+ setStatus(status) {
67
+ lastStatus = status;
68
+ },
69
+ dispose() {
70
+ worker.terminate();
71
+ delete window.__widgeticFreezeWatchdog;
72
+ },
73
+ };
74
+ window.__widgeticFreezeWatchdog = watchdog;
75
+ return watchdog;
76
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Keeps a scrollable container pinned to its bottom edge until the user scrolls
3
+ * up. Opening the widget-details panel on a small screen lays the panel out
4
+ * after mount, which can leave the outer scroll area (status bar + chat +
5
+ * footer) resting above its bottom — clipping the chat input. The observer
6
+ * re-pins to bottom whenever the content height or the box changes, so the
7
+ * input is always visible on open. Any manual upward scroll un-pins until the
8
+ * user returns to the bottom, preserving normal reading behavior.
9
+ */
10
+ export declare function pinScrollToBottom(node: HTMLElement): {
11
+ destroy(): void;
12
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Keeps a scrollable container pinned to its bottom edge until the user scrolls
3
+ * up. Opening the widget-details panel on a small screen lays the panel out
4
+ * after mount, which can leave the outer scroll area (status bar + chat +
5
+ * footer) resting above its bottom — clipping the chat input. The observer
6
+ * re-pins to bottom whenever the content height or the box changes, so the
7
+ * input is always visible on open. Any manual upward scroll un-pins until the
8
+ * user returns to the bottom, preserving normal reading behavior.
9
+ */
10
+ export function pinScrollToBottom(node) {
11
+ let pinned = true;
12
+ let rafId = null;
13
+ const scrollToBottom = () => {
14
+ if (!pinned)
15
+ return;
16
+ // rAF so the layout pass has settled before reading scrollHeight.
17
+ rafId = requestAnimationFrame(() => {
18
+ node.scrollTo({ top: node.scrollHeight, behavior: 'instant' });
19
+ });
20
+ };
21
+ const onScroll = () => {
22
+ const atBottom = node.scrollTop + node.clientHeight >= node.scrollHeight - 8;
23
+ pinned = atBottom;
24
+ };
25
+ const observer = new ResizeObserver(scrollToBottom);
26
+ observer.observe(node);
27
+ // Content growing also needs re-pinning; observe children changes.
28
+ const contentObserver = new MutationObserver(scrollToBottom);
29
+ contentObserver.observe(node, { childList: true, subtree: true, characterData: true });
30
+ node.addEventListener('scroll', onScroll, { passive: true });
31
+ // Initial pin after the first layout pass.
32
+ const initialRaf = requestAnimationFrame(scrollToBottom);
33
+ return {
34
+ destroy() {
35
+ if (rafId !== null)
36
+ cancelAnimationFrame(rafId);
37
+ cancelAnimationFrame(initialRaf);
38
+ observer.disconnect();
39
+ contentObserver.disconnect();
40
+ node.removeEventListener('scroll', onScroll);
41
+ },
42
+ };
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@widgetic/creator",
3
- "version": "0.3.63",
3
+ "version": "0.3.65",
4
4
  "type": "module",
5
5
  "peerDependencies": {
6
6
  "@sveltejs/kit": "^2.20.0",