@agent-native/core 0.161.9 → 0.161.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/calendar/changelog/2026-08-18-calendar-event-time-editing-respects-the-event-timezone.md +6 -0
  3. package/corpus/templates/clips/app/components/player/video-player.tsx +1 -1
  4. package/corpus/templates/clips/app/i18n/en-US.ts +1 -1
  5. package/corpus/templates/clips/changelog/2026-08-18-centered-clip-play-button-and-comment-panel-hint.md +6 -0
  6. package/corpus/templates/content/actions/export-document.ts +68 -3
  7. package/corpus/templates/content/changelog/2026-08-06-database-exports-now-include-their-pages.md +6 -0
  8. package/corpus/templates/content/shared/document-export.ts +25 -0
  9. package/corpus/templates/design/.agents/skills/creative-context/SKILL.md +3 -0
  10. package/corpus/templates/design/app/components/editor/PromptDialog.tsx +66 -0
  11. package/corpus/templates/design/app/pages/Index.tsx +45 -1
  12. package/corpus/templates/design/app/pages/design-editor/creative-context-precedent.ts +233 -0
  13. package/corpus/templates/design/changelog/2026-08-11-new-designs-now-skip-the-intake-questions-and-follow-your-ex.md +6 -0
  14. package/corpus/templates/factory/actions/start-builder-for-item.ts +241 -22
  15. package/corpus/templates/factory/server/plugins/agent-chat.ts +9 -5
  16. package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +50 -14
  17. package/corpus/templates/factory/server/triage/builder-executor.ts +1 -0
  18. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +231 -9
  19. package/corpus/templates/slides/app/components/layout/AgentWorkIndicator.tsx +56 -9
  20. package/corpus/templates/slides/app/context/DeckContext.tsx +99 -3
  21. package/corpus/templates/slides/app/i18n/en-US.ts +6 -0
  22. package/corpus/templates/slides/app/pages/DeckEditor.tsx +8 -2
  23. package/dist/action.js +19 -2
  24. package/dist/agent/chat-health-alert.d.ts +25 -0
  25. package/dist/agent/chat-health-alert.js +141 -0
  26. package/dist/client/conversation/use-near-bottom-autoscroll.js +34 -4
  27. package/dist/client/embed-auth.js +40 -3
  28. package/dist/client/route-state.js +42 -5
  29. package/dist/client/use-change-version.js +71 -25
  30. package/dist/client/use-db-sync.d.ts +1 -0
  31. package/dist/client/use-db-sync.js +223 -45
  32. package/dist/collab/awareness.d.ts +2 -2
  33. package/dist/collab/awareness.js +37 -4
  34. package/dist/collab/client.js +17 -1
  35. package/dist/collab/routes.d.ts +1 -1
  36. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  37. package/dist/notifications/routes.d.ts +1 -1
  38. package/dist/observability/routes.d.ts +5 -5
  39. package/dist/progress/routes.d.ts +1 -1
  40. package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
  41. package/dist/provider-api/actions/provider-api.d.ts +8 -8
  42. package/dist/resources/handlers.d.ts +1 -1
  43. package/dist/secrets/routes.d.ts +9 -9
  44. package/dist/server/agent-chat-plugin.js +15 -2
  45. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  46. package/dist/server/poll.d.ts +22 -14
  47. package/dist/server/poll.js +243 -67
  48. package/package.json +1 -1
