@widgetic/creator 0.3.59 → 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
@@ -147,18 +148,31 @@
147
148
  const name = (widget.name || '').trim() || 'Untitled Widget';
148
149
  // console.log('[CreatorApp] Notify host widget name:', widget.id, name);
149
150
  onWidgetRenamed?.({ widgetId: widget.id, name });
151
+ // Also broadcast — mount props can be stale across embed remounts.
152
+ if (typeof window !== 'undefined') {
153
+ window.dispatchEvent(
154
+ new CustomEvent('widgetic:creator-primary-renamed', {
155
+ detail: { widgetId: widget.id, name },
156
+ }),
157
+ );
158
+ }
150
159
  }
151
160
 
152
161
  /**
153
162
  * Rewrite the embedded URL so a refresh resumes the current canvas instead of
154
163
  * re-running the new-widget bootstrap (which creates a blank canvas every time).
164
+ *
165
+ * The canvas id goes into the **path** (`/canvas/<canvasId>`), matching the host route.
155
166
  * Uses history.replaceState — no navigation, no host remount.
156
167
  */
157
168
  function syncEmbeddedCanvasUrl(canvasIdToSync: string): void {
158
169
  if (!embedded || !canvasIdToSync) return;
159
170
  try {
160
171
  const url = new URL(window.location.href);
161
- url.searchParams.set('canvas_id', canvasIdToSync);
172
+ url.pathname = `/canvas/${encodeURIComponent(canvasIdToSync)}`;
173
+ url.searchParams.delete('canvas_id');
174
+ url.searchParams.delete('canvas-id');
175
+ url.searchParams.delete('widget_id');
162
176
  url.searchParams.delete('new_widget');
163
177
  window.history.replaceState(window.history.state, '', url.toString());
164
178
  // console.log('[CreatorApp] URL synced to canvas:', canvasIdToSync);
@@ -200,8 +214,11 @@
200
214
  if (selectedWidget?.id === targetId) {
201
215
  selectedWidget = { ...selectedWidget, name: trimmed };
202
216
  }
217
+ widgetNamesRevision += 1;
203
218
  panelDisplayRevision += 1;
204
219
  backfillWidgetShapeMetadata();
220
+ syncWidgetShapeLivePreviews();
221
+ emitCanvasWidgetsList('host-widget-renamed');
205
222
  // console.log('[CreatorApp] Applied host widget rename:', targetId.substring(0, 8), trimmed);
206
223
 
207
224
  if (!widgetsClient) return;
@@ -448,6 +465,8 @@
448
465
  import SelectorDropdown from './components/SelectorDropdown.svelte';
449
466
  import type { DropdownItem } from './components/SelectorDropdown.svelte';
450
467
  import WidgetShapeLivePreviews from './components/WidgetShapeLivePreviews.svelte';
468
+ import HoverTooltip from './components/HoverTooltip.svelte';
469
+ import VersionDropdownRow from './components/VersionDropdownRow.svelte';
451
470
 
452
471
  // WIDGET DETAILS COMPONENT — lazy-loaded to reduce peak build memory
453
472
  let WidgetDetails: any;
@@ -558,6 +577,18 @@
558
577
  // Delete canvas dialog
559
578
  let showDeleteCanvasDialog = false;
560
579
  let deleteCanvasTarget: { id: string; name: string } | null = null;
580
+ /**
581
+ * Widgets the pending canvas deletion would remove, grouped by project.
582
+ * A canvas can hold widgets from more than one project (moving a widget
583
+ * rewrites `project_id` but leaves `canvas_id`), so the dialog states exactly
584
+ * what is about to be lost instead of only reassuring that it is irreversible.
585
+ */
586
+ let deleteCanvasPreview: {
587
+ widgetCount: number;
588
+ groups: Array<{ projectName: string; widgetNames: string[] }>;
589
+ } | null = null;
590
+ let deleteCanvasPreviewLoading = false;
591
+ let deleteCanvasError = '';
561
592
 
562
593
  // Widgets state
563
594
  interface WidgetSummary {
@@ -679,6 +710,21 @@
679
710
  }
680
711
  showToast('success', 'Widget deleted from database');
681
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
+ }
682
728
  console.error('[WidgetCreator] Failed to delete widget from DB:', err);
683
729
  showToast('error', 'Failed to delete widget from database');
684
730
  }
@@ -890,7 +936,10 @@
890
936
  contextType: options?.source === 'preview-screenshot' ? 'browser-ic' : 'widget-codegen',
891
937
  source: options?.source || 'chat-upload',
892
938
  widgetId: persistWidgetId && !isDraftConvertId(persistWidgetId) ? persistWidgetId : undefined,
893
- conversationId: currentChatConversationId || undefined,
939
+ conversationId:
940
+ currentChatConversationId && !isLocalOnlyConversationId(currentChatConversationId)
941
+ ? currentChatConversationId
942
+ : undefined,
894
943
  },
895
944
  { onProgress: options?.onProgress },
896
945
  );
@@ -1287,14 +1336,15 @@
1287
1336
  return dataUrls.slice(0, 1);
1288
1337
  }
1289
1338
 
1290
- async function persistWidgetListThumbnail(widgetId: string, imageDataUrl: string): Promise<void> {
1291
- if (!widgetsClient || !widgetId || !imageDataUrl) return;
1339
+ async function persistWidgetListThumbnail(widgetId: string, imageDataUrl: string, isCurrent = () => true): Promise<void> {
1340
+ if (!widgetsClient || !widgetId || !imageDataUrl || !isCurrent()) return;
1292
1341
  try {
1293
1342
  const compact = imageDataUrl.startsWith('data:image/')
1294
1343
  ? await compressImageDataUrlForCodegen(imageDataUrl, 384, 180000)
1295
1344
  : imageDataUrl;
1345
+ if (!isCurrent()) return;
1296
1346
  const thumbnailUrl = await persistWidgetThumbnail(widgetsClient, widgetId, compact);
1297
- if (thumbnailUrl) {
1347
+ if (thumbnailUrl && isCurrent()) {
1298
1348
  widgets = widgets.map((w) =>
1299
1349
  w.id === widgetId ? { ...w, thumbnailUrl, thumbnail_url: thumbnailUrl } : w
1300
1350
  );
@@ -1485,40 +1535,205 @@
1485
1535
  // console.log('[WidgetCreator] Cleared prompt draft for widget:', widgetId.substring(0, 8));
1486
1536
  }
1487
1537
 
1488
- /** Screenshot: capture this panel's widget preview iframe and return data URL */
1489
- async function handleChatScreenshot(widgetId?: string | null): Promise<string | null> {
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
+ */
1548
+ function resolveLiveWidgetShapeIframe(widgetId: string | null | undefined): HTMLIFrameElement | null {
1490
1549
  const targetId = widgetId || focusedPanelWidgetId || selectedWidgetId;
1491
- const panelRef = getWidgetDetailsRef(targetId);
1492
- const iframe =
1493
- (panelRef?.getPreviewIframe?.() as HTMLIFrameElement | null) ||
1494
- (document.querySelector('iframe[title="Widget preview"]') as HTMLIFrameElement | null);
1495
-
1496
- if (!iframe?.contentWindow) {
1497
- // console.log('[Chat→Screenshot] Preview iframe not ready', {
1498
- // targetId: targetId?.substring(0, 8),
1499
- // hasValidPreview: panelRef?.hasValidPreview?.() ?? false,
1500
- // });
1501
- return null;
1550
+ if (targetId) {
1551
+ const liveById = document.querySelector(
1552
+ `iframe.widget-shape-live-preview-iframe[data-widget-id="${CSS.escape(targetId)}"]`,
1553
+ ) as HTMLIFrameElement | null;
1554
+ return liveById?.contentWindow ? liveById : null;
1555
+ }
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;
1562
+ }
1563
+
1564
+ function isWorkerPreviewUrl(url: string | null | undefined): boolean {
1565
+ if (!url) return false;
1566
+ try {
1567
+ const u = new URL(url, window.location.origin);
1568
+ return /\/preview\/[^/]+/i.test(u.pathname) || u.pathname.includes('/__widget_builder__/');
1569
+ } catch {
1570
+ return /\/preview\//i.test(url);
1502
1571
  }
1572
+ }
1503
1573
 
1504
- // console.log('[Chat→Screenshot] Requesting screenshot from preview iframe', targetId?.substring(0, 8));
1505
- return new Promise<string | null>((resolve) => {
1574
+ function requestIframeScreenshot(
1575
+ iframe: HTMLIFrameElement,
1576
+ timeoutMs = 6000,
1577
+ ): Promise<{ data: string | null; error: string | null }> {
1578
+ return new Promise((resolve) => {
1579
+ if (!iframe.contentWindow) {
1580
+ resolve({ data: null, error: 'no-content-window' });
1581
+ return;
1582
+ }
1506
1583
  const timeout = setTimeout(() => {
1507
1584
  window.removeEventListener('message', handler);
1508
- console.warn('[Chat→Screenshot] Timed out waiting for widgetic:screenshot');
1509
- resolve(null);
1510
- }, 8000);
1585
+ resolve({ data: null, error: 'timeout' });
1586
+ }, timeoutMs);
1511
1587
 
1512
1588
  function handler(event: MessageEvent) {
1513
1589
  if (event.data?.type !== 'widgetic:screenshot') return;
1590
+ // Ignore other widgets and synthetic replies with no source window.
1591
+ if (event.source !== iframe.contentWindow) return;
1514
1592
  clearTimeout(timeout);
1515
1593
  window.removeEventListener('message', handler);
1516
- resolve(event.data.data || null);
1594
+ resolve({
1595
+ data: typeof event.data.data === 'string' ? event.data.data : null,
1596
+ error: typeof event.data.error === 'string' ? event.data.error : null,
1597
+ });
1517
1598
  }
1518
1599
  window.addEventListener('message', handler);
1519
- iframe.contentWindow!.postMessage({ type: 'widgetic:request-screenshot' }, '*');
1600
+ iframe.contentWindow.postMessage({ type: 'widgetic:request-screenshot' }, '*');
1520
1601
  });
1521
1602
  }
1603
+
1604
+ /**
1605
+ * Published CDN artifacts often lack the Worker screenshot script.
1606
+ * Capture via a temporary same-path Worker /preview iframe (has html2canvas handler).
1607
+ */
1608
+ async function captureViaHiddenWorkerPreview(widgetId: string): Promise<string | null> {
1609
+ const workerUrl = `${widgetBuilderPreviewBaseUrl()}/preview/${widgetId}`;
1610
+ console.log('[Chat→Screenshot] Capturing via hidden Worker preview', {
1611
+ widgetId: widgetId.substring(0, 8),
1612
+ workerUrl,
1613
+ });
1614
+
1615
+ const host = document.createElement('div');
1616
+ host.className = 'wc-hidden-screenshot-host';
1617
+ host.setAttribute(
1618
+ 'style',
1619
+ 'position:fixed;left:-10000px;top:0;width:800px;height:600px;opacity:0;pointer-events:none;z-index:-1;',
1620
+ );
1621
+ const iframe = document.createElement('iframe');
1622
+ iframe.title = 'Hidden widget screenshot';
1623
+ iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms');
1624
+ iframe.style.cssText = 'width:800px;height:600px;border:0;';
1625
+ host.appendChild(iframe);
1626
+ document.body.appendChild(host);
1627
+
1628
+ try {
1629
+ await new Promise<void>((resolve, reject) => {
1630
+ const timeout = setTimeout(() => reject(new Error('worker-preview-load-timeout')), 12000);
1631
+ iframe.onload = () => {
1632
+ clearTimeout(timeout);
1633
+ resolve();
1634
+ };
1635
+ iframe.onerror = () => {
1636
+ clearTimeout(timeout);
1637
+ reject(new Error('worker-preview-load-error'));
1638
+ };
1639
+ iframe.src = workerUrl;
1640
+ });
1641
+ // Give injected screenshot handler + images a beat to settle
1642
+ await new Promise((r) => setTimeout(r, 800));
1643
+ const result = await requestIframeScreenshot(iframe, 12000);
1644
+ if (result.data) return result.data;
1645
+ console.warn('[Chat→Screenshot] Hidden Worker capture failed:', result.error);
1646
+ return null;
1647
+ } catch (err) {
1648
+ console.warn('[Chat→Screenshot] Hidden Worker preview error:', err);
1649
+ return null;
1650
+ } finally {
1651
+ host.remove();
1652
+ }
1653
+ }
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
+
1682
+ /** 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> {
1684
+ const targetId = widgetId || focusedPanelWidgetId || selectedWidgetId;
1685
+ if (!targetId) {
1686
+ console.warn('[Chat→Screenshot] No widget id');
1687
+ return null;
1688
+ }
1689
+
1690
+ const liveIframe = requiredIframe ?? resolveLiveWidgetShapeIframe(targetId);
1691
+ if (requiredIframe && (requiredIframe !== resolveLiveWidgetShapeIframe(targetId) || !requiredIframe.isConnected)) return null;
1692
+ const liveSrc = liveIframe
1693
+ ? liveIframe.getAttribute('src') || liveIframe.src || ''
1694
+ : '';
1695
+
1696
+ // 1) Always try the visible live iframe first (published HTML from Worker includes screenshot script)
1697
+ if (liveIframe?.contentWindow) {
1698
+ console.log('[Chat→Screenshot] Live WidgetShape iframe', {
1699
+ targetId: targetId.substring(0, 8),
1700
+ isWorker: isWorkerPreviewUrl(liveSrc),
1701
+ src: liveSrc.slice(0, 120),
1702
+ });
1703
+ const fromLive = await requestIframeScreenshot(liveIframe, 12000);
1704
+ if (fromLive.data && fromLive.data.length > 500) {
1705
+ return fromLive.data;
1706
+ }
1707
+ console.warn('[Chat→Screenshot] Live iframe capture failed:', fromLive.error);
1708
+ }
1709
+
1710
+ // Explicit refresh must not persist a different version or a stale Fabric image.
1711
+ if (requiredIframe) return null;
1712
+
1713
+ // 2) Hidden Worker /preview (same capture script; wait for load)
1714
+ const fromWorker = await captureViaHiddenWorkerPreview(targetId);
1715
+ if (fromWorker && fromWorker.length > 500) return fromWorker;
1716
+
1717
+ // 3) Last resort: Fabric thumbnail (often stale / wireframe) — only if nothing else works
1718
+ const shapeDataUrl =
1719
+ typeof canvasRef?.getWidgetShapeDataUrl === 'function'
1720
+ ? canvasRef.getWidgetShapeDataUrl(targetId)
1721
+ : null;
1722
+ if (shapeDataUrl && shapeDataUrl.length > 100) {
1723
+ console.warn(
1724
+ '[Chat→Screenshot] Falling back to Fabric thumbnail (may be stale wireframe)',
1725
+ targetId.substring(0, 8),
1726
+ );
1727
+ return shapeDataUrl;
1728
+ }
1729
+
1730
+ console.warn('[Chat→Screenshot] All capture paths failed', {
1731
+ targetId: targetId.substring(0, 8),
1732
+ hadLiveIframe: !!liveIframe,
1733
+ liveSrc: liveSrc.slice(0, 100),
1734
+ });
1735
+ return null;
1736
+ }
1522
1737
 
1523
1738
  function normalizeConversationRecords(json: any): any[] {
1524
1739
  const raw = json?.data ?? json?.conversations ?? json;
@@ -1606,6 +1821,8 @@
1606
1821
  /** Load messages for a conversation from backend */
1607
1822
  async function handleLoadMessages(conversationId: string, widgetIdForSync?: string | null): Promise<any[]> {
1608
1823
  try {
1824
+ // Client-only conversations (draft convert panels) have no DB row.
1825
+ if (isLocalOnlyConversationId(conversationId)) return [];
1609
1826
  if (!messagesClient) {
1610
1827
  console.warn('[Chat→Backend] messagesClient not initialized');
1611
1828
  return [];
@@ -1669,7 +1886,17 @@
1669
1886
  try {
1670
1887
  if (!conversationsClient) {
1671
1888
  console.warn('[Chat→Backend] conversationsClient not initialized');
1672
- return { id: crypto.randomUUID(), title };
1889
+ return null;
1890
+ }
1891
+ // Draft convert panels have no DB widget yet, so there is no table row to
1892
+ // attach a conversation to. `@widgetic/chat` requires an object with an
1893
+ // `id` here and treats anything else as a fatal init error, so hand it a
1894
+ // clearly-marked client-only id. `finalizeDraftConversion` never migrates
1895
+ // this id into the real widget's panel state, so messages sent before the
1896
+ // widget exists can never be persisted against a phantom conversation.
1897
+ if (!contextId || isDraftConvertId(contextId) || String(contextId).startsWith('draft')) {
1898
+ const localConversationId = `local_${contextId || 'draft'}_${Date.now()}`;
1899
+ return { id: localConversationId, title, isLocalOnly: true };
1673
1900
  }
1674
1901
  const response = await conversationsClient.createConversationForWidgetRaw({
1675
1902
  createConversationForWidgetRequest: {
@@ -1693,7 +1920,8 @@
1693
1920
  return conversationId ? { ...conversation, id: conversationId } : conversation;
1694
1921
  } catch (error) {
1695
1922
  console.error('[Chat→Backend] Failed to create conversation:', error);
1696
- return { id: crypto.randomUUID(), title };
1923
+ // Never invent a local UUID — saving messages against it yields 403 and stops codegen.
1924
+ return null;
1697
1925
  }
1698
1926
  }
1699
1927
 
@@ -1810,6 +2038,11 @@
1810
2038
  persistWidgetId?: string | null,
1811
2039
  ): Promise<any> {
1812
2040
  try {
2041
+ // Draft convert panels hold a client-only conversation until the widget
2042
+ // exists; there is no `conversations` row to attach messages to yet.
2043
+ if (isLocalOnlyConversationId(conversationId)) {
2044
+ return { id: `local_msg_${Date.now()}`, attachments: attachments ?? [] };
2045
+ }
1813
2046
  if (!messagesClient) {
1814
2047
  console.warn('[Chat→Backend] messagesClient not initialized');
1815
2048
  return null;
@@ -1865,7 +2098,11 @@
1865
2098
  }
1866
2099
 
1867
2100
  /** Restore conversation to a checkpoint message via backend (deletes messages + git rollback) */
1868
- async function handleRestoreCheckpoint(conversationId: string, checkpointMessageId: string): Promise<any> {
2101
+ async function handleRestoreCheckpoint(
2102
+ conversationId: string,
2103
+ checkpointMessageId: string,
2104
+ restoreWidgetId?: string | null,
2105
+ ): Promise<any> {
1869
2106
  try {
1870
2107
  if (!messagesClient) {
1871
2108
  console.warn('[Chat→Backend] messagesClient not initialized');
@@ -1882,16 +2119,39 @@
1882
2119
  const data = json?.data || json;
1883
2120
  // console.log('[Chat→Backend] Checkpoint restored:', data);
1884
2121
 
1885
- // Refresh the widget preview after restore — the backend rebuilds the widget
1886
- // from the reverted code, so we just need to reload the iframe.
1887
- // Check both camelCase (after responseTransformMiddleware) and snake_case paths.
1888
2122
  const gitRollback = data?.gitRollback || data?.git_rollback || data?.data?.gitRollback || data?.data?.git_rollback;
1889
2123
  const undoMode = data?.undoMode ?? data?.undo_mode ?? false;
1890
2124
  const targetCommit = gitRollback?.targetCommit ?? gitRollback?.target_commit;
1891
- // console.log('[Chat→Backend] Git rollback result:', gitRollback, { undoMode, targetCommit });
2125
+ const panelWidgetId =
2126
+ restoreWidgetId ||
2127
+ focusedPanelWidgetId ||
2128
+ selectedWidgetId ||
2129
+ null;
2130
+ // console.log('[Chat→Backend] Git rollback result:', gitRollback, { undoMode, targetCommit, panelWidgetId });
1892
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;
1893
2135
  if (targetCommit) {
1894
2136
  lastCommitId = targetCommit;
2137
+ if (panelWidgetId) {
2138
+ patchPanelSnapshot(panelWidgetId, { lastCommitId: targetCommit });
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
+ }
2154
+ }
1895
2155
  }
1896
2156
  showToast('success', undoMode ? 'Change undone' : 'Checkpoint restored', {
1897
2157
  description: undoMode
@@ -1905,20 +2165,33 @@
1905
2165
  compositionDesignSchema = null;
1906
2166
  compositionContentSchema = null;
1907
2167
  pendingCreatePreviewDefaultsWidgetId = null;
1908
- // Rollback commit may take a moment to be visible from the Worker's clone; retry
1909
- // build-from-repo with escalating delays to survive GitLab propagation lag.
1910
- _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
+ }
2175
+ const panelRef = panelWidgetId ? getWidgetDetailsRef(panelWidgetId) : widgetDetails;
1911
2176
  const scheduleBuildRetry = async () => {
1912
2177
  const delays = [2500, 5000, 8000];
1913
2178
  for (let i = 0; i < delays.length; i++) {
1914
2179
  await new Promise((r) => setTimeout(r, delays[i]));
1915
2180
  if (i === 0) suppressNextBuildFromRepoChatMessage = true;
1916
2181
  try {
1917
- await widgetDetails?.triggerBuildFromRepo({ force: true });
1918
- const err = widgetDetails?.getPreviewError?.() ?? null;
2182
+ const activeRef = panelWidgetId ? getWidgetDetailsRef(panelWidgetId) : panelRef;
2183
+ await activeRef?.triggerBuildFromRepo({ force: true });
2184
+ const err = activeRef?.getPreviewError?.() ?? null;
1919
2185
  if (!err) {
1920
- // console.log('[Restore] triggerBuildFromRepo succeeded on attempt', i + 1);
1921
2186
  _isRestoringCheckpoint = false;
2187
+ if (panelWidgetId) {
2188
+ shapePreviewSourceByWidgetId = {
2189
+ ...shapePreviewSourceByWidgetId,
2190
+ [panelWidgetId]: 'preview',
2191
+ };
2192
+ syncWidgetShapeLivePreviews();
2193
+ setTimeout(() => handleRefreshScreenshot(panelWidgetId, { quiet: true }), 2000);
2194
+ }
1922
2195
  return;
1923
2196
  }
1924
2197
  console.warn('[Restore] triggerBuildFromRepo attempt', i + 1, 'has error:', err);
@@ -1941,14 +2214,16 @@
1941
2214
  pendingCreatePreviewDefaultsWidgetId = null;
1942
2215
  setTimeout(() => {
1943
2216
  suppressNextBuildFromRepoChatMessage = true;
1944
- widgetDetails?.triggerBuildFromRepo({ force: true });
2217
+ const activeRef = panelWidgetId ? getWidgetDetailsRef(panelWidgetId) : widgetDetails;
2218
+ void activeRef?.triggerBuildFromRepo({ force: true });
1945
2219
  }, 1500);
1946
2220
  }
1947
-
1948
2221
  return data;
1949
2222
  } catch (error) {
1950
2223
  console.error('[Chat→Backend] Failed to restore checkpoint:', error);
1951
- _isRestoringCheckpoint = false;
2224
+ showToast('error', 'Failed to restore checkpoint', {
2225
+ description: error instanceof Error ? error.message : 'Unknown error',
2226
+ });
1952
2227
  return null;
1953
2228
  }
1954
2229
  }
@@ -1985,7 +2260,7 @@
1985
2260
  let lastPublishedVersion: number | null = null;
1986
2261
  let selectedWidgetJsPath: string | null = null;
1987
2262
 
1988
- // Publish History state — loaded when user expands the history panel
2263
+ // Version history state — loaded when user expands the history panel
1989
2264
  interface PublishHistoryRecord {
1990
2265
  id: string;
1991
2266
  version: number;
@@ -1994,10 +2269,23 @@
1994
2269
  commit_sha?: string | null;
1995
2270
  artifactUrl?: string;
1996
2271
  artifact_url?: string;
1997
- publishedAt?: string;
1998
- 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;
1999
2279
  publishedBy?: string | null;
2000
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;
2001
2289
  }
2002
2290
  let publishHistory: PublishHistoryRecord[] = [];
2003
2291
  let isHistoryOpen = false;
@@ -2123,14 +2411,15 @@
2123
2411
  let editingCompositionName = '';
2124
2412
  let deletingCompositionId: string | null = null;
2125
2413
 
2126
- /** Explicit chat box height (px). Drag grows this and pushes Publish down. */
2127
- const CHAT_DEFAULT_HEIGHT = 550;
2128
- const CHAT_MIN_HEIGHT = 260;
2414
+ /** Explicit chat box height (px). null/0 = fill available; user drag sets a fixed px. */
2415
+ const CHAT_DEFAULT_HEIGHT = 400;
2416
+ const CHAT_MIN_HEIGHT = 400;
2129
2417
  const CHAT_MAX_HEIGHT = 2000;
2130
2418
 
2131
- function getChatContainerHeightForPanel(widgetId: string): number {
2419
+ function getChatContainerHeightForPanel(widgetId: string): number | null {
2132
2420
  const stored = getPanelState(widgetId).chatContainerHeight;
2133
- if (stored == null || stored <= 0) return CHAT_DEFAULT_HEIGHT;
2421
+ // null/0 → fill available space (flex-grow). Explicit resize stores a px value.
2422
+ if (stored == null || stored <= 0) return null;
2134
2423
  return Math.max(CHAT_MIN_HEIGHT, Math.min(CHAT_MAX_HEIGHT, stored));
2135
2424
  }
2136
2425
 
@@ -2138,9 +2427,12 @@
2138
2427
  const measured = document
2139
2428
  .querySelector(`[data-widget-chat-height="${widgetId}"]`)
2140
2429
  ?.getBoundingClientRect().height;
2141
- const current = measured && measured > 0 ? measured : getChatContainerHeightForPanel(widgetId);
2430
+ const current =
2431
+ measured && measured > 0
2432
+ ? measured
2433
+ : (getChatContainerHeightForPanel(widgetId) ?? CHAT_DEFAULT_HEIGHT);
2142
2434
  const next = Math.max(CHAT_MIN_HEIGHT, Math.min(CHAT_MAX_HEIGHT, current + deltaPx));
2143
- // console.log('[Chat] Resize container height:', widgetId?.substring(0, 8), current, '→', next);
2435
+ console.log('[Chat] Resize container height:', widgetId?.substring(0, 8), current, '→', next);
2144
2436
  patchPanelSnapshot(widgetId, { chatContainerHeight: next });
2145
2437
  }
2146
2438
 
@@ -2293,6 +2585,11 @@
2293
2585
  }
2294
2586
 
2295
2587
  function scheduleWidgetSchemaLoad(widgetId: string): Promise<void> {
2588
+ // Draft convert panels have no DB widget yet — `/widgets/:id/schema/*` validates
2589
+ // a UUID, so calling it with a `draft_convert_*` id only produces a 400 and a
2590
+ // console error. The real schemas are loaded once the panel migrates to the
2591
+ // widget id after the first send.
2592
+ if (isDraftConvertId(widgetId)) return Promise.resolve();
2296
2593
  const cached = widgetSchemaCache.get(widgetId);
2297
2594
  if (cached?.design || cached?.content) {
2298
2595
  applyWidgetSchemaCacheToSnapshot(widgetId);
@@ -2462,6 +2759,9 @@
2462
2759
  }
2463
2760
 
2464
2761
  async function executeWidgetSchemaLoad(widgetId: string): Promise<void> {
2762
+ // Defence in depth: callers already skip drafts, but any path that reaches
2763
+ // here with a synthetic id would hit `/widgets/:id/schema/*` and get a 400.
2764
+ if (isDraftConvertId(widgetId)) return;
2465
2765
  schemaLoadInFlight.add(widgetId);
2466
2766
 
2467
2767
  try {
@@ -2760,7 +3060,6 @@
2760
3060
  // Only reuse js_path when it already points at this widget + this version.
2761
3061
  // `/widgets/` alone used to win and keep Edit on the previous CDN version after publish.
2762
3062
  if (!pointsAtOtherWidget && jsPathVersion === version) {
2763
- console.log('[Publish] Using js_path artifact URL', resolved);
2764
3063
  return resolved;
2765
3064
  }
2766
3065
  }
@@ -3883,63 +4182,162 @@
3883
4182
  }
3884
4183
 
3885
4184
  function handleHostSaveCanvasEvent(): void {
3886
- canvasRef?.saveNow?.();
4185
+ // Explicit Save must always persist — bypass soft load-cooldown / not-ready guards.
4186
+ const json = canvasRef?.saveCanvas?.() ?? null;
4187
+ if (!json) {
4188
+ console.warn('[CreatorApp] Host Save ignored — saveCanvas returned null');
4189
+ showToast('error', 'Nothing to save', { description: 'Canvas is not ready yet.' });
4190
+ return;
4191
+ }
4192
+ void handleCanvasAutoSave(json, false, { force: true });
3887
4193
  }
3888
4194
 
3889
- async function persistPrimaryWidgetId(widgetId: string) {
3890
- if (!widgetId) return;
4195
+ async function persistPrimaryWidgetId(widgetId: string): Promise<boolean> {
4196
+ if (!widgetId) return false;
4197
+ const targetCanvasId =
4198
+ (typeof canvasId === 'string' && canvasId.trim() ? canvasId.trim() : null) ||
4199
+ (typeof sessionCanvasId === 'string' && sessionCanvasId.trim() ? sessionCanvasId.trim() : null);
4200
+ const widget = getWidgetById(widgetId);
4201
+ const widgetCanvasId =
4202
+ (widget as { canvasId?: string | null; canvas_id?: string | null } | null)?.canvasId ??
4203
+ (widget as { canvasId?: string | null; canvas_id?: string | null } | null)?.canvas_id ??
4204
+ null;
4205
+ const linkedIds = canvasRef?.getLinkedWidgetIds?.() ?? linkedWidgetIds ?? new Set<string>();
4206
+ const shapeOnThisCanvas = linkedIds instanceof Set ? linkedIds.has(widgetId) : false;
4207
+ // Explicit user intent (picking a primary) is the strongest ownership signal, so a
4208
+ // shape the user drew / sees on THIS canvas always wins over a stale canvas_id.
4209
+ // Legacy canvas duplication left the same shape spread across many canvases; refusing
4210
+ // here made primary unsettable. Adopt (move) instead — never touch the shape itself.
4211
+ const shouldAdoptToThisCanvas =
4212
+ !!targetCanvasId && shapeOnThisCanvas && (!widgetCanvasId || widgetCanvasId !== targetCanvasId);
4213
+ if (targetCanvasId && widgetCanvasId && widgetCanvasId !== targetCanvasId && !shapeOnThisCanvas) {
4214
+ console.warn(
4215
+ '[CreatorApp] Refusing primary_widget_id — widget belongs to another canvas and has no shape here',
4216
+ widgetId.slice(0, 8),
4217
+ 'widgetCanvas',
4218
+ String(widgetCanvasId).slice(0, 8),
4219
+ 'targetCanvas',
4220
+ targetCanvasId.slice(0, 8),
4221
+ );
4222
+ emitCanvasWidgetsList('primary-refused');
4223
+ return false;
4224
+ }
3891
4225
  canvasPrimaryWidgetId = widgetId;
4226
+ saveSelectedWidgetId(widgetId);
3892
4227
  // console.log('[CreatorApp] Set primary widget:', widgetId);
3893
- if (canvasId && canvasesClient) {
4228
+ if (targetCanvasId && canvasesClient) {
3894
4229
  try {
3895
4230
  await canvasesClient.updateCanvas({
3896
- id: canvasId,
4231
+ id: targetCanvasId,
3897
4232
  updateCanvasRequest: { primaryWidgetId: widgetId }
3898
4233
  });
3899
4234
  } catch (err) {
3900
4235
  console.warn('[CreatorApp] Failed to save primary_widget_id:', err);
3901
4236
  }
3902
4237
  }
3903
- const widget = getWidgetById(widgetId);
3904
- if (embedded && typeof window !== 'undefined') {
4238
+ // Adopt orphans AND cross-canvas strays whose shape lives here (explicit user intent).
4239
+ if (targetCanvasId && widgetsClient && shouldAdoptToThisCanvas) {
3905
4240
  try {
3906
- const url = new URL(window.location.href);
3907
- if (url.searchParams.get('widget_id') !== widgetId) {
3908
- url.searchParams.set('widget_id', widgetId);
3909
- window.history.replaceState({}, '', url.pathname + url.search + url.hash);
3910
- }
4241
+ await widgetsClient.updateWidget({
4242
+ id: widgetId,
4243
+ updateWidgetRequest: { canvasId: targetCanvasId },
4244
+ });
4245
+ widgets = widgets.map((w: any) =>
4246
+ w.id === widgetId ? { ...w, canvasId: targetCanvasId, canvas_id: targetCanvasId } : w,
4247
+ );
4248
+ console.log(
4249
+ '[CreatorApp] Adopted widget canvas_id to host canvas (primary pick)',
4250
+ widgetId.slice(0, 8),
4251
+ 'from',
4252
+ widgetCanvasId ? String(widgetCanvasId).slice(0, 8) : 'null',
4253
+ 'to',
4254
+ targetCanvasId.slice(0, 8),
4255
+ );
3911
4256
  } catch (err) {
3912
- console.warn('[CreatorApp] Failed to sync primary widget_id in URL:', err);
4257
+ console.warn('[CreatorApp] Failed to heal widget canvas_id:', err);
3913
4258
  }
3914
4259
  }
4260
+ if (embedded) {
4261
+ syncUrlWidgetId(widgetId);
4262
+ }
3915
4263
  if (widget) notifyHostWidgetName(widget);
3916
4264
  emitCanvasWidgetsList('primary-changed');
4265
+ const anyW = widget as { thumbnailUrl?: string | null; thumbnail_url?: string | null } | null;
4266
+ const hasThumb = !!(anyW?.thumbnailUrl || anyW?.thumbnail_url);
4267
+ if (!hasThumb) {
4268
+ setTimeout(() => {
4269
+ void handleRefreshScreenshot(widgetId, { quiet: true });
4270
+ }, 1200);
4271
+ }
4272
+ return true;
3917
4273
  }
3918
4274
 
3919
4275
  function emitCanvasWidgetsList(reason: string) {
3920
4276
  if (!embedded || typeof window === 'undefined') return;
4277
+ const hostCanvasId = resolveHostCanvasId();
3921
4278
  const ids = canvasRef?.getLinkedWidgetIds?.() ?? linkedWidgetIds ?? new Set<string>();
3922
4279
  const items: { id: string; name: string }[] = [];
4280
+ const seen = new Set<string>();
4281
+ const pushItem = (id: string, name?: string | null) => {
4282
+ if (!id || seen.has(id) || isDraftConvertId(id)) return;
4283
+ seen.add(id);
4284
+ items.push({ id, name: (name || '').trim() || 'Untitled Widget' });
4285
+ };
4286
+ // Canvas widgets are those whose DB canvas_id matches this canvas (shapes may lag behind).
4287
+ if (hostCanvasId) {
4288
+ for (const widget of widgets) {
4289
+ const id = (widget as { id?: string }).id;
4290
+ if (!id) continue;
4291
+ const widgetCanvas = widgetCanvasIdOf(
4292
+ widget as { canvasId?: string; canvas_id?: string } | null,
4293
+ );
4294
+ if (widgetCanvas !== hostCanvasId) continue;
4295
+ pushItem(id, (widget as { name?: string }).name);
4296
+ }
4297
+ }
4298
+ // Linked shapes that live on THIS canvas. An orphan (null canvas_id) drawn here is
4299
+ // legitimately this canvas's widget — only foreign canvas_id ownership is excluded.
3923
4300
  for (const id of ids) {
4301
+ if (!id || isDraftConvertId(id)) continue;
3924
4302
  const widget = getWidgetById(id);
3925
- items.push({
3926
- id,
3927
- name: (widget?.name || '').trim() || 'Untitled Widget'
3928
- });
3929
- }
3930
- if (canvasPrimaryWidgetId && !items.some((item) => item.id === canvasPrimaryWidgetId)) {
3931
- const primary = getWidgetById(canvasPrimaryWidgetId);
3932
- if (primary?.id) {
4303
+ if (!widget?.id) continue;
4304
+ const widgetCanvas = widgetCanvasIdOf(
4305
+ widget as { canvasId?: string; canvas_id?: string } | null,
4306
+ );
4307
+ if (!hostCanvasId || (widgetCanvas && widgetCanvas !== hostCanvasId)) continue;
4308
+ pushItem(id, widget.name);
4309
+ }
4310
+ let primaryWidgetId = (canvasPrimaryWidgetId || '').trim();
4311
+ const primaryOnList = primaryWidgetId && items.some((item) => item.id === primaryWidgetId);
4312
+ if (primaryWidgetId && !primaryOnList) {
4313
+ const primary = getWidgetById(primaryWidgetId);
4314
+ const primaryCanvas = widgetCanvasIdOf(
4315
+ primary as { canvasId?: string; canvas_id?: string } | null,
4316
+ );
4317
+ if (hostCanvasId && primaryCanvas && primaryCanvas === hostCanvasId && primary?.id) {
3933
4318
  items.unshift({
3934
4319
  id: primary.id,
3935
4320
  name: (primary.name || '').trim() || 'Untitled Widget'
3936
4321
  });
4322
+ } else {
4323
+ console.warn(
4324
+ '[CreatorApp] Dropping stale primary from canvas widgets emit',
4325
+ reason,
4326
+ primaryWidgetId.slice(0, 8),
4327
+ 'host',
4328
+ hostCanvasId?.slice(0, 8) || 'none',
4329
+ );
4330
+ primaryWidgetId = items[0]?.id || '';
4331
+ canvasPrimaryWidgetId = primaryWidgetId || null;
4332
+ if (primaryWidgetId) syncUrlWidgetId(primaryWidgetId);
3937
4333
  }
3938
4334
  }
3939
- const primaryWidgetId = canvasPrimaryWidgetId || '';
4335
+ if (!primaryWidgetId && items[0]?.id) {
4336
+ primaryWidgetId = items[0].id;
4337
+ }
3940
4338
  // console.log('[CreatorApp] Emit canvas widgets for header', reason, items.length, primaryWidgetId);
3941
4339
  window.dispatchEvent(new CustomEvent(CREATOR_CANVAS_WIDGETS_EVENT, {
3942
- detail: { widgets: items, primaryWidgetId }
4340
+ detail: { widgets: items, primaryWidgetId: primaryWidgetId || '' }
3943
4341
  }));
3944
4342
  }
3945
4343
 
@@ -3953,10 +4351,19 @@
3953
4351
  console.warn('[CreatorApp] Host set primary ignored — missing widgetId');
3954
4352
  return;
3955
4353
  }
4354
+ const previousPrimary = canvasPrimaryWidgetId;
4355
+ // Optimistic — so host-request emits during persist cannot resurrect the previous primary.
4356
+ canvasPrimaryWidgetId = widgetId;
4357
+ saveSelectedWidgetId(widgetId);
3956
4358
  // console.log('[CreatorApp] Host set primary widget', widgetId.substring(0, 8));
3957
4359
  canvasRef?.setPrimaryWidgetShape?.(widgetId);
3958
4360
  canvasRef?.selectWidgetShape?.(widgetId);
3959
- void persistPrimaryWidgetId(widgetId);
4361
+ void persistPrimaryWidgetId(widgetId).then((ok) => {
4362
+ if (ok) return;
4363
+ canvasPrimaryWidgetId = previousPrimary;
4364
+ if (previousPrimary) saveSelectedWidgetId(previousPrimary);
4365
+ emitCanvasWidgetsList('primary-reverted');
4366
+ });
3960
4367
  }
3961
4368
 
3962
4369
  function handleSetPrimaryWidget(widgetId: string) {
@@ -3966,7 +4373,33 @@
3966
4373
  async function hydrateCanvasPrimaryWidget(linkedIds?: string[]): Promise<string | null> {
3967
4374
  const targetCanvasId = (typeof canvasId === 'string' && canvasId.trim()) ? canvasId.trim() : null;
3968
4375
  if (!targetCanvasId) return canvasPrimaryWidgetId || null;
4376
+
4377
+ // NOTE: `?widget_id=` is deliberately NOT consulted here. The canvas's DB
4378
+ // `primary_widget_id` is the single source of truth for what opens selected/red on
4379
+ // this canvas. A widget_id coming from a link is treated as a one-time intent in
4380
+ // ensureEmbeddedWidgetDeepLink(), which validates it and promotes it to primary —
4381
+ // so a stale id from a previously opened canvas can never win on load again.
4382
+
3969
4383
  let primaryId = await resolvePrimaryWidgetIdForCanvas(targetCanvasId);
4384
+ // Reject cross-canvas primary (stale DB rows mix project cards / header) unless the
4385
+ // widget's shape actually lives on this canvas — then the shape wins over stale metadata.
4386
+ if (primaryId) {
4387
+ const primaryWidget = getWidgetById(primaryId);
4388
+ const primaryCanvasId = widgetCanvasIdOf(
4389
+ primaryWidget as { canvasId?: string | null; canvas_id?: string | null } | null,
4390
+ );
4391
+ const primaryShapeHere = !!canvasRef?.getLinkedWidgetIds?.()?.has(primaryId);
4392
+ if (primaryCanvasId && primaryCanvasId !== targetCanvasId && !primaryShapeHere) {
4393
+ console.warn(
4394
+ '[CreatorApp] Ignoring cross-canvas primary_widget_id',
4395
+ primaryId.slice(0, 8),
4396
+ 'on canvas',
4397
+ targetCanvasId.slice(0, 8),
4398
+ );
4399
+ primaryId = null;
4400
+ canvasPrimaryWidgetId = null;
4401
+ }
4402
+ }
3970
4403
  if (!primaryId) {
3971
4404
  const fromLinks = (linkedIds || []).filter((id) => id && !isDraftConvertId(id) && !String(id).startsWith('draft'));
3972
4405
  const fromList = widgets
@@ -3975,7 +4408,11 @@
3975
4408
  return cid === targetCanvasId && w.id && !isDraftConvertId(w.id);
3976
4409
  })
3977
4410
  .map((w: any) => w.id);
3978
- primaryId = fromLinks[0] || fromList[0] || null;
4411
+ primaryId = fromLinks.find((id) => {
4412
+ const w = getWidgetById(id);
4413
+ const cid = (w as any)?.canvasId || (w as any)?.canvas_id;
4414
+ return !cid || cid === targetCanvasId;
4415
+ }) || fromList[0] || null;
3979
4416
  if (primaryId) {
3980
4417
  // console.log('[CreatorApp] Healing empty primary_widget_id →', primaryId.substring(0, 8));
3981
4418
  await persistPrimaryWidgetId(primaryId);
@@ -3989,6 +4426,12 @@
3989
4426
  return primaryId || canvasPrimaryWidgetId || null;
3990
4427
  }
3991
4428
 
4429
+ function applyPrimarySelectionOnCanvas(primaryId: string | null | undefined): void {
4430
+ if (!primaryId || !canvasRef || isConvertOrDraftPanelOpen()) return;
4431
+ canvasRef.setPrimaryWidgetShape?.(primaryId);
4432
+ canvasRef.selectWidgetShape?.(primaryId);
4433
+ }
4434
+
3992
4435
  async function handleHostMoveCanvasFolderEvent(event: Event) {
3993
4436
  const folderId = (event as CustomEvent).detail?.folderId;
3994
4437
  if (!folderId || typeof folderId !== 'string' || !widgetsClient) return;
@@ -4441,12 +4884,46 @@
4441
4884
  }, delayMs);
4442
4885
  }
4443
4886
 
4887
+ /**
4888
+ * Read the canvas id from the address bar.
4889
+ *
4890
+ * The canvas id lives in the **path** (`/canvas/<canvasId>`), not in a query string.
4891
+ *
4892
+ * `loadCurrentCanvas()` is also triggered by a reactive auth effect, which can run BEFORE
4893
+ * the onMount route subscription populates `routeCanvasId`. In that race, `routeCanvasId`
4894
+ * was still null, so the loader fell through to `getSavedSelectedCanvasId()` and a canvas
4895
+ * remembered from a *previous* session silently replaced the canvas in the URL. Reading the
4896
+ * address bar synchronously here makes the opened canvas authoritative in every entry path.
4897
+ */
4898
+ function readCanvasIdFromAddressBar(): string | null {
4899
+ if (typeof window === 'undefined') return null;
4900
+ try {
4901
+ const pathParts = window.location.pathname.split('/');
4902
+ const canvasIndex = pathParts.indexOf('canvas');
4903
+ const fromPath = canvasIndex >= 0 ? (pathParts[canvasIndex + 1] || '').trim() : '';
4904
+ if (fromPath) return decodeURIComponent(fromPath);
4905
+ // Legacy query string — kept only so an old bookmark still resolves to its canvas.
4906
+ const params = new URLSearchParams(window.location.search);
4907
+ const fromQuery = params.get('canvas_id') || params.get('canvas-id');
4908
+ return fromQuery && fromQuery.trim() ? fromQuery.trim() : null;
4909
+ } catch {
4910
+ return null;
4911
+ }
4912
+ }
4913
+
4444
4914
  async function loadCurrentCanvas() {
4445
4915
  // console.log('Widget Creator: loadCurrentCanvas():', routeCanvasId, isLoadingCanvasOnce);
4446
4916
  if (isLoadingCanvasOnce) return;
4447
4917
  try {
4448
4918
  isLoadingCanvasOnce = true;
4449
4919
 
4920
+ // Resolve the canvas from the address bar before any localStorage fallback — see
4921
+ // readCanvasIdFromAddressBar(). This must happen before initialCandidateCanvasId.
4922
+ if (!routeCanvasId) {
4923
+ const fromAddressBar = readCanvasIdFromAddressBar();
4924
+ if (fromAddressBar) routeCanvasId = fromAddressBar;
4925
+ }
4926
+
4450
4927
  // set widgets loading state
4451
4928
  isLoadingWidgets = true;
4452
4929
  widgetsError = null;
@@ -4479,6 +4956,16 @@
4479
4956
  // Prefer canvasId from route (path or query)
4480
4957
  if (routeCanvasId) {
4481
4958
  canvasListResolved = true;
4959
+ // The route is authoritative — keep the remembered canvas in step with it. Since the
4960
+ // host now passes `canvasId` as a prop, the `!canvasId` guard above can skip the sync
4961
+ // and leave localStorage pointing at the previously opened canvas, which then wins
4962
+ // the next time a bare `/canvas` is opened.
4963
+ if (sessionCanvasId !== routeCanvasId || canvasId !== routeCanvasId) {
4964
+ rememberCanvasId(routeCanvasId);
4965
+ }
4966
+ // New canvas session — clear primary from previous canvas before hydrate.
4967
+ canvasPrimaryWidgetId = null;
4968
+ embeddedDeepLinkAutoOpenDone = false;
4482
4969
  // console.log('WidgetCreator.loadCurrentCanvas: Loading data for route canvasId:', canvasId);
4483
4970
  }
4484
4971
  // Fallback: get latest canvas from current user's canvases
@@ -4582,10 +5069,16 @@
4582
5069
  const { loaded, widgetIds: widgetIdsOnCanvas } = await parallelCanvasLoadPromise;
4583
5070
  await maybeAutoRestoreWidgetShapes();
4584
5071
  backfillWidgetShapeMetadata();
4585
- const primaryId = await hydrateCanvasPrimaryWidget(widgetIdsOnCanvas);
4586
- if (loaded && canvasRef && primaryId && !isConvertOrDraftPanelOpen()) {
4587
- canvasRef.setPrimaryWidgetShape?.(primaryId);
4588
- canvasRef.selectWidgetShape(primaryId);
5072
+ const linkedIds =
5073
+ widgetIdsOnCanvas.length > 0
5074
+ ? widgetIdsOnCanvas
5075
+ : [...(canvasRef?.getLinkedWidgetIds?.() ?? [])];
5076
+ const primaryId = await hydrateCanvasPrimaryWidget(linkedIds);
5077
+ if (loaded) {
5078
+ applyPrimarySelectionOnCanvas(primaryId);
5079
+ requestAnimationFrame(() => {
5080
+ setTimeout(() => applyPrimarySelectionOnCanvas(primaryId), 80);
5081
+ });
4589
5082
  }
4590
5083
  return;
4591
5084
  }
@@ -4602,10 +5095,16 @@
4602
5095
  await syncWidgetsWithCanvas(widgetIdsOnCanvas);
4603
5096
  await maybeAutoRestoreWidgetShapes();
4604
5097
  backfillWidgetShapeMetadata();
4605
- const primaryId = await hydrateCanvasPrimaryWidget(widgetIdsOnCanvas);
4606
- if (loaded && canvasRef && primaryId && !isConvertOrDraftPanelOpen()) {
4607
- canvasRef.setPrimaryWidgetShape?.(primaryId);
4608
- canvasRef.selectWidgetShape(primaryId);
5098
+ const linkedIds =
5099
+ widgetIdsOnCanvas.length > 0
5100
+ ? widgetIdsOnCanvas
5101
+ : [...(canvasRef?.getLinkedWidgetIds?.() ?? [])];
5102
+ const primaryId = await hydrateCanvasPrimaryWidget(linkedIds);
5103
+ if (loaded || isCanvasStateLoaded) {
5104
+ applyPrimarySelectionOnCanvas(primaryId);
5105
+ requestAnimationFrame(() => {
5106
+ setTimeout(() => applyPrimarySelectionOnCanvas(primaryId), 80);
5107
+ });
4609
5108
  }
4610
5109
  } catch (error) {
4611
5110
  console.error('Widget Creator: Load canvases error:', error);
@@ -4675,6 +5174,13 @@
4675
5174
  sessionCanvasId = entry.id;
4676
5175
  activeCanvasName = entry.name;
4677
5176
  saveSelectedCanvasId(canvasId);
5177
+ // The deferred cooldown save (if any) holds the previous canvas's shapes.
5178
+ discardPendingCooldownAutosave();
5179
+ // Address bar must track the canvas we actually switched to, and must not keep a
5180
+ // widget_id belonging to the previous canvas.
5181
+ routeCanvasId = entry.id;
5182
+ canvasPrimaryWidgetId = null;
5183
+ syncUrlCanvasId(entry.id);
4678
5184
 
4679
5185
  // Clear existing widgets and canvas visually
4680
5186
  widgets = [];
@@ -4692,6 +5198,21 @@
4692
5198
  }
4693
5199
  }
4694
5200
 
5201
+ /**
5202
+ * Clear the cross-tab "generating"/"publishing" flags when the tab closes.
5203
+ *
5204
+ * Lives at component scope (not inside `onMount`) so `onDestroy` can pass the
5205
+ * same reference to `removeEventListener`. When it was declared inside
5206
+ * `onMount`, leaving the canvas threw `handleBeforeUnload is not defined`,
5207
+ * which aborted the rest of the cleanup and blocked the navigation.
5208
+ */
5209
+ function handleBeforeUnload() {
5210
+ for (const wId of Object.keys(generatingWidgetIds)) {
5211
+ setCrossTabGenerating(false, wId);
5212
+ }
5213
+ if (isPublishing) setCrossTabPublishing(false);
5214
+ }
5215
+
4695
5216
  /** Close canvas dropdown on outside click */
4696
5217
  function handleCanvasDropdownOutsideClick(e: MouseEvent) {
4697
5218
  const target = e.target as HTMLElement;
@@ -4796,6 +5317,7 @@
4796
5317
  function beginBlankCanvasSession(reason: string): void {
4797
5318
  ignoreIncomingCanvasLoads = true;
4798
5319
  canvasLoadEpoch += 1;
5320
+ discardPendingCooldownAutosave();
4799
5321
  lastCommitId = null;
4800
5322
  lastPublishedVersion = null;
4801
5323
  selectedWidgetJsPath = null;
@@ -4806,6 +5328,19 @@
4806
5328
  // console.log('[CreatorApp] Blank canvas session:', reason, 'epoch', canvasLoadEpoch);
4807
5329
  }
4808
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
+
4809
5344
  function relinkSelectedWidgetShape(): void {
4810
5345
  placeSelectedWidgetShapeOnCanvas();
4811
5346
  }
@@ -4878,7 +5413,15 @@
4878
5413
  }
4879
5414
 
4880
5415
  try {
4881
- const newName = `Canvas ${userCanvases.length + 1}`;
5416
+ // Names must be unique per user — duplicate keys ("Canvas 1" x N) made
5417
+ // canvases indistinguishable and contributed to cross-canvas widget mix-ups.
5418
+ const usedNames = new Set(userCanvases.map((c) => (c.name || '').trim()).filter(Boolean));
5419
+ let canvasIndex = userCanvases.length + 1;
5420
+ let newName = `Canvas ${canvasIndex}`;
5421
+ while (usedNames.has(newName)) {
5422
+ canvasIndex += 1;
5423
+ newName = `Canvas ${canvasIndex}`;
5424
+ }
4882
5425
  const createdRaw = await canvasesClient.createCanvas({
4883
5426
  createCanvasRequest: { persistenceKey: newName }
4884
5427
  });
@@ -4887,6 +5430,7 @@
4887
5430
 
4888
5431
  if (createdId) {
4889
5432
  canvasLoadEpoch += 1;
5433
+ discardPendingCooldownAutosave();
4890
5434
  isCanvasStateLoaded = false;
4891
5435
  userCanvases = [{ id: createdId, name: newName, hasContent: false }, ...userCanvases];
4892
5436
  showCanvasDropdown = false;
@@ -4991,16 +5535,51 @@
4991
5535
  deleteCanvasTarget = entry;
4992
5536
  showDeleteCanvasDialog = true;
4993
5537
  showCanvasDropdown = false;
5538
+ deleteCanvasError = '';
5539
+ void loadDeleteCanvasPreview(entry.id);
5540
+ }
5541
+
5542
+ /**
5543
+ * Fetch what the deletion would remove so the dialog can name it.
5544
+ * Failure is non-fatal: the dialog still explains the action, just without
5545
+ * the per-project breakdown.
5546
+ */
5547
+ async function loadDeleteCanvasPreview(canvasId: string): Promise<void> {
5548
+ deleteCanvasPreview = null;
5549
+ deleteCanvasPreviewLoading = true;
5550
+ try {
5551
+ const response = await canvasesClient?.previewDeleteCanvas?.({ id: canvasId });
5552
+ const preview = response?.data;
5553
+
5554
+ if (!preview) {
5555
+ // Older SDK build without the delete-preview method — no breakdown.
5556
+ console.warn('[WidgetCreator] Canvas delete preview unavailable');
5557
+ return;
5558
+ }
5559
+
5560
+ deleteCanvasPreview = {
5561
+ widgetCount: preview.widgetCount ?? 0,
5562
+ groups: (preview.groups ?? []).map((group) => ({
5563
+ projectName: group.projectName || 'Unknown project',
5564
+ widgetNames: group.widgetNames ?? []
5565
+ }))
5566
+ };
5567
+ } catch (err) {
5568
+ console.warn('[WidgetCreator] Could not load canvas delete preview:', err);
5569
+ } finally {
5570
+ deleteCanvasPreviewLoading = false;
5571
+ }
4994
5572
  }
4995
5573
 
4996
5574
  async function handleDeleteCanvasConfirm() {
4997
5575
  if (!deleteCanvasTarget || !canvasesClient) return;
4998
5576
  const targetId = deleteCanvasTarget.id;
4999
5577
  const targetName = deleteCanvasTarget.name;
5000
- showDeleteCanvasDialog = false;
5578
+ deleteCanvasError = '';
5001
5579
  try {
5002
5580
  await canvasesClient.deleteCanvas({ id: targetId });
5003
5581
  // console.log('[WidgetCreator] Canvas deleted:', targetId, targetName);
5582
+ showDeleteCanvasDialog = false;
5004
5583
  userCanvases = userCanvases.filter(c => c.id !== targetId);
5005
5584
  if (canvasId === targetId) {
5006
5585
  const next = userCanvases[0] || null;
@@ -5016,6 +5595,10 @@
5016
5595
  }
5017
5596
  } catch (err) {
5018
5597
  console.error('[WidgetCreator] Failed to delete canvas:', targetId, err);
5598
+ // Keep the dialog open so the user can react; a partial failure leaves
5599
+ // the canvas intact on purpose (the API refuses to orphan widgets).
5600
+ deleteCanvasError = 'Could not delete this canvas. Some of its widgets could not be removed, so nothing was deleted.';
5601
+ return;
5019
5602
  }
5020
5603
  deleteCanvasTarget = null;
5021
5604
  }
@@ -5085,15 +5668,39 @@
5085
5668
  // console.log('Widgets for canvas:', canvasId, ':', widgets);
5086
5669
  syncOrphanedWidgetShapes();
5087
5670
 
5088
- // Auto-select: embedded deep-link > localStorage > latest
5671
+ // Auto-select: embedded deep-link > localStorage > latest.
5672
+ // Both sources must belong to THIS canvas — localStorage is shared across canvases,
5673
+ // so a saved id from a previous canvas would otherwise leak in as primary.
5089
5674
  if (embedded && forceCreateNewWidget && !initialWidgetId) {
5090
5675
  // console.log('[WidgetCreator] Skipping auto-select of existing widgets (new widget from site)');
5091
5676
  } else if (widgets.length > 0) {
5092
- const deepLinkedWidget = initialWidgetId
5093
- ? getWidgetById(initialWidgetId)
5677
+ const hostCanvasId = resolveHostCanvasId();
5678
+ // A saved widget is only trustworthy when we can PROVE it belongs here: the
5679
+ // canvas must be known and the widget's canvas_id must match (null canvas_id
5680
+ // is only adopted via an explicit primary pick, not via localStorage).
5681
+ const savedWidgetRaw = getSavedSelectedWidgetId()
5682
+ ? getWidgetById(getSavedSelectedWidgetId() as string)
5094
5683
  : null;
5095
- const savedWidgetId = getSavedSelectedWidgetId();
5096
- const savedWidget = savedWidgetId ? getWidgetById(savedWidgetId) : null;
5684
+ const savedWidget =
5685
+ savedWidgetRaw &&
5686
+ hostCanvasId &&
5687
+ widgetCanvasIdOf(
5688
+ savedWidgetRaw as { canvasId?: string | null; canvas_id?: string | null },
5689
+ ) === hostCanvasId
5690
+ ? savedWidgetRaw
5691
+ : null;
5692
+ if (savedWidgetRaw && !savedWidget) {
5693
+ console.warn(
5694
+ '[CreatorApp] Ignoring saved widget from another canvas',
5695
+ savedWidgetRaw.id?.slice(0, 8),
5696
+ 'host',
5697
+ hostCanvasId?.slice(0, 8) || 'unknown',
5698
+ );
5699
+ saveSelectedWidgetId(null);
5700
+ }
5701
+ // The explicit deep-link is validated by ensureEmbeddedWidgetDeepLink(); a stale
5702
+ // URL id is never used to auto-select a widget from another canvas.
5703
+ const deepLinkedWidget = initialWidgetId ? getWidgetById(initialWidgetId) : null;
5097
5704
  const widgetToSelect = deepLinkedWidget ?? savedWidget ?? widgets[0];
5098
5705
  if (deepLinkedWidget) {
5099
5706
  // console.log('[WidgetCreator] Selecting widget from embedded deep-link:', deepLinkedWidget.name);
@@ -5360,6 +5967,17 @@
5360
5967
 
5361
5968
  if (missingIds.length === 0) return;
5362
5969
 
5970
+ // Scope merge to THIS canvas: a shape can linger on several canvases (legacy canvas
5971
+ // duplication), but a widget belongs to exactly one. Merging foreign widgets is what
5972
+ // made another canvas's widgets show up in this canvas's list/picker.
5973
+ const hostCanvasId = resolveHostCanvasId();
5974
+ const belongsHere = (widget: WidgetSummary): boolean => {
5975
+ const widgetCanvas = widgetCanvasIdOf(
5976
+ widget as { canvasId?: string | null; canvas_id?: string | null },
5977
+ );
5978
+ return !widgetCanvas || !hostCanvasId || widgetCanvas === hostCanvasId;
5979
+ };
5980
+
5363
5981
  try {
5364
5982
  // console.log(
5365
5983
  // '[WidgetCreator] Merging',
@@ -5370,8 +5988,21 @@
5370
5988
  getWidgetsBatchRequest: { widgetIds: missingIds }
5371
5989
  });
5372
5990
  const batch = response as { widgets?: WidgetSummary[]; data?: { widgets?: WidgetSummary[] } };
5373
- const fetched = batch?.widgets ?? batch?.data?.widgets ?? [];
5374
- if (!Array.isArray(fetched) || fetched.length === 0) return;
5991
+ const fetchedRaw = batch?.widgets ?? batch?.data?.widgets ?? [];
5992
+ if (!Array.isArray(fetchedRaw) || fetchedRaw.length === 0) return;
5993
+ const fetched = fetchedRaw.filter(belongsHere);
5994
+ const foreignCount = fetchedRaw.length - fetched.length;
5995
+ if (foreignCount > 0) {
5996
+ console.warn(
5997
+ '[CreatorApp] Ignoring',
5998
+ foreignCount,
5999
+ 'widget(s) linked by shape but owned by another canvas',
6000
+ );
6001
+ // Keep the stale shape but stop treating it as a widget of this canvas.
6002
+ syncOrphanedWidgetShapes();
6003
+ emitCanvasWidgetsList('foreign-shapes');
6004
+ }
6005
+ if (fetched.length === 0) return;
5375
6006
 
5376
6007
  const previousById = new Map(
5377
6008
  widgets.filter((w) => w.id).map((w) => [w.id as string, w]),
@@ -5393,9 +6024,26 @@
5393
6024
  widgets.length > 0 &&
5394
6025
  !(embedded && forceCreateNewWidget && !initialWidgetId)
5395
6026
  ) {
6027
+ const hostCanvasId = resolveHostCanvasId();
6028
+ const primaryOnCanvas =
6029
+ (canvasPrimaryWidgetId && getWidgetById(canvasPrimaryWidgetId)) ||
6030
+ widgets.find((w: any) => {
6031
+ const cid = w.canvasId || w.canvas_id;
6032
+ return hostCanvasId && cid === hostCanvasId && w.id === canvasPrimaryWidgetId;
6033
+ }) ||
6034
+ null;
5396
6035
  const savedWidgetId = getSavedSelectedWidgetId();
5397
6036
  const savedWidget = savedWidgetId ? getWidgetById(savedWidgetId) : null;
5398
- applyInitialWidgetSelectionFromList(savedWidget || widgets[0]);
6037
+ const savedOnThisCanvas =
6038
+ savedWidget &&
6039
+ (!hostCanvasId ||
6040
+ widgetCanvasIdOf(savedWidget as { canvasId?: string; canvas_id?: string }) ===
6041
+ hostCanvasId)
6042
+ ? savedWidget
6043
+ : null;
6044
+ applyInitialWidgetSelectionFromList(
6045
+ primaryOnCanvas || savedOnThisCanvas || widgets[0],
6046
+ );
5399
6047
  }
5400
6048
  } catch (err) {
5401
6049
  console.warn('[WidgetCreator] Failed to merge widgets linked on canvas:', err);
@@ -5404,8 +6052,8 @@
5404
6052
 
5405
6053
  /**
5406
6054
  * Site embeds CreatorApp once; `initialWidgetId` props stay frozen in prod.
5407
- * Always prefer the live `?widget_id=` from the address bar so deep-link
5408
- * reopen / selection match the header (avoids Audio Player header + Image Slider panel).
6055
+ * Prefer live `?widget_id=` only when it belongs to the host canvas — otherwise
6056
+ * a stale widget_id from the previous canvas overwrites the header/primary.
5409
6057
  */
5410
6058
  function resolveEmbeddedDeepLinkWidgetId(): string | null {
5411
6059
  if (typeof window !== 'undefined') {
@@ -5419,6 +6067,84 @@
5419
6067
  return initialWidgetId ?? null;
5420
6068
  }
5421
6069
 
6070
+ function resolveHostCanvasId(): string | null {
6071
+ // Priority: the canvas id in the address bar is authoritative (it is what the user
6072
+ // opened and what a refresh resumes). It lives in the route path (`/canvas/<id>`).
6073
+ // `canvasId`/`sessionCanvasId` can still hold a stale value hydrated from
6074
+ // localStorage on a previous canvas, which is what allowed another canvas's widgets
6075
+ // to leak into this canvas's list/picker.
6076
+ const fromUrl = readCanvasIdFromAddressBar();
6077
+ return (
6078
+ fromUrl ||
6079
+ (typeof canvasId === 'string' && canvasId.trim() ? canvasId.trim() : null) ||
6080
+ (typeof sessionCanvasId === 'string' && sessionCanvasId.trim() ? sessionCanvasId.trim() : null) ||
6081
+ (typeof routeCanvasId === 'string' && routeCanvasId.trim() ? routeCanvasId.trim() : null)
6082
+ );
6083
+ }
6084
+
6085
+ function widgetCanvasIdOf(widget: {
6086
+ canvasId?: string | null;
6087
+ canvas_id?: string | null;
6088
+ } | null | undefined): string | null {
6089
+ if (!widget) return null;
6090
+ const cid = widget.canvasId ?? widget.canvas_id ?? null;
6091
+ return typeof cid === 'string' && cid.trim() ? cid.trim() : null;
6092
+ }
6093
+
6094
+ async function fetchWidgetCanvasId(widgetId: string): Promise<string | null> {
6095
+ const local = getWidgetById(widgetId);
6096
+ const localCanvas = widgetCanvasIdOf(local as { canvasId?: string; canvas_id?: string } | null);
6097
+ if (localCanvas) return localCanvas;
6098
+ if (!widgetsClient) return null;
6099
+ try {
6100
+ const batch = await widgetsClient.getWidgetsBatch({
6101
+ getWidgetsBatchRequest: { widgetIds: [widgetId] },
6102
+ });
6103
+ const row =
6104
+ (batch as { widgets?: Array<{ canvasId?: string; canvas_id?: string }> })?.widgets?.[0] ??
6105
+ (batch as { data?: { widgets?: Array<{ canvasId?: string; canvas_id?: string }> } })?.data
6106
+ ?.widgets?.[0];
6107
+ return widgetCanvasIdOf(row);
6108
+ } catch (err) {
6109
+ console.warn('[CreatorApp] Failed to fetch widget canvas for deep-link', widgetId.slice(0, 8), err);
6110
+ return null;
6111
+ }
6112
+ }
6113
+
6114
+ /**
6115
+ * `canvas_id` + the canvas's DB `primary_widget_id` are the source of truth for
6116
+ * "which widget is open on this canvas". A `widget_id` in the address bar is only a
6117
+ * transient intent read once at mount.
6118
+ *
6119
+ * IMPORTANT: this deliberately does NOT touch `widget_id` any more. Rewriting the URL
6120
+ * here used to (a) persist a stale id from a previously opened canvas as if it were the
6121
+ * current primary, and (b) trigger a host remount because the site keys CreatorApp's
6122
+ * mount on the URL params. Leaving the address bar untouched is what makes the primary
6123
+ * stable; the canvas's DB `primary_widget_id` decides what opens.
6124
+ */
6125
+ function syncUrlWidgetId(_widgetId: string | null): void {
6126
+ if (typeof window === 'undefined' || !embedded) return;
6127
+ syncEmbeddedDeepLinkDismissTracking();
6128
+ }
6129
+
6130
+ /** Ensure the address bar points at the canvas we actually loaded (refresh-safe). */
6131
+ function syncUrlCanvasId(canvasIdToSync: string | null): void {
6132
+ if (typeof window === 'undefined' || !embedded) return;
6133
+ if (!canvasIdToSync || !canvasIdToSync.trim()) return;
6134
+ try {
6135
+ const url = new URL(window.location.href);
6136
+ const nextPath = `/canvas/${encodeURIComponent(canvasIdToSync)}`;
6137
+ if (url.pathname === nextPath && !url.searchParams.has('canvas_id')) return;
6138
+ url.pathname = nextPath;
6139
+ // The canvas id belongs in the path only — never duplicated into the query string.
6140
+ url.searchParams.delete('canvas_id');
6141
+ url.searchParams.delete('canvas-id');
6142
+ window.history.replaceState({}, '', url.pathname + url.search + url.hash);
6143
+ } catch (err) {
6144
+ console.warn('[CreatorApp] Failed to sync canvas_id in URL:', err);
6145
+ }
6146
+ }
6147
+
5422
6148
  /**
5423
6149
  * Canvas-only embed deep-link (My widgets / canvas without widget_id):
5424
6150
  * read canvases.primary_widget_id so Creator can open that widget panel.
@@ -5435,20 +6161,44 @@
5435
6161
  (record as { primary_widget_id?: unknown })?.primary_widget_id ??
5436
6162
  null;
5437
6163
  const primaryId = typeof raw === 'string' && raw.trim() ? raw.trim() : null;
5438
- if (primaryId) {
5439
- canvasPrimaryWidgetId = primaryId;
5440
- // console.log(
5441
- // '[CreatorApp] Resolved primary widget for canvas',
5442
- // targetCanvasId.substring(0, 8),
5443
- // '→',
5444
- // primaryId.substring(0, 8)
5445
- // );
5446
- } else {
5447
- // console.log(
5448
- // '[CreatorApp] No primary_widget_id on canvas',
5449
- // targetCanvasId.substring(0, 8)
5450
- // );
6164
+ if (!primaryId) {
6165
+ return null;
5451
6166
  }
6167
+ // Validate primary belongs to this canvas (DB has many cross-canvas orphans).
6168
+ let widgetCanvasId: string | null = null;
6169
+ const local = getWidgetById(primaryId);
6170
+ if (local) {
6171
+ widgetCanvasId =
6172
+ (local as { canvasId?: string | null; canvas_id?: string | null }).canvasId ??
6173
+ (local as { canvasId?: string | null; canvas_id?: string | null }).canvas_id ??
6174
+ null;
6175
+ } else if (widgetsClient) {
6176
+ try {
6177
+ const batch = await widgetsClient.getWidgetsBatch({
6178
+ getWidgetsBatchRequest: { widgetIds: [primaryId] },
6179
+ });
6180
+ const row =
6181
+ (batch as { widgets?: Array<{ canvasId?: string; canvas_id?: string }> })?.widgets?.[0] ??
6182
+ (batch as { data?: { widgets?: Array<{ canvasId?: string; canvas_id?: string }> } })?.data
6183
+ ?.widgets?.[0];
6184
+ widgetCanvasId = row?.canvasId ?? row?.canvas_id ?? null;
6185
+ } catch (batchErr) {
6186
+ console.warn('[CreatorApp] Failed to validate primary widget canvas', batchErr);
6187
+ }
6188
+ }
6189
+ if (widgetCanvasId && widgetCanvasId !== targetCanvasId) {
6190
+ console.warn(
6191
+ '[CreatorApp] Canvas primary_widget_id points at another canvas — treating as empty',
6192
+ primaryId.slice(0, 8),
6193
+ 'target',
6194
+ targetCanvasId.slice(0, 8),
6195
+ 'widgetCanvas',
6196
+ widgetCanvasId.slice(0, 8),
6197
+ );
6198
+ canvasPrimaryWidgetId = null;
6199
+ return null;
6200
+ }
6201
+ canvasPrimaryWidgetId = primaryId;
5452
6202
  return primaryId;
5453
6203
  } catch (err) {
5454
6204
  console.warn(
@@ -5465,11 +6215,6 @@
5465
6215
  syncEmbeddedDeepLinkDismissTracking();
5466
6216
  if (!embedded || !widgetsClient || !hasValidToken) return;
5467
6217
  if (embeddedDeepLinkPanelDismissed) {
5468
- const dismissedId = resolveEmbeddedDeepLinkWidgetId();
5469
- // console.log(
5470
- // '[CreatorApp] Skip embedded deep-link select — user dismissed panel:',
5471
- // dismissedId ? dismissedId.substring(0, 8) : '(none)'
5472
- // );
5473
6218
  return;
5474
6219
  }
5475
6220
  // Select primary widget once per Creator mount. Do not auto-open Widget Details.
@@ -5477,70 +6222,79 @@
5477
6222
  return;
5478
6223
  }
5479
6224
  if (isConvertOrDraftPanelOpen()) {
5480
- // console.log('[CreatorApp] Skip embedded deep-link select — convert/draft panel is open');
5481
6225
  embeddedDeepLinkAutoOpenDone = true;
5482
6226
  return;
5483
6227
  }
5484
6228
 
6229
+ const hostCanvasId = resolveHostCanvasId();
5485
6230
  let deepLinkWidgetId = resolveEmbeddedDeepLinkWidgetId();
5486
- // Canvas-only deep-link: no ?widget_id= / initialWidgetId — select canvases.primary_widget_id.
5487
- if (!deepLinkWidgetId && canvasesClient) {
5488
- const targetCanvasId =
5489
- (typeof canvasId === 'string' && canvasId.trim() ? canvasId.trim() : null) ||
5490
- (typeof sessionCanvasId === 'string' && sessionCanvasId.trim() ? sessionCanvasId.trim() : null) ||
5491
- (typeof routeCanvasId === 'string' && routeCanvasId.trim() ? routeCanvasId.trim() : null);
5492
- if (targetCanvasId) {
5493
- deepLinkWidgetId = await resolvePrimaryWidgetIdForCanvas(targetCanvasId);
5494
- if (embeddedDeepLinkPanelDismissed || embeddedDeepLinkAutoOpenDone) {
5495
- return;
5496
- }
5497
- // Align address-bar widget_id so header / dismiss tracking match the opened panel.
5498
- if (deepLinkWidgetId && typeof window !== 'undefined') {
5499
- try {
5500
- const url = new URL(window.location.href);
5501
- if (url.searchParams.get('widget_id') !== deepLinkWidgetId) {
5502
- url.searchParams.set('widget_id', deepLinkWidgetId);
5503
- window.history.replaceState({}, '', url.pathname + url.search + url.hash);
5504
- syncEmbeddedDeepLinkDismissTracking();
5505
- }
5506
- } catch (err) {
5507
- console.warn('[CreatorApp] Failed to sync primary widget_id in URL:', err);
5508
- }
5509
- }
6231
+
6232
+ // Stale ?widget_id= from a previous canvas must NOT win over this canvas's primary.
6233
+ if (deepLinkWidgetId && hostCanvasId) {
6234
+ const widgetCanvasId = await fetchWidgetCanvasId(deepLinkWidgetId);
6235
+ if (widgetCanvasId && widgetCanvasId !== hostCanvasId) {
6236
+ console.warn(
6237
+ '[CreatorApp] Ignoring stale URL widget_id from another canvas',
6238
+ deepLinkWidgetId.slice(0, 8),
6239
+ 'widgetCanvas',
6240
+ widgetCanvasId.slice(0, 8),
6241
+ 'host',
6242
+ hostCanvasId.slice(0, 8),
6243
+ );
6244
+ deepLinkWidgetId = null;
6245
+ syncUrlWidgetId(null);
6246
+ saveSelectedWidgetId(null);
5510
6247
  }
5511
6248
  }
5512
- if (!deepLinkWidgetId) return;
6249
+
6250
+ if (!deepLinkWidgetId && hostCanvasId && canvasesClient) {
6251
+ deepLinkWidgetId = await resolvePrimaryWidgetIdForCanvas(hostCanvasId);
6252
+ if (embeddedDeepLinkPanelDismissed || embeddedDeepLinkAutoOpenDone) {
6253
+ return;
6254
+ }
6255
+ }
6256
+
6257
+ if (!deepLinkWidgetId) {
6258
+ embeddedDeepLinkAutoOpenDone = true;
6259
+ return;
6260
+ }
6261
+
6262
+ // Keep URL + localStorage aligned to THIS canvas's primary only.
6263
+ canvasPrimaryWidgetId = deepLinkWidgetId;
6264
+ saveSelectedWidgetId(deepLinkWidgetId);
6265
+ syncUrlWidgetId(deepLinkWidgetId);
5513
6266
 
5514
6267
  const requestGen = ++embeddedDeepLinkRequestGen;
6268
+ const expectedWidgetId = deepLinkWidgetId;
5515
6269
 
5516
6270
  function deepLinkOpenStillValid(): boolean {
5517
6271
  return (
5518
6272
  requestGen === embeddedDeepLinkRequestGen &&
5519
6273
  !embeddedDeepLinkPanelDismissed &&
5520
- resolveEmbeddedDeepLinkWidgetId() === deepLinkWidgetId
6274
+ canvasPrimaryWidgetId === expectedWidgetId
5521
6275
  );
5522
6276
  }
5523
6277
 
5524
6278
  const alreadyLinked =
5525
- selectedWidgetId === deepLinkWidgetId && !!getWidgetById(deepLinkWidgetId);
6279
+ selectedWidgetId === expectedWidgetId && !!getWidgetById(expectedWidgetId);
5526
6280
  if (alreadyLinked) {
5527
6281
  if (embeddedInitialStepPending && initialStep) {
5528
6282
  widgetDetailsStep = resolveCreatorWorkflowStep(initialStep);
5529
6283
  embeddedInitialStepPending = false;
5530
6284
  }
5531
6285
  embeddedDeepLinkAutoOpenDone = true;
5532
- if (canvasRef && deepLinkWidgetId) {
5533
- canvasRef.selectWidgetShape(deepLinkWidgetId);
5534
- }
6286
+ applyPrimarySelectionOnCanvas(expectedWidgetId);
6287
+ const primaryWidget = getWidgetById(expectedWidgetId);
6288
+ if (primaryWidget) notifyHostWidgetName(primaryWidget);
6289
+ emitCanvasWidgetsList('deeplink-already-linked');
5535
6290
  return;
5536
6291
  }
5537
6292
 
5538
6293
  try {
5539
6294
  const response = await widgetsClient.getWidgetsBatch({
5540
- getWidgetsBatchRequest: { widgetIds: [deepLinkWidgetId] }
6295
+ getWidgetsBatchRequest: { widgetIds: [expectedWidgetId] }
5541
6296
  });
5542
6297
  if (!deepLinkOpenStillValid()) {
5543
- // console.log('[CreatorApp] Stale embedded deep-link fetch — user changed selection or closed panel');
5544
6298
  return;
5545
6299
  }
5546
6300
 
@@ -5550,47 +6304,52 @@
5550
6304
  };
5551
6305
  const target = batch?.widgets?.[0] ?? batch?.data?.widgets?.[0];
5552
6306
  if (!target?.id) {
5553
- console.warn('[CreatorApp] Embedded deep-link widget not found:', deepLinkWidgetId);
6307
+ console.warn('[CreatorApp] Embedded deep-link widget not found:', expectedWidgetId);
5554
6308
  embeddedDeepLinkAutoOpenDone = true;
5555
6309
  return;
5556
6310
  }
5557
6311
 
5558
- const targetCanvasId =
5559
- (target as { canvasId?: string | null; canvas_id?: string | null }).canvasId ??
5560
- (target as { canvasId?: string | null; canvas_id?: string | null }).canvas_id ??
5561
- null;
6312
+ const targetCanvasId = widgetCanvasIdOf(
6313
+ target as { canvasId?: string | null; canvas_id?: string | null },
6314
+ );
6315
+ if (hostCanvasId && targetCanvasId && targetCanvasId !== hostCanvasId) {
6316
+ console.warn(
6317
+ '[CreatorApp] Deep-link widget does not belong to host canvas — abort select',
6318
+ target.id.slice(0, 8),
6319
+ 'target',
6320
+ targetCanvasId.slice(0, 8),
6321
+ 'host',
6322
+ hostCanvasId.slice(0, 8),
6323
+ );
6324
+ embeddedDeepLinkAutoOpenDone = true;
6325
+ const healed = await hydrateCanvasPrimaryWidget(
6326
+ [...(canvasRef?.getLinkedWidgetIds?.() ?? [])],
6327
+ );
6328
+ applyPrimarySelectionOnCanvas(healed);
6329
+ return;
6330
+ }
5562
6331
 
5563
6332
  if (!getWidgetById(target.id)) {
5564
6333
  widgets = sortWidgetSummaries([...widgets, target]);
5565
6334
  }
5566
6335
  if (!deepLinkOpenStillValid()) {
5567
- // console.log('[CreatorApp] Skip embedded deep-link open — stale after widget fetch');
5568
6336
  return;
5569
6337
  }
5570
6338
 
5571
6339
  embeddedDeepLinkAutoOpenDone = true;
5572
- // Select the primary widget (and its canvas shape) — do not open Widget Details.
6340
+ canvasPrimaryWidgetId = target.id;
5573
6341
  await selectWidget(target, { skipLoadWidget: true, skipPlaceShape: true });
5574
6342
  if (!deepLinkOpenStillValid()) return;
5575
- if (canvasRef) {
5576
- canvasRef.selectWidgetShape(target.id);
5577
- }
6343
+ applyPrimarySelectionOnCanvas(target.id);
6344
+ requestAnimationFrame(() => {
6345
+ setTimeout(() => applyPrimarySelectionOnCanvas(target.id), 80);
6346
+ });
6347
+ notifyHostWidgetName(target);
6348
+ emitCanvasWidgetsList('deeplink-primary');
5578
6349
  if (embeddedInitialStepPending && initialStep) {
5579
6350
  widgetDetailsStep = resolveCreatorWorkflowStep(initialStep);
5580
6351
  embeddedInitialStepPending = false;
5581
6352
  }
5582
-
5583
- if (targetCanvasId && targetCanvasId !== canvasId) {
5584
- // console.log('[CreatorApp] Switching canvas for embedded deep-link:', targetCanvasId);
5585
- routeCanvasId = targetCanvasId;
5586
- canvasId = targetCanvasId;
5587
- saveSelectedCanvasId(canvasId);
5588
- isLoadingCanvasOnce = false;
5589
- isLoadingWidgetsOnce = false;
5590
- lastWidgetsCanvasId = null;
5591
- void loadCurrentCanvas();
5592
- return;
5593
- }
5594
6353
  } catch (err) {
5595
6354
  console.error('[CreatorApp] ensureEmbeddedWidgetDeepLink failed:', err);
5596
6355
  embeddedDeepLinkAutoOpenDone = true;
@@ -5993,7 +6752,11 @@
5993
6752
  ...(lastCommitId ? { hasGeneratedCode: true } : {}),
5994
6753
  });
5995
6754
  if (lastCommitId) hasGeneratedCode = true;
5996
- 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) {
5997
6760
  // Load history against the specific widget id (not selectedWidgetId) so the
5998
6761
  // panel snapshot is guaranteed to be marked publishHistoryResolved even if the
5999
6762
  // user switches widget between hydrate and API response.
@@ -6143,6 +6906,11 @@
6143
6906
  }
6144
6907
 
6145
6908
  notifyHostWidgetName({ id: widgetId, name: newName });
6909
+ emitCanvasWidgetsList('widget-renamed');
6910
+ widgetNamesRevision += 1;
6911
+ panelDisplayRevision += 1;
6912
+ backfillWidgetShapeMetadata();
6913
+ syncWidgetShapeLivePreviews();
6146
6914
  // console.log('[WidgetRename] Successfully renamed widget:', widgetId, 'to:', newName);
6147
6915
  showToast('success', `Widget renamed to "${newName}"`);
6148
6916
  } catch (error) {
@@ -6337,26 +7105,37 @@
6337
7105
  * Aborts the polling and resets UI state
6338
7106
  */
6339
7107
  function stopGeneration() {
6340
- // Abort the generation for the currently selected widget
6341
- if (selectedWidgetId) {
6342
- const controller = widgetAbortControllers.get(selectedWidgetId);
7108
+ // Prefer the focused panel widget (Stop lives in that panel's Chat), then selected, then all in-flight.
7109
+ const stopTargetIds = Array.from(
7110
+ new Set(
7111
+ [focusedPanelWidgetId, selectedWidgetId, ...Object.keys(generatingWidgetIds)].filter(
7112
+ (id): id is string => typeof id === 'string' && id.length > 0,
7113
+ ),
7114
+ ),
7115
+ );
7116
+
7117
+ for (const widgetId of stopTargetIds) {
7118
+ const controller = widgetAbortControllers.get(widgetId);
6343
7119
  if (controller) {
6344
7120
  controller.abort();
6345
- widgetAbortControllers.delete(selectedWidgetId);
7121
+ widgetAbortControllers.delete(widgetId);
6346
7122
  }
6347
- const { [selectedWidgetId]: _, ...restStop } = generatingWidgetIds;
6348
- generatingWidgetIds = restStop;
7123
+ clearActiveOperationId(widgetId);
6349
7124
  }
7125
+
7126
+ generatingWidgetIds = {};
6350
7127
  // Legacy fallback
6351
7128
  if (codeGenerationAbortController) {
6352
7129
  codeGenerationAbortController.abort();
6353
7130
  codeGenerationAbortController = null;
6354
7131
  }
6355
-
7132
+
7133
+ currentGenerationOperationId = null;
6356
7134
  isGeneratingCode = false;
6357
7135
  setCrossTabGenerating(false);
6358
7136
  generateCodeStatus = '⏹ Generation stopped by user';
6359
- getChatMethodsForWidget(selectedWidgetId)?.updateLatestCodegenStatusMessage?.(
7137
+ const statusWidgetId = focusedPanelWidgetId || selectedWidgetId || stopTargetIds[0] || null;
7138
+ getChatMethodsForWidget(statusWidgetId)?.updateLatestCodegenStatusMessage?.(
6360
7139
  'Generation stopped by user. You can Retry this prompt.',
6361
7140
  );
6362
7141
  showToast('info', 'Generation stopped', {
@@ -6863,12 +7642,44 @@
6863
7642
  // Convert drafts capture from the canvas shape, never by opening a panel.
6864
7643
  if (!openWidgetPanelIds.includes(widgetId)) {
6865
7644
  if (quiet || isConvertOrDraftPanelOpen()) {
6866
- const shapeDataUrl = canvasRef?.getWidgetShapeDataUrl?.(widgetId);
6867
- if (shapeDataUrl && canvasRef) {
6868
- canvasRef.updateWidgetImage(widgetId, shapeDataUrl);
6869
- void persistWidgetListThumbnail(widgetId, shapeDataUrl);
6870
- } else {
6871
- // 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
+ }
6872
7683
  }
6873
7684
  return;
6874
7685
  }
@@ -7522,6 +8333,18 @@ Requirements:
7522
8333
  // );
7523
8334
  }
7524
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
+
7525
8348
  // Always reconcile GitLab HEAD after codegen (metadata.commitId can be missing/stale).
7526
8349
  if (generationWidgetId) {
7527
8350
  void (async () => {
@@ -7659,37 +8482,49 @@ Requirements:
7659
8482
  // cache-busts the same URL and looks like an extra reload.
7660
8483
  // console.log('[CreatorApp] Skipping extra preview remount — Worker iframe already showing');
7661
8484
  activePanelRef?.setPreviewLoadingState(false);
8485
+ // previewReady may not re-fire when we skip remount — clear stuck status.
8486
+ patchCodegenUiState(generationWidgetId, {
8487
+ generateCodeStatus: `Code generation completed!${commitShort ? ` Commit: ${commitShort}` : ''} Preview ready.`,
8488
+ });
7662
8489
  } else {
7663
8490
  activePanelRef?.setPreviewLoadingState(true);
7664
8491
  await activePanelRef?.refreshPreview?.({ withRetry: true, skipIfAlreadyShowing: true });
7665
8492
  }
7666
8493
  activePanelRef?.setPreviewBuildError(null);
8494
+ // Show Worker preview on the canvas shape (not the previous Live CDN artifact).
8495
+ shapePreviewSourceByWidgetId = {
8496
+ ...shapePreviewSourceByWidgetId,
8497
+ [generationWidgetId]: 'preview',
8498
+ };
8499
+ syncWidgetShapeLivePreviews();
8500
+ panelDisplayRevision += 1;
7667
8501
  if (!completedPreviewBuildErr) {
7668
8502
  await applyDesignSchemaDefaultsToPreview(generationWidgetId);
7669
8503
  pendingCreatePreviewDefaultsWidgetId = null;
7670
8504
  panelChat?.updateLatestCodegenStatusMessage?.(
7671
8505
  `Code generation completed!${commitShort ? ` Commit: ${commitShort}` : ''}\nPreview ready.`,
7672
8506
  );
8507
+ patchCodegenUiState(generationWidgetId, {
8508
+ generateCodeStatus: `Code generation completed!${commitShort ? ` Commit: ${commitShort}` : ''} Preview ready.`,
8509
+ });
7673
8510
  setTimeout(async () => {
7674
8511
  await panelChat?.reloadConversationMessages?.();
7675
8512
  panelChat?.markWidgetHeadAtLatestCommit?.();
7676
8513
  }, 2500);
7677
- setTimeout(() => handleRefreshScreenshot(generationWidgetId, { quiet: true }), 3000);
7678
- void (async () => {
7679
- try {
7680
- console.log('[Publish] Auto-publishing after codegen', generationWidgetId.substring(0, 8));
7681
- await executePublishWidget(generationWidgetId, { silent: true, fromCodegen: true });
7682
- panelChat?.updateLatestCodegenStatusMessage?.(
7683
- `Code generation completed!${commitShort ? ` Commit: ${commitShort}` : ''}\nPreview ready. Live published.`,
7684
- );
7685
- } catch (autoPublishErr) {
7686
- console.warn('[Publish] Auto-publish after codegen failed:', autoPublishErr);
7687
- }
7688
- })();
8514
+ setTimeout(() => {
8515
+ void (async () => {
8516
+ await handleRefreshScreenshot(generationWidgetId, { quiet: true });
8517
+ // List thumb only from live iframe capture — never Fabric shape icon.
8518
+ })();
8519
+ }, 3000);
8520
+ // Do NOT auto-publish — user previews first, then clicks Publish / Set live.
7689
8521
  } else {
7690
8522
  panelChat?.updateLatestCodegenStatusMessage?.(
7691
8523
  `Code saved${commitShort ? ` (${commitShort})` : ''} — preview rebuilt from saved code.`,
7692
8524
  );
8525
+ patchCodegenUiState(generationWidgetId, {
8526
+ generateCodeStatus: `Code saved${commitShort ? ` (${commitShort})` : ''} — preview rebuilt from saved code.`,
8527
+ });
7693
8528
  }
7694
8529
  }, completedPreviewBuildErr ? 400 : 800);
7695
8530
  } else {
@@ -7905,11 +8740,26 @@ Requirements:
7905
8740
  */
7906
8741
  async function executePublishWidget(
7907
8742
  publishForWidgetId?: string | null,
7908
- publishOpts: { silent?: boolean; fromCodegen?: boolean } = {},
8743
+ publishOpts: { silent?: boolean; fromCodegen?: boolean; targetVersion?: number | null } = {},
7909
8744
  ) {
7910
8745
  const capturedWidgetId = publishForWidgetId ?? focusedPanelWidgetId ?? selectedWidgetId;
7911
8746
  const panelState = capturedWidgetId ? getPanelDisplayState(capturedWidgetId) : null;
7912
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;
7913
8763
  let capturedRepositoryId = resolveRepositoryIdForWidget(capturedWidgetId);
7914
8764
  if (!capturedRepositoryId && capturedWidgetId) {
7915
8765
  const widget = getWidgetById(capturedWidgetId) ?? selectedWidget;
@@ -7918,10 +8768,16 @@ Requirements:
7918
8768
  if (capturedRepositoryId) stampWidgetRepositoryId(capturedWidgetId, capturedRepositoryId);
7919
8769
  }
7920
8770
  }
7921
- const commitShaForPublish =
7922
- panelState?.lastCommitId
7923
- || (capturedWidgetId ? loadCommitIdForWidget(capturedWidgetId) : null)
7924
- || 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);
7925
8781
 
7926
8782
  if (!capturedWidgetId || !capturedCanvasId || !capturedRepositoryId) {
7927
8783
  showToast('error', 'Cannot publish', { description: 'Widget, canvas, and repository are required.' });
@@ -7990,7 +8846,10 @@ Requirements:
7990
8846
  widgetId: capturedWidgetId,
7991
8847
  agentWidgetBuilderPublishWidgetIdPostRequest: {
7992
8848
  componentId,
7993
- 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
7994
8853
  }
7995
8854
  });
7996
8855
  // console.log('[Publish] Widget Builder publish completed:', publishData);
@@ -8099,6 +8958,7 @@ Requirements:
8099
8958
  selectedWidgetId = null;
8100
8959
  ignoreIncomingCanvasLoads = true;
8101
8960
  canvasLoadEpoch += 1;
8961
+ discardPendingCooldownAutosave();
8102
8962
  lastCommitId = null;
8103
8963
  lastPublishedVersion = null;
8104
8964
  widgetDetailsStep = 'create';
@@ -8132,22 +8992,21 @@ Requirements:
8132
8992
  window.addEventListener('storage', handleCrossTabStorageEvent);
8133
8993
  window.addEventListener('widgetic:session-extended', handleSiteSessionExtended);
8134
8994
 
8135
- // Clean up cross-tab flags when tab is closed/refreshed
8136
- const handleBeforeUnload = () => {
8137
- for (const wId of Object.keys(generatingWidgetIds)) {
8138
- setCrossTabGenerating(false, wId);
8139
- }
8140
- if (isPublishing) setCrossTabPublishing(false);
8141
- };
8995
+ // Clean up cross-tab flags when tab is closed/refreshed. The handler lives at
8996
+ // component scope so `onDestroy` can remove the same reference.
8142
8997
  window.addEventListener('beforeunload', handleBeforeUnload);
8143
8998
 
8144
- // Read canvas id from route (supports /?canvasId=uuid). Skip in embedded when prop provided.
8145
- if (!canvasId) {
8999
+ // Read canvas id from the route. The route path (`/canvas/<canvasId>`) is the
9000
+ // AUTHORITATIVE canvas (site links, refresh, deep-links); localStorage is only a
9001
+ // fallback and must never win over it.
9002
+ if (!routeCanvasId) {
8146
9003
  try {
8147
9004
  const sub = pageStore.subscribe(($page) => {
8148
- const qId =
8149
- ($page?.url?.searchParams?.get && $page.url.searchParams.get('canvas-id')) || null;
8150
- routeCanvasId = qId;
9005
+ // `readCanvasIdFromAddressBar()` also covers the legacy query string, so an
9006
+ // old bookmark `/canvas?canvas_id=…` still resolves to its canvas.
9007
+ const nextCanvasId = readCanvasIdFromAddressBar();
9008
+ // Never clobber an already-known canvas with null.
9009
+ if (nextCanvasId) routeCanvasId = nextCanvasId;
8151
9010
  });
8152
9011
  setTimeout(() => {
8153
9012
  if (sub && typeof sub === 'function') sub();
@@ -8439,6 +9298,10 @@ Requirements:
8439
9298
  function formatCodegenProgressStatus(op: OperationStatus): string {
8440
9299
  const stage = getOperationStageMessage(op.metadata as Record<string, unknown> | undefined);
8441
9300
  const pct = typeof op.progress === 'number' ? op.progress : null;
9301
+ // User mode: never expose model names / provider in the status line.
9302
+ if (!debugMode) {
9303
+ return pct != null && pct > 0 ? `Generating code... (${pct}%)` : 'Generating code...';
9304
+ }
8442
9305
  if (stage) {
8443
9306
  return pct != null && pct > 0 ? `${stage} (${pct}%)` : stage;
8444
9307
  }
@@ -8468,13 +9331,22 @@ Requirements:
8468
9331
  >,
8469
9332
  ): void {
8470
9333
  ensurePanelState(widgetId);
8471
- patchPanelSnapshot(widgetId, patch);
9334
+ const nextPatch = { ...patch };
9335
+ if (typeof nextPatch.generateCodeStatus === 'string' && !debugMode) {
9336
+ nextPatch.generateCodeStatus = nextPatch.generateCodeStatus
9337
+ .replace(/\s*Commit:\s*[a-f0-9]{7,40}/gi, '')
9338
+ .replace(/\s*\(\s*Commit:\s*[a-f0-9]{7,40}\s*\)/gi, '')
9339
+ .replace(/\s*\(\s*[a-f0-9]{7,40}\s*\)/g, '')
9340
+ .replace(/\s{2,}/g, ' ')
9341
+ .trim();
9342
+ }
9343
+ patchPanelSnapshot(widgetId, nextPatch);
8472
9344
  if (widgetId === focusedPanelWidgetId) {
8473
- if (patch.generateCodeStatus !== undefined) generateCodeStatus = patch.generateCodeStatus;
8474
- if (patch.lastGenerationFailed !== undefined) lastGenerationFailed = patch.lastGenerationFailed;
8475
- if (patch.lastGenerationError !== undefined) lastGenerationError = patch.lastGenerationError;
8476
- if (patch.lastCommitId !== undefined) lastCommitId = patch.lastCommitId;
8477
- if (patch.hasGeneratedCode !== undefined) hasGeneratedCode = patch.hasGeneratedCode;
9345
+ if (nextPatch.generateCodeStatus !== undefined) generateCodeStatus = nextPatch.generateCodeStatus;
9346
+ if (nextPatch.lastGenerationFailed !== undefined) lastGenerationFailed = nextPatch.lastGenerationFailed;
9347
+ if (nextPatch.lastGenerationError !== undefined) lastGenerationError = nextPatch.lastGenerationError;
9348
+ if (nextPatch.lastCommitId !== undefined) lastCommitId = nextPatch.lastCommitId;
9349
+ if (nextPatch.hasGeneratedCode !== undefined) hasGeneratedCode = nextPatch.hasGeneratedCode;
8478
9350
  }
8479
9351
  }
8480
9352
 
@@ -8614,13 +9486,15 @@ Requirements:
8614
9486
  compositionSchemasLoading: boolean;
8615
9487
  compositionSchemasError: string | null;
8616
9488
  schemaLoadStartedAt: number | null;
8617
- chatContainerHeight: number;
9489
+ chatContainerHeight: number; // 0 = fill available; >0 = user-resized fixed px
8618
9490
  panelPreviewCompileError: string | null;
8619
9491
  };
8620
9492
 
8621
9493
  let widgetPanelSnapshots: Record<string, WidgetPanelSnapshot> = {};
8622
9494
  /** Bumped on every panel snapshot write so {#each} panels re-read display state. */
8623
9495
  let panelDisplayRevision = 0;
9496
+ /** Bumped on rename so shape chrome / host title re-read widget names. */
9497
+ let widgetNamesRevision = 0;
8624
9498
  const schemaLoadInFlight = new Set<string>();
8625
9499
  const schemaLoadPromises = new Map<string, Promise<void>>();
8626
9500
  const compositionsLoadInFlight = new Set<string>();
@@ -8716,7 +9590,7 @@ Requirements:
8716
9590
  compositionSchemasLoading,
8717
9591
  compositionSchemasError,
8718
9592
  schemaLoadStartedAt: focusedSnap?.schemaLoadStartedAt ?? null,
8719
- chatContainerHeight: focusedSnap?.chatContainerHeight ?? CHAT_DEFAULT_HEIGHT,
9593
+ chatContainerHeight: focusedSnap?.chatContainerHeight ?? 0,
8720
9594
  panelPreviewCompileError: focusedSnap?.panelPreviewCompileError ?? null,
8721
9595
  };
8722
9596
  }
@@ -8782,7 +9656,7 @@ Requirements:
8782
9656
  compositionSchemasLoading: false,
8783
9657
  compositionSchemasError: null,
8784
9658
  schemaLoadStartedAt: null,
8785
- chatContainerHeight: 550,
9659
+ chatContainerHeight: 0,
8786
9660
  panelPreviewCompileError: null,
8787
9661
  };
8788
9662
  }
@@ -8838,7 +9712,7 @@ Requirements:
8838
9712
  : livePublishMatchesPanel
8839
9713
  ? live.publishHistoryResolvedForWidgetId
8840
9714
  : snap.publishHistoryResolvedForWidgetId,
8841
- chatContainerHeight: snap.chatContainerHeight ?? CHAT_DEFAULT_HEIGHT,
9715
+ chatContainerHeight: snap.chatContainerHeight ?? 0,
8842
9716
  compositionDesignSchema: resolvedDesignSchema,
8843
9717
  compositionContentSchema: resolvedContentSchema,
8844
9718
  compositionSchemasLoading: schemaLoading,
@@ -8856,7 +9730,7 @@ Requirements:
8856
9730
  ...snap,
8857
9731
  lastCommitId: snap.lastCommitId || loadCommitIdForWidget(widgetId),
8858
9732
  hasGeneratedCode: !!(snap.hasGeneratedCode || snap.lastCommitId || loadCommitIdForWidget(widgetId)),
8859
- chatContainerHeight: snap.chatContainerHeight ?? CHAT_DEFAULT_HEIGHT,
9733
+ chatContainerHeight: snap.chatContainerHeight ?? 0,
8860
9734
  compositionDesignSchema: resolvedDesignSchema,
8861
9735
  compositionContentSchema: resolvedContentSchema,
8862
9736
  compositionSchemasError: resolvedSchemaError,
@@ -9001,43 +9875,391 @@ Requirements:
9001
9875
  return resolvePublishedWidgetUrl(widgetId, version, jsPath);
9002
9876
  }
9003
9877
 
9004
- /** Widget Builder origin for canvas shape iframes (mirrors WidgetDetails). */
9878
+ /** Public display origin for shape/hidden capture iframes; keep WidgetDetails fetch proxy separate. */
9005
9879
  function widgetBuilderPreviewBaseUrl(): string {
9006
- const publicBase = (
9880
+ // Do not proxy sandboxed display frames onto the editor's origin.
9881
+ return (
9007
9882
  widgetBuilderUrl ||
9008
9883
  import.meta.env.VITE_WIDGET_BUILDER_URL ||
9009
9884
  import.meta.env.PUBLIC_WIDGET_BUILDER_URL ||
9010
9885
  'https://wbuilder.widgetic.com'
9011
9886
  ).replace(/\/+$/, '');
9012
- if (import.meta.env.DEV && publicBase.includes('wbuilder.widgetic.com')) {
9013
- return '/__widget_builder__';
9014
- }
9015
- return publicBase;
9016
9887
  }
9017
9888
 
9018
9889
  /**
9019
9890
  * Live preview URL for a canvas WidgetShape overlay.
9020
- * Prefer published CDN when available; else Worker /preview when code exists.
9021
- * Returns null → screenshot-only fallback on the Fabric shape.
9891
+ * Source: 'live' | 'preview' | 'v:{N}' (historical published version).
9892
+ * Publish history is loaded without requiring chat to be open.
9022
9893
  */
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
+ }
9917
+ let shapePublishHistoryByWidgetId: Record<string, any[]> = {};
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> = {};
9930
+
9931
+ async function ensureShapePublishHistory(widgetId: string): Promise<void> {
9932
+ if (!widgetId || shapeHistoryLoadInFlight[widgetId]) return;
9933
+ // Already fetched once — an empty history is a valid, final answer.
9934
+ if (shapeHistoryResolvedByWidgetId[widgetId]) return;
9935
+ const cached = shapePublishHistoryByWidgetId[widgetId];
9936
+ if (cached && cached.length > 0) {
9937
+ shapeHistoryResolvedByWidgetId = { ...shapeHistoryResolvedByWidgetId, [widgetId]: true };
9938
+ return;
9939
+ }
9940
+ const fromPanel = getPanelDisplayState(widgetId).publishHistory;
9941
+ if (fromPanel && fromPanel.length > 0) {
9942
+ shapePublishHistoryByWidgetId = {
9943
+ ...shapePublishHistoryByWidgetId,
9944
+ [widgetId]: [...fromPanel],
9945
+ };
9946
+ shapeHistoryResolvedByWidgetId = { ...shapeHistoryResolvedByWidgetId, [widgetId]: true };
9947
+ return;
9948
+ }
9949
+ shapeHistoryLoadInFlight = { ...shapeHistoryLoadInFlight, [widgetId]: true };
9950
+ try {
9951
+ await loadPublishHistoryForPanel(widgetId, { silent: true });
9952
+ const history = getPanelDisplayState(widgetId).publishHistory || [];
9953
+ shapePublishHistoryByWidgetId = {
9954
+ ...shapePublishHistoryByWidgetId,
9955
+ [widgetId]: [...history],
9956
+ };
9957
+ shapeHistoryResolvedByWidgetId = { ...shapeHistoryResolvedByWidgetId, [widgetId]: true };
9958
+ syncWidgetShapeLivePreviews();
9959
+ } finally {
9960
+ const { [widgetId]: _, ...rest } = shapeHistoryLoadInFlight;
9961
+ shapeHistoryLoadInFlight = rest;
9962
+ }
9963
+ }
9964
+
9965
+ function getShapePublishHistory(widgetId: string): any[] {
9966
+ const cached = shapePublishHistoryByWidgetId[widgetId];
9967
+ // Honour a cached empty array: it means the fetch already happened.
9968
+ if (cached) return cached;
9969
+ const fromPanel = getPanelDisplayState(widgetId).publishHistory;
9970
+ return fromPanel?.length ? fromPanel : [];
9971
+ }
9972
+
9023
9973
  function resolveWidgetShapeLivePreviewUrl(widgetId: string): string | null {
9024
9974
  if (!widgetId) return null;
9975
+ const source = shapePreviewSourceByWidgetId[widgetId] || 'live';
9976
+ const ps = getPanelDisplayState(widgetId);
9977
+ const hasCode = !!(ps.lastCommitId || loadCommitIdForWidget(widgetId));
9978
+ const workerUrl = hasCode
9979
+ ? `${widgetBuilderPreviewBaseUrl()}/preview/${widgetId}`
9980
+ : null;
9981
+
9982
+ if (source.startsWith('v:')) {
9983
+ const version = parseInt(source.slice(2), 10);
9984
+ if (Number.isFinite(version)) {
9985
+ const record = getShapePublishHistory(widgetId).find(
9986
+ (row) => Number(row.version) === version,
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);
9991
+ const artifact =
9992
+ record?.artifactUrl ||
9993
+ record?.artifact_url ||
9994
+ (version === ps.lastPublishedVersion ? ps.selectedWidgetJsPath : null);
9995
+
9996
+ if (isPublished && artifact) {
9997
+ const url = resolvePublishedWidgetUrl(widgetId, version, artifact);
9998
+ if (url) return url;
9999
+ }
10000
+ return resolveWidgetShapeVersionPreviewState(widgetId)?.url ?? null;
10001
+ }
10002
+ return null;
10003
+ }
10004
+
10005
+ // After codegen (unpublished), prefer Worker preview over the Live CDN artifact.
10006
+ if (source === 'preview' || ps.pendingPublishAfterCodegen) {
10007
+ if (workerUrl) return workerUrl;
10008
+ }
10009
+
9025
10010
  const published = getPanelEditPreviewUrl(widgetId);
9026
- if (published) return published;
10011
+ if (published && source !== 'preview') return published;
10012
+ return workerUrl;
10013
+ }
9027
10014
 
9028
- const widget = getWidgetById(widgetId);
9029
- if (!widget) return null;
10015
+ function resolveWidgetShapeVersionLabel(widgetId: string): string | null {
10016
+ const source = shapePreviewSourceByWidgetId[widgetId] || 'live';
9030
10017
  const ps = getPanelDisplayState(widgetId);
9031
- const hasCode = !!(ps.lastCommitId || loadCommitIdForWidget(widgetId));
9032
- if (!hasCode) return null;
10018
+ const liveVersion =
10019
+ ps.lastPublishedVersion ?? resolvePublishedVersionFromWidget(getWidgetById(widgetId));
10020
+ if (source.startsWith('v:')) {
10021
+ const version = parseInt(source.slice(2), 10);
10022
+ if (!Number.isFinite(version)) return null;
10023
+ return `v${version}`;
10024
+ }
10025
+ return liveVersion != null ? `v${liveVersion}` : null;
10026
+ }
10027
+
10028
+ function formatPublishHistoryDate(record: {
10029
+ publishedAt?: string;
10030
+ published_at?: string;
10031
+ }): string {
10032
+ const d = new Date(record.publishedAt ?? record.published_at ?? '');
10033
+ if (isNaN(d.getTime())) return '—';
10034
+ const month = String(d.getMonth() + 1).padStart(2, '0');
10035
+ const day = String(d.getDate()).padStart(2, '0');
10036
+ const timePart = d.toLocaleTimeString(undefined, {
10037
+ hour: '2-digit',
10038
+ minute: '2-digit',
10039
+ hour12: false,
10040
+ });
10041
+ return `${month}/${day}/${d.getFullYear()} ${timePart}`;
10042
+ }
10043
+
10044
+ function resolveWidgetShapeVersionOptions(
10045
+ widgetId: string,
10046
+ ): Array<{
10047
+ id: string;
10048
+ label: string;
10049
+ shortLabel?: string;
10050
+ dateLabel?: string;
10051
+ isLive?: boolean;
10052
+ isDraft?: boolean;
10053
+ /** Immutable commit; Restore Checkpoint reuses this artifact when it matches. */
10054
+ commitSha?: string;
10055
+ }> | null {
10056
+ const ps = getPanelDisplayState(widgetId);
10057
+ const liveVersion =
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.
10061
+ const history = [...getShapePublishHistory(widgetId)].sort(
10062
+ (a, b) => Number(b.version) - Number(a.version),
10063
+ );
10064
+ const options: Array<{
10065
+ id: string;
10066
+ label: string;
10067
+ shortLabel?: string;
10068
+ dateLabel?: string;
10069
+ isLive?: boolean;
10070
+ isDraft?: boolean;
10071
+ }> = [];
10072
+ for (const record of history) {
10073
+ const version = Number(record.version);
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);
10079
+ options.push({
10080
+ id: `v:${version}`,
10081
+ label: `Version ${version}`,
10082
+ shortLabel: `v${version}`,
10083
+ dateLabel: formatPublishHistoryDate(record),
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,
10088
+ });
10089
+ }
10090
+ if (options.length === 0 && liveVersion != null) {
10091
+ options.push({
10092
+ id: `v:${liveVersion}`,
10093
+ label: `Version ${liveVersion}`,
10094
+ shortLabel: `v${liveVersion}`,
10095
+ dateLabel: '—',
10096
+ isLive: true,
10097
+ });
10098
+ }
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) {
10103
+ void ensureShapePublishHistory(widgetId);
10104
+ }
10105
+ return options.length > 0 ? options : null;
10106
+ }
9033
10107
 
9034
- return `${widgetBuilderPreviewBaseUrl()}/preview/${widgetId}`;
10108
+ function resolveWidgetShapeVersionSelectedId(widgetId: string): string | null {
10109
+ const source = shapePreviewSourceByWidgetId[widgetId];
10110
+ if (source?.startsWith('v:')) return source;
10111
+ const ps = getPanelDisplayState(widgetId);
10112
+ const liveVersion =
10113
+ ps.lastPublishedVersion ?? resolvePublishedVersionFromWidget(getWidgetById(widgetId));
10114
+ return liveVersion != null ? `v:${liveVersion}` : null;
10115
+ }
10116
+
10117
+ function handleWidgetShapeVersionChange(widgetId: string, versionId: string, buildPreview = true): void {
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;
10123
+ shapePreviewSourceByWidgetId = {
10124
+ ...shapePreviewSourceByWidgetId,
10125
+ [widgetId]: versionId,
10126
+ };
10127
+ shapeVersionPreviewRequestId += 1;
10128
+ shapeVersionPreviewByWidgetId = {
10129
+ ...shapeVersionPreviewByWidgetId,
10130
+ [widgetId]: {
10131
+ versionId, requestId: shapeVersionPreviewRequestId,
10132
+ loading: false, error: null, url: null,
10133
+ },
10134
+ };
10135
+ syncWidgetShapeLivePreviews();
10136
+ panelDisplayRevision += 1;
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
+ }
10213
+ }
10214
+
10215
+ /** Commit SHA for the version currently selected in the version menu (drives Head-at-commit badge). */
10216
+ function resolveViewingCommitShaForPanel(widgetId: string): string | null {
10217
+ const ps = getPanelDisplayState(widgetId);
10218
+ const source = shapePreviewSourceByWidgetId[widgetId] || 'live';
10219
+ if (source === 'preview') {
10220
+ return normalizeCommitSha(ps.lastCommitId);
10221
+ }
10222
+ let version: number | null = null;
10223
+ if (source.startsWith('v:')) {
10224
+ const parsed = parseInt(source.slice(2), 10);
10225
+ version = Number.isFinite(parsed) ? parsed : null;
10226
+ } else {
10227
+ version = ps.lastPublishedVersion ?? resolvePublishedVersionFromWidget(getWidgetById(widgetId));
10228
+ }
10229
+ if (version == null) return normalizeCommitSha(ps.lastCommitId);
10230
+ const history = ps.publishHistory?.length ? ps.publishHistory : publishHistory;
10231
+ const record = history.find((r) => Number(r.version) === Number(version));
10232
+ const fromHistory = normalizeCommitSha(record?.commitSha ?? record?.commit_sha);
10233
+ if (fromHistory) return fromHistory;
10234
+ // Live version with no history row yet — fall back to published SHA, then HEAD.
10235
+ if (Number(version) === Number(ps.lastPublishedVersion)) {
10236
+ return (
10237
+ normalizeCommitSha(getLivePublishedCommitShaForPanel(widgetId))
10238
+ ?? normalizeCommitSha(ps.lastCommitId)
10239
+ );
10240
+ }
10241
+ return normalizeCommitSha(ps.lastCommitId);
9035
10242
  }
9036
10243
 
9037
10244
  function syncWidgetShapeLivePreviews(): void {
9038
10245
  try {
9039
10246
  canvasSelectedWidgetShapeId = canvasRef?.getSelectedWidgetShapeId?.() ?? null;
9040
10247
  widgetShapeLivePreviewsRef?.sync?.();
10248
+ // Hide Fabric screenshots under live iframes (avoids ghost bleed in padding).
10249
+ const rects = canvasRef?.listWidgetShapeScreenRects?.() ?? [];
10250
+ const liveWidgetIds = new Set<string>();
10251
+ for (const rect of rects) {
10252
+ if (!rect?.widgetId) continue;
10253
+ if (!resolveWidgetShapeLivePreviewUrl(rect.widgetId)) continue;
10254
+ liveWidgetIds.add(rect.widgetId);
10255
+ canvasRef?.setWidgetShapeLivePreviewHiddenImage?.(rect.widgetId, true);
10256
+ }
10257
+ const linkedIds = canvasRef?.getLinkedWidgetIds?.() ?? new Set<string>();
10258
+ for (const widgetId of linkedIds) {
10259
+ if (!liveWidgetIds.has(widgetId)) {
10260
+ canvasRef?.setWidgetShapeLivePreviewHiddenImage?.(widgetId, false);
10261
+ }
10262
+ }
9041
10263
  } catch (err) {
9042
10264
  console.warn('[CreatorApp] WidgetShape live preview sync failed', err);
9043
10265
  }
@@ -9380,6 +10602,9 @@ Requirements:
9380
10602
  if (detailsSessionWidgetId === fromId) detailsSessionWidgetId = toId;
9381
10603
  if (widgetDetailsOpenTargetId === fromId) widgetDetailsOpenTargetId = toId;
9382
10604
  showWidgetDetails = openWidgetPanelIds.length > 0;
10605
+ // Any *other* draft panel still registered would keep a real widget panel
10606
+ // bound to a `draft_convert_*` context id — clear those too.
10607
+ purgeDraftConvertPanels();
9383
10608
  // console.log(
9384
10609
  // '[CreatorApp] Migrated Widget Details panel',
9385
10610
  // fromId.substring(0, 16),
@@ -9975,6 +11200,16 @@ Requirements:
9975
11200
 
9976
11201
  function hasUnpublishedChangesForPanel(widgetId: string): boolean {
9977
11202
  const ps = getPanelDisplayState(widgetId);
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
+
11212
+ const pendingSha = loadPendingPublishShaForWidget(widgetId);
9978
11213
  const headSha = resolvePublishHeadCommitId(widgetId, ps);
9979
11214
  if (!headSha || ps.lastPublishedVersion === null) return false;
9980
11215
 
@@ -9982,6 +11217,7 @@ Requirements:
9982
11217
  getLivePublishedCommitShaForPanel(widgetId)
9983
11218
  ?? getWidgetLastPublishCommitSha(widgetId);
9984
11219
  if (!liveSha) return false;
11220
+ if (pendingSha && !commitsMatch(pendingSha, liveSha)) return true;
9985
11221
 
9986
11222
  return !commitsMatch(headSha, liveSha);
9987
11223
  }
@@ -10064,7 +11300,7 @@ Requirements:
10064
11300
  * Single source of truth for Publish button label / disabled / toast reason.
10065
11301
  * Prevents "Publish new vN" while click toasts "already live".
10066
11302
  */
10067
- function getPublishGateForPanel(widgetId: string): {
11303
+ function getPublishGateForPanel(widgetId: string, targetVersion = null): {
10068
11304
  disabled: boolean;
10069
11305
  label: string;
10070
11306
  reason: string | null;
@@ -10129,6 +11365,27 @@ Requirements:
10129
11365
  showDisabledHint: true,
10130
11366
  };
10131
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
+
10132
11389
  if (!hasCode) {
10133
11390
  return {
10134
11391
  disabled: true,
@@ -10216,6 +11473,31 @@ Requirements:
10216
11473
  return 'No new version to publish';
10217
11474
  }
10218
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
+
10219
11501
  function isPublishDisabledForPanel(widgetId: string): boolean {
10220
11502
  return getPublishGateForPanel(widgetId).disabled;
10221
11503
  }
@@ -10250,7 +11532,12 @@ Requirements:
10250
11532
  );
10251
11533
  }
10252
11534
 
10253
- 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 {
10254
11541
  void (async () => {
10255
11542
  if (isPublishing && !publishingWidgetId) {
10256
11543
  console.warn('[Publish] Clearing stuck global isPublishing flag');
@@ -10263,7 +11550,7 @@ Requirements:
10263
11550
 
10264
11551
  // Gate BEFORE focus/tick — focusing can reconcile HEAD/live SHA mid-click and
10265
11552
  // used to toast "already live" after the button still said "Publish new vN".
10266
- const gateBefore = getPublishGateForPanel(panelWidgetId);
11553
+ const gateBefore = getPublishGateForPanel(panelWidgetId, targetVersion ?? null);
10267
11554
  if (gateBefore.disabled) {
10268
11555
  if (gateBefore.reason) {
10269
11556
  showToast('info', 'Cannot publish yet', { description: gateBefore.reason, duration: 5000 });
@@ -10276,7 +11563,7 @@ Requirements:
10276
11563
  await tick();
10277
11564
  }
10278
11565
 
10279
- const gateAfter = getPublishGateForPanel(panelWidgetId);
11566
+ const gateAfter = getPublishGateForPanel(panelWidgetId, targetVersion ?? null);
10280
11567
  if (gateAfter.disabled) {
10281
11568
  if (gateAfter.reason) {
10282
11569
  showToast('info', 'Cannot publish yet', { description: gateAfter.reason, duration: 5000 });
@@ -10284,7 +11571,7 @@ Requirements:
10284
11571
  return;
10285
11572
  }
10286
11573
 
10287
- await executePublishWidget(panelWidgetId);
11574
+ await executePublishWidget(panelWidgetId, { targetVersion: targetVersion ?? null });
10288
11575
  })();
10289
11576
  }
10290
11577
 
@@ -10422,6 +11709,13 @@ async function loadPublishHistoryForPanel(widgetId: string, opts: { silent?: boo
10422
11709
  publishHistoryResolved: true,
10423
11710
  publishHistoryResolvedForWidgetId: widgetId,
10424
11711
  });
11712
+ shapePublishHistoryByWidgetId = {
11713
+ ...shapePublishHistoryByWidgetId,
11714
+ [widgetId]: [...history],
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 };
10425
11719
  if (widgetId === focusedPanelWidgetId) {
10426
11720
  publishHistory = history;
10427
11721
  publishHistoryResolved = true;
@@ -10461,11 +11755,26 @@ async function loadPublishHistory(opts: { silent?: boolean } = {}) {
10461
11755
  }
10462
11756
 
10463
11757
  /**
10464
- * Sets a historical version as the current live version without rebuilding.
10465
- * 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.
10466
11764
  */
10467
- async function setLiveVersion(version: number) {
10468
- if (!selectedWidgetId || !$userSession?.jwt) return;
11765
+ async function setLiveVersionForWidget(widgetId: string, version: number) {
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
+ }
10469
11778
 
10470
11779
  isSettingLiveVersion = version;
10471
11780
  if (!widgetsClient) {
@@ -10475,12 +11784,12 @@ async function setLiveVersion(version: number) {
10475
11784
  }
10476
11785
  try {
10477
11786
  const liveRes = await widgetsClient.setWidgetPublishHistoryLive({
10478
- widgetId: selectedWidgetId,
11787
+ widgetId,
10479
11788
  version
10480
11789
  });
10481
11790
  const nested = liveRes?.data as { data?: Record<string, unknown>; artifactUrl?: string; artifact_url?: string; commitSha?: string; commit_sha?: string } | undefined;
10482
11791
  const liveData = nested?.data ?? nested ?? {};
10483
- const history = getPanelDisplayState(selectedWidgetId).publishHistory;
11792
+ const history = getPanelDisplayState(widgetId).publishHistory;
10484
11793
  const record = history.find((row) => row.version === version);
10485
11794
  const commitSha = (liveData as { commitSha?: string; commit_sha?: string }).commitSha
10486
11795
  || (liveData as { commit_sha?: string }).commit_sha
@@ -10488,15 +11797,23 @@ async function setLiveVersion(version: number) {
10488
11797
  const artifactUrl = (liveData as { artifactUrl?: string; artifact_url?: string }).artifactUrl
10489
11798
  || (liveData as { artifact_url?: string }).artifact_url
10490
11799
  || record?.artifactUrl || record?.artifact_url || null;
10491
- applyPublishSuccessToWidgetState(selectedWidgetId, version, artifactUrl, commitSha);
11800
+ applyPublishSuccessToWidgetState(widgetId, version, artifactUrl, commitSha);
11801
+ shapePreviewSourceByWidgetId = {
11802
+ ...shapePreviewSourceByWidgetId,
11803
+ [widgetId]: `v:${version}`,
11804
+ };
11805
+ syncWidgetShapeLivePreviews();
11806
+ panelDisplayRevision += 1;
10492
11807
  isHistoryOpen = false;
10493
- const liveUrl = resolvePublishedWidgetUrl(selectedWidgetId, version, artifactUrl);
10494
- const detailsRef = getWidgetDetailsRef(selectedWidgetId);
11808
+ const liveUrl = resolvePublishedWidgetUrl(widgetId, version, artifactUrl);
11809
+ const detailsRef = getWidgetDetailsRef(widgetId);
10495
11810
  detailsRef?.showPublishedArtifact?.(liveUrl);
10496
11811
  detailsRef?.beginCompositionHydration?.();
10497
- revealCompositionPreview();
10498
- showToast('success', `Reverted to v${version}`, {
10499
- description: 'Widget is now live at this version. No rebuild needed.',
11812
+ if (widgetId === selectedWidgetId || widgetId === focusedPanelWidgetId) {
11813
+ revealCompositionPreview();
11814
+ }
11815
+ showToast('success', `Live: v${version}`, {
11816
+ description: 'This version is now what visitors see on your sites.',
10500
11817
  duration: 5000
10501
11818
  });
10502
11819
  } catch (err: any) {
@@ -10507,12 +11824,22 @@ async function setLiveVersion(version: number) {
10507
11824
  }
10508
11825
  }
10509
11826
 
10510
- /** Toggle publish history panel — reloads data on every open */
11827
+ async function setLiveVersion(version: number) {
11828
+ if (!selectedWidgetId) return;
11829
+ await setLiveVersionForWidget(selectedWidgetId, version);
11830
+ }
11831
+
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. */
10511
11835
  function togglePublishHistory(triggerEl?: HTMLElement | null) {
10512
11836
  isHistoryOpen = !isHistoryOpen;
10513
11837
  if (isHistoryOpen) {
10514
11838
  if (triggerEl) {
10515
- 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();
10516
11843
  liveHistoryPanelTop = Math.round(rect.bottom + 4);
10517
11844
  liveHistoryPanelRight = Math.round(window.innerWidth - rect.right);
10518
11845
  }
@@ -10999,6 +12326,13 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
10999
12326
  let lastWidgetShapeClickId = '';
11000
12327
  const WIDGET_SHAPE_CLICK_DEBOUNCE_MS = 450;
11001
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
+
11002
12336
  /**
11003
12337
  * Called when a Widget Image shape is clicked on the canvas.
11004
12338
  * Selects the linked widget and opens WidgetDetails.
@@ -11065,18 +12399,10 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
11065
12399
  }
11066
12400
  }
11067
12401
 
11068
- // Keep address-bar widget_id on the canvas primary (duplicate/select must not steal it).
11069
- if (typeof window !== 'undefined' && embedded && canvasPrimaryWidgetId && widgetId === canvasPrimaryWidgetId) {
11070
- try {
11071
- const url = new URL(window.location.href);
11072
- if (url.searchParams.get('widget_id') !== widgetId) {
11073
- url.searchParams.set('widget_id', widgetId);
11074
- window.history.replaceState({}, '', url.pathname + url.search + url.hash);
11075
- syncEmbeddedDeepLinkDismissTracking();
11076
- }
11077
- } catch (err) {
11078
- console.warn('[WidgetCreator] Failed to sync widget_id in URL:', err);
11079
- }
12402
+ // Clicking a widget shape means "open this widget" — make it the canvas primary so a
12403
+ // refresh reopens it. The URL only carries canvas_id; widget_id is no longer written.
12404
+ if (embedded && widgetId !== canvasPrimaryWidgetId) {
12405
+ void persistPrimaryWidgetId(widgetId);
11080
12406
  }
11081
12407
  // Full select so commit/preview/header match the shape's linked widget (not a stale URL widget).
11082
12408
  if (selectedWidgetId !== widgetId) {
@@ -11308,6 +12634,10 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
11308
12634
  }
11309
12635
  notifyHostWidgetName({ id: widgetId, name: newName });
11310
12636
  emitCanvasWidgetsList('widget-renamed');
12637
+ widgetNamesRevision += 1;
12638
+ panelDisplayRevision += 1;
12639
+ backfillWidgetShapeMetadata();
12640
+ syncWidgetShapeLivePreviews();
11311
12641
  showToast('success', `Widget renamed to "${newName}"`);
11312
12642
  } catch (error) {
11313
12643
  console.error('[WidgetCreator] Failed to rename widget:', error);
@@ -11359,14 +12689,15 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
11359
12689
  }
11360
12690
  await syncWidgetsWithCanvas(widgetIdsOnCanvas);
11361
12691
  await maybeAutoRestoreWidgetShapes();
11362
- const primaryId = await hydrateCanvasPrimaryWidget(widgetIdsOnCanvas);
11363
- if (primaryId && canvasRef && !isConvertOrDraftPanelOpen()) {
11364
- canvasRef.setPrimaryWidgetShape?.(primaryId);
11365
- const found = canvasRef.selectWidgetShape(primaryId);
11366
- // console.log('[CreatorApp] Primary widget shape after canvas load:', primaryId, found);
11367
- } else if (isConvertOrDraftPanelOpen()) {
11368
- // console.log('[CreatorApp] Skip primary shape select — convert/draft panel is open');
11369
- }
12692
+ const linkedIds =
12693
+ widgetIdsOnCanvas.length > 0
12694
+ ? widgetIdsOnCanvas
12695
+ : [...(canvasRef?.getLinkedWidgetIds?.() ?? [])];
12696
+ const primaryId = await hydrateCanvasPrimaryWidget(linkedIds);
12697
+ applyPrimarySelectionOnCanvas(primaryId);
12698
+ requestAnimationFrame(() => {
12699
+ setTimeout(() => applyPrimarySelectionOnCanvas(primaryId), 80);
12700
+ });
11370
12701
  syncWidgetShapeLivePreviews();
11371
12702
  }
11372
12703
 
@@ -11640,15 +12971,29 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
11640
12971
  */
11641
12972
  let autosaveSequence = 0;
11642
12973
  let lastResolvedAutosaveSequence = 0;
11643
-
11644
- async function handleCanvasAutoSave(json: string | null, isUnmountSave = false) {
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;
12982
+
12983
+ async function handleCanvasAutoSave(
12984
+ json: string | null,
12985
+ isUnmountSave = false,
12986
+ opts: { force?: boolean } = {},
12987
+ ) {
11645
12988
  if (!json) {
11646
12989
  if (isUnmountSave) console.warn('[CanvasDebug] onBeforeUnmount: saveCanvas returned null — canvas not initialized');
11647
12990
  return;
11648
12991
  }
11649
12992
 
12993
+ const force = opts.force === true;
12994
+
11650
12995
  // Guard: don't overwrite DB with an empty canvas if state hasn't loaded yet
11651
- if (!isCanvasStateLoaded) {
12996
+ if (!force && !isCanvasStateLoaded) {
11652
12997
  // console.log(
11653
12998
  // '[CanvasDebug] Skipping autosave — canvas state not loaded yet (guard active, avoids overwrite with empty)'
11654
12999
  // );
@@ -11656,8 +13001,21 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
11656
13001
  }
11657
13002
 
11658
13003
  // Guard: skip autosaves triggered by loadCanvas() object:added events
11659
- if (isLoadCooldownActive && !isUnmountSave) {
11660
- // console.log('[CanvasDebug] Skipping autosave — load cooldown active (prevents overwriting freshly loaded data)');
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.
13010
+ if (!force && isLoadCooldownActive && !isUnmountSave) {
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);
11661
13019
  return;
11662
13020
  }
11663
13021
 
@@ -11679,11 +13037,17 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
11679
13037
  // Unmount saves (HMR) are always blocked when going from N→0.
11680
13038
  // Normal autosaves going from N→0 are allowed only if the drop is gradual
11681
13039
  // (user deleted objects one by one) — a sudden jump from many to 0 is suspicious.
13040
+ // Explicit Save (force) still blocks sudden N→0 to avoid wiping the canvas.
11682
13041
  if (objectCount === 0 && lastSavedCanvasObjectCount > 0) {
11683
13042
  if (isUnmountSave || lastSavedCanvasObjectCount > 1) {
11684
13043
  console.warn(
11685
- `[CanvasDebug] BLOCKING save: would overwrite ${lastSavedCanvasObjectCount} shapes with 0 (isUnmount=${isUnmountSave})`
13044
+ `[CanvasDebug] BLOCKING save: would overwrite ${lastSavedCanvasObjectCount} shapes with 0 (isUnmount=${isUnmountSave}, force=${force})`
11686
13045
  );
13046
+ if (force) {
13047
+ showToast('error', 'Save blocked', {
13048
+ description: 'Canvas snapshot looked empty — refresh and try again.',
13049
+ });
13050
+ }
11687
13051
  return;
11688
13052
  }
11689
13053
  // lastSavedCanvasObjectCount === 1 → user deleted the last object, allow it
@@ -11720,12 +13084,25 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
11720
13084
  return;
11721
13085
  }
11722
13086
  lastResolvedAutosaveSequence = currentSeq;
13087
+ if (force) {
13088
+ showToast('success', 'Canvas saved', { duration: 2000 });
13089
+ }
11723
13090
  // console.log(
11724
13091
  // `[CanvasDebug] Canvas ${canvasId} saved to API: ${objectCount} shapes (seq ${currentSeq})`
11725
13092
  // );
11726
13093
  } catch (err) {
11727
13094
  console.warn('[WidgetCreator] Failed to save canvas to API:', err);
13095
+ if (force) {
13096
+ showToast('error', 'Canvas save failed', {
13097
+ description: 'Kept a local copy — check your connection and retry.',
13098
+ });
13099
+ }
11728
13100
  }
13101
+ } else if (force) {
13102
+ showToast('success', 'Canvas saved locally', {
13103
+ description: canvasId ? 'API client unavailable.' : 'No canvas id yet.',
13104
+ duration: 2500,
13105
+ });
11729
13106
  }
11730
13107
 
11731
13108
  refreshLinkedWidgetIds();
@@ -12069,6 +13446,68 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
12069
13446
  return !!widgetId && widgetId.startsWith(DRAFT_CONVERT_ID_PREFIX);
12070
13447
  }
12071
13448
 
13449
+ /**
13450
+ * True for conversations that exist only in the browser.
13451
+ *
13452
+ * Draft convert panels need a conversation object before the widget has a DB
13453
+ * row, so `handleCreateConversation` issues a `local_*` id. Message load/save
13454
+ * must treat those as local-only rather than querying the API with a key that
13455
+ * cannot exist there.
13456
+ */
13457
+ function isLocalOnlyConversationId(conversationId: string | null | undefined): boolean {
13458
+ return !!conversationId && conversationId.startsWith('local_');
13459
+ }
13460
+
13461
+ /**
13462
+ * Drop synthetic draft ids from the panel registry.
13463
+ *
13464
+ * `migrateOpenPanelWidgetId` renames every map keyed by widget id, but the
13465
+ * `{#each openWidgetPanelIds}` block is keyed by `panelInstanceKeyByWidgetId`,
13466
+ * so a draft id that survives anywhere in that list can re-render a real
13467
+ * widget's panel while `context.id` still points at the draft. That panel then
13468
+ * asks the API for `draft_convert_*` conversations and schemas — 400s and
13469
+ * "Failed to initialize chat" for a widget whose history is perfectly fine.
13470
+ *
13471
+ * Draft panels are only ever opened through the convert flow, which re-creates
13472
+ * them from `draftConvertObjectIds`, so dropping them here is safe.
13473
+ */
13474
+ function purgeDraftConvertPanels(): void {
13475
+ if (!openWidgetPanelIds.some((id) => isDraftConvertId(id))) return;
13476
+
13477
+ const draftIds = openWidgetPanelIds.filter((id) => isDraftConvertId(id));
13478
+ const remainingIds = openWidgetPanelIds.filter((id) => !isDraftConvertId(id));
13479
+ const nextInstanceKeys = { ...panelInstanceKeyByWidgetId };
13480
+ const nextZIndexes = { ...panelZIndexByWidgetId };
13481
+ const nextRefs = { ...widgetDetailsRefs };
13482
+ const nextChatMethods = { ...chatMethodsByWidgetId };
13483
+ for (const draftId of draftIds) {
13484
+ delete nextInstanceKeys[draftId];
13485
+ delete nextZIndexes[draftId];
13486
+ delete nextRefs[draftId];
13487
+ delete nextChatMethods[draftId];
13488
+ }
13489
+ panelInstanceKeyByWidgetId = nextInstanceKeys;
13490
+ panelZIndexByWidgetId = nextZIndexes;
13491
+ widgetDetailsRefs = nextRefs;
13492
+ chatMethodsByWidgetId = nextChatMethods;
13493
+ openWidgetPanelIds = remainingIds;
13494
+
13495
+ if (focusedPanelWidgetId && isDraftConvertId(focusedPanelWidgetId)) {
13496
+ focusedPanelWidgetId = remainingIds.at(-1) ?? null;
13497
+ }
13498
+ if (selectedWidgetId && isDraftConvertId(selectedWidgetId)) {
13499
+ selectedWidgetId = focusedPanelWidgetId;
13500
+ }
13501
+ if (detailsSessionWidgetId && isDraftConvertId(detailsSessionWidgetId)) {
13502
+ detailsSessionWidgetId = null;
13503
+ }
13504
+ if (widgetDetailsOpenTargetId && isDraftConvertId(widgetDetailsOpenTargetId)) {
13505
+ widgetDetailsOpenTargetId = null;
13506
+ }
13507
+ showWidgetDetails = openWidgetPanelIds.length > 0;
13508
+ console.warn('[CreatorApp] Purged draft convert panels:', draftIds.length);
13509
+ }
13510
+
12072
13511
  function isConvertOrDraftPanelOpen(): boolean {
12073
13512
  return (
12074
13513
  isDraftConvertId(focusedPanelWidgetId)
@@ -12507,13 +13946,14 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
12507
13946
  <!-- Right: Debug toggle + Auth info (2 lines) + Logout -->
12508
13947
  <div class="wc-top-bar-right flex items-center gap-3 shrink-0">
12509
13948
  {#if debugModeAvailable}
13949
+ <HoverTooltip text={debugMode ? 'Debug mode ON — click to disable (hides console tab, widgets panel)' : 'Debug mode OFF — click to enable'}>
12510
13950
  <button
12511
13951
  onclick={toggleDebugMode}
12512
13952
  class="wc-debug-toggle px-2.5 py-1 cursor-pointer text-xs font-medium rounded-lg border transition-colors {debugMode ? 'bg-red-50 border-red-400 text-red-600 hover:bg-red-100' : 'bg-gray-100 border-gray-300 text-gray-500 hover:bg-gray-200'}"
12513
- title={debugMode ? 'Debug mode ON — click to disable (hides console tab, widgets panel)' : 'Debug mode OFF — click to enable'}
12514
13953
  >
12515
13954
  🐛 Debug {debugMode ? 'ON' : 'OFF'}
12516
13955
  </button>
13956
+ </HoverTooltip>
12517
13957
  {/if}
12518
13958
  <div class="wc-auth-info flex flex-col gap-px items-start">
12519
13959
  <div class="wc-auth-line1 flex items-center gap-2">
@@ -12581,6 +14021,7 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
12581
14021
  <svelte:component this={WidgeticCanvas}
12582
14022
  bind:this={canvasRef}
12583
14023
  showContainerFrame={!embedded}
14024
+ widgetSelectionLayerTarget="floating-panels-overlay"
12584
14025
  bind:wireframeMode={canvasWireframeMode}
12585
14026
  advancedFeatures={canvasAdvancedFeatures}
12586
14027
  onReady={handleCanvasReady}
@@ -12598,10 +14039,11 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
12598
14039
  onWidgetShapeDuplicated={handleWidgetShapeDuplicated}
12599
14040
  onWidgetShapeDeleting={handleWidgetShapeDeleting}
12600
14041
  onWidgetRename={handleWidgetRename}
12601
- onRefreshScreenshot={handleRefreshScreenshot}
14042
+ onRefreshScreenshot={null}
12602
14043
  onSetPrimaryWidget={handleSetPrimaryWidget}
12603
14044
  onAfterRender={syncWidgetShapeLivePreviews}
12604
14045
  getWidgetName={getWidgetNameById}
14046
+ widgetNameRevision={widgetNamesRevision}
12605
14047
  isWidgetKnown={isWidgetKnownById}
12606
14048
  onNonWidgetSelected={() => {
12607
14049
  if (suppressWidgetDetailsClose || isConvertOrDraftPanelOpen()) {
@@ -12630,17 +14072,43 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
12630
14072
  bind:this={widgetShapeLivePreviewsRef}
12631
14073
  getRects={() => canvasRef?.listWidgetShapeScreenRects?.() ?? []}
12632
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}
14081
+ resolveTitle={getWidgetNameById}
14082
+ titleRevision={widgetNamesRevision + panelDisplayRevision}
14083
+ resolveVersionLabel={resolveWidgetShapeVersionLabel}
14084
+ resolveVersionOptions={resolveWidgetShapeVersionOptions}
14085
+ resolveVersionSelectedId={resolveWidgetShapeVersionSelectedId}
14086
+ onVersionChange={handleWidgetShapeVersionChange}
14087
+ onSetLive={(widgetId, version) => {
14088
+ void setLiveVersionForWidget(widgetId, version);
14089
+ }}
14090
+ settingLiveVersion={isSettingLiveVersion}
14091
+ resolveCanPublish={(widgetId) => hasUnpublishedChangesForPanel(widgetId)}
14092
+ resolveCanPublishDraft={(widgetId, version) => canPublishDraftVersionForPanel(widgetId, version)}
14093
+ resolveIsPublishing={(widgetId) => isPublishing && publishingWidgetId === widgetId}
14094
+ onPublish={(widgetId) => runPublishForPanel(widgetId)}
14095
+ onPublishDraft={(widgetId, version) => runPublishForPanel(widgetId, version)}
12633
14096
  selectedWidgetId={canvasSelectedWidgetShapeId}
12634
14097
  onTranslate={(widgetId, dx, dy) => {
12635
14098
  canvasRef?.translateWidgetShapeByScreen?.(widgetId, dx, dy);
12636
14099
  }}
14100
+ onTranslateEnd={(widgetId) => {
14101
+ canvasRef?.commitWidgetShapeTranslate?.(widgetId);
14102
+ }}
12637
14103
  onSelectShape={(widgetId) => {
12638
14104
  canvasRef?.selectWidgetShapeById?.(widgetId);
12639
14105
  canvasSelectedWidgetShapeId = widgetId;
14106
+ void ensureShapePublishHistory(widgetId);
12640
14107
  }}
12641
14108
  onOpenChat={(widgetId) => {
12642
14109
  void openWidgetDetailsPanel(widgetId);
12643
14110
  }}
14111
+ onRefresh={handleWidgetShapeRefresh}
12644
14112
  />
12645
14113
 
12646
14114
  <!-- ═══ PANEL 2: Widgets Panel — debug + showWidgetsList (site passes false) ═══ -->
@@ -12934,6 +14402,16 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
12934
14402
  {@const panelWidget = widgetsById.get(panelWidgetId) ?? null}
12935
14403
  {@const isPanelFocused = focusedPanelWidgetId === panelWidgetId}
12936
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))}
12937
14415
  {@const panelHasUnpublishedChanges = (panelDisplayRevision, widgets.length, hasUnpublishedChangesForPanel(panelWidgetId))}
12938
14416
  {@const panelHasCompileErrorState = (panelDisplayRevision, panelHasCompileError(panelWidgetId))}
12939
14417
  {@const panelHasInfraPreviewError = (panelDisplayRevision, panelHasInfrastructurePreviewError(panelWidgetId))}
@@ -13236,9 +14714,55 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
13236
14714
  hasAutoOpenedPreview = detail.hasAutoOpenedPreview;
13237
14715
  }}
13238
14716
  >
14717
+ <svelte:fragment slot="header-meta">
14718
+ {#if saveStatus === 'saving' && isPanelFocused}
14719
+ <span class="wc-header-save-status text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 animate-pulse">Saving...</span>
14720
+ {:else if saveStatus === 'saved' && isPanelFocused}
14721
+ <span class="wc-header-save-status text-[10px] px-1.5 py-0.5 rounded bg-green-100 text-green-700">Saved</span>
14722
+ {:else if saveStatus === 'error' && isPanelFocused}
14723
+ <span class="wc-header-save-status text-[10px] px-1.5 py-0.5 rounded bg-red-100 text-red-700">Save Error</span>
14724
+ {/if}
14725
+ {#if debugMode}
14726
+ <span class="wc-header-commit text-[10px] text-gray-500 whitespace-nowrap">
14727
+ Commit:
14728
+ <span class="font-mono font-medium text-gray-600" title={ps.lastCommitId || ''}>
14729
+ {ps.lastCommitId ? ps.lastCommitId.substring(0, 8) : '—'}
14730
+ </span>
14731
+ </span>
14732
+ {/if}
14733
+ </svelte:fragment>
13239
14734
  <svelte:fragment slot="version-history">
13240
- {#if ps.lastPublishedVersion !== null}
13241
- <div class="wc-version-history-ct relative shrink-0">
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:')
14750
+ ? parseInt(shapeSource.slice(2), 10)
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}
14764
+ <div class="wc-version-history-ct relative shrink-0 flex items-center gap-0.5">
14765
+ <HoverTooltip text="Switch version" maxWidth={120}>
13242
14766
  <button
13243
14767
  type="button"
13244
14768
  onclick={(e) => {
@@ -13246,11 +14770,66 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
13246
14770
  togglePublishHistory(e.currentTarget);
13247
14771
  }}
13248
14772
  onmousedown={(e) => e.stopPropagation()}
13249
- class="wc-version-history-toggle flex items-center gap-1 text-[10px] font-medium 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"
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"
13250
14774
  >
13251
- Live: v{ps.lastPublishedVersion}
14775
+ {`v${Number.isFinite(viewingVersion) ? viewingVersion : panelPublishedVersion}`}
13252
14776
  <span class="text-emerald-500">{isHistoryOpen && isPanelFocused ? '▲' : '▼'}</span>
13253
14777
  </button>
14778
+ </HoverTooltip>
14779
+ {#if panelHasUnpublishedChanges && !Number.isFinite(viewingVersion)}
14780
+ <button
14781
+ type="button"
14782
+ onclick={(e) => {
14783
+ e.stopPropagation();
14784
+ if (!isPanelFocused) focusWidgetPanel(panelWidgetId);
14785
+ runPublishForPanel(panelWidgetId);
14786
+ }}
14787
+ disabled={isPublishDisabledForPanel(panelWidgetId) || (isPublishing && publishingWidgetId === panelWidgetId)}
14788
+ title={getPublishDisabledReasonForPanel(panelWidgetId) ?? 'Publish the latest generated code as the Live version'}
14789
+ 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"
14790
+ >
14791
+ {isPublishing && publishingWidgetId === panelWidgetId ? '...' : 'Publish'}
14792
+ </button>
14793
+ {:else if viewingIsLive}
14794
+ <span
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"
14796
+ title="Live — generate new code to Publish a new version"
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}
14816
+ {:else}
14817
+ <button
14818
+ type="button"
14819
+ onclick={(e) => {
14820
+ e.stopPropagation();
14821
+ if (!isPanelFocused) focusWidgetPanel(panelWidgetId);
14822
+ if (Number.isFinite(viewingVersion)) {
14823
+ void setLiveVersionForWidget(panelWidgetId, Number(viewingVersion));
14824
+ }
14825
+ }}
14826
+ disabled={isSettingLiveVersion === Number(viewingVersion)}
14827
+ title="Publish this version so visitors see it on your website"
14828
+ 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"
14829
+ >
14830
+ {isSettingLiveVersion === Number(viewingVersion) ? '...' : 'Set live'}
14831
+ </button>
14832
+ {/if}
13254
14833
  {#if isHistoryOpen && isPanelFocused}
13255
14834
  <Portal target="wc-live-history-portal-{panelWidgetId}">
13256
14835
  <button
@@ -13260,49 +14839,44 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
13260
14839
  onclick={() => { isHistoryOpen = false; }}
13261
14840
  ></button>
13262
14841
  <div
13263
- 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"
13264
14843
  style="position: fixed; top: {liveHistoryPanelTop}px; right: {liveHistoryPanelRight}px; max-width: min(22rem, calc(100vw - 16px));"
13265
14844
  >
13266
14845
  {#if isLoadingHistory}
13267
14846
  <p class="text-xs text-emerald-500 animate-pulse text-center py-2">Loading history...</p>
13268
14847
  {:else if ps.publishHistory.length === 0}
13269
- <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>
13270
14849
  {:else}
13271
14850
  {#each ps.publishHistory as record (record.id)}
13272
- <div class="publish-history-row flex items-center gap-2 rounded border border-emerald-100 bg-white px-2 py-1.5 w-max">
13273
- <span class="text-xs font-mono font-bold text-emerald-700 shrink-0">v{record.version}</span>
13274
- {#if debugMode && (record.commitSha || record.commit_sha)}
13275
- <span class="text-xs font-mono text-slate-400 shrink-0" title={record.commitSha ?? record.commit_sha}>
13276
- {(record.commitSha ?? record.commit_sha).slice(0, 7)}
13277
- </span>
13278
- {/if}
13279
- <span class="wc-live-history-date text-xs text-slate-400 shrink-0 whitespace-nowrap tabular-nums" style="padding-right: 4px;">
13280
- {(() => {
13281
- const d = new Date(record.publishedAt ?? record.published_at ?? '');
13282
- if (isNaN(d.getTime())) return '—';
13283
- const month = String(d.getMonth() + 1).padStart(2, '0');
13284
- const day = String(d.getDate()).padStart(2, '0');
13285
- const timePart = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false });
13286
- return `${month}/${day}/${d.getFullYear()} ${timePart}`;
13287
- })()}
13288
- </span>
13289
- {#if record.version !== ps.lastPublishedVersion}
13290
- <button
13291
- onclick={() => { void setLiveVersion(record.version); }}
13292
- disabled={isSettingLiveVersion === record.version}
13293
- 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"
13294
- title="Set this version as the live published widget"
13295
- >
13296
- {isSettingLiveVersion === record.version ? '...' : 'Set live'}
13297
- </button>
13298
- {:else}
13299
- <button
13300
- type="button"
13301
- 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 cursor-pointer"
13302
- onclick={() => { isHistoryOpen = false; }}
13303
- >live</button>
13304
- {/if}
13305
- </div>
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={() => {
14870
+ handleWidgetShapeVersionChange(panelWidgetId, `v:${record.version}`);
14871
+ }}
14872
+ onpublish={() => {
14873
+ handleWidgetShapeVersionChange(panelWidgetId, `v:${rowVersion}`, false);
14874
+ runPublishForPanel(panelWidgetId, rowVersion);
14875
+ }}
14876
+ onsetlive={() => {
14877
+ void setLiveVersionForWidget(panelWidgetId, record.version);
14878
+ }}
14879
+ />
13306
14880
  {/each}
13307
14881
  {/if}
13308
14882
  </div>
@@ -13338,7 +14912,7 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
13338
14912
  </svelte:fragment>
13339
14913
 
13340
14914
  <svelte:fragment slot="code-generation-status">
13341
- <div class="generate-code-status-ct flex flex-col gap-2 shrink-0" style="padding: 10px; margin: 0px;">
14915
+ <div class="generate-code-status-ct flex flex-col gap-2 shrink-0 border-b border-gray-200 bg-gray-50" style="padding: 10px; margin: 0px; border-radius: 0;">
13342
14916
  <div class="flex items-center justify-between gap-2 min-w-0">
13343
14917
  <div class="generate-code-status-el text-xs flex-1 min-w-0 truncate {ps.lastGenerationFailed ? 'text-red-600' : 'text-gray-500'}">
13344
14918
  {#if ps.lastGenerationFailed}
@@ -13349,7 +14923,7 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
13349
14923
  </div>
13350
14924
  </div>
13351
14925
  {#if ps.lastGenerationFailed}
13352
- <div class="p-3 bg-red-50 border border-red-200 rounded-lg">
14926
+ <div class="p-3 bg-red-50 border border-red-200" style="border-radius: 0;">
13353
14927
  <p class="text-xs text-red-700 whitespace-pre-wrap">{ps.lastGenerationError}</p>
13354
14928
  <div class="flex gap-2 mt-3 justify-center">
13355
14929
  <Button
@@ -13387,20 +14961,22 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
13387
14961
 
13388
14962
  <svelte:fragment slot="code-generation">
13389
14963
  <!-- svelte-ignore a11y_no_static_element_interactions -->
13390
- {@const chatUserHeight = (panelDisplayRevision, Math.max(CHAT_MIN_HEIGHT, Math.min(CHAT_MAX_HEIGHT, (ps.chatContainerHeight > 0 ? ps.chatContainerHeight : CHAT_DEFAULT_HEIGHT))))}
14964
+ {@const chatUserHeight = (panelDisplayRevision, getChatContainerHeightForPanel(panelWidgetId))}
13391
14965
  <div
13392
14966
  class="generation-scroll-ct flex flex-col h-full min-h-0 overflow-y-auto border-t border-gray-200"
13393
- style="padding: 10px;"
13394
- use:nativeScrollbarPadding={10}
14967
+ style="padding: 0;"
14968
+ use:nativeScrollbarPadding={0}
13395
14969
  onmousedown={() => { if (!isPanelFocused) focusWidgetPanel(panelWidgetId); }}
13396
14970
  >
13397
14971
  {#key panelInstanceKey}
13398
14972
  <div
13399
- class="code-generation-chat-host w-full min-h-0"
14973
+ class="code-generation-chat-host w-full min-h-0 {chatUserHeight == null ? 'flex-1' : 'shrink-0'}"
13400
14974
  data-widget-chat-height={panelWidgetId}
13401
- style="flex: 1 0 {chatUserHeight}px; min-height: {CHAT_MIN_HEIGHT}px; height: {chatUserHeight}px;"
14975
+ style={chatUserHeight == null
14976
+ ? `flex: 1 1 auto; min-height: ${CHAT_MIN_HEIGHT}px;`
14977
+ : `flex: 0 0 ${chatUserHeight}px; min-height: ${CHAT_MIN_HEIGHT}px; height: ${chatUserHeight}px;`}
13402
14978
  >
13403
- <Chat class="code-generation-chat w-full h-full"
14979
+ <Chat class="code-generation-chat w-full h-full rounded-none border-0"
13404
14980
  style="height: 100%; min-height: {CHAT_MIN_HEIGHT}px;"
13405
14981
  resizeExpandsContainer={true}
13406
14982
  onMessagesResize={(deltaPx) => handleChatMessagesResize(panelWidgetId, deltaPx)}
@@ -13428,7 +15004,7 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
13428
15004
  autoCodeDetection: false,
13429
15005
  }
13430
15006
  }}
13431
- widgetHeadCommitSha={ps.lastCommitId}
15007
+ widgetHeadCommitSha={resolveViewingCommitShaForPanel(panelWidgetId) || ps.lastCommitId}
13432
15008
  onSendMessage={(content, attachments) => handleChatSendMessage(content, attachments, panelWidgetId)}
13433
15009
  onRetryMessage={(content) => handleChatRetryMessage(content, panelWidgetId)}
13434
15010
  onUploadChatFile={handleChatUploadFile}
@@ -13441,7 +15017,9 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
13441
15017
  onSaveMessage={(conversationId, content, messageType, attachments) =>
13442
15018
  handleSaveMessage(conversationId, content, messageType, attachments, panelWidgetId)
13443
15019
  }
13444
- onRestoreCheckpointBackend={handleRestoreCheckpoint}
15020
+ onRestoreCheckpointBackend={(conversationId, checkpointMessageId) =>
15021
+ handleRestoreCheckpoint(conversationId, checkpointMessageId, panelWidgetId)
15022
+ }
13445
15023
  onChatReady={(methods) => handleChatReady(methods, panelWidgetId)}
13446
15024
  onDraftChange={() => handleChatDraftChange(panelWidgetId)}
13447
15025
  onConversationChange={(conversationId) => handleChatConversationChange(conversationId, panelWidgetId)}
@@ -13503,7 +15081,7 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
13503
15081
  {#if isEditStepWithoutPublishForPanel(panelWidgetId)}
13504
15082
  <div class="edit-not-published-banner-ct mx-3 mt-2 px-3 py-2 rounded-md border border-amber-200 bg-amber-50 text-amber-800 text-xs">
13505
15083
  <strong class="font-semibold">Not published yet.</strong>
13506
- Generate on Create — the widget publishes automatically so you can edit the composition.
15084
+ Generate on Create, preview the result, then Publish when you are ready.
13507
15085
  </div>
13508
15086
  {/if}
13509
15087
 
@@ -13993,11 +15571,46 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
13993
15571
  Are you sure you want to delete <strong>"{deleteCanvasTarget?.name || ''}"</strong>? This action cannot be undone.
13994
15572
  </Dialog.Description>
13995
15573
  </Dialog.Header>
15574
+
15575
+ {#if deleteCanvasPreviewLoading}
15576
+ <p class="confirm-delete-canvas-preview-loading text-sm text-gray-500 pt-2">
15577
+ Checking what will be deleted…
15578
+ </p>
15579
+ {:else if deleteCanvasPreview && deleteCanvasPreview.widgetCount > 0}
15580
+ <div class="confirm-delete-canvas-preview pt-2">
15581
+ <p class="text-sm text-gray-700">
15582
+ This will also delete
15583
+ <strong>{deleteCanvasPreview.widgetCount}</strong>
15584
+ {deleteCanvasPreview.widgetCount === 1 ? 'widget' : 'widgets'} on this canvas:
15585
+ </p>
15586
+ <ul class="confirm-delete-canvas-preview-groups mt-2 space-y-1.5 max-h-40 overflow-y-auto scrollbar-thin text-sm">
15587
+ {#each deleteCanvasPreview.groups as group}
15588
+ <li class="confirm-delete-canvas-preview-group">
15589
+ <span class="font-medium text-gray-800">{group.projectName}</span>
15590
+ <span class="text-gray-500"> — {group.widgetNames.length}</span>
15591
+ <p class="text-gray-500 text-xs truncate" title={group.widgetNames.join(', ')}>
15592
+ {group.widgetNames.join(', ')}
15593
+ </p>
15594
+ </li>
15595
+ {/each}
15596
+ </ul>
15597
+ {#if deleteCanvasPreview.groups.length > 1}
15598
+ <p class="confirm-delete-canvas-preview-warning text-xs text-amber-700 mt-2">
15599
+ These widgets belong to more than one project.
15600
+ </p>
15601
+ {/if}
15602
+ </div>
15603
+ {/if}
15604
+
15605
+ {#if deleteCanvasError}
15606
+ <p class="confirm-delete-canvas-error text-sm text-red-600 pt-2">{deleteCanvasError}</p>
15607
+ {/if}
15608
+
13996
15609
  <div class="confirm-delete-canvas-dialog-actions flex justify-end gap-2 pt-2">
13997
15610
  <button
13998
15611
  type="button"
13999
15612
  class="px-4 py-2 text-sm rounded-lg border border-gray-300 hover:bg-gray-100 cursor-pointer"
14000
- onclick={() => { showDeleteCanvasDialog = false; deleteCanvasTarget = null; }}
15613
+ onclick={() => { showDeleteCanvasDialog = false; deleteCanvasTarget = null; deleteCanvasError = ''; }}
14001
15614
  >Cancel</button>
14002
15615
  <button
14003
15616
  type="button"