@widgetic/creator 0.3.60 → 0.3.61

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.
@@ -28,6 +28,7 @@
28
28
  ResponseError
29
29
  } from '@widgetic/api-sdk';
30
30
  import type { Middleware } from '@widgetic/api-sdk';
31
+ import { parseVersionPreviewError } from './utils/versionPreviewError.js';
31
32
 
32
33
  /**
33
34
  * Idempotency middleware (Phase 2 hardening): the gateway rejects
@@ -465,6 +466,7 @@
465
466
  import type { DropdownItem } from './components/SelectorDropdown.svelte';
466
467
  import WidgetShapeLivePreviews from './components/WidgetShapeLivePreviews.svelte';
467
468
  import HoverTooltip from './components/HoverTooltip.svelte';
469
+ import VersionDropdownRow from './components/VersionDropdownRow.svelte';
468
470
 
469
471
  // WIDGET DETAILS COMPONENT — lazy-loaded to reduce peak build memory
470
472
  let WidgetDetails: any;
@@ -708,6 +710,21 @@
708
710
  }
709
711
  showToast('success', 'Widget deleted from database');
710
712
  } catch (err) {
713
+ // A shape can outlive its widget row (deleted from the Projects page or
714
+ // another session). The canvas only needs the shape and local entry gone,
715
+ // so treat "already gone" as success instead of a blocking error — that
716
+ // left orphan shapes impossible to remove from the canvas.
717
+ if (err instanceof ResponseError && err.response?.status === 404) {
718
+ console.warn('[WidgetCreator] Widget already absent from DB — removing locally:', widgetId);
719
+ widgets = widgets.filter((w: any) => w.id !== widgetId);
720
+ if (selectedWidgetId === widgetId) {
721
+ selectedWidgetId = null;
722
+ }
723
+ showToast('info', 'Widget was already deleted', {
724
+ description: 'Removed the leftover shape from this canvas.',
725
+ });
726
+ return;
727
+ }
711
728
  console.error('[WidgetCreator] Failed to delete widget from DB:', err);
712
729
  showToast('error', 'Failed to delete widget from database');
713
730
  }
@@ -1319,14 +1336,15 @@
1319
1336
  return dataUrls.slice(0, 1);
1320
1337
  }
1321
1338
 
1322
- async function persistWidgetListThumbnail(widgetId: string, imageDataUrl: string): Promise<void> {
1323
- if (!widgetsClient || !widgetId || !imageDataUrl) return;
1339
+ async function persistWidgetListThumbnail(widgetId: string, imageDataUrl: string, isCurrent = () => true): Promise<void> {
1340
+ if (!widgetsClient || !widgetId || !imageDataUrl || !isCurrent()) return;
1324
1341
  try {
1325
1342
  const compact = imageDataUrl.startsWith('data:image/')
1326
1343
  ? await compressImageDataUrlForCodegen(imageDataUrl, 384, 180000)
1327
1344
  : imageDataUrl;
1345
+ if (!isCurrent()) return;
1328
1346
  const thumbnailUrl = await persistWidgetThumbnail(widgetsClient, widgetId, compact);
1329
- if (thumbnailUrl) {
1347
+ if (thumbnailUrl && isCurrent()) {
1330
1348
  widgets = widgets.map((w) =>
1331
1349
  w.id === widgetId ? { ...w, thumbnailUrl, thumbnail_url: thumbnailUrl } : w
1332
1350
  );
@@ -1518,19 +1536,29 @@
1518
1536
  }
1519
1537
 
1520
1538
  /** Live WidgetShape iframe only — never the hidden panel preview column. */
1539
+ /**
1540
+ * Find the live preview iframe for a widget.
1541
+ *
1542
+ * When a widget id is given we must return *that* widget's iframe or nothing
1543
+ * at all. Falling back to "the first widget-shape iframe on the page" meant a
1544
+ * screenshot request for widget A could capture widget B's preview and store it
1545
+ * as A's thumbnail — which is how a gallery thumbnail ended up on an unrelated
1546
+ * widget in the Projects list.
1547
+ */
1521
1548
  function resolveLiveWidgetShapeIframe(widgetId: string | null | undefined): HTMLIFrameElement | null {
1522
1549
  const targetId = widgetId || focusedPanelWidgetId || selectedWidgetId;
1523
1550
  if (targetId) {
1524
1551
  const liveById = document.querySelector(
1525
1552
  `iframe.widget-shape-live-preview-iframe[data-widget-id="${CSS.escape(targetId)}"]`,
1526
1553
  ) as HTMLIFrameElement | null;
1527
- if (liveById?.contentWindow) return liveById;
1554
+ return liveById?.contentWindow ? liveById : null;
1528
1555
  }
1529
- return (
1530
- (document.querySelector(
1531
- 'iframe.widget-shape-live-preview-iframe',
1532
- ) as HTMLIFrameElement | null) || null
1533
- );
1556
+ // No id known: only unambiguous when exactly one shape is mounted.
1557
+ const all = Array.from(
1558
+ document.querySelectorAll('iframe.widget-shape-live-preview-iframe'),
1559
+ ) as HTMLIFrameElement[];
1560
+ const withWindow = all.filter((frame) => !!frame.contentWindow);
1561
+ return withWindow.length === 1 ? withWindow[0] : null;
1534
1562
  }
1535
1563
 
1536
1564
  function isWorkerPreviewUrl(url: string | null | undefined): boolean {
@@ -1559,8 +1587,8 @@
1559
1587
 
1560
1588
  function handler(event: MessageEvent) {
1561
1589
  if (event.data?.type !== 'widgetic:screenshot') return;
1562
- // Prefer replies from this iframe when source is available
1563
- if (event.source && event.source !== iframe.contentWindow) return;
1590
+ // Ignore other widgets and synthetic replies with no source window.
1591
+ if (event.source !== iframe.contentWindow) return;
1564
1592
  clearTimeout(timeout);
1565
1593
  window.removeEventListener('message', handler);
1566
1594
  resolve({
@@ -1624,15 +1652,43 @@
1624
1652
  }
1625
1653
  }
1626
1654
 
1655
+ /** Explicit Refresh only: capture the loaded frame without opening a panel or publishing. */
1656
+ async function handleWidgetShapeRefresh(widgetId, iframe, isCurrentFrame) {
1657
+ const capturedCanvasId = getEffectiveCanvasId();
1658
+ const capturedCanvas = canvasRef;
1659
+ const capturedSource = shapePreviewSourceByWidgetId[widgetId] || 'live';
1660
+ // Frame/intent identity owns this capture, not focused-panel HEAD hydration
1661
+ // or another widget's global generation operation.
1662
+ const isCurrent = () => isCurrentFrame() && !!getWidgetById(widgetId)
1663
+ && !isDraftConvertId(widgetId) && !generatingWidgetIds[widgetId]
1664
+ && capturedCanvasId === getEffectiveCanvasId() && capturedCanvas === canvasRef
1665
+ && capturedSource === (shapePreviewSourceByWidgetId[widgetId] || 'live')
1666
+ && resolveLiveWidgetShapeIframe(widgetId) === iframe;
1667
+ if (!isCurrent()) return;
1668
+ try {
1669
+ const screenshot = await handleChatScreenshot(widgetId, iframe);
1670
+ if (!screenshot || !isCurrent()) return;
1671
+ // Persist before Fabric/autosave side effects can resync the live overlay.
1672
+ await persistWidgetListThumbnail(widgetId, screenshot, isCurrent);
1673
+ if (!isCurrent()) return;
1674
+ capturedCanvas?.updateWidgetImage(widgetId, screenshot);
1675
+ const canvasJson = capturedCanvas?.saveCanvas?.();
1676
+ if (canvasJson) void handleCanvasAutoSave(canvasJson);
1677
+ } catch (error) {
1678
+ console.warn('[WidgetShape] Refresh thumbnail capture failed', widgetId, error);
1679
+ }
1680
+ }
1681
+
1627
1682
  /** Screenshot: capture the live WidgetShape iframe (CDN or Worker), not Fabric wireframe. */
1628
- async function handleChatScreenshot(widgetId?: string | null): Promise<string | null> {
1683
+ async function handleChatScreenshot(widgetId?: string | null, requiredIframe: HTMLIFrameElement | null = null): Promise<string | null> {
1629
1684
  const targetId = widgetId || focusedPanelWidgetId || selectedWidgetId;
1630
1685
  if (!targetId) {
1631
1686
  console.warn('[Chat→Screenshot] No widget id');
1632
1687
  return null;
1633
1688
  }
1634
1689
 
1635
- const liveIframe = resolveLiveWidgetShapeIframe(targetId);
1690
+ const liveIframe = requiredIframe ?? resolveLiveWidgetShapeIframe(targetId);
1691
+ if (requiredIframe && (requiredIframe !== resolveLiveWidgetShapeIframe(targetId) || !requiredIframe.isConnected)) return null;
1636
1692
  const liveSrc = liveIframe
1637
1693
  ? liveIframe.getAttribute('src') || liveIframe.src || ''
1638
1694
  : '';
@@ -1651,6 +1707,9 @@
1651
1707
  console.warn('[Chat→Screenshot] Live iframe capture failed:', fromLive.error);
1652
1708
  }
1653
1709
 
1710
+ // Explicit refresh must not persist a different version or a stale Fabric image.
1711
+ if (requiredIframe) return null;
1712
+
1654
1713
  // 2) Hidden Worker /preview (same capture script; wait for load)
1655
1714
  const fromWorker = await captureViaHiddenWorkerPreview(targetId);
1656
1715
  if (fromWorker && fromWorker.length > 500) return fromWorker;
@@ -2070,11 +2129,28 @@
2070
2129
  null;
2071
2130
  // console.log('[Chat→Backend] Git rollback result:', gitRollback, { undoMode, targetCommit, panelWidgetId });
2072
2131
  if (gitRollback?.success) {
2132
+ // Fast path: a recorded version at this commit has an immutable cached
2133
+ // artifact — reuse it instead of the forced repo rebuild + retry ladder.
2134
+ let previewFromVersionCache = false;
2073
2135
  if (targetCommit) {
2074
2136
  lastCommitId = targetCommit;
2075
2137
  if (panelWidgetId) {
2076
2138
  patchPanelSnapshot(panelWidgetId, { lastCommitId: targetCommit });
2077
2139
  patchCodegenUiState(panelWidgetId, {});
2140
+ // Message-extracted SHAs may be short (7+ chars); compare by prefix.
2141
+ const targetSha = String(targetCommit).toLowerCase();
2142
+ const matchedVersion = (resolveWidgetShapeVersionOptions(panelWidgetId) ?? [])
2143
+ .find((option) => {
2144
+ const optionSha = String(option.commitSha ?? '').toLowerCase();
2145
+ return optionSha.length >= 7 && targetSha.length >= 7
2146
+ && (optionSha === targetSha || optionSha.startsWith(targetSha) || targetSha.startsWith(optionSha));
2147
+ });
2148
+ if (matchedVersion) {
2149
+ shapeVersionPreviewErrorsByKey = { ...shapeVersionPreviewErrorsByKey, [`${panelWidgetId}:v:${matchedVersion.id.slice(2)}`]: null };
2150
+ shapePreviewSourceByWidgetId = { ...shapePreviewSourceByWidgetId, [panelWidgetId]: matchedVersion.id };
2151
+ handleWidgetShapeVersionChange(panelWidgetId, matchedVersion.id);
2152
+ previewFromVersionCache = true;
2153
+ }
2078
2154
  }
2079
2155
  }
2080
2156
  showToast('success', undoMode ? 'Change undone' : 'Checkpoint restored', {
@@ -2089,7 +2165,13 @@
2089
2165
  compositionDesignSchema = null;
2090
2166
  compositionContentSchema = null;
2091
2167
  pendingCreatePreviewDefaultsWidgetId = null;
2092
- _isRestoringCheckpoint = true;
2168
+ _isRestoringCheckpoint = !previewFromVersionCache;
2169
+ if (previewFromVersionCache) {
2170
+ // Immutable artifact already serves the preview; no rebuild ladder.
2171
+ syncWidgetShapeLivePreviews();
2172
+ setTimeout(() => handleRefreshScreenshot(panelWidgetId, { quiet: true }), 2000);
2173
+ return { ...data, previewFromVersionCache };
2174
+ }
2093
2175
  const panelRef = panelWidgetId ? getWidgetDetailsRef(panelWidgetId) : widgetDetails;
2094
2176
  const scheduleBuildRetry = async () => {
2095
2177
  const delays = [2500, 5000, 8000];
@@ -2178,7 +2260,7 @@
2178
2260
  let lastPublishedVersion: number | null = null;
2179
2261
  let selectedWidgetJsPath: string | null = null;
2180
2262
 
2181
- // Publish History state — loaded when user expands the history panel
2263
+ // Version history state — loaded when user expands the history panel
2182
2264
  interface PublishHistoryRecord {
2183
2265
  id: string;
2184
2266
  version: number;
@@ -2187,10 +2269,23 @@
2187
2269
  commit_sha?: string | null;
2188
2270
  artifactUrl?: string;
2189
2271
  artifact_url?: string;
2190
- publishedAt?: string;
2191
- published_at?: string;
2272
+ /**
2273
+ * When this version went Live. Null/absent means it is a Draft — a version
2274
+ * that has been generated but never published. This is the single
2275
+ * discriminator between Draft and Live; there is no second version type.
2276
+ */
2277
+ publishedAt?: string | null;
2278
+ published_at?: string | null;
2192
2279
  publishedBy?: string | null;
2193
2280
  published_by?: string | null;
2281
+ createdAt?: string | null;
2282
+ created_at?: string | null;
2283
+ /** Derived server-side so the client does not re-infer the Draft/Live split. */
2284
+ isPublished?: boolean;
2285
+ is_published?: boolean;
2286
+ thumbnailUrl?: string | null;
2287
+ thumbnail_url?: string | null;
2288
+ prompt?: string | null;
2194
2289
  }
2195
2290
  let publishHistory: PublishHistoryRecord[] = [];
2196
2291
  let isHistoryOpen = false;
@@ -5079,6 +5174,8 @@
5079
5174
  sessionCanvasId = entry.id;
5080
5175
  activeCanvasName = entry.name;
5081
5176
  saveSelectedCanvasId(canvasId);
5177
+ // The deferred cooldown save (if any) holds the previous canvas's shapes.
5178
+ discardPendingCooldownAutosave();
5082
5179
  // Address bar must track the canvas we actually switched to, and must not keep a
5083
5180
  // widget_id belonging to the previous canvas.
5084
5181
  routeCanvasId = entry.id;
@@ -5220,6 +5317,7 @@
5220
5317
  function beginBlankCanvasSession(reason: string): void {
5221
5318
  ignoreIncomingCanvasLoads = true;
5222
5319
  canvasLoadEpoch += 1;
5320
+ discardPendingCooldownAutosave();
5223
5321
  lastCommitId = null;
5224
5322
  lastPublishedVersion = null;
5225
5323
  selectedWidgetJsPath = null;
@@ -5230,6 +5328,19 @@
5230
5328
  // console.log('[CreatorApp] Blank canvas session:', reason, 'epoch', canvasLoadEpoch);
5231
5329
  }
5232
5330
 
5331
+ /**
5332
+ * Drop a deferred cooldown autosave. Must run whenever the active canvas
5333
+ * changes: the captured JSON belongs to the previous canvas id, and flushing
5334
+ * it later would write one canvas's shapes into another.
5335
+ */
5336
+ function discardPendingCooldownAutosave(): void {
5337
+ if (cooldownAutosaveTimer) {
5338
+ clearTimeout(cooldownAutosaveTimer);
5339
+ cooldownAutosaveTimer = null;
5340
+ }
5341
+ pendingCooldownAutosaveJson = null;
5342
+ }
5343
+
5233
5344
  function relinkSelectedWidgetShape(): void {
5234
5345
  placeSelectedWidgetShapeOnCanvas();
5235
5346
  }
@@ -5319,6 +5430,7 @@
5319
5430
 
5320
5431
  if (createdId) {
5321
5432
  canvasLoadEpoch += 1;
5433
+ discardPendingCooldownAutosave();
5322
5434
  isCanvasStateLoaded = false;
5323
5435
  userCanvases = [{ id: createdId, name: newName, hasContent: false }, ...userCanvases];
5324
5436
  showCanvasDropdown = false;
@@ -6640,7 +6752,11 @@
6640
6752
  ...(lastCommitId ? { hasGeneratedCode: true } : {}),
6641
6753
  });
6642
6754
  if (lastCommitId) hasGeneratedCode = true;
6643
- if (lastPublishedVersion !== null) {
6755
+ // Fetch history whenever the widget has code (generated or published).
6756
+ // Gating on `lastPublishedVersion` alone meant a widget that had only
6757
+ // ever been generated never loaded its history — so its version picker
6758
+ // stayed empty even though generations were recorded server-side.
6759
+ if (lastPublishedVersion !== null || lastCommitId || currentRepositoryId) {
6644
6760
  // Load history against the specific widget id (not selectedWidgetId) so the
6645
6761
  // panel snapshot is guaranteed to be marked publishHistoryResolved even if the
6646
6762
  // user switches widget between hydrate and API response.
@@ -7526,13 +7642,44 @@
7526
7642
  // Convert drafts capture from the canvas shape, never by opening a panel.
7527
7643
  if (!openWidgetPanelIds.includes(widgetId)) {
7528
7644
  if (quiet || isConvertOrDraftPanelOpen()) {
7529
- const shapeDataUrl = canvasRef?.getWidgetShapeDataUrl?.(widgetId);
7530
- if (shapeDataUrl && canvasRef) {
7531
- canvasRef.updateWidgetImage(widgetId, shapeDataUrl);
7532
- // Do NOT persist Fabric placeholder/icon as project-list thumbnail —
7533
- // only iframe screenshots (below) represent the real widget.
7534
- } else {
7535
- // console.log('[handleRefreshScreenshot] Skip quiet capture — panel closed:', widgetId.substring(0, 8));
7645
+ // A draft convert has no widget row and no live iframe yet — the
7646
+ // Fabric sketch is genuinely all there is to show.
7647
+ if (isDraftConvertId(widgetId)) {
7648
+ const draftShapeDataUrl = canvasRef?.getWidgetShapeDataUrl?.(widgetId);
7649
+ if (draftShapeDataUrl && canvasRef) {
7650
+ canvasRef.updateWidgetImage(widgetId, draftShapeDataUrl);
7651
+ }
7652
+ return;
7653
+ }
7654
+
7655
+ // Real widget with the details panel closed: the live shape iframe
7656
+ // is still on the canvas. Capture from it so both the canvas image
7657
+ // and the project-list thumbnail track the latest preview.
7658
+ //
7659
+ // Without this the Fabric `_imageElement` set at convert time wins
7660
+ // forever — it is the sketch screenshot frozen at creation, so the
7661
+ // Projects list kept showing the initial design after later
7662
+ // generations and preview refreshes.
7663
+ if (!shapeThumbnailCaptureInFlight.has(widgetId)) {
7664
+ shapeThumbnailCaptureInFlight.add(widgetId);
7665
+ try {
7666
+ const freshCapture = await handleChatScreenshot(widgetId);
7667
+ if (freshCapture) {
7668
+ canvasRef?.updateWidgetImage(widgetId, freshCapture);
7669
+ const freshJson = canvasRef?.saveCanvas?.();
7670
+ if (freshJson) void handleCanvasAutoSave(freshJson);
7671
+ void persistWidgetListThumbnail(widgetId, freshCapture);
7672
+ } else {
7673
+ // No live iframe/Worker preview reachable — leave the
7674
+ // existing image alone rather than persisting a wireframe.
7675
+ console.warn(
7676
+ '[handleRefreshScreenshot] Quiet capture produced no image — keeping existing shape preview',
7677
+ widgetId.substring(0, 8),
7678
+ );
7679
+ }
7680
+ } finally {
7681
+ shapeThumbnailCaptureInFlight.delete(widgetId);
7682
+ }
7536
7683
  }
7537
7684
  return;
7538
7685
  }
@@ -8186,6 +8333,18 @@ Requirements:
8186
8333
  // );
8187
8334
  }
8188
8335
 
8336
+ // Refresh version history after every generation so the new commit shows
8337
+ // up in the version menu immediately. Publishing is deliberately not
8338
+ // automatic — the user previews and presses Publish themselves.
8339
+ if (generationWidgetId) {
8340
+ const { [generationWidgetId]: _resolved, ...restResolved } = shapeHistoryResolvedByWidgetId;
8341
+ shapeHistoryResolvedByWidgetId = restResolved;
8342
+ void loadPublishHistoryForPanel(generationWidgetId, { silent: true }).then(() => {
8343
+ syncWidgetShapeLivePreviews();
8344
+ if (isFocusedPanel) panelDisplayRevision += 1;
8345
+ });
8346
+ }
8347
+
8189
8348
  // Always reconcile GitLab HEAD after codegen (metadata.commitId can be missing/stale).
8190
8349
  if (generationWidgetId) {
8191
8350
  void (async () => {
@@ -8581,11 +8740,26 @@ Requirements:
8581
8740
  */
8582
8741
  async function executePublishWidget(
8583
8742
  publishForWidgetId?: string | null,
8584
- publishOpts: { silent?: boolean; fromCodegen?: boolean } = {},
8743
+ publishOpts: { silent?: boolean; fromCodegen?: boolean; targetVersion?: number | null } = {},
8585
8744
  ) {
8586
8745
  const capturedWidgetId = publishForWidgetId ?? focusedPanelWidgetId ?? selectedWidgetId;
8587
8746
  const panelState = capturedWidgetId ? getPanelDisplayState(capturedWidgetId) : null;
8588
8747
  const capturedCanvasId = getEffectiveCanvasId();
8748
+ // An explicit version publishes *that* version — it is a Draft the user
8749
+ // picked in the version list. Without one, the newest Draft / current
8750
+ // commit is promoted, which is the "publish latest" flow.
8751
+ // `Number(null)` is 0 (finite), so reject null/undefined/'' explicitly
8752
+ // rather than letting them silently become version 0.
8753
+ const rawTargetVersion = publishOpts.targetVersion;
8754
+ const targetVersion =
8755
+ rawTargetVersion == null || rawTargetVersion === ''
8756
+ ? null
8757
+ : Number.isFinite(Number(rawTargetVersion))
8758
+ ? Number(rawTargetVersion)
8759
+ : null;
8760
+ // Recheck after async focus (and for direct callers), never substitute HEAD
8761
+ // when a requested Draft is missing or no longer publishable.
8762
+ if (rawTargetVersion != null && getPublishGateForPanel(capturedWidgetId, rawTargetVersion).disabled) return;
8589
8763
  let capturedRepositoryId = resolveRepositoryIdForWidget(capturedWidgetId);
8590
8764
  if (!capturedRepositoryId && capturedWidgetId) {
8591
8765
  const widget = getWidgetById(capturedWidgetId) ?? selectedWidget;
@@ -8594,10 +8768,16 @@ Requirements:
8594
8768
  if (capturedRepositoryId) stampWidgetRepositoryId(capturedWidgetId, capturedRepositoryId);
8595
8769
  }
8596
8770
  }
8597
- const commitShaForPublish =
8598
- panelState?.lastCommitId
8599
- || (capturedWidgetId ? loadCommitIdForWidget(capturedWidgetId) : null)
8600
- || lastCommitId;
8771
+ // A targeted publish builds from that version's own commit, so the server
8772
+ // must receive the version — sending HEAD's SHA would publish the wrong code.
8773
+ const targetRecord = targetVersion !== null
8774
+ ? getShapePublishHistory(capturedWidgetId).find((row) => Number(row.version) === targetVersion)
8775
+ : undefined;
8776
+ const commitShaForPublish = targetVersion !== null
8777
+ ? (targetRecord?.commitSha ?? targetRecord?.commit_sha ?? null)
8778
+ : (panelState?.lastCommitId
8779
+ || (capturedWidgetId ? loadCommitIdForWidget(capturedWidgetId) : null)
8780
+ || lastCommitId);
8601
8781
 
8602
8782
  if (!capturedWidgetId || !capturedCanvasId || !capturedRepositoryId) {
8603
8783
  showToast('error', 'Cannot publish', { description: 'Widget, canvas, and repository are required.' });
@@ -8666,7 +8846,10 @@ Requirements:
8666
8846
  widgetId: capturedWidgetId,
8667
8847
  agentWidgetBuilderPublishWidgetIdPostRequest: {
8668
8848
  componentId,
8669
- commitSha: commitShaForPublish || undefined
8849
+ commitSha: commitShaForPublish || undefined,
8850
+ // Publishing a Draft the user chose: the server rebuilds from that
8851
+ // version's commit and stamps it Live instead of minting a new one.
8852
+ version: targetVersion ?? undefined
8670
8853
  }
8671
8854
  });
8672
8855
  // console.log('[Publish] Widget Builder publish completed:', publishData);
@@ -8775,6 +8958,7 @@ Requirements:
8775
8958
  selectedWidgetId = null;
8776
8959
  ignoreIncomingCanvasLoads = true;
8777
8960
  canvasLoadEpoch += 1;
8961
+ discardPendingCooldownAutosave();
8778
8962
  lastCommitId = null;
8779
8963
  lastPublishedVersion = null;
8780
8964
  widgetDetailsStep = 'create';
@@ -9691,18 +9875,15 @@ Requirements:
9691
9875
  return resolvePublishedWidgetUrl(widgetId, version, jsPath);
9692
9876
  }
9693
9877
 
9694
- /** Widget Builder origin for canvas shape iframes (mirrors WidgetDetails). */
9878
+ /** Public display origin for shape/hidden capture iframes; keep WidgetDetails fetch proxy separate. */
9695
9879
  function widgetBuilderPreviewBaseUrl(): string {
9696
- const publicBase = (
9880
+ // Do not proxy sandboxed display frames onto the editor's origin.
9881
+ return (
9697
9882
  widgetBuilderUrl ||
9698
9883
  import.meta.env.VITE_WIDGET_BUILDER_URL ||
9699
9884
  import.meta.env.PUBLIC_WIDGET_BUILDER_URL ||
9700
9885
  'https://wbuilder.widgetic.com'
9701
9886
  ).replace(/\/+$/, '');
9702
- if (import.meta.env.DEV && publicBase.includes('wbuilder.widgetic.com')) {
9703
- return '/__widget_builder__';
9704
- }
9705
- return publicBase;
9706
9887
  }
9707
9888
 
9708
9889
  /**
@@ -9711,19 +9892,58 @@ Requirements:
9711
9892
  * Publish history is loaded without requiring chat to be open.
9712
9893
  */
9713
9894
  let shapePreviewSourceByWidgetId: Record<string, string> = {};
9895
+ // Historical previews are never interchangeable with the shared HEAD preview.
9896
+ let shapeVersionPreviewByWidgetId: Record<string, {
9897
+ versionId: string; requestId: number; loading: boolean; error: string | null; url: string | null;
9898
+ errorDetails?: Awaited<ReturnType<typeof parseVersionPreviewError>> | null;
9899
+ }> = {};
9900
+ let shapeVersionPreviewRequestId = 0;
9901
+ /** Remember failed versions after switching rows; unrelated versions stay publishable. */
9902
+ let shapeVersionPreviewErrorsByKey = {};
9903
+
9904
+ function getVersionPreviewPublishBlockReason(widgetId, targetVersion = null) {
9905
+ const selectedState = resolveWidgetShapeVersionPreviewState(widgetId);
9906
+ const versionId = targetVersion == null
9907
+ ? shapePreviewSourceByWidgetId[widgetId]
9908
+ : `v:${targetVersion}`;
9909
+ const state = selectedState?.versionId === versionId ? selectedState : null;
9910
+ if (state?.loading) return 'Wait for this version preview to finish building.';
9911
+ return state?.error || shapeVersionPreviewErrorsByKey[`${widgetId}:${versionId}`] || null;
9912
+ }
9913
+ function resolveWidgetShapeVersionPreviewState(widgetId: string) {
9914
+ const state = shapeVersionPreviewByWidgetId[widgetId];
9915
+ return state?.versionId === shapePreviewSourceByWidgetId[widgetId] ? state : null;
9916
+ }
9714
9917
  let shapePublishHistoryByWidgetId: Record<string, any[]> = {};
9715
9918
  let shapeHistoryLoadInFlight: Record<string, boolean> = {};
9919
+ /**
9920
+ * Widgets whose shape publish history has already been fetched — *including*
9921
+ * widgets that came back with zero rows.
9922
+ *
9923
+ * Without this, an empty result is indistinguishable from "not fetched yet"
9924
+ * (see the `cached.length > 0` check below), so a widget that is published
9925
+ * (`js_path`/`version` set) but has no `widget_publish_history` rows re-issued
9926
+ * the request on every render. That thrashed the network and kept remounting
9927
+ * the live preview — the canvas looked like it constantly refreshed.
9928
+ */
9929
+ let shapeHistoryResolvedByWidgetId: Record<string, boolean> = {};
9716
9930
 
9717
9931
  async function ensureShapePublishHistory(widgetId: string): Promise<void> {
9718
9932
  if (!widgetId || shapeHistoryLoadInFlight[widgetId]) return;
9933
+ // Already fetched once — an empty history is a valid, final answer.
9934
+ if (shapeHistoryResolvedByWidgetId[widgetId]) return;
9719
9935
  const cached = shapePublishHistoryByWidgetId[widgetId];
9720
- if (cached && cached.length > 0) return;
9936
+ if (cached && cached.length > 0) {
9937
+ shapeHistoryResolvedByWidgetId = { ...shapeHistoryResolvedByWidgetId, [widgetId]: true };
9938
+ return;
9939
+ }
9721
9940
  const fromPanel = getPanelDisplayState(widgetId).publishHistory;
9722
9941
  if (fromPanel && fromPanel.length > 0) {
9723
9942
  shapePublishHistoryByWidgetId = {
9724
9943
  ...shapePublishHistoryByWidgetId,
9725
9944
  [widgetId]: [...fromPanel],
9726
9945
  };
9946
+ shapeHistoryResolvedByWidgetId = { ...shapeHistoryResolvedByWidgetId, [widgetId]: true };
9727
9947
  return;
9728
9948
  }
9729
9949
  shapeHistoryLoadInFlight = { ...shapeHistoryLoadInFlight, [widgetId]: true };
@@ -9734,6 +9954,7 @@ Requirements:
9734
9954
  ...shapePublishHistoryByWidgetId,
9735
9955
  [widgetId]: [...history],
9736
9956
  };
9957
+ shapeHistoryResolvedByWidgetId = { ...shapeHistoryResolvedByWidgetId, [widgetId]: true };
9737
9958
  syncWidgetShapeLivePreviews();
9738
9959
  } finally {
9739
9960
  const { [widgetId]: _, ...rest } = shapeHistoryLoadInFlight;
@@ -9743,7 +9964,8 @@ Requirements:
9743
9964
 
9744
9965
  function getShapePublishHistory(widgetId: string): any[] {
9745
9966
  const cached = shapePublishHistoryByWidgetId[widgetId];
9746
- if (cached?.length) return cached;
9967
+ // Honour a cached empty array: it means the fetch already happened.
9968
+ if (cached) return cached;
9747
9969
  const fromPanel = getPanelDisplayState(widgetId).publishHistory;
9748
9970
  return fromPanel?.length ? fromPanel : [];
9749
9971
  }
@@ -9763,13 +9985,21 @@ Requirements:
9763
9985
  const record = getShapePublishHistory(widgetId).find(
9764
9986
  (row) => Number(row.version) === version,
9765
9987
  );
9988
+ // Published versions use their stable artifact; drafts require an
9989
+ // explicit commit-specific build. Never substitute shared HEAD.
9990
+ const isPublished = !!(record?.publishedAt ?? record?.published_at);
9766
9991
  const artifact =
9767
9992
  record?.artifactUrl ||
9768
9993
  record?.artifact_url ||
9769
9994
  (version === ps.lastPublishedVersion ? ps.selectedWidgetJsPath : null);
9770
- const url = resolvePublishedWidgetUrl(widgetId, version, artifact ?? null);
9771
- if (url) return url;
9995
+
9996
+ if (isPublished && artifact) {
9997
+ const url = resolvePublishedWidgetUrl(widgetId, version, artifact);
9998
+ if (url) return url;
9999
+ }
10000
+ return resolveWidgetShapeVersionPreviewState(widgetId)?.url ?? null;
9772
10001
  }
10002
+ return null;
9773
10003
  }
9774
10004
 
9775
10005
  // After codegen (unpublished), prefer Worker preview over the Live CDN artifact.
@@ -9788,7 +10018,8 @@ Requirements:
9788
10018
  const liveVersion =
9789
10019
  ps.lastPublishedVersion ?? resolvePublishedVersionFromWidget(getWidgetById(widgetId));
9790
10020
  if (source.startsWith('v:')) {
9791
- const version = source.slice(2);
10021
+ const version = parseInt(source.slice(2), 10);
10022
+ if (!Number.isFinite(version)) return null;
9792
10023
  return `v${version}`;
9793
10024
  }
9794
10025
  return liveVersion != null ? `v${liveVersion}` : null;
@@ -9818,10 +10049,15 @@ Requirements:
9818
10049
  shortLabel?: string;
9819
10050
  dateLabel?: string;
9820
10051
  isLive?: boolean;
10052
+ isDraft?: boolean;
10053
+ /** Immutable commit; Restore Checkpoint reuses this artifact when it matches. */
10054
+ commitSha?: string;
9821
10055
  }> | null {
9822
10056
  const ps = getPanelDisplayState(widgetId);
9823
10057
  const liveVersion =
9824
10058
  ps.lastPublishedVersion ?? resolvePublishedVersionFromWidget(getWidgetById(widgetId));
10059
+ // Newest first. Versions are one ascending sequence, so the number orders
10060
+ // them correctly on its own; sorting by time is a stable tiebreak.
9825
10061
  const history = [...getShapePublishHistory(widgetId)].sort(
9826
10062
  (a, b) => Number(b.version) - Number(a.version),
9827
10063
  );
@@ -9831,16 +10067,24 @@ Requirements:
9831
10067
  shortLabel?: string;
9832
10068
  dateLabel?: string;
9833
10069
  isLive?: boolean;
10070
+ isDraft?: boolean;
9834
10071
  }> = [];
9835
10072
  for (const record of history) {
9836
10073
  const version = Number(record.version);
9837
10074
  if (!Number.isFinite(version)) continue;
10075
+ // One version sequence: a version is a Draft until it is published.
10076
+ // `published_at` is the discriminator, so there is no second numbering
10077
+ // scheme and no `gN` prefix — the history reads as plain `v1, v2, v3…`.
10078
+ const isPublished = !!(record.publishedAt ?? record.published_at);
9838
10079
  options.push({
9839
10080
  id: `v:${version}`,
9840
10081
  label: `Version ${version}`,
9841
10082
  shortLabel: `v${version}`,
9842
10083
  dateLabel: formatPublishHistoryDate(record),
9843
- isLive: version === liveVersion,
10084
+ // Only a published version can be what visitors see.
10085
+ isLive: isPublished && version === liveVersion,
10086
+ isDraft: !isPublished,
10087
+ commitSha: record.commitSha ?? record.commit_sha,
9844
10088
  });
9845
10089
  }
9846
10090
  if (options.length === 0 && liveVersion != null) {
@@ -9852,8 +10096,10 @@ Requirements:
9852
10096
  isLive: true,
9853
10097
  });
9854
10098
  }
9855
- // Kick off history fetch so the dropdown fills even before chat opens
9856
- if (history.length === 0 && liveVersion != null) {
10099
+ // Kick off history fetch so the dropdown fills even before chat opens.
10100
+ // `ensureShapePublishHistory` is idempotent per widget (it records resolved
10101
+ // widgets, including empty results), so calling it from render is safe.
10102
+ if (history.length === 0) {
9857
10103
  void ensureShapePublishHistory(widgetId);
9858
10104
  }
9859
10105
  return options.length > 0 ? options : null;
@@ -9868,15 +10114,102 @@ Requirements:
9868
10114
  return liveVersion != null ? `v:${liveVersion}` : null;
9869
10115
  }
9870
10116
 
9871
- function handleWidgetShapeVersionChange(widgetId: string, versionId: string): void {
10117
+ function handleWidgetShapeVersionChange(widgetId: string, versionId: string, buildPreview = true): void {
9872
10118
  if (!versionId) return;
10119
+ // Re-selecting an already successful/in-flight preview must not reset its state.
10120
+ const currentPreview = resolveWidgetShapeVersionPreviewState(widgetId);
10121
+ if (shapePreviewSourceByWidgetId[widgetId] === versionId
10122
+ && (currentPreview?.loading || currentPreview?.url)) return;
9873
10123
  shapePreviewSourceByWidgetId = {
9874
10124
  ...shapePreviewSourceByWidgetId,
9875
10125
  [widgetId]: versionId,
9876
10126
  };
10127
+ shapeVersionPreviewRequestId += 1;
10128
+ shapeVersionPreviewByWidgetId = {
10129
+ ...shapeVersionPreviewByWidgetId,
10130
+ [widgetId]: {
10131
+ versionId, requestId: shapeVersionPreviewRequestId,
10132
+ loading: false, error: null, url: null,
10133
+ },
10134
+ };
9877
10135
  syncWidgetShapeLivePreviews();
9878
10136
  panelDisplayRevision += 1;
9879
- console.log('[WidgetShape] Preview source →', versionId, widgetId.substring(0, 8));
10137
+ // Explicit Publish builds its own target; do not race it with a preview build.
10138
+ if (!buildPreview) return;
10139
+ // Published artifacts need no build. Drafts must never substitute HEAD.
10140
+ if (versionId.startsWith('v:')) {
10141
+ const version = Number(versionId.slice(2));
10142
+ if (resolveWidgetShapeLivePreviewUrl(widgetId)) {
10143
+ const record = getShapePublishHistory(widgetId).find((row) => Number(row.version) === version);
10144
+ console.log('[VersionPreview] Selection', { widgetId, version, commitSha: record?.commitSha ?? record?.commit_sha ?? null, source: 'published' });
10145
+ } else {
10146
+ void ensureWidgetShapeVersionPreview(widgetId, version);
10147
+ }
10148
+ }
10149
+ }
10150
+
10151
+ /** Explicit retry retains the selected version and its remembered publish block. */
10152
+ async function retryWidgetShapeVersionPreview(widgetId) {
10153
+ const state = resolveWidgetShapeVersionPreviewState(widgetId);
10154
+ if (!state?.error || state.loading || !state.versionId.startsWith('v:')) return;
10155
+ await ensureWidgetShapeVersionPreview(widgetId, Number(state.versionId.slice(2)), true);
10156
+ }
10157
+
10158
+ async function ensureWidgetShapeVersionPreview(widgetId: string, version: number, retry = false): Promise<void> {
10159
+ if (!widgetId || !Number.isSafeInteger(version) || version < 1) return;
10160
+ const versionId = `v:${version}`;
10161
+ if (shapePreviewSourceByWidgetId[widgetId] !== versionId) return;
10162
+ const previous = shapeVersionPreviewByWidgetId[widgetId];
10163
+ if (previous?.versionId === versionId && (previous.loading || (!retry && (previous.url || previous.error)))) return;
10164
+ const requestId = ++shapeVersionPreviewRequestId;
10165
+ shapeVersionPreviewByWidgetId = {
10166
+ ...shapeVersionPreviewByWidgetId,
10167
+ [widgetId]: { versionId, requestId, loading: true, url: null,
10168
+ error: retry ? previous?.error ?? null : null,
10169
+ errorDetails: retry ? previous?.errorDetails ?? null : null },
10170
+ };
10171
+ syncWidgetShapeLivePreviews();
10172
+ panelDisplayRevision += 1;
10173
+ try {
10174
+ const response = await agentClient.previewWidgetVersion({ widgetId, version });
10175
+ const previewUrl = response?.data?.previewUrl ?? null;
10176
+ const commitSha = response?.data?.commitSha ?? null;
10177
+ // Drop stale responses: the selector may have moved on (or away) meanwhile.
10178
+ if (
10179
+ shapePreviewSourceByWidgetId[widgetId] !== versionId
10180
+ || shapeVersionPreviewByWidgetId[widgetId]?.requestId !== requestId
10181
+ ) {
10182
+ return;
10183
+ }
10184
+ if (!previewUrl) {
10185
+ throw new Error('Preview URL missing from response');
10186
+ }
10187
+ shapeVersionPreviewByWidgetId = {
10188
+ ...shapeVersionPreviewByWidgetId,
10189
+ [widgetId]: { versionId, requestId, loading: false, error: null, url: previewUrl },
10190
+ };
10191
+ syncWidgetShapeLivePreviews();
10192
+ panelDisplayRevision += 1;
10193
+ shapeVersionPreviewErrorsByKey = { ...shapeVersionPreviewErrorsByKey, [`${widgetId}:${versionId}`]: null };
10194
+ console.log('[VersionPreview] Selection', { widgetId, version, commitSha });
10195
+ } catch (error: unknown) {
10196
+ if (
10197
+ shapePreviewSourceByWidgetId[widgetId] !== versionId
10198
+ || shapeVersionPreviewByWidgetId[widgetId]?.requestId !== requestId
10199
+ ) return;
10200
+ const errorDetails = await parseVersionPreviewError(error);
10201
+ if (shapePreviewSourceByWidgetId[widgetId] !== versionId
10202
+ || shapeVersionPreviewByWidgetId[widgetId]?.requestId !== requestId) return;
10203
+ shapeVersionPreviewErrorsByKey = { ...shapeVersionPreviewErrorsByKey, [`${widgetId}:${versionId}`]: errorDetails.message };
10204
+ // One diagnostic per failed request; raw server wording never reaches the overlay.
10205
+ console.warn('[VersionPreview] Selection', { widgetId, version, ...errorDetails });
10206
+ shapeVersionPreviewByWidgetId = {
10207
+ ...shapeVersionPreviewByWidgetId,
10208
+ [widgetId]: { versionId, requestId, loading: false, error: errorDetails.message, errorDetails, url: null },
10209
+ };
10210
+ syncWidgetShapeLivePreviews();
10211
+ panelDisplayRevision += 1;
10212
+ }
9880
10213
  }
9881
10214
 
9882
10215
  /** Commit SHA for the version currently selected in the version menu (drives Head-at-commit badge). */
@@ -10868,6 +11201,14 @@ Requirements:
10868
11201
  function hasUnpublishedChangesForPanel(widgetId: string): boolean {
10869
11202
  const ps = getPanelDisplayState(widgetId);
10870
11203
  if (ps.pendingPublishAfterCodegen) return true;
11204
+
11205
+ // Never published but code exists: this is the first publishable version.
11206
+ // Without this the panel showed a "Draft" badge that could not be acted on —
11207
+ // there was no way to promote the first generation to Live.
11208
+ if (ps.lastPublishedVersion === null) {
11209
+ return panelHasPublishableCode(widgetId, ps);
11210
+ }
11211
+
10871
11212
  const pendingSha = loadPendingPublishShaForWidget(widgetId);
10872
11213
  const headSha = resolvePublishHeadCommitId(widgetId, ps);
10873
11214
  if (!headSha || ps.lastPublishedVersion === null) return false;
@@ -10959,7 +11300,7 @@ Requirements:
10959
11300
  * Single source of truth for Publish button label / disabled / toast reason.
10960
11301
  * Prevents "Publish new vN" while click toasts "already live".
10961
11302
  */
10962
- function getPublishGateForPanel(widgetId: string): {
11303
+ function getPublishGateForPanel(widgetId: string, targetVersion = null): {
10963
11304
  disabled: boolean;
10964
11305
  label: string;
10965
11306
  reason: string | null;
@@ -11024,6 +11365,27 @@ Requirements:
11024
11365
  showDisabledHint: true,
11025
11366
  };
11026
11367
  }
11368
+ const previewBlockReason = getVersionPreviewPublishBlockReason(widgetId, targetVersion);
11369
+ if (previewBlockReason) {
11370
+ return { disabled: true, label: 'Publish', reason: previewBlockReason, showDisabledHint: true };
11371
+ }
11372
+ // An explicit Draft owns its commit: HEAD being Live must not block it.
11373
+ // Keep all shared auth/canvas/repository/concurrency checks above this branch.
11374
+ if (targetVersion !== null) {
11375
+ const record = getShapePublishHistory(widgetId).find(
11376
+ (row) => Number(row.version) === Number(targetVersion),
11377
+ );
11378
+ const validVersion = targetVersion !== '' && Number.isInteger(Number(targetVersion)) && Number(targetVersion) > 0;
11379
+ const reason = !validVersion || !record
11380
+ ? 'The selected draft version was not found.'
11381
+ : (record.publishedAt ?? record.published_at)
11382
+ ? 'This version is already published.'
11383
+ : !(record.commitSha ?? record.commit_sha)
11384
+ ? 'This draft has no recorded commit to publish.'
11385
+ : null;
11386
+ return { disabled: !!reason, label: 'Publish', reason, showDisabledHint: !!reason };
11387
+ }
11388
+
11027
11389
  if (!hasCode) {
11028
11390
  return {
11029
11391
  disabled: true,
@@ -11111,6 +11473,31 @@ Requirements:
11111
11473
  return 'No new version to publish';
11112
11474
  }
11113
11475
 
11476
+ /**
11477
+ * Whether a specific Draft can be published.
11478
+ *
11479
+ * The main publish gate answers "is there newer code than Live?", which is
11480
+ * about HEAD. Selecting an older Draft in the version list is a different
11481
+ * question — that row has its own commit and can be promoted regardless of
11482
+ * where HEAD sits. Drafts with no recorded commit (a row that predates commit
11483
+ * tracking) cannot be rebuilt, so they are excluded rather than published
11484
+ * from whatever HEAD happens to contain.
11485
+ */
11486
+ function canPublishDraftVersionForPanel(widgetId: string, version: number | null | undefined): boolean {
11487
+ if (version == null || version === '' || !Number.isFinite(Number(version))) return false;
11488
+ if (isPublishing && publishingWidgetId === widgetId) return false;
11489
+ if (isPublishing && publishingWidgetId !== null && publishingWidgetId !== widgetId) return false;
11490
+ if (generatingWidgetIds[widgetId]) return false;
11491
+ if (!widgetId || !getEffectiveCanvasId()) return false;
11492
+ if (!hasValidToken || !$userSession?.user) return false;
11493
+ const record = getPanelDisplayState(widgetId).publishHistory.find(
11494
+ (row) => Number(row.version) === Number(version),
11495
+ );
11496
+ if (!record) return false;
11497
+ if (record.publishedAt ?? record.published_at) return false;
11498
+ return !!(record.commitSha ?? record.commit_sha);
11499
+ }
11500
+
11114
11501
  function isPublishDisabledForPanel(widgetId: string): boolean {
11115
11502
  return getPublishGateForPanel(widgetId).disabled;
11116
11503
  }
@@ -11145,7 +11532,12 @@ Requirements:
11145
11532
  );
11146
11533
  }
11147
11534
 
11148
- function runPublishForPanel(panelWidgetId: string): void {
11535
+ /**
11536
+ * Publish for a panel. Pass `targetVersion` to publish a specific Draft the
11537
+ * user selected in the version list (built from that version's own commit);
11538
+ * omit it to publish the newest generated code.
11539
+ */
11540
+ function runPublishForPanel(panelWidgetId: string, targetVersion?: number | null): void {
11149
11541
  void (async () => {
11150
11542
  if (isPublishing && !publishingWidgetId) {
11151
11543
  console.warn('[Publish] Clearing stuck global isPublishing flag');
@@ -11158,7 +11550,7 @@ Requirements:
11158
11550
 
11159
11551
  // Gate BEFORE focus/tick — focusing can reconcile HEAD/live SHA mid-click and
11160
11552
  // used to toast "already live" after the button still said "Publish new vN".
11161
- const gateBefore = getPublishGateForPanel(panelWidgetId);
11553
+ const gateBefore = getPublishGateForPanel(panelWidgetId, targetVersion ?? null);
11162
11554
  if (gateBefore.disabled) {
11163
11555
  if (gateBefore.reason) {
11164
11556
  showToast('info', 'Cannot publish yet', { description: gateBefore.reason, duration: 5000 });
@@ -11171,7 +11563,7 @@ Requirements:
11171
11563
  await tick();
11172
11564
  }
11173
11565
 
11174
- const gateAfter = getPublishGateForPanel(panelWidgetId);
11566
+ const gateAfter = getPublishGateForPanel(panelWidgetId, targetVersion ?? null);
11175
11567
  if (gateAfter.disabled) {
11176
11568
  if (gateAfter.reason) {
11177
11569
  showToast('info', 'Cannot publish yet', { description: gateAfter.reason, duration: 5000 });
@@ -11179,7 +11571,7 @@ Requirements:
11179
11571
  return;
11180
11572
  }
11181
11573
 
11182
- await executePublishWidget(panelWidgetId);
11574
+ await executePublishWidget(panelWidgetId, { targetVersion: targetVersion ?? null });
11183
11575
  })();
11184
11576
  }
11185
11577
 
@@ -11321,6 +11713,9 @@ async function loadPublishHistoryForPanel(widgetId: string, opts: { silent?: boo
11321
11713
  ...shapePublishHistoryByWidgetId,
11322
11714
  [widgetId]: [...history],
11323
11715
  };
11716
+ // Mark resolved even when empty — an empty history is a final answer and
11717
+ // must stop `ensureShapePublishHistory` from re-fetching on every render.
11718
+ shapeHistoryResolvedByWidgetId = { ...shapeHistoryResolvedByWidgetId, [widgetId]: true };
11324
11719
  if (widgetId === focusedPanelWidgetId) {
11325
11720
  publishHistory = history;
11326
11721
  publishHistoryResolved = true;
@@ -11360,11 +11755,26 @@ async function loadPublishHistory(opts: { silent?: boolean } = {}) {
11360
11755
  }
11361
11756
 
11362
11757
  /**
11363
- * Sets a historical version as the current live version without rebuilding.
11364
- * Updates widgets.js_path to point to the older version's artifact URL.
11758
+ * Sets an already-published version as the current live version without rebuilding.
11759
+ * Updates widgets.js_path to point to that version's artifact URL.
11760
+ *
11761
+ * Only published versions can be promoted — a Draft has never been built to a
11762
+ * stable artifact. Publishing a Draft goes through the normal Publish flow, which
11763
+ * is why the UI never offers "Set live" for one.
11365
11764
  */
11366
11765
  async function setLiveVersionForWidget(widgetId: string, version: number) {
11367
11766
  if (!widgetId || !$userSession?.jwt) return;
11767
+ if (!Number.isFinite(version)) return;
11768
+
11769
+ const record = getPanelDisplayState(widgetId).publishHistory.find(
11770
+ (row) => Number(row.version) === version,
11771
+ );
11772
+ if (record && !(record.publishedAt ?? record.published_at)) {
11773
+ showToast('info', 'This is a draft version', {
11774
+ description: 'Press Publish to make the latest generated code live.',
11775
+ });
11776
+ return;
11777
+ }
11368
11778
 
11369
11779
  isSettingLiveVersion = version;
11370
11780
  if (!widgetsClient) {
@@ -11419,12 +11829,17 @@ async function setLiveVersion(version: number) {
11419
11829
  await setLiveVersionForWidget(selectedWidgetId, version);
11420
11830
  }
11421
11831
 
11422
- /** Toggle publish history panel — reloads data on every open */
11832
+ /** Toggle publish history panel — reloads data on every open.
11833
+ * Right-aligns the dropdown with the whole header cluster (vN trigger +
11834
+ * LIVE/PUBLISH badge), not just the trigger button, per UX request. */
11423
11835
  function togglePublishHistory(triggerEl?: HTMLElement | null) {
11424
11836
  isHistoryOpen = !isHistoryOpen;
11425
11837
  if (isHistoryOpen) {
11426
11838
  if (triggerEl) {
11427
- const rect = triggerEl.getBoundingClientRect();
11839
+ // The vN trigger sits left of the LIVE/PUBLISH badge inside the same
11840
+ // header cluster; align the panel to that cluster's right edge.
11841
+ const cluster = triggerEl.closest('.wc-version-history-ct') as HTMLElement | null;
11842
+ const rect = (cluster ?? triggerEl).getBoundingClientRect();
11428
11843
  liveHistoryPanelTop = Math.round(rect.bottom + 4);
11429
11844
  liveHistoryPanelRight = Math.round(window.innerWidth - rect.right);
11430
11845
  }
@@ -11911,6 +12326,13 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
11911
12326
  let lastWidgetShapeClickId = '';
11912
12327
  const WIDGET_SHAPE_CLICK_DEBOUNCE_MS = 450;
11913
12328
 
12329
+ /**
12330
+ * Widgets whose live-iframe thumbnail capture is currently running.
12331
+ * Prevents overlapping captures when several generation/refresh events fire
12332
+ * close together for the same widget.
12333
+ */
12334
+ const shapeThumbnailCaptureInFlight = new Set<string>();
12335
+
11914
12336
  /**
11915
12337
  * Called when a Widget Image shape is clicked on the canvas.
11916
12338
  * Selects the linked widget and opens WidgetDetails.
@@ -12549,6 +12971,14 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
12549
12971
  */
12550
12972
  let autosaveSequence = 0;
12551
12973
  let lastResolvedAutosaveSequence = 0;
12974
+ /**
12975
+ * Trailing save deferred while `isLoadCooldownActive` is set.
12976
+ * See {@link handleCanvasAutoSave}: dropping cooldown saves lost user edits.
12977
+ */
12978
+ let pendingCooldownAutosaveJson: string | null = null;
12979
+ let cooldownAutosaveTimer: ReturnType<typeof setTimeout> | null = null;
12980
+ /** How long after a canvas load we still defer (not drop) autosaves. */
12981
+ const CANVAS_COOLDOWN_AUTOSAVE_DELAY_MS = 3000;
12552
12982
 
12553
12983
  async function handleCanvasAutoSave(
12554
12984
  json: string | null,
@@ -12571,8 +13001,21 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
12571
13001
  }
12572
13002
 
12573
13003
  // Guard: skip autosaves triggered by loadCanvas() object:added events
13004
+ //
13005
+ // Dropping the save outright loses any edit the user made during the cooldown
13006
+ // (canvas hydration, shape restore, and `loadCanvas`'s object:added events all
13007
+ // run inside it). Defer one trailing save instead: the load burst still cannot
13008
+ // overwrite freshly painted state, but a real move/resize a moment later is
13009
+ // persisted rather than silently discarded.
12574
13010
  if (!force && isLoadCooldownActive && !isUnmountSave) {
12575
- // console.log('[CanvasDebug] Skipping autosave — load cooldown active (prevents overwriting freshly loaded data)');
13011
+ pendingCooldownAutosaveJson = json;
13012
+ if (cooldownAutosaveTimer) clearTimeout(cooldownAutosaveTimer);
13013
+ cooldownAutosaveTimer = setTimeout(() => {
13014
+ cooldownAutosaveTimer = null;
13015
+ const deferred = pendingCooldownAutosaveJson;
13016
+ pendingCooldownAutosaveJson = null;
13017
+ if (deferred) void handleCanvasAutoSave(deferred);
13018
+ }, CANVAS_COOLDOWN_AUTOSAVE_DELAY_MS);
12576
13019
  return;
12577
13020
  }
12578
13021
 
@@ -13578,6 +14021,7 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
13578
14021
  <svelte:component this={WidgeticCanvas}
13579
14022
  bind:this={canvasRef}
13580
14023
  showContainerFrame={!embedded}
14024
+ widgetSelectionLayerTarget="floating-panels-overlay"
13581
14025
  bind:wireframeMode={canvasWireframeMode}
13582
14026
  advancedFeatures={canvasAdvancedFeatures}
13583
14027
  onReady={handleCanvasReady}
@@ -13628,6 +14072,12 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
13628
14072
  bind:this={widgetShapeLivePreviewsRef}
13629
14073
  getRects={() => canvasRef?.listWidgetShapeScreenRects?.() ?? []}
13630
14074
  resolveUrl={resolveWidgetShapeLivePreviewUrl}
14075
+ resolvePreviewState={resolveWidgetShapeVersionPreviewState}
14076
+ onRetryPreview={retryWidgetShapeVersionPreview}
14077
+ primaryWidgetId={canvasPrimaryWidgetId}
14078
+ onSetPrimary={(widgetId) => handleHostSetPrimaryWidgetEvent(new CustomEvent(HOST_SET_PRIMARY_WIDGET_EVENT, { detail: { widgetId } }))}
14079
+ resolvePublishDisabled={(widgetId, version) => getPublishGateForPanel(widgetId, version ?? null).disabled}
14080
+ resolvePublishDisabledReason={(widgetId, version) => getPublishGateForPanel(widgetId, version ?? null).reason}
13631
14081
  resolveTitle={getWidgetNameById}
13632
14082
  titleRevision={widgetNamesRevision + panelDisplayRevision}
13633
14083
  resolveVersionLabel={resolveWidgetShapeVersionLabel}
@@ -13639,8 +14089,10 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
13639
14089
  }}
13640
14090
  settingLiveVersion={isSettingLiveVersion}
13641
14091
  resolveCanPublish={(widgetId) => hasUnpublishedChangesForPanel(widgetId)}
14092
+ resolveCanPublishDraft={(widgetId, version) => canPublishDraftVersionForPanel(widgetId, version)}
13642
14093
  resolveIsPublishing={(widgetId) => isPublishing && publishingWidgetId === widgetId}
13643
14094
  onPublish={(widgetId) => runPublishForPanel(widgetId)}
14095
+ onPublishDraft={(widgetId, version) => runPublishForPanel(widgetId, version)}
13644
14096
  selectedWidgetId={canvasSelectedWidgetShapeId}
13645
14097
  onTranslate={(widgetId, dx, dy) => {
13646
14098
  canvasRef?.translateWidgetShapeByScreen?.(widgetId, dx, dy);
@@ -13656,9 +14108,7 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
13656
14108
  onOpenChat={(widgetId) => {
13657
14109
  void openWidgetDetailsPanel(widgetId);
13658
14110
  }}
13659
- onRefresh={(widgetId) => {
13660
- console.log('[WidgetShape] Iframe refreshed', widgetId.substring(0, 8));
13661
- }}
14111
+ onRefresh={handleWidgetShapeRefresh}
13662
14112
  />
13663
14113
 
13664
14114
  <!-- ═══ PANEL 2: Widgets Panel — debug + showWidgetsList (site passes false) ═══ -->
@@ -13952,6 +14402,16 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
13952
14402
  {@const panelWidget = widgetsById.get(panelWidgetId) ?? null}
13953
14403
  {@const isPanelFocused = focusedPanelWidgetId === panelWidgetId}
13954
14404
  {@const ps = (panelDisplayRevision, getPanelDisplayState(panelWidgetId))}
14405
+ <!--
14406
+ Same published-version resolution the WidgetDetails instance below gets.
14407
+ `ps.lastPublishedVersion` alone is only hydrated from the focused panel /
14408
+ DB snapshot; a widget whose summary carries `jsPath` (/v<N>/) or `version`
14409
+ can be published while `ps.lastPublishedVersion` is still null. The header
14410
+ version menu must not disagree with the panel's own Publish/Live state.
14411
+ -->
14412
+ {@const panelPublishedVersion = isDraftConvertId(panelWidgetId)
14413
+ ? null
14414
+ : (ps.lastPublishedVersion ?? resolvePublishedVersionFromWidget(panelWidget ?? null))}
13955
14415
  {@const panelHasUnpublishedChanges = (panelDisplayRevision, widgets.length, hasUnpublishedChangesForPanel(panelWidgetId))}
13956
14416
  {@const panelHasCompileErrorState = (panelDisplayRevision, panelHasCompileError(panelWidgetId))}
13957
14417
  {@const panelHasInfraPreviewError = (panelDisplayRevision, panelHasInfrastructurePreviewError(panelWidgetId))}
@@ -14272,14 +14732,37 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14272
14732
  {/if}
14273
14733
  </svelte:fragment>
14274
14734
  <svelte:fragment slot="version-history">
14275
- {#if ps.lastPublishedVersion !== null}
14276
- {@const shapeSource = shapePreviewSourceByWidgetId[panelWidgetId] || `v:${ps.lastPublishedVersion}`}
14277
- {@const viewingVersion = shapeSource.startsWith('v:')
14735
+ <!--
14736
+ A widget that has only ever been generated (never published) still
14737
+ has versions to preview — the history holds them as Drafts. Gating
14738
+ purely on `panelPublishedVersion` hid the picker for exactly those
14739
+ widgets, which is why the dropdown looked missing.
14740
+ -->
14741
+ {#if panelPublishedVersion !== null || ps.publishHistory.length > 0}
14742
+ {@const newestDraft = ps.publishHistory.find(
14743
+ (row) => !(row.publishedAt ?? row.published_at),
14744
+ )}
14745
+ {@const defaultVersionSource = panelPublishedVersion !== null
14746
+ ? `v:${panelPublishedVersion}`
14747
+ : (newestDraft ? `v:${newestDraft.version}` : null)}
14748
+ {@const shapeSource = shapePreviewSourceByWidgetId[panelWidgetId] || defaultVersionSource}
14749
+ {@const viewingVersion = shapeSource && shapeSource.startsWith('v:')
14278
14750
  ? parseInt(shapeSource.slice(2), 10)
14279
- : ps.lastPublishedVersion}
14280
- {@const viewingIsLive = Number(viewingVersion) === Number(ps.lastPublishedVersion)}
14751
+ : panelPublishedVersion}
14752
+ {@const viewingIsLive = Number(viewingVersion) === Number(panelPublishedVersion)}
14753
+ <!--
14754
+ A version is a Draft until it is published. The history is one
14755
+ sequence (`v1, v2, v3…`), so "draft" is read from the record's
14756
+ `publishedAt`, not from any second numbering scheme.
14757
+ -->
14758
+ {@const viewingRecord = ps.publishHistory.find(
14759
+ (row) => Number(row.version) === Number(viewingVersion),
14760
+ )}
14761
+ {@const viewingIsDraft = viewingRecord
14762
+ ? !(viewingRecord.publishedAt ?? viewingRecord.published_at)
14763
+ : false}
14281
14764
  <div class="wc-version-history-ct relative shrink-0 flex items-center gap-0.5">
14282
- <HoverTooltip text="Live is the version visitors see on your website. Open the list to preview another version, or Set live to publish it.">
14765
+ <HoverTooltip text="Switch version" maxWidth={120}>
14283
14766
  <button
14284
14767
  type="button"
14285
14768
  onclick={(e) => {
@@ -14289,11 +14772,11 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14289
14772
  onmousedown={(e) => e.stopPropagation()}
14290
14773
  class="wc-version-history-toggle flex items-center gap-0.5 text-[10px] font-semibold tabular-nums px-1.5 py-0.5 rounded border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 cursor-pointer whitespace-nowrap"
14291
14774
  >
14292
- v{Number.isFinite(viewingVersion) ? viewingVersion : ps.lastPublishedVersion}
14775
+ {`v${Number.isFinite(viewingVersion) ? viewingVersion : panelPublishedVersion}`}
14293
14776
  <span class="text-emerald-500">{isHistoryOpen && isPanelFocused ? '▲' : '▼'}</span>
14294
14777
  </button>
14295
14778
  </HoverTooltip>
14296
- {#if panelHasUnpublishedChanges}
14779
+ {#if panelHasUnpublishedChanges && !Number.isFinite(viewingVersion)}
14297
14780
  <button
14298
14781
  type="button"
14299
14782
  onclick={(e) => {
@@ -14312,6 +14795,24 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14312
14795
  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"
14313
14796
  title="Live — generate new code to Publish a new version"
14314
14797
  >Live</span>
14798
+ {:else if viewingIsDraft}
14799
+ {#if viewingIsDraft}
14800
+ <button
14801
+ type="button"
14802
+ onclick={(e) => {
14803
+ e.stopPropagation();
14804
+ if (!isPanelFocused) focusWidgetPanel(panelWidgetId);
14805
+ if (getPublishGateForPanel(panelWidgetId, Number(viewingVersion)).disabled) return;
14806
+ handleWidgetShapeVersionChange(panelWidgetId, `v:${viewingVersion}`, false);
14807
+ runPublishForPanel(panelWidgetId, Number(viewingVersion));
14808
+ }}
14809
+ disabled={!canPublishDraftVersionForPanel(panelWidgetId, viewingVersion) || getPublishGateForPanel(panelWidgetId, Number(viewingVersion)).disabled}
14810
+ title="Build this draft version and make it the Live version"
14811
+ 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"
14812
+ >
14813
+ {isPublishing && publishingWidgetId === panelWidgetId ? '...' : 'Publish'}
14814
+ </button>
14815
+ {/if}
14315
14816
  {:else}
14316
14817
  <button
14317
14818
  type="button"
@@ -14338,60 +14839,44 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14338
14839
  onclick={() => { isHistoryOpen = false; }}
14339
14840
  ></button>
14340
14841
  <div
14341
- class="wc-version-history-panel flex flex-col gap-1 max-h-48 overflow-y-auto rounded-md border border-emerald-200 bg-white shadow-lg p-1.5 z-[9999] w-max"
14842
+ 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"
14342
14843
  style="position: fixed; top: {liveHistoryPanelTop}px; right: {liveHistoryPanelRight}px; max-width: min(22rem, calc(100vw - 16px));"
14343
14844
  >
14344
14845
  {#if isLoadingHistory}
14345
14846
  <p class="text-xs text-emerald-500 animate-pulse text-center py-2">Loading history...</p>
14346
14847
  {:else if ps.publishHistory.length === 0}
14347
- <p class="text-xs text-emerald-400 text-center py-2">No history records yet. Publish to start tracking.</p>
14848
+ <p class="text-xs text-emerald-400 text-center py-2">No history records yet. Generate or publish to start tracking.</p>
14348
14849
  {:else}
14349
14850
  {#each ps.publishHistory as record (record.id)}
14350
- {@const rowIsViewing = Number(record.version) === Number(viewingVersion)}
14351
- {@const rowIsLive = Number(record.version) === Number(ps.lastPublishedVersion)}
14352
- <!-- svelte-ignore a11y_no_static_element_interactions -->
14353
- <div
14354
- class="publish-history-row flex items-center gap-2 rounded border px-2 py-1.5 w-max cursor-pointer
14355
- {rowIsViewing ? 'border-emerald-400 bg-emerald-50' : 'border-emerald-100 bg-white hover:bg-emerald-50/60'}"
14356
- role="option"
14357
- aria-selected={rowIsViewing}
14358
- tabindex="0"
14359
- onclick={() => {
14851
+ {@const rowVersion = Number(record.version)}
14852
+ {@const rowIsDraft = !(record.publishedAt ?? record.published_at)}
14853
+ {@const rowIsViewing = rowVersion === Number(viewingVersion)}
14854
+ {@const rowIsLive = !rowIsDraft && rowVersion === Number(panelPublishedVersion)}
14855
+ {@const rowDateLabel = formatPublishHistoryDate(record)}
14856
+ {@const publishGate = getPublishGateForPanel(panelWidgetId, rowVersion)}
14857
+ <VersionDropdownRow
14858
+ versionLabel={'v' + rowVersion}
14859
+ commitSha={record.commitSha ?? record.commit_sha ?? null}
14860
+ showCommit={debugMode}
14861
+ dateLabel={rowDateLabel}
14862
+ isLive={rowIsLive}
14863
+ isDraft={rowIsDraft}
14864
+ selected={rowIsViewing}
14865
+ busy={isPublishing && publishingWidgetId === panelWidgetId}
14866
+ publishDisabled={!canPublishDraftVersionForPanel(panelWidgetId, rowVersion) || publishGate.disabled}
14867
+ publishDisabledReason={publishGate.disabled ? publishGate.reason : null}
14868
+ setLiveDisabled={isSettingLiveVersion === record.version}
14869
+ onselect={() => {
14360
14870
  handleWidgetShapeVersionChange(panelWidgetId, `v:${record.version}`);
14361
14871
  }}
14362
- onkeydown={(e) => {
14363
- if (e.key === 'Enter' || e.key === ' ') {
14364
- e.preventDefault();
14365
- handleWidgetShapeVersionChange(panelWidgetId, `v:${record.version}`);
14366
- }
14872
+ onpublish={() => {
14873
+ handleWidgetShapeVersionChange(panelWidgetId, `v:${rowVersion}`, false);
14874
+ runPublishForPanel(panelWidgetId, rowVersion);
14367
14875
  }}
14368
- >
14369
- <span class="publish-history-version-label text-xs font-mono font-bold text-emerald-700 shrink-0">v{record.version}</span>
14370
- {#if debugMode && (record.commitSha || record.commit_sha)}
14371
- <span class="text-xs font-mono text-slate-400 shrink-0" title={record.commitSha ?? record.commit_sha}>
14372
- {(record.commitSha ?? record.commit_sha).slice(0, 7)}
14373
- </span>
14374
- {/if}
14375
- <span class="wc-live-history-date text-xs text-slate-400 shrink-0 whitespace-nowrap tabular-nums" style="padding-right: 4px;">
14376
- {formatPublishHistoryDate(record)}
14377
- </span>
14378
- {#if !rowIsLive}
14379
- <button
14380
- type="button"
14381
- onclick={(e) => {
14382
- e.stopPropagation();
14383
- void setLiveVersionForWidget(panelWidgetId, record.version);
14384
- }}
14385
- disabled={isSettingLiveVersion === record.version}
14386
- class="shrink-0 inline-flex items-center justify-center min-w-[4.75rem] text-xs 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 transition-colors"
14387
- title="Publish this version so visitors see it on your website"
14388
- >
14389
- {isSettingLiveVersion === record.version ? '...' : 'Set live'}
14390
- </button>
14391
- {:else}
14392
- <span class="shrink-0 inline-flex items-center justify-center min-w-[4.75rem] text-xs px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-600 font-medium">live</span>
14393
- {/if}
14394
- </div>
14876
+ onsetlive={() => {
14877
+ void setLiveVersionForWidget(panelWidgetId, record.version);
14878
+ }}
14879
+ />
14395
14880
  {/each}
14396
14881
  {/if}
14397
14882
  </div>