@agent-native/core 0.136.5 → 0.137.1

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 (174) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/app/components/AgentCompletionSound.tsx +105 -0
  3. package/corpus/templates/analytics/app/components/layout/Layout.tsx +44 -35
  4. package/corpus/templates/analytics/app/i18n-data.ts +38 -0
  5. package/corpus/templates/analytics/app/pages/Settings.tsx +53 -1
  6. package/corpus/templates/analytics/app/pages/settings/settings-search.ts +6 -0
  7. package/corpus/templates/analytics/changelog/2026-08-04-choose-whether-analytics-plays-a-bell-when-the-agent-finishe.md +6 -0
  8. package/corpus/templates/analytics/shared/analytics-user-prefs.ts +2 -0
  9. package/corpus/templates/clips/app/components/import-menu.tsx +109 -0
  10. package/corpus/templates/clips/app/components/library/library-grid.tsx +8 -18
  11. package/corpus/templates/clips/app/components/library/library-layout.tsx +14 -50
  12. package/corpus/templates/clips/app/components/library/sort-menu.tsx +20 -6
  13. package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +10 -34
  14. package/corpus/templates/clips/app/routes/_app.library._index.tsx +0 -2
  15. package/corpus/templates/clips/changelog/2026-08-04-cleaner-library-controls-with-an-icon-only-sort-button.md +6 -0
  16. package/corpus/templates/clips/changelog/2026-08-04-upload-and-loom-imports-now-live-under-one-import-menu.md +6 -0
  17. package/corpus/templates/content/actions/_database-utils.ts +6 -0
  18. package/corpus/templates/content/actions/bind-content-database-source-field.ts +273 -97
  19. package/corpus/templates/content/actions/change-content-database-source-role.ts +65 -29
  20. package/corpus/templates/content/actions/configure-document-property.ts +113 -32
  21. package/corpus/templates/content/actions/delete-content-database.ts +5 -7
  22. package/corpus/templates/content/actions/delete-document-property.ts +109 -62
  23. package/corpus/templates/content/actions/delete-document.ts +178 -36
  24. package/corpus/templates/content/actions/duplicate-database-item.ts +27 -0
  25. package/corpus/templates/content/actions/duplicate-database-items.ts +31 -1
  26. package/corpus/templates/content/actions/permanently-delete-document.ts +4 -6
  27. package/corpus/templates/content/actions/remove-database-items.ts +13 -0
  28. package/corpus/templates/content/actions/set-document-property.ts +69 -1
  29. package/corpus/templates/content/actions/upsert-database-item-by-key.ts +814 -0
  30. package/corpus/templates/content/parity/matrix.md +1 -1
  31. package/corpus/templates/content/parity/matrix.ts +1 -0
  32. package/corpus/templates/content/server/db/schema.ts +27 -0
  33. package/corpus/templates/content/server/plugins/db.ts +24 -0
  34. package/corpus/templates/design/app/components/design/BreakpointBar.tsx +122 -114
  35. package/corpus/templates/design/app/components/design/CanvasContextMenu.tsx +2 -2
  36. package/corpus/templates/design/app/components/design/EditPanel.tsx +68 -34
  37. package/corpus/templates/design/app/components/design/KeyboardShortcutsPanel.tsx +78 -4
  38. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +8 -0
  39. package/corpus/templates/design/app/components/design/QuestionFlow.tsx +5 -2
  40. package/corpus/templates/design/app/components/design/edit-panel/panel-primitives.tsx +10 -9
  41. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +31 -69
  42. package/corpus/templates/design/app/components/design/inspector/design-icons.tsx +80 -106
  43. package/corpus/templates/design/app/components/design/multi-screen/types.ts +4 -0
  44. package/corpus/templates/design/app/hooks/use-editor-preferences.ts +75 -0
  45. package/corpus/templates/design/app/i18n-data.ts +8 -0
  46. package/corpus/templates/design/app/i18n-keyboard-shortcuts.ts +61 -0
  47. package/corpus/templates/design/app/pages/design-editor/editor-preferences.ts +98 -0
  48. package/corpus/templates/design/app/pages/design-editor/nudge-intent.ts +491 -0
  49. package/corpus/templates/design/app/pages/design-editor/paste-placement.ts +133 -0
  50. package/corpus/templates/design/app/pages/design-editor/png-export-render.ts +8 -0
  51. package/corpus/templates/design/app/pages/design-editor/selection-state.ts +5 -12
  52. package/corpus/templates/design/changelog/2026-08-03-arrow-keys-now-reorder-a-layer-inside-an-auto-layout-frame.md +6 -0
  53. package/corpus/templates/design/changelog/2026-08-03-auto-layout-flow-icons-show-the-direction-at-a-glance-instea.md +6 -0
  54. package/corpus/templates/design/changelog/2026-08-03-auto-layout-icons-share-one-outlined-square-style-so-flow-ga.md +6 -0
  55. package/corpus/templates/design/changelog/2026-08-03-export-has-a-named-preview-section-you-can-expand-replacing-.md +6 -0
  56. package/corpus/templates/design/changelog/2026-08-03-export-preview-shows-the-selected-element-instead-of-the-who.md +6 -0
  57. package/corpus/templates/design/changelog/2026-08-03-inspector-sections-show-an-expand-indicator-so-it-is-clear-w.md +6 -0
  58. package/corpus/templates/design/changelog/2026-08-03-paste-lands-inside-a-selected-frame-instead-of-beside-it.md +6 -0
  59. package/corpus/templates/design/changelog/2026-08-03-the-add-breakpoint-button-sits-outside-the-breakpoint-select.md +6 -0
  60. package/corpus/templates/design/changelog/2026-08-03-the-agent-s-follow-up-questions-keep-space-below-the-continu.md +6 -0
  61. package/corpus/templates/design/changelog/2026-08-03-the-agent-s-follow-up-questions-share-the-chat-panel-s-backg.md +6 -0
  62. package/corpus/templates/design/changelog/2026-08-03-the-export-panel-no-longer-stacks-a-duplicate-export-block-o.md +6 -0
  63. package/corpus/templates/design/changelog/2026-08-03-the-text-tool-uses-a-t-icon-matching-the-letter-it-inserts.md +6 -0
  64. package/corpus/templates/factory/.env.example +17 -0
  65. package/corpus/templates/factory/AGENTS.md +27 -17
  66. package/corpus/templates/factory/README.md +19 -9
  67. package/corpus/templates/factory/actions/get-slack-feedback-context.ts +90 -0
  68. package/corpus/templates/factory/actions/get-triage-config.ts +14 -3
  69. package/corpus/templates/factory/actions/govern-agent-native-pull-request.ts +386 -0
  70. package/corpus/templates/factory/actions/list-factory-automations.ts +53 -0
  71. package/corpus/templates/factory/actions/list-triage-items.ts +7 -2
  72. package/corpus/templates/factory/actions/poll-github-sources.ts +203 -0
  73. package/corpus/templates/factory/actions/poll-sentry-errors.ts +161 -0
  74. package/corpus/templates/factory/actions/poll-slack-channel.ts +4 -2
  75. package/corpus/templates/factory/actions/run-factory-automation.ts +42 -0
  76. package/corpus/templates/factory/actions/save-factory-automation.ts +69 -0
  77. package/corpus/templates/factory/actions/save-triage-config.ts +28 -2
  78. package/corpus/templates/factory/actions/start-builder-for-item.ts +411 -0
  79. package/corpus/templates/factory/app/components/factory/FactoryCanvas.tsx +14 -3
  80. package/corpus/templates/factory/app/components/ui/checkbox.tsx +1 -0
  81. package/corpus/templates/factory/app/hooks/use-navigation-state.ts +7 -0
  82. package/corpus/templates/factory/app/i18n/en-US.ts +35 -1
  83. package/corpus/templates/factory/app/routes/factory.tsx +462 -9
  84. package/corpus/templates/factory/changelog/2026-08-04-added-organization-owned-factory-automations-for-slack-bug-t.md +6 -0
  85. package/corpus/templates/factory/changelog/2026-08-04-automation-editor-controls-and-model-picker.md +6 -0
  86. package/corpus/templates/factory/netlify.toml +2 -2
  87. package/corpus/templates/factory/server/connectors/credentials.ts +6 -5
  88. package/corpus/templates/factory/server/connectors/slack.ts +133 -5
  89. package/corpus/templates/factory/server/db/schema.ts +6 -0
  90. package/corpus/templates/factory/server/lib/require-factory-automation.ts +65 -0
  91. package/corpus/templates/factory/server/plugins/agent-chat.ts +29 -11
  92. package/corpus/templates/factory/server/plugins/auth.ts +1 -0
  93. package/corpus/templates/factory/server/plugins/factory-migrations.ts +12 -0
  94. package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +218 -67
  95. package/corpus/templates/factory/server/triage/builder-executor.ts +4 -0
  96. package/corpus/templates/factory/server/triage/contracts.ts +14 -1
  97. package/corpus/templates/factory/server/triage/github-client.ts +404 -0
  98. package/corpus/templates/factory/server/triage/metadata.ts +44 -0
  99. package/corpus/templates/factory/server/triage/pr-babysit.ts +5 -5
  100. package/corpus/templates/factory/server/triage/pr-policy.ts +144 -0
  101. package/corpus/templates/factory/server/triage/sentry-client.ts +180 -0
  102. package/corpus/templates/factory/server/triage/slack-client.ts +43 -0
  103. package/corpus/templates/factory/server/triage/slack-poller.ts +9 -2
  104. package/corpus/templates/plan/.agents/skills/visual-plan/SKILL.md +27 -0
  105. package/corpus/templates/plan/.agents/skills/visual-plan/references/canvas.md +14 -4
  106. package/corpus/templates/plan/.agents/skills/visual-plan/references/wireframe.md +7 -0
  107. package/corpus/templates/plan/.agents/skills/visual-recap/references/wireframe.md +7 -0
  108. package/corpus/templates/plan/actions/create-plan-design.ts +8 -6
  109. package/corpus/templates/plan/actions/create-prototype-plan.ts +8 -6
  110. package/corpus/templates/plan/actions/create-ui-plan.ts +7 -7
  111. package/corpus/templates/plan/actions/get-plan-blocks.ts +7 -1
  112. package/corpus/templates/plan/actions/validate-plan-input.ts +39 -0
  113. package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +167 -11
  114. package/corpus/templates/plan/app/i18n/en-US.ts +1 -0
  115. package/corpus/templates/plan/changelog/2026-08-04-canvas-zoom-controls-now-explain-the-command-ctrl-plus-scrol.md +6 -0
  116. package/corpus/templates/plan/changelog/2026-08-04-pinch-to-zoom-is-now-supported-on-touchscreens-in-visual-pla.md +6 -0
  117. package/corpus/templates/plan/shared/plan-content.ts +17 -2
  118. package/corpus/templates/slides/app/components/editor/NewDeckReferenceStep.tsx +321 -0
  119. package/corpus/templates/slides/app/components/editor/PromptDialog.tsx +32 -11
  120. package/corpus/templates/slides/app/lib/recent-references.ts +82 -0
  121. package/corpus/templates/slides/app/pages/DeckEditor.tsx +3 -0
  122. package/corpus/templates/slides/app/pages/Index.tsx +236 -181
  123. package/dist/cli/skills-content/canvas.d.ts +3 -3
  124. package/dist/cli/skills-content/canvas.js +16 -5
  125. package/dist/cli/skills-content/visual-plan-skill.d.ts +1 -1
  126. package/dist/cli/skills-content/visual-plan-skill.js +27 -0
  127. package/dist/cli/skills-content/wireframe.d.ts +2 -2
  128. package/dist/cli/skills-content/wireframe.js +7 -0
  129. package/dist/client/AgentPanel.js +23 -8
  130. package/dist/client/DefaultSpinner.d.ts +0 -12
  131. package/dist/client/DefaultSpinner.js +22 -2
  132. package/dist/client/api-path.d.ts +6 -0
  133. package/dist/client/api-path.js +55 -0
  134. package/dist/client/onboarding/FirstRunOnboarding.d.ts +3 -0
  135. package/dist/client/onboarding/FirstRunOnboarding.js +93 -0
  136. package/dist/client/onboarding/first-run-enabled.d.ts +2 -0
  137. package/dist/client/onboarding/first-run-enabled.js +9 -0
  138. package/dist/client/onboarding/index.d.ts +2 -1
  139. package/dist/client/onboarding/index.js +1 -0
  140. package/dist/client/onboarding/use-onboarding.d.ts +6 -1
  141. package/dist/client/onboarding/use-onboarding.js +35 -1
  142. package/dist/client/route-state.js +10 -0
  143. package/dist/collab/awareness.d.ts +2 -2
  144. package/dist/collab/routes.d.ts +1 -1
  145. package/dist/connections/catalog.d.ts +4 -4
  146. package/dist/connections/catalog.js +3 -3
  147. package/dist/db/index.d.ts +1 -1
  148. package/dist/db/index.js +1 -1
  149. package/dist/deploy/build.js +2 -1
  150. package/dist/notifications/routes.d.ts +3 -3
  151. package/dist/observability/routes.d.ts +1 -1
  152. package/dist/onboarding/app-profile.d.ts +4 -0
  153. package/dist/onboarding/app-profile.js +378 -0
  154. package/dist/onboarding/index.d.ts +2 -1
  155. package/dist/onboarding/index.js +1 -0
  156. package/dist/onboarding/plugin.d.ts +2 -0
  157. package/dist/onboarding/plugin.js +21 -1
  158. package/dist/onboarding/types.d.ts +19 -0
  159. package/dist/progress/routes.d.ts +1 -1
  160. package/dist/provider-api/actions/custom-provider-registration.d.ts +9 -9
  161. package/dist/provider-api/actions/provider-api.d.ts +12 -12
  162. package/dist/secrets/routes.d.ts +3 -3
  163. package/dist/server/auth.js +21 -1
  164. package/dist/server/better-auth-instance.d.ts +3 -2
  165. package/dist/server/better-auth-instance.js +9 -7
  166. package/dist/server/email-markdown.js +3 -8
  167. package/dist/shared/first-run-onboarding.d.ts +3 -0
  168. package/dist/shared/first-run-onboarding.js +3 -0
  169. package/dist/templates/workspace-core/.agents/skills/address-feedback/SKILL.md +25 -9
  170. package/dist/triggers/index.d.ts +3 -0
  171. package/dist/triggers/index.js +5 -0
  172. package/docs/content/cross-app-sso.mdx +26 -0
  173. package/package.json +1 -1
  174. package/src/templates/workspace-core/.agents/skills/address-feedback/SKILL.md +25 -9
@@ -77,6 +77,18 @@ type WorldPoint = {
77
77
  y: number;
78
78
  };
79
79
 
80
+ type ClientPoint = {
81
+ x: number;
82
+ y: number;
83
+ };
84
+
85
+ type PinchGesture = {
86
+ pointerIds: [number, number];
87
+ startDistance: number;
88
+ startMidpoint: ClientPoint;
89
+ startView: CanvasView;
90
+ };
91
+
80
92
  type PendingMarkup = {
81
93
  mode: "text" | "callout";
82
94
  origin: WorldPoint;
@@ -140,6 +152,10 @@ export function CanvasArea({
140
152
  [canvas.viewport?.pan?.x, canvas.viewport?.pan?.y, canvas.viewport?.zoom],
141
153
  );
142
154
  const [view, setView] = useState<CanvasView>(initialView);
155
+ const viewRef = useRef(view);
156
+ viewRef.current = view;
157
+ const activePointersRef = useRef(new Map<number, ClientPoint>());
158
+ const pinchGestureRef = useRef<PinchGesture | null>(null);
143
159
  const [drag, setDrag] = useState<{
144
160
  pointerId: number;
145
161
  startX: number;
@@ -581,8 +597,48 @@ export function CanvasArea({
581
597
  const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
582
598
  if (event.button !== 0 && event.button !== 1) return;
583
599
  const target = event.target as HTMLElement;
600
+ const activePointers = activePointersRef.current;
584
601
  // Don't start a pan when grabbing interactive chrome (zoom controls etc.).
585
- if (event.button === 0 && target.closest("[data-plan-interactive]")) return;
602
+ if (
603
+ event.button === 0 &&
604
+ target.closest("[data-plan-interactive]") &&
605
+ activePointers.size === 0
606
+ ) {
607
+ return;
608
+ }
609
+ activePointers.set(event.pointerId, {
610
+ x: event.clientX,
611
+ y: event.clientY,
612
+ });
613
+ if (activePointers.size >= 2) {
614
+ const pointerIds = Array.from(activePointers.keys()).slice(0, 2) as [
615
+ number,
616
+ number,
617
+ ];
618
+ const first = activePointers.get(pointerIds[0]);
619
+ const second = activePointers.get(pointerIds[1]);
620
+ if (!first || !second) return;
621
+ const rect = viewportRef.current?.getBoundingClientRect();
622
+ const startMidpoint = getMidpoint(first, second);
623
+ pinchGestureRef.current = {
624
+ pointerIds,
625
+ startDistance: getDistance(first, second),
626
+ startMidpoint: rect
627
+ ? {
628
+ x: startMidpoint.x - rect.left,
629
+ y: startMidpoint.y - rect.top,
630
+ }
631
+ : startMidpoint,
632
+ startView: viewRef.current,
633
+ };
634
+ setDrag(null);
635
+ setDraftCallout(null);
636
+ setPendingMarkup(null);
637
+ event.preventDefault();
638
+ event.stopPropagation();
639
+ event.currentTarget.setPointerCapture(event.pointerId);
640
+ return;
641
+ }
586
642
  if (!isEditableShortcutTarget(target)) {
587
643
  event.currentTarget.focus({ preventScroll: true });
588
644
  }
@@ -674,6 +730,37 @@ export function CanvasArea({
674
730
  onPointerDown={onPointerDown}
675
731
  onKeyDown={onViewportKeyDown}
676
732
  onPointerMove={(event) => {
733
+ const activePointer = activePointersRef.current.get(event.pointerId);
734
+ if (activePointer) {
735
+ activePointer.x = event.clientX;
736
+ activePointer.y = event.clientY;
737
+ }
738
+ const pinchGesture = pinchGestureRef.current;
739
+ if (pinchGesture) {
740
+ const first = activePointersRef.current.get(
741
+ pinchGesture.pointerIds[0],
742
+ );
743
+ const second = activePointersRef.current.get(
744
+ pinchGesture.pointerIds[1],
745
+ );
746
+ const rect = viewportRef.current?.getBoundingClientRect();
747
+ if (!first || !second || !rect) return;
748
+ const midpoint = getMidpoint(first, second);
749
+ const currentMidpoint = {
750
+ x: midpoint.x - rect.left,
751
+ y: midpoint.y - rect.top,
752
+ };
753
+ updateView(() =>
754
+ viewForPinchGesture({
755
+ gesture: pinchGesture,
756
+ currentDistance: getDistance(first, second),
757
+ currentMidpoint,
758
+ }),
759
+ );
760
+ event.preventDefault();
761
+ event.stopPropagation();
762
+ return;
763
+ }
677
764
  if (draftCallout?.pointerId === event.pointerId) {
678
765
  const point = clientPointToWorld(event);
679
766
  if (!point) return;
@@ -697,6 +784,28 @@ export function CanvasArea({
697
784
  }));
698
785
  }}
699
786
  onPointerUp={(event) => {
787
+ activePointersRef.current.delete(event.pointerId);
788
+ if (pinchGestureRef.current) {
789
+ event.preventDefault();
790
+ event.stopPropagation();
791
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
792
+ event.currentTarget.releasePointerCapture(event.pointerId);
793
+ }
794
+ const remaining = Array.from(
795
+ activePointersRef.current.entries(),
796
+ )[0];
797
+ pinchGestureRef.current = null;
798
+ if (remaining) {
799
+ setDrag({
800
+ pointerId: remaining[0],
801
+ startX: remaining[1].x,
802
+ startY: remaining[1].y,
803
+ panX: viewRef.current.pan.x,
804
+ panY: viewRef.current.pan.y,
805
+ });
806
+ }
807
+ return;
808
+ }
700
809
  if (draftCallout?.pointerId === event.pointerId) {
701
810
  const point = clientPointToWorld(event) ?? draftCallout.current;
702
811
  event.preventDefault();
@@ -722,7 +831,9 @@ export function CanvasArea({
722
831
  setDrag(null);
723
832
  }
724
833
  }}
725
- onPointerCancel={() => {
834
+ onPointerCancel={(event) => {
835
+ activePointersRef.current.delete(event.pointerId);
836
+ pinchGestureRef.current = null;
726
837
  setDraftCallout(null);
727
838
  setDrag(null);
728
839
  }}
@@ -838,6 +949,7 @@ export function CanvasArea({
838
949
  <div
839
950
  className="plan-canvas-zoom absolute bottom-3 left-3 z-10 flex items-center gap-0.5 rounded-lg border border-plan-line bg-plan-chrome p-0.5 shadow-md backdrop-blur"
840
951
  data-plan-interactive
952
+ title={t("raw.canvas.zoomHint")}
841
953
  >
842
954
  <Button
843
955
  type="button"
@@ -912,6 +1024,18 @@ function surfaceOf(frame: PlanArtboard): PlanWireframeSurface {
912
1024
  return frame.surface ?? frame.wireframe?.surface ?? "desktop";
913
1025
  }
914
1026
 
1027
+ /**
1028
+ * Keep the surface width stable while allowing long screens to reserve a
1029
+ * taller, non-scrolling artboard in canvas mode.
1030
+ */
1031
+ export function canvasFrameSize(frame: PlanArtboard) {
1032
+ const preset = SURFACE_SIZE[surfaceOf(frame)];
1033
+ return {
1034
+ width: preset.width,
1035
+ height: Math.max(preset.height, frame.height ?? preset.height),
1036
+ };
1037
+ }
1038
+
915
1039
  function sameCanvasView(a: CanvasView, b: CanvasView) {
916
1040
  return (
917
1041
  Math.abs(a.zoom - b.zoom) < 0.0001 &&
@@ -980,11 +1104,9 @@ function layoutArtboards(frames: PlanArtboard[]): PlanArtboard[] {
980
1104
 
981
1105
  return frames.map((frame) => {
982
1106
  const surface = surfaceOf(frame);
983
- const preset = SURFACE_SIZE[surface];
984
- // SURFACE owns the footprint/aspect ignore any model-supplied width/height
985
- // so a popover is always ~square and can never render "too wide".
986
- const width = preset.width;
987
- const height = preset.height;
1107
+ // Surface owns the width/aspect. A larger explicit height is the
1108
+ // non-scrolling escape hatch for long HTML screens on the canvas.
1109
+ const { width, height } = canvasFrameSize(frame);
988
1110
 
989
1111
  if (frame.x !== undefined || frame.y !== undefined) {
990
1112
  return {
@@ -1038,10 +1160,7 @@ function CanvasArtboard({
1038
1160
  onDesignElementSelect?: (selection: DesignElementSelection) => void;
1039
1161
  }) {
1040
1162
  const surface = surfaceOf(frame);
1041
- const preset = SURFACE_SIZE[surface];
1042
- // SURFACE-locked footprint (see layoutArtboards) — model width/height ignored.
1043
- const width = preset.width;
1044
- const height = preset.height;
1163
+ const { width, height } = canvasFrameSize(frame);
1045
1164
  const label = frame.label ?? block?.title;
1046
1165
  // Report the frame's real rendered height so board connectors can anchor to
1047
1166
  // the content box (frames are capped at `height` but usually shorter).
@@ -2280,6 +2399,43 @@ function connectorRoute(
2280
2399
  };
2281
2400
  }
2282
2401
 
2402
+ export function viewForPinchGesture(input: {
2403
+ gesture: PinchGesture;
2404
+ currentDistance: number;
2405
+ currentMidpoint: ClientPoint;
2406
+ }): CanvasView {
2407
+ const { gesture, currentDistance, currentMidpoint } = input;
2408
+ const nextZoom = clamp(
2409
+ gesture.startView.zoom * (currentDistance / gesture.startDistance),
2410
+ MIN_ZOOM,
2411
+ MAX_ZOOM,
2412
+ );
2413
+ const worldX =
2414
+ (gesture.startMidpoint.x - gesture.startView.pan.x) /
2415
+ gesture.startView.zoom;
2416
+ const worldY =
2417
+ (gesture.startMidpoint.y - gesture.startView.pan.y) /
2418
+ gesture.startView.zoom;
2419
+ return {
2420
+ zoom: nextZoom,
2421
+ pan: {
2422
+ x: currentMidpoint.x - worldX * nextZoom,
2423
+ y: currentMidpoint.y - worldY * nextZoom,
2424
+ },
2425
+ };
2426
+ }
2427
+
2428
+ function getMidpoint(first: ClientPoint, second: ClientPoint): ClientPoint {
2429
+ return {
2430
+ x: (first.x + second.x) / 2,
2431
+ y: (first.y + second.y) / 2,
2432
+ };
2433
+ }
2434
+
2435
+ function getDistance(first: ClientPoint, second: ClientPoint) {
2436
+ return Math.max(Math.hypot(first.x - second.x, first.y - second.y), 1);
2437
+ }
2438
+
2283
2439
  function distance(a: WorldPoint, b: WorldPoint) {
2284
2440
  return Math.hypot(a.x - b.x, a.y - b.y);
2285
2441
  }
@@ -157,6 +157,7 @@ const messages = {
157
157
  artboardCanvas: "Plan artboard canvas",
158
158
  zoomIn: "Zoom in",
159
159
  zoomOut: "Zoom out",
160
+ zoomHint: "Hold ⌘/Ctrl and scroll to zoom",
160
161
  markupSaveFailed: "Couldn't save markup. Try again.",
161
162
  },
162
163
  document: {
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-08-04
4
+ ---
5
+
6
+ Canvas zoom controls now explain the Command/Ctrl plus scroll shortcut
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-08-04
4
+ ---
5
+
6
+ Pinch to zoom is now supported on touchscreens in visual plan canvases
@@ -1495,12 +1495,27 @@ export const questionFormDataSchema: z.ZodType<PlanQuestionFormBlock["data"]> =
1495
1495
  submitLabel: z.string().trim().max(400).optional(),
1496
1496
  });
1497
1497
 
1498
+ /**
1499
+ * Rich-text stores Markdown as runtime text. A fully escaped one-line payload
1500
+ * ("### Heading\\n\\nBody") renders as one giant heading, so reject that shape at
1501
+ * the shared schema instead of persisting a document that only looks valid.
1502
+ * Escapes are still fine when they occur alongside real line breaks, such as
1503
+ * an intentional `\\n` inside a code example.
1504
+ */
1505
+ const planMarkdownSchema = z
1506
+ .string()
1507
+ .max(100_000)
1508
+ .refine(
1509
+ (value) => value.includes("\n") || !value.includes("\\n"),
1510
+ "Rich-text Markdown must use actual line breaks, not a fully escaped `\\n` string.",
1511
+ );
1512
+
1498
1513
  export const planBlockSchema: z.ZodType<PlanBlock> = z.lazy(() =>
1499
1514
  z.discriminatedUnion("type", [
1500
1515
  baseBlockSchema.extend({
1501
1516
  type: z.literal("rich-text"),
1502
1517
  data: z.object({
1503
- markdown: z.string().max(100_000),
1518
+ markdown: planMarkdownSchema,
1504
1519
  }),
1505
1520
  }),
1506
1521
  baseBlockSchema.extend({
@@ -2758,7 +2773,7 @@ const planContentPatchUnion = z.discriminatedUnion("op", [
2758
2773
  op: z.literal("update-rich-text"),
2759
2774
  blockId: idSchema,
2760
2775
  title: z.string().trim().min(1).max(180).optional(),
2761
- markdown: z.string().max(100_000).optional(),
2776
+ markdown: planMarkdownSchema.optional(),
2762
2777
  }),
2763
2778
  z.object({
2764
2779
  op: z.literal("update-custom-html"),
@@ -0,0 +1,321 @@
1
+ import {
2
+ IconArrowRight,
3
+ IconPalette,
4
+ IconPresentation,
5
+ IconUpload,
6
+ } from "@tabler/icons-react";
7
+ import type { ChangeEvent, ReactNode } from "react";
8
+
9
+ import { Button } from "@/components/ui/button";
10
+ import {
11
+ Dialog,
12
+ DialogContent,
13
+ DialogDescription,
14
+ DialogHeader,
15
+ DialogTitle,
16
+ } from "@/components/ui/dialog";
17
+ import {
18
+ Select,
19
+ SelectContent,
20
+ SelectGroup,
21
+ SelectItem,
22
+ SelectLabel,
23
+ SelectTrigger,
24
+ SelectValue,
25
+ } from "@/components/ui/select";
26
+ import type { Deck } from "@/context/DeckContext";
27
+ import type { RecentReference } from "@/lib/recent-references";
28
+ import { cn } from "@/lib/utils";
29
+
30
+ export interface NewDeckReferenceSelection {
31
+ designSystemId?: string | null;
32
+ referenceDeckId?: string | null;
33
+ }
34
+
35
+ interface DesignSystemOption {
36
+ id: string;
37
+ title: string;
38
+ isDefault?: boolean;
39
+ }
40
+
41
+ interface NewDeckReferenceStepProps {
42
+ open: boolean;
43
+ designSystems: DesignSystemOption[];
44
+ decks: Deck[];
45
+ defaultDesignSystemId: string | null;
46
+ defaultReferenceDeckId: string | null;
47
+ recentReferences: RecentReference[];
48
+ onSelect: (selection: NewDeckReferenceSelection) => void;
49
+ onImport: (files: File[]) => Promise<void>;
50
+ onSkip: () => void;
51
+ onOpenChange: (open: boolean) => void;
52
+ importing?: boolean;
53
+ title: string;
54
+ designSystemLabel: string;
55
+ referenceDeckLabel: string;
56
+ chooseDeckLabel: string;
57
+ importFileLabel: string;
58
+ importingLabel: string;
59
+ skipLabel: string;
60
+ defaultSuffix: string;
61
+ starredLabel: string;
62
+ otherDecksLabel: string;
63
+ description: string;
64
+ promptNote?: string;
65
+ }
66
+
67
+ export function NewDeckReferenceStep({
68
+ open,
69
+ designSystems,
70
+ decks,
71
+ defaultDesignSystemId,
72
+ defaultReferenceDeckId,
73
+ recentReferences,
74
+ onSelect,
75
+ onImport,
76
+ onSkip,
77
+ onOpenChange,
78
+ importing = false,
79
+ title,
80
+ designSystemLabel,
81
+ referenceDeckLabel,
82
+ chooseDeckLabel,
83
+ importFileLabel,
84
+ importingLabel,
85
+ skipLabel,
86
+ defaultSuffix,
87
+ starredLabel,
88
+ otherDecksLabel,
89
+ description,
90
+ promptNote,
91
+ }: NewDeckReferenceStepProps) {
92
+ const designSystemById = new Map(
93
+ designSystems.map((designSystem) => [designSystem.id, designSystem]),
94
+ );
95
+ const deckById = new Map(decks.map((deck) => [deck.id, deck]));
96
+ const recentOptions = recentReferences
97
+ .map((reference) => {
98
+ const item =
99
+ reference.kind === "design-system"
100
+ ? designSystemById.get(reference.id)
101
+ : deckById.get(reference.id);
102
+ return item ? { reference, item } : null;
103
+ })
104
+ .filter(
105
+ (
106
+ value,
107
+ ): value is {
108
+ reference: RecentReference;
109
+ item: DesignSystemOption | Deck;
110
+ } => value !== null,
111
+ );
112
+ const defaultReferenceKeys = new Set(
113
+ [
114
+ defaultDesignSystemId ? `design-system:${defaultDesignSystemId}` : null,
115
+ defaultReferenceDeckId ? `deck:${defaultReferenceDeckId}` : null,
116
+ ].filter((value): value is string => value !== null),
117
+ );
118
+ const visibleRecentOptions = recentOptions.filter(
119
+ ({ reference }) =>
120
+ !defaultReferenceKeys.has(`${reference.kind}:${reference.id}`),
121
+ );
122
+ const starredDecks = decks.filter((deck) => deck.starred);
123
+ const otherDecks = decks.filter((deck) => !deck.starred);
124
+
125
+ const handleImport = async (event: ChangeEvent<HTMLInputElement>) => {
126
+ const files = Array.from(event.target.files ?? []);
127
+ event.target.value = "";
128
+ if (files.length === 0) return;
129
+ await onImport(files);
130
+ };
131
+
132
+ return (
133
+ <Dialog open={open} onOpenChange={onOpenChange}>
134
+ <DialogContent className="max-h-[min(720px,calc(100vh-32px))] overflow-y-auto sm:max-w-2xl">
135
+ <DialogHeader>
136
+ <DialogTitle>{title}</DialogTitle>
137
+ <DialogDescription>{description}</DialogDescription>
138
+ </DialogHeader>
139
+
140
+ <div className="grid gap-4">
141
+ {(defaultDesignSystemId || defaultReferenceDeckId) && (
142
+ <div className="grid gap-2 sm:grid-cols-2">
143
+ {defaultDesignSystemId &&
144
+ designSystemById.has(defaultDesignSystemId) && (
145
+ <ReferenceCard
146
+ icon={<IconPalette className="size-4" />}
147
+ label={
148
+ designSystemById.get(defaultDesignSystemId)?.title ??
149
+ designSystemLabel
150
+ }
151
+ meta={`${designSystemLabel}${defaultSuffix}`}
152
+ onClick={() =>
153
+ onSelect({
154
+ designSystemId: defaultDesignSystemId,
155
+ referenceDeckId: null,
156
+ })
157
+ }
158
+ />
159
+ )}
160
+ {defaultReferenceDeckId &&
161
+ deckById.has(defaultReferenceDeckId) && (
162
+ <ReferenceCard
163
+ icon={<IconPresentation className="size-4" />}
164
+ label={deckById.get(defaultReferenceDeckId)?.title ?? ""}
165
+ meta={`${referenceDeckLabel}${defaultSuffix}`}
166
+ onClick={() =>
167
+ onSelect({
168
+ designSystemId: null,
169
+ referenceDeckId: defaultReferenceDeckId,
170
+ })
171
+ }
172
+ />
173
+ )}
174
+ </div>
175
+ )}
176
+
177
+ {visibleRecentOptions.length > 0 && (
178
+ <div className="grid gap-2">
179
+ <div className="grid gap-2 sm:grid-cols-2">
180
+ {visibleRecentOptions.map(({ reference, item }) => (
181
+ <ReferenceCard
182
+ key={`${reference.kind}:${reference.id}`}
183
+ icon={
184
+ reference.kind === "design-system" ? (
185
+ <IconPalette className="size-4" />
186
+ ) : (
187
+ <IconPresentation className="size-4" />
188
+ )
189
+ }
190
+ label={item.title}
191
+ meta={
192
+ reference.kind === "design-system"
193
+ ? designSystemLabel
194
+ : referenceDeckLabel
195
+ }
196
+ onClick={() =>
197
+ onSelect(
198
+ reference.kind === "design-system"
199
+ ? {
200
+ designSystemId: reference.id,
201
+ referenceDeckId: null,
202
+ }
203
+ : {
204
+ designSystemId: null,
205
+ referenceDeckId: reference.id,
206
+ },
207
+ )
208
+ }
209
+ />
210
+ ))}
211
+ </div>
212
+ </div>
213
+ )}
214
+
215
+ {decks.length > 0 && (
216
+ <div className="grid gap-2">
217
+ <span className="text-xs font-medium text-muted-foreground">
218
+ {referenceDeckLabel}
219
+ </span>
220
+ <Select
221
+ onValueChange={(value) =>
222
+ onSelect({ designSystemId: null, referenceDeckId: value })
223
+ }
224
+ >
225
+ <SelectTrigger className="w-full">
226
+ <SelectValue placeholder={chooseDeckLabel} />
227
+ </SelectTrigger>
228
+ <SelectContent>
229
+ {starredDecks.length > 0 && (
230
+ <SelectGroup>
231
+ <SelectLabel>{starredLabel}</SelectLabel>
232
+ {starredDecks.map((deck) => (
233
+ <SelectItem key={deck.id} value={deck.id}>
234
+ {deck.title}
235
+ </SelectItem>
236
+ ))}
237
+ </SelectGroup>
238
+ )}
239
+ {otherDecks.length > 0 && (
240
+ <SelectGroup>
241
+ <SelectLabel>{otherDecksLabel}</SelectLabel>
242
+ {otherDecks.map((deck) => (
243
+ <SelectItem key={deck.id} value={deck.id}>
244
+ {deck.title}
245
+ </SelectItem>
246
+ ))}
247
+ </SelectGroup>
248
+ )}
249
+ </SelectContent>
250
+ </Select>
251
+ </div>
252
+ )}
253
+
254
+ <div className="flex flex-wrap items-center gap-2 border-t border-border pt-3">
255
+ <label
256
+ className={cn(
257
+ "inline-flex cursor-pointer items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors hover:bg-accent",
258
+ importing && "pointer-events-none opacity-60",
259
+ )}
260
+ >
261
+ <IconUpload className="size-4" />
262
+ {importing ? importingLabel : importFileLabel}
263
+ <input
264
+ type="file"
265
+ className="sr-only"
266
+ accept=".pptx,.pdf,.docx"
267
+ multiple
268
+ disabled={importing}
269
+ onChange={(event) => void handleImport(event)}
270
+ />
271
+ </label>
272
+ </div>
273
+ </div>
274
+
275
+ {promptNote && (
276
+ <p className="truncate border-t border-border pt-3 text-xs text-muted-foreground">
277
+ {promptNote}
278
+ </p>
279
+ )}
280
+
281
+ <div className="flex items-center justify-between border-t border-border pt-3">
282
+ <Button type="button" variant="ghost" onClick={onSkip}>
283
+ {skipLabel}
284
+ </Button>
285
+ <IconArrowRight className="size-4 text-muted-foreground" />
286
+ </div>
287
+ </DialogContent>
288
+ </Dialog>
289
+ );
290
+ }
291
+
292
+ function ReferenceCard({
293
+ icon,
294
+ label,
295
+ meta,
296
+ onClick,
297
+ }: {
298
+ icon: ReactNode;
299
+ label: string;
300
+ meta: string;
301
+ onClick: () => void;
302
+ }) {
303
+ return (
304
+ <button
305
+ type="button"
306
+ onClick={onClick}
307
+ className="flex min-w-0 items-center gap-3 rounded-lg border border-border bg-card px-3 py-3 text-start transition-colors hover:border-foreground/30 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
308
+ >
309
+ <span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-accent text-muted-foreground">
310
+ {icon}
311
+ </span>
312
+ <span className="min-w-0">
313
+ <span className="block truncate text-sm font-medium">{label}</span>
314
+ <span className="block truncate text-xs text-muted-foreground">
315
+ {meta}
316
+ </span>
317
+ </span>
318
+ <IconArrowRight className="ms-auto size-4 shrink-0 text-muted-foreground" />
319
+ </button>
320
+ );
321
+ }
@@ -16,6 +16,37 @@ export interface UploadedFile {
16
16
  size: number;
17
17
  }
18
18
 
19
+ export async function uploadPromptFiles(
20
+ files: File[],
21
+ ): Promise<UploadedFile[]> {
22
+ if (files.length === 0) return [];
23
+ const formData = new FormData();
24
+ files.forEach((file) => formData.append("files", file));
25
+ const response = await fetch(`${appBasePath()}/api/uploads`, {
26
+ method: "POST",
27
+ body: formData,
28
+ });
29
+ if (!response.ok) {
30
+ let message = "Upload failed";
31
+ try {
32
+ const data: unknown = await response.json();
33
+ if (
34
+ data &&
35
+ typeof data === "object" &&
36
+ "error" in data &&
37
+ typeof data.error === "string" &&
38
+ data.error.trim()
39
+ ) {
40
+ message = data.error;
41
+ }
42
+ } catch (error) {
43
+ throw new Error(`Upload failed (${response.status})`, { cause: error });
44
+ }
45
+ throw new Error(message);
46
+ }
47
+ return (await response.json()) as UploadedFile[];
48
+ }
49
+
19
50
  /**
20
51
  * Radix popovers portal to `document.body`, so a mousedown inside the model
21
52
  * picker or attachment menu reads as "outside" any panel that hosts a composer.
@@ -137,17 +168,7 @@ export default function PromptPopover({
137
168
  if (files.length === 0) return [];
138
169
  setUploading(true);
139
170
  try {
140
- const formData = new FormData();
141
- files.forEach((f) => formData.append("files", f));
142
- const res = await fetch(`${appBasePath()}/api/uploads`, {
143
- method: "POST",
144
- body: formData,
145
- });
146
- if (!res.ok) {
147
- const data = await res.json().catch(() => null);
148
- throw new Error(data?.error || "Upload failed");
149
- }
150
- return (await res.json()) as UploadedFile[];
171
+ return await uploadPromptFiles(files);
151
172
  } finally {
152
173
  setUploading(false);
153
174
  }