@@ -662,9 +662,13 @@ interface SlideEditorProps {
662
662
  * reconcile path knows not to replace the deck under an active in-progress
663
663
  * edit, even before a `content` update has been flushed.
664
664
  */
665
- onInlineEditStart?: () => void;
666
- /** Called after inline edit mode exits and its draft has been handed off. */
667
- onInlineEditEnd?: () => void;
665
+ onInlineEditStart?: (slideId: string) => void;
666
+ /** Called after inline edit mode exits and its draft has been handed off.
667
+ * Always receives the slide that was actually being edited — the slide
668
+ * prop may have already advanced to a new slide by the time this fires
669
+ * (see the slide-switch effect), so callers must not substitute their own
670
+ * "current slide" state for this argument. */
671
+ onInlineEditEnd?: (slideId: string) => void;
668
672
  /** Other users (besides the current user) currently viewing/editing THIS
669
673
  * slide. Drives the soft same-slide-edit indicator on the canvas so a user
670
674
  * knows before they clobber someone else's last-writer-wins text edit. */
@@ -1183,6 +1187,11 @@ export default function SlideEditor({
1183
1187
  () => getCopiedElementStyle() !== null,
1184
1188
  );
1185
1189
  const contextMenuTargetRef = useRef<HTMLElement | null>(null);
1190
+ const contextMenuTableCellRef = useRef<HTMLTableCellElement | null>(null);
1191
+ const [contextMenuTableInfo, setContextMenuTableInfo] = useState<{
1192
+ rowCount: number;
1193
+ colCount: number;
1194
+ } | null>(null);
1186
1195
  const selectionOverlayMeasurementKey = createSelectionOverlayMeasurementKey({
1187
1196
  slideId: slide.id,
1188
1197
  content,
@@ -1527,6 +1536,13 @@ export default function SlideEditor({
1527
1536
  useEffect(() => {
1528
1537
  onUpdateSlideRef.current = onUpdateSlide;
1529
1538
  }, [onUpdateSlide]);
1539
+ /** Latest onInlineEditEnd in a ref so the unmount cleanup below (which must
1540
+ * run with a stable, empty dependency array) always calls the current
1541
+ * version instead of whatever was passed on the very first render. */
1542
+ const onInlineEditEndRef = useRef(onInlineEditEnd);
1543
+ useEffect(() => {
1544
+ onInlineEditEndRef.current = onInlineEditEnd;
1545
+ }, [onInlineEditEnd]);
1530
1546
  const inlineEditDraftRef = useRef<InlineEditContentSnapshot | null>(null);
1531
1547
  const inlineEditInitialContentRef = useRef<InlineEditContentSnapshot | null>(
1532
1548
  null,
@@ -1870,7 +1886,7 @@ export default function SlideEditor({
1870
1886
  ) {
1871
1887
  onUpdateSlideRef.current({ content: html });
1872
1888
  }
1873
- onInlineEditEnd?.();
1889
+ onInlineEditEnd?.(slide.id);
1874
1890
  inlineEditDraftRef.current = null;
1875
1891
  inlineEditInitialContentRef.current = null;
1876
1892
  const escape = slidesCanvasInteractionCore.escape({
@@ -1910,7 +1926,7 @@ export default function SlideEditor({
1910
1926
  // Mark the deck dirty immediately so SSE/poll refreshes do not replace
1911
1927
  // the deck under an active contentEditable edit, even before the user
1912
1928
  // types and triggers an onUpdateSlide flush.
1913
- onInlineEditStart?.();
1929
+ onInlineEditStart?.(slide.id);
1914
1930
  // Don't override the selection. The browser's native double-click
1915
1931
  // word-select (or single-click caret) is already on the element from the
1916
1932
  // user's gesture; re-selecting from JS clobbers it. focus() on an
@@ -1953,12 +1969,23 @@ export default function SlideEditor({
1953
1969
  onUpdateSlideRef.current({ content: draft.content }, previousSlideId);
1954
1970
  inlineEditDraftRef.current = null;
1955
1971
  }
1956
- if (editing || draft) onInlineEditEnd?.();
1972
+ if (editing || draft) onInlineEditEnd?.(previousSlideId);
1957
1973
  inlineEditInitialContentRef.current = null;
1958
1974
 
1959
1975
  previousSlideIdRef.current = slide.id;
1960
1976
  }, [onInlineEditEnd, slide.id]);
1961
1977
 
1978
+ // Editor unmount (navigating away, closing the deck) skips the slide-switch
1979
+ // effect above entirely, so an active edit's "mid-edit" marker would
1980
+ // otherwise never clear and permanently block live sync for that slide.
1981
+ useEffect(() => {
1982
+ return () => {
1983
+ if (editingElRef.current || inlineEditDraftRef.current) {
1984
+ onInlineEditEndRef.current?.(previousSlideIdRef.current);
1985
+ }
1986
+ };
1987
+ }, []);
1988
+
1962
1989
  useEffect(() => {
1963
1990
  if (!editingEl) return;
1964
1991
  const editingSlideId = slide.id;
@@ -2608,6 +2635,107 @@ export default function SlideEditor({
2608
2635
  resolveSelectedElement,
2609
2636
  ]);
2610
2637
 
2638
+ const commitTableMutation = useCallback(() => {
2639
+ const html = readCurrentSlideContentHtml();
2640
+ if (html !== null) onUpdateSlideRef.current({ content: html });
2641
+ }, [readCurrentSlideContentHtml]);
2642
+
2643
+ /** Row/column ops below index cells by position, which only holds for a
2644
+ * regular grid. `colspan`/`rowspan` are valid on imported tables
2645
+ * (sanitize-slide-html allows them), so every op bails here rather than
2646
+ * silently touching the wrong cell. */
2647
+ const tableHasMergedCells = useCallback((table: HTMLTableElement) => {
2648
+ return Array.from(table.rows).some((row) =>
2649
+ Array.from(row.cells).some((c) => c.colSpan > 1 || c.rowSpan > 1),
2650
+ );
2651
+ }, []);
2652
+
2653
+ /** Column ops change cell count per row but not col tracks. A colgroup
2654
+ * (imported decks can carry one — sanitize-slide-html allows it)
2655
+ * positionally maps each col element to a column, so an unsynced insert
2656
+ * or delete leaves too few/many tracks and visually misaligns columns.
2657
+ * Row ops don't touch columns, so this only guards column ops. */
2658
+ const tableHasColgroup = useCallback((table: HTMLTableElement) => {
2659
+ return table.querySelector("colgroup") !== null;
2660
+ }, []);
2661
+
2662
+ const insertTableRow = useCallback(
2663
+ (cell: HTMLTableCellElement, position: "above" | "below") => {
2664
+ const row = cell.closest("tr");
2665
+ const table = row?.closest("table");
2666
+ if (!row || !table || tableHasMergedCells(table)) return;
2667
+ const newRow = row.cloneNode(true) as HTMLTableRowElement;
2668
+ Array.from(newRow.cells).forEach((c) => {
2669
+ c.innerHTML = "";
2670
+ });
2671
+ row.insertAdjacentElement(
2672
+ position === "above" ? "beforebegin" : "afterend",
2673
+ newRow,
2674
+ );
2675
+ commitTableMutation();
2676
+ },
2677
+ [commitTableMutation, tableHasMergedCells],
2678
+ );
2679
+
2680
+ const deleteTableRow = useCallback(
2681
+ (cell: HTMLTableCellElement) => {
2682
+ const row = cell.closest("tr");
2683
+ const table = row?.closest("table");
2684
+ if (
2685
+ !row ||
2686
+ !table ||
2687
+ table.rows.length <= 1 ||
2688
+ tableHasMergedCells(table)
2689
+ ) {
2690
+ return;
2691
+ }
2692
+ row.remove();
2693
+ commitTableMutation();
2694
+ },
2695
+ [commitTableMutation, tableHasMergedCells],
2696
+ );
2697
+
2698
+ const insertTableColumn = useCallback(
2699
+ (cell: HTMLTableCellElement, position: "left" | "right") => {
2700
+ const table = cell.closest("table");
2701
+ if (!table) return;
2702
+ const columnIndex = cell.cellIndex;
2703
+ Array.from(table.rows).forEach((row) => {
2704
+ const refCell = row.cells[columnIndex];
2705
+ if (!refCell) return;
2706
+ if (tableHasMergedCells(table) || tableHasColgroup(table)) return;
2707
+ const newCell = document.createElement(
2708
+ refCell.tagName,
2709
+ ) as HTMLTableCellElement;
2710
+ newCell.className = refCell.className;
2711
+ const style = refCell.getAttribute("style");
2712
+ if (style) newCell.setAttribute("style", style);
2713
+ refCell.insertAdjacentElement(
2714
+ position === "left" ? "beforebegin" : "afterend",
2715
+ newCell,
2716
+ );
2717
+ });
2718
+ commitTableMutation();
2719
+ },
2720
+ [commitTableMutation, tableHasMergedCells, tableHasColgroup],
2721
+ );
2722
+
2723
+ const deleteTableColumn = useCallback(
2724
+ (cell: HTMLTableCellElement) => {
2725
+ const table = cell.closest("table");
2726
+ if (!table) return;
2727
+ const columnIndex = cell.cellIndex;
2728
+ const firstRow = table.rows[0];
2729
+ if (!firstRow || firstRow.cells.length <= 1) return;
2730
+ Array.from(table.rows).forEach((row) => {
2731
+ if (tableHasMergedCells(table) || tableHasColgroup(table)) return;
2732
+ row.cells[columnIndex]?.remove();
2733
+ });
2734
+ commitTableMutation();
2735
+ },
2736
+ [commitTableMutation, tableHasMergedCells, tableHasColgroup],
2737
+ );
2738
+
2611
2739
  // Delete/Backspace removes the selected slide content (single or
2612
2740
  // multi-select). Only active when something is selected for styling (not
2613
2741
  // while inline-editing text, where Backspace should delete a character) and
@@ -2694,6 +2822,23 @@ export default function SlideEditor({
2694
2822
  [],
2695
2823
  );
2696
2824
 
2825
+ const findTableCell = useCallback(
2826
+ (
2827
+ target: HTMLElement,
2828
+ slideContent: HTMLElement,
2829
+ ): HTMLTableCellElement | null => {
2830
+ let el: HTMLElement | null = target;
2831
+ while (el && slideContent.contains(el) && el !== slideContent) {
2832
+ if (el.tagName === "TD" || el.tagName === "TH") {
2833
+ return el as HTMLTableCellElement;
2834
+ }
2835
+ el = el.parentElement;
2836
+ }
2837
+ return null;
2838
+ },
2839
+ [],
2840
+ );
2841
+
2697
2842
  /** True if the click is on "whitespace" inside the slide (not on any leaf) */
2698
2843
  const isSlideWhitespaceTarget = useCallback(
2699
2844
  (target: HTMLElement, slideContent: HTMLElement): boolean => {
@@ -4525,7 +4670,6 @@ export default function SlideEditor({
4525
4670
 
4526
4671
  applyMultiSelection(hits);
4527
4672
  };
4528
-
4529
4673
  const onUp = () => finish(false);
4530
4674
  const onCancel = () => finish(true);
4531
4675
  window.addEventListener("pointermove", onMove);
@@ -4838,13 +4982,29 @@ export default function SlideEditor({
4838
4982
  const slideContent = getSlideContent();
4839
4983
  if (!slideContent) {
4840
4984
  contextMenuTargetRef.current = null;
4985
+ contextMenuTableCellRef.current = null;
4986
+ setContextMenuTableInfo(null);
4841
4987
  return;
4842
4988
  }
4843
4989
 
4990
+ const tableCell = readOnly ? null : findTableCell(target, slideContent);
4991
+ contextMenuTableCellRef.current = tableCell;
4992
+ const table = tableCell?.closest("table") ?? null;
4993
+ setContextMenuTableInfo(
4994
+ table
4995
+ ? {
4996
+ rowCount: table.rows.length,
4997
+ colCount: tableCell
4998
+ ? (tableCell.parentElement?.childElementCount ?? 0)
4999
+ : 0,
5000
+ }
5001
+ : null,
5002
+ );
5003
+
4844
5004
  const selectable = findSelectableElement(target, slideContent);
4845
5005
  if (!selectable) {
4846
5006
  contextMenuTargetRef.current = null;
4847
- clearCanvasSelection();
5007
+ if (!tableCell) clearCanvasSelection();
4848
5008
  return;
4849
5009
  }
4850
5010
 
@@ -4866,8 +5026,10 @@ export default function SlideEditor({
4866
5026
  clearCanvasSelection,
4867
5027
  clearMultiSelection,
4868
5028
  findSelectableElement,
5029
+ findTableCell,
4869
5030
  getSlideContent,
4870
5031
  multiSelection,
5032
+ readOnly,
4871
5033
  selectElementForStyling,
4872
5034
  ],
4873
5035
  );
@@ -5251,7 +5413,11 @@ export default function SlideEditor({
5251
5413
  >
5252
5414
  <ContextMenu
5253
5415
  onOpenChange={(open) => {
5254
- if (!open) contextMenuTargetRef.current = null;
5416
+ if (!open) {
5417
+ contextMenuTargetRef.current = null;
5418
+ contextMenuTableCellRef.current = null;
5419
+ setContextMenuTableInfo(null);
5420
+ }
5255
5421
  }}
5256
5422
  >
5257
5423
  <ContextMenuTrigger asChild disabled={readOnly}>
@@ -5341,6 +5507,62 @@ export default function SlideEditor({
5341
5507
  </div>
5342
5508
  </ContextMenuTrigger>
5343
5509
  <ContextMenuContent>
5510
+ {contextMenuTableInfo && (
5511
+ <>
5512
+ <ContextMenuItem
5513
+ onSelect={() => {
5514
+ const cell = contextMenuTableCellRef.current;
5515
+ if (cell) insertTableRow(cell, "above");
5516
+ }}
5517
+ >
5518
+ {t("styleInspector.insertRowAbove")}
5519
+ </ContextMenuItem>
5520
+ <ContextMenuItem
5521
+ onSelect={() => {
5522
+ const cell = contextMenuTableCellRef.current;
5523
+ if (cell) insertTableRow(cell, "below");
5524
+ }}
5525
+ >
5526
+ {t("styleInspector.insertRowBelow")}
5527
+ </ContextMenuItem>
5528
+ <ContextMenuItem
5529
+ disabled={contextMenuTableInfo.rowCount <= 1}
5530
+ onSelect={() => {
5531
+ const cell = contextMenuTableCellRef.current;
5532
+ if (cell) deleteTableRow(cell);
5533
+ }}
5534
+ >
5535
+ {t("styleInspector.deleteRow")}
5536
+ </ContextMenuItem>
5537
+ <ContextMenuSeparator />
5538
+ <ContextMenuItem
5539
+ onSelect={() => {
5540
+ const cell = contextMenuTableCellRef.current;
5541
+ if (cell) insertTableColumn(cell, "left");
5542
+ }}
5543
+ >
5544
+ {t("styleInspector.insertColumnLeft")}
5545
+ </ContextMenuItem>
5546
+ <ContextMenuItem
5547
+ onSelect={() => {
5548
+ const cell = contextMenuTableCellRef.current;
5549
+ if (cell) insertTableColumn(cell, "right");
5550
+ }}
5551
+ >
5552
+ {t("styleInspector.insertColumnRight")}
5553
+ </ContextMenuItem>
5554
+ <ContextMenuItem
5555
+ disabled={contextMenuTableInfo.colCount <= 1}
5556
+ onSelect={() => {
5557
+ const cell = contextMenuTableCellRef.current;
5558
+ if (cell) deleteTableColumn(cell);
5559
+ }}
5560
+ >
5561
+ {t("styleInspector.deleteColumn")}
5562
+ </ContextMenuItem>
5563
+ <ContextMenuSeparator />
5564
+ </>
5565
+ )}
5344
5566
  <ContextMenuItem
5345
5567
  disabled={!hasObjectSelection}
5346
5568
  onSelect={copySelectedObjects}
@@ -15,7 +15,25 @@ export function isAgentSidebarVisible() {
15
15
  if (style.display === "none" || style.visibility === "hidden") return false;
16
16
 
17
17
  const rect = panel.getBoundingClientRect();
18
- return rect.width > 0 && rect.height > 0;
18
+ if (rect.width <= 0 || rect.height <= 0) return false;
19
+
20
+ for (
21
+ let ancestor = panel.parentElement;
22
+ ancestor && ancestor !== document.body;
23
+ ancestor = ancestor.parentElement
24
+ ) {
25
+ if (ancestor.getAttribute("aria-hidden") === "true") return false;
26
+ if (ancestor.inert) return false;
27
+ const ancestorStyle = window.getComputedStyle(ancestor);
28
+ if (
29
+ ancestorStyle.display === "none" ||
30
+ ancestorStyle.visibility === "hidden"
31
+ ) {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ return true;
19
37
  }
20
38
 
21
39
  function useAgentSidebarVisible() {
@@ -25,20 +43,49 @@ function useAgentSidebarVisible() {
25
43
  const update = () => setVisible(isAgentSidebarVisible());
26
44
  update();
27
45
 
28
- const observer = new MutationObserver(update);
29
- observer.observe(document.body, {
30
- attributes: true,
31
- attributeFilter: ["aria-hidden", "class", "inert", "style"],
32
- childList: true,
33
- subtree: true,
34
- });
46
+ // The panel is a portal and is normally a direct child of body. Discover
47
+ // portal mount/unmounts with a shallow child-list observer, then watch only
48
+ // the panel and its immediate parent. Observing every body attribute and
49
+ // descendant mutation made this tiny indicator run on every editor render.
50
+ let panelObserver: MutationObserver | null = null;
51
+ let parentObserver: MutationObserver | null = null;
52
+
53
+ const observePanel = () => {
54
+ panelObserver?.disconnect();
55
+ parentObserver?.disconnect();
56
+ const panel = document.querySelector<HTMLElement>(".agent-sidebar-panel");
57
+ if (!panel) return;
58
+
59
+ panelObserver = new MutationObserver(update);
60
+ panelObserver.observe(panel, {
61
+ attributes: true,
62
+ attributeFilter: ["aria-hidden", "class", "inert", "style"],
63
+ });
64
+ if (panel.parentElement && panel.parentElement !== document.body) {
65
+ parentObserver = new MutationObserver(update);
66
+ parentObserver.observe(panel.parentElement, {
67
+ attributes: true,
68
+ attributeFilter: ["aria-hidden", "class", "inert", "style"],
69
+ childList: true,
70
+ });
71
+ }
72
+ };
73
+ const updateAndObserve = () => {
74
+ update();
75
+ observePanel();
76
+ };
77
+ const discovery = new MutationObserver(updateAndObserve);
78
+ discovery.observe(document.body, { childList: true });
79
+ updateAndObserve();
35
80
  window.addEventListener("resize", update);
36
81
  window.addEventListener("agent-panel:open", update);
37
82
  window.addEventListener("agent-panel:toggle", update);
38
83
  window.addEventListener("agent-panel:set-mode", update);
39
84
 
40
85
  return () => {
41
- observer.disconnect();
86
+ discovery.disconnect();
87
+ panelObserver?.disconnect();
88
+ parentObserver?.disconnect();
42
89
  window.removeEventListener("resize", update);
43
90
  window.removeEventListener("agent-panel:open", update);
44
91
  window.removeEventListener("agent-panel:toggle", update);
@@ -379,6 +379,35 @@ const DECK_SAVE_RETRY_BASE_MS = 250;
379
379
  // Ops are appended by enqueueDeckOp and drained when the debounce fires.
380
380
  const pendingOpsQueue = new Map<string, GranularOp[]>();
381
381
 
382
+ // The ops a deck's current in-flight save actually sent, so a slide-scoped
383
+ // check can tell "this slide's save is in flight" from "some other slide in
384
+ // this deck has a save in flight" — `inFlightSaves` alone can't, since it is
385
+ // deck-wide and previously made every slide in the deck look pending for the
386
+ // whole request duration, starving unrelated agent writes of live sync.
387
+ const inFlightOpSlides = new Map<string, GranularOp[]>();
388
+
389
+ // Slides currently mid inline-edit (contentEditable open, keystrokes not yet
390
+ // committed to a "patch-slide" op). `onInlineEditStart`/`onInlineEditEnd`
391
+ // keep this current; a slide only reaches `pendingOpsQueue` when editing
392
+ // ends, so this is the only signal that catches live, uncommitted typing.
393
+ const activeInlineEditSlides = new Map<string, Set<string>>();
394
+
395
+ /** Mark `slideId` as mid inline-edit so a concurrent agent write to the same
396
+ * slide is not adopted over the user's uncommitted keystrokes. */
397
+ export function markSlideEditingActive(deckId: string, slideId: string) {
398
+ const set = activeInlineEditSlides.get(deckId) ?? new Set<string>();
399
+ set.add(slideId);
400
+ activeInlineEditSlides.set(deckId, set);
401
+ }
402
+
403
+ /** Clear the mid-edit mark once inline editing ends (committed or discarded). */
404
+ export function clearSlideEditingActive(deckId: string, slideId: string) {
405
+ const set = activeInlineEditSlides.get(deckId);
406
+ if (!set) return;
407
+ set.delete(slideId);
408
+ if (set.size === 0) activeInlineEditSlides.delete(deckId);
409
+ }
410
+
382
411
  // Cached snapshot for useSyncExternalStore. MUST be stable when either value
383
412
  // is unchanged or React will infinite-loop (it compares snapshots with
384
413
  // Object.is — a fresh object literal every call schedules a new update,
@@ -528,6 +557,7 @@ function drainPendingDeckOps(deckId: string): Promise<void> {
528
557
  typeof AbortController === "undefined" ? null : new AbortController();
529
558
  if (controller) inFlightSaveControllers.set(deckId, controller);
530
559
  inFlightSaves.add(deckId);
560
+ inFlightOpSlides.set(deckId, ops);
531
561
  const isCurrentGeneration = () =>
532
562
  (deckSaveGenerations.get(deckId) ?? 0) === generation;
533
563
  const next = persistDeckOps(deckId, ops, controller?.signal)
@@ -568,6 +598,7 @@ function drainPendingDeckOps(deckId: string): Promise<void> {
568
598
  inFlightSaveControllers.delete(deckId);
569
599
  }
570
600
  inFlightSaves.delete(deckId);
601
+ inFlightOpSlides.delete(deckId);
571
602
  const flushImmediately = immediateFlushRequests.delete(deckId);
572
603
  notifySaveListeners();
573
604
  if (flushImmediately) {
@@ -1250,6 +1281,61 @@ export function mergeServerAddedSlides(local: Deck, server: Deck): Deck {
1250
1281
  return { ...local, slides: merged };
1251
1282
  }
1252
1283
 
1284
+ /** True when `op` is a deck-wide write (touches every slide) or targets
1285
+ * `slideId` specifically. */
1286
+ function opTargetsSlide(op: GranularOp, slideId: string): boolean {
1287
+ return (
1288
+ op.op === "full-replace" ||
1289
+ op.op === "reorder-slides" ||
1290
+ ("slideId" in op && op.slideId === slideId)
1291
+ );
1292
+ }
1293
+
1294
+ /**
1295
+ * True when some queued-or-in-flight write for `deckId` could still touch
1296
+ * `slideId`, so adopting the server's copy of that slide would race a local
1297
+ * write instead of reflecting it. Checking the actual ops (queued or
1298
+ * in-flight) rather than a deck-wide "a save is running" flag matters: an
1299
+ * in-flight save for slide B must not block slide A's live update for the
1300
+ * whole request duration. The slide being mid inline-edit (typing not yet
1301
+ * committed to any op) also counts as a pending write.
1302
+ */
1303
+ function hasPendingWriteForSlide(deckId: string, slideId: string): boolean {
1304
+ if (activeInlineEditSlides.get(deckId)?.has(slideId)) return true;
1305
+ const inFlightOps = inFlightOpSlides.get(deckId);
1306
+ if (inFlightOps?.some((op) => opTargetsSlide(op, slideId))) return true;
1307
+ const queue = pendingOpsQueue.get(deckId);
1308
+ if (!queue) return false;
1309
+ return queue.some((op) => opTargetsSlide(op, slideId));
1310
+ }
1311
+
1312
+ /**
1313
+ * Same additive merge as `mergeServerAddedSlides`, but additionally adopts
1314
+ * the server's content for `changedSlideId` when no local write is pending
1315
+ * for that specific slide. This closes the gap where an agent edit to a
1316
+ * slide that already exists locally (e.g. removing a table row) was
1317
+ * otherwise invisible until the deck went fully clean or the page reloaded —
1318
+ * see the module doc on `refetchOpenDeckIfChanged`.
1319
+ */
1320
+ export function mergeServerSlideUpdate(
1321
+ local: Deck,
1322
+ server: Deck,
1323
+ changedSlideId: string | undefined,
1324
+ deckId: string,
1325
+ ): Deck {
1326
+ const merged = mergeServerAddedSlides(local, server);
1327
+ if (!changedSlideId || hasPendingWriteForSlide(deckId, changedSlideId)) {
1328
+ return merged;
1329
+ }
1330
+ const serverSlide = server.slides.find((s) => s.id === changedSlideId);
1331
+ if (!serverSlide) return merged;
1332
+ const idx = merged.slides.findIndex((s) => s.id === changedSlideId);
1333
+ if (idx < 0 || merged.slides[idx] === serverSlide) return merged;
1334
+ const nextSlides = [...merged.slides];
1335
+ nextSlides[idx] = serverSlide;
1336
+ return { ...merged, slides: nextSlides };
1337
+ }
1338
+
1253
1339
  export const defaultSlideContent: Record<SlideLayout, string> = {
1254
1340
  title: `<div class="fmd-slide" style="padding: 80px 110px; justify-content: space-between;">
1255
1341
  <div>
@@ -1557,7 +1643,7 @@ export function DeckProvider({ children }: { children: ReactNode }) {
1557
1643
  const refetchOpenDeckIfChanged = useCallback(
1558
1644
  async (
1559
1645
  currentOpenId: string,
1560
- options?: { clearPendingWrites?: boolean },
1646
+ options?: { clearPendingWrites?: boolean; changedSlideId?: string },
1561
1647
  ): Promise<Deck | null> => {
1562
1648
  const requestId =
1563
1649
  (openDeckRequestIdByDeckRef.current.get(currentOpenId) ?? 0) + 1;
@@ -1584,7 +1670,12 @@ export function DeckProvider({ children }: { children: ReactNode }) {
1584
1670
 
1585
1671
  if (hasLocalEdits && clientDeck) {
1586
1672
  // Content-preserving: only ADD server slides missing locally.
1587
- const merged = mergeServerAddedSlides(clientDeck, serverDeck);
1673
+ const merged = mergeServerSlideUpdate(
1674
+ clientDeck,
1675
+ serverDeck,
1676
+ options?.changedSlideId,
1677
+ currentOpenId,
1678
+ );
1588
1679
  if (merged === clientDeck) return serverDeck; // nothing new to surface
1589
1680
  lastExternalUpdateRef.current = Date.now();
1590
1681
  setDecks((prev) => {
@@ -1874,7 +1965,12 @@ export function DeckProvider({ children }: { children: ReactNode }) {
1874
1965
  // write that arrived during the same local edit. The reconciler
1875
1966
  // preserves local slide bodies and local-only slides while still
1876
1967
  // surfacing server-added slides immediately.
1877
- void refetchOpenDeckIfChanged(data.deckId).catch((error) => {
1968
+ const changedSlideId =
1969
+ typeof data.slideId === "string" ? data.slideId : undefined;
1970
+ const refetchPromise = refetchOpenDeckIfChanged(data.deckId, {
1971
+ changedSlideId,
1972
+ });
1973
+ void refetchPromise.catch((error) => {
1878
1974
  console.error(
1879
1975
  `Failed to refresh deck ${data.deckId} after sync event:`,
1880
1976
  error,
@@ -444,6 +444,12 @@ const messages = {
444
444
  pasteStyle: "Paste style",
445
445
  copy: "Copy",
446
446
  paste: "Paste",
447
+ insertRowAbove: "Insert row above",
448
+ insertRowBelow: "Insert row below",
449
+ deleteRow: "Delete row",
450
+ insertColumnLeft: "Insert column left",
451
+ insertColumnRight: "Insert column right",
452
+ deleteColumn: "Delete column",
447
453
  },
448
454
  animations: {
449
455
  appear: "Appear",
@@ -58,8 +58,10 @@ import {
58
58
  } from "@/components/ui/alert-dialog";
59
59
  import { Button } from "@/components/ui/button";
60
60
  import {
61
+ clearSlideEditingActive,
61
62
  deckIdFromPathname,
62
63
  hasUnsavedDeckChanges,
64
+ markSlideEditingActive,
63
65
  type Slide,
64
66
  useDecks,
65
67
  useSaveState,
@@ -1561,11 +1563,15 @@ export default function DeckEditor() {
1561
1563
  options,
1562
1564
  )
1563
1565
  }
1564
- onInlineEditStart={() => {
1566
+ onInlineEditStart={(slideId) => {
1565
1567
  setInlineEditActive(true);
1566
1568
  markDeckDirty(id);
1569
+ if (id) markSlideEditingActive(id, slideId);
1570
+ }}
1571
+ onInlineEditEnd={(slideId) => {
1572
+ setInlineEditActive(false);
1573
+ if (id) clearSlideEditingActive(id, slideId);
1567
1574
  }}
1568
- onInlineEditEnd={() => setInlineEditActive(false)}
1569
1575
  onGenerateImage={() => setImageGenOpen(true)}
1570
1576
  onOpenAssetLibrary={(src) => {
1571
1577
  setReplaceImageSrc(src);
package/dist/action.js CHANGED
@@ -326,8 +326,8 @@ const SUBSCHEMA_MAP_KEYS = [
326
326
  "dependentSchemas",
327
327
  ];
328
328
  /**
329
- * Remove JSON Schema keywords that some providers' function-calling schema
330
- * validators reject. OpenAI (and Gemini via the Builder gateway) reject
329
+ * Remove or rewrite JSON Schema keywords that some providers' function-calling
330
+ * schema validators reject. OpenAI (and Gemini via the Builder gateway) reject
331
331
  * `propertyNames` — which Zod v4 emits for `z.record(z.string(), …)` — with a
332
332
  * `400 invalid_function_parameters` error, causing the model turn to produce no
333
333
  * content (surfacing as an empty assistant response). Anthropic ignores the
@@ -335,6 +335,15 @@ const SUBSCHEMA_MAP_KEYS = [
335
335
  * portable. `propertyNames` only constrained object *keys*; the value/shape of
336
336
  * the object is unaffected by its removal.
337
337
  *
338
+ * OpenAI rejects `oneOf` the same way — "Invalid schema for function 'x': …
339
+ * 'oneOf' is not permitted" — while accepting `anyOf`. Zod v4's `toJSONSchema`
340
+ * emits `oneOf` for every `z.discriminatedUnion`, so any action with one was
341
+ * 400'd for the whole request before a token streamed, and the gateway folded
342
+ * that 400 into a generic 500. Measured: 178k events / 786 users over seven
343
+ * weeks from a single action. For a discriminated union the two keywords accept
344
+ * the same documents, and the tool-input validator already handles both
345
+ * (see `narrowUnionBranchErrors`), so the rewrite is behaviour-preserving.
346
+ *
338
347
  * Only descends through actual subschema positions (properties, items, union
339
348
  * branches, definitions, etc.) — never through value-bearing keywords like
340
349
  * `default`, `const`, `enum`, or `examples`, whose objects may legitimately
@@ -371,6 +380,14 @@ function stripUnsupportedSchemaKeywords(node) {
371
380
  }
372
381
  }
373
382
  }
383
+ // After descending, so nested unions are rewritten too. Merged rather than
384
+ // overwritten on the pathological case where a schema carries both: dropping
385
+ // either arm would silently narrow what the tool accepts.
386
+ if (Array.isArray(obj.oneOf)) {
387
+ const existing = Array.isArray(obj.anyOf) ? obj.anyOf : [];
388
+ obj.anyOf = [...existing, ...obj.oneOf];
389
+ delete obj.oneOf;
390
+ }
374
391
  return node;
375
392
  }
376
393
  /**