@agent-native/core 0.70.2 → 0.71.0

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 (93) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +19 -0
  3. package/corpus/core/docs/content/multi-app-workspace.md +2 -2
  4. package/corpus/core/docs/design/durable-agent-runs.md +458 -4
  5. package/corpus/core/package.json +1 -1
  6. package/corpus/core/src/action.ts +21 -10
  7. package/corpus/core/src/agent/durable-background.ts +192 -0
  8. package/corpus/core/src/agent/production-agent.ts +330 -13
  9. package/corpus/core/src/agent/run-manager.ts +66 -3
  10. package/corpus/core/src/agent/run-store.ts +129 -23
  11. package/corpus/core/src/agent/types.ts +20 -0
  12. package/corpus/core/src/client/AgentPanel.tsx +12 -9
  13. package/corpus/core/src/client/blocks/library/FileTreeBlock.tsx +72 -14
  14. package/corpus/core/src/deploy/build.ts +96 -0
  15. package/corpus/core/src/deploy/workspace-deploy.ts +121 -0
  16. package/corpus/core/src/mcp/build-server.ts +22 -13
  17. package/corpus/core/src/server/agent-chat-plugin.ts +133 -66
  18. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +19 -0
  19. package/corpus/templates/analytics/AGENTS.md +8 -0
  20. package/corpus/templates/analytics/actions/compose-dashboard.ts +317 -0
  21. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +33 -6
  22. package/corpus/templates/analytics/changelog/2026-06-23-build-large-first-party-analytics-dashboards-in-one-fast-cal.md +6 -0
  23. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +574 -0
  24. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +8 -2
  25. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +1 -9
  26. package/corpus/templates/clips/changelog/2026-06-23-dragging-the-desktop-camera-bubble-now-glides-to-a-stop-at-t.md +6 -0
  27. package/corpus/templates/clips/changelog/2026-06-23-screen-recordings-now-capture-at-a-crisp-1080p-bitrate-inste.md +6 -0
  28. package/corpus/templates/clips/chrome-extension/package.json +1 -0
  29. package/corpus/templates/clips/chrome-extension/scripts/dev.ts +102 -0
  30. package/corpus/templates/clips/chrome-extension/src/background.ts +164 -32
  31. package/corpus/templates/clips/chrome-extension/src/content-script.ts +163 -13
  32. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +198 -35
  33. package/corpus/templates/clips/chrome-extension/src/overlay.css +73 -23
  34. package/corpus/templates/clips/chrome-extension/src/overlay.ts +82 -13
  35. package/corpus/templates/clips/chrome-extension/src/popup.html +62 -5
  36. package/corpus/templates/clips/chrome-extension/src/popup.ts +290 -1
  37. package/corpus/templates/clips/chrome-extension/src/styles.css +34 -0
  38. package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +21 -16
  39. package/corpus/templates/clips/desktop/src/lib/recorder.ts +17 -20
  40. package/corpus/templates/clips/desktop/src/overlays/bubble.tsx +73 -23
  41. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +0 -6
  42. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +56 -46
  43. package/corpus/templates/clips/desktop/src/styles.css +33 -5
  44. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +113 -0
  45. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -0
  46. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +2 -2
  47. package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +9 -1
  48. package/corpus/templates/clips/shared/upload-limits.ts +5 -2
  49. package/corpus/templates/plan/app/pages/PlansPage.tsx +11 -7
  50. package/corpus/templates/plan/changelog/2026-06-23-plan-loading-skeleton-no-longer-cuts-off-the-canvas-preview-.md +6 -0
  51. package/dist/action.js +21 -10
  52. package/dist/action.js.map +1 -1
  53. package/dist/agent/durable-background.d.ts +60 -0
  54. package/dist/agent/durable-background.d.ts.map +1 -0
  55. package/dist/agent/durable-background.js +144 -0
  56. package/dist/agent/durable-background.js.map +1 -0
  57. package/dist/agent/production-agent.d.ts +20 -0
  58. package/dist/agent/production-agent.d.ts.map +1 -1
  59. package/dist/agent/production-agent.js +292 -14
  60. package/dist/agent/production-agent.js.map +1 -1
  61. package/dist/agent/run-manager.d.ts +48 -0
  62. package/dist/agent/run-manager.d.ts.map +1 -1
  63. package/dist/agent/run-manager.js +45 -3
  64. package/dist/agent/run-manager.js.map +1 -1
  65. package/dist/agent/run-store.d.ts +30 -1
  66. package/dist/agent/run-store.d.ts.map +1 -1
  67. package/dist/agent/run-store.js +124 -24
  68. package/dist/agent/run-store.js.map +1 -1
  69. package/dist/agent/types.d.ts +20 -0
  70. package/dist/agent/types.d.ts.map +1 -1
  71. package/dist/agent/types.js.map +1 -1
  72. package/dist/client/AgentPanel.d.ts.map +1 -1
  73. package/dist/client/AgentPanel.js +2 -2
  74. package/dist/client/AgentPanel.js.map +1 -1
  75. package/dist/client/blocks/library/FileTreeBlock.d.ts.map +1 -1
  76. package/dist/client/blocks/library/FileTreeBlock.js +37 -4
  77. package/dist/client/blocks/library/FileTreeBlock.js.map +1 -1
  78. package/dist/deploy/build.d.ts +29 -0
  79. package/dist/deploy/build.d.ts.map +1 -1
  80. package/dist/deploy/build.js +85 -0
  81. package/dist/deploy/build.js.map +1 -1
  82. package/dist/deploy/workspace-deploy.d.ts.map +1 -1
  83. package/dist/deploy/workspace-deploy.js +108 -0
  84. package/dist/deploy/workspace-deploy.js.map +1 -1
  85. package/dist/mcp/build-server.d.ts.map +1 -1
  86. package/dist/mcp/build-server.js +23 -9
  87. package/dist/mcp/build-server.js.map +1 -1
  88. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  89. package/dist/server/agent-chat-plugin.js +68 -13
  90. package/dist/server/agent-chat-plugin.js.map +1 -1
  91. package/docs/content/multi-app-workspace.md +2 -2
  92. package/docs/design/durable-agent-runs.md +458 -4
  93. package/package.json +1 -1
@@ -606,40 +606,90 @@ export function Bubble() {
606
606
  }, []);
607
607
 
608
608
  // ---- explicit drag handler --------------------------------------------
609
- // We bypass `data-tauri-drag-region` entirely it was unreliable across
610
- // three iterations (see PR history). Tauri's attribute hook watches for
611
- // elements with the attribute at page-load time, and also has gotchas
612
- // with pointer-events, unfocused-window first-click swallowing, and
613
- // WKWebView's latched event target. Calling `startDragging()` explicitly
614
- // on mousedown is the canonical robust path (per Tauri's own docs for
615
- // "customize drag behavior") and skips all of those footguns.
609
+ // Loom-style manual drag. We deliberately do NOT use Tauri's native
610
+ // `startDragging()` (or `data-tauri-drag-region`): with a native drag the OS
611
+ // window server owns the position, so the only way to keep the bubble on
612
+ // screen is to clamp AFTER each move and `set_position` it back — and the OS
613
+ // immediately shoves it back out toward the cursor on the next frame. That
614
+ // fight is exactly the jitter/snap-back we're killing here.
616
615
  //
617
- // Interactive children (close X, size dots) are marked `data-no-drag`
618
- // so their clicks land on their onClick handlers instead of starting
619
- // a window drag.
620
- const handleBubbleMouseDown = (e: React.MouseEvent) => {
621
- // Only left mouse button initiates drag.
616
+ // Instead we drive the move ourselves. On pointer-down Rust snapshots the
617
+ // cursor + window anchor; each frame `bubble_drag_move` reads the live cursor,
618
+ // offsets the window by the cursor delta, and clamps BEFORE moving. The
619
+ // window therefore stops dead at the edge like a puck against a wall — the
620
+ // cursor keeps going, the bubble stays pinned, nothing to snap back from.
621
+ //
622
+ // Interactive children (close X, size dots) are marked `data-no-drag` so
623
+ // their clicks land on their onClick handlers instead of starting a drag.
624
+ const draggingRef = useRef(false);
625
+ const moveFrameRef = useRef<number | null>(null);
626
+
627
+ const handleBubblePointerDown = (e: React.PointerEvent) => {
628
+ // Only the left button initiates a drag.
622
629
  if (e.button !== 0) return;
623
630
  const target = e.target as HTMLElement;
624
- // Walk up from the target — any ancestor marked `data-no-drag`
625
- // means we're over a real control, not the draggable surface.
631
+ // Any ancestor marked `data-no-drag` means we're over a real control.
626
632
  if (target.closest("[data-no-drag]")) return;
627
- console.log("[bubble] startDragging");
628
- getCurrentWindow()
629
- .startDragging()
630
- .catch((err) => {
631
- console.warn("[bubble] startDragging failed", err);
633
+ e.preventDefault();
634
+ draggingRef.current = true;
635
+ // Pointer capture keeps pointermove/up flowing even after the cursor
636
+ // leaves the (small) bubble window — which it will the moment the bubble
637
+ // hits an edge and the cursor outruns it.
638
+ try {
639
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
640
+ } catch {
641
+ // capture is best-effort
642
+ }
643
+ void invoke("bubble_drag_start").catch((err) => {
644
+ console.warn("[bubble] bubble_drag_start failed", err);
645
+ });
646
+ };
647
+
648
+ const handleBubblePointerMove = (e: React.PointerEvent) => {
649
+ if (!draggingRef.current) return;
650
+ // Coalesce a burst of pointermove events into at most one reposition per
651
+ // animation frame, no matter how fast the OS delivers them. Rust reads the
652
+ // live cursor itself, so the frame callback needs no coordinates.
653
+ if (moveFrameRef.current != null) return;
654
+ moveFrameRef.current = requestAnimationFrame(() => {
655
+ moveFrameRef.current = null;
656
+ if (!draggingRef.current) return;
657
+ void invoke("bubble_drag_move").catch(() => {
658
+ // Transient failures (e.g. window mid-teardown) are non-fatal; the
659
+ // next pointermove schedules another frame.
632
660
  });
661
+ });
662
+ };
663
+
664
+ const endBubbleDrag = (e: React.PointerEvent) => {
665
+ if (!draggingRef.current) return;
666
+ draggingRef.current = false;
667
+ if (moveFrameRef.current != null) {
668
+ cancelAnimationFrame(moveFrameRef.current);
669
+ moveFrameRef.current = null;
670
+ }
671
+ try {
672
+ (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
673
+ } catch {
674
+ // already released
675
+ }
676
+ void invoke("bubble_drag_end").catch((err) => {
677
+ console.warn("[bubble] bubble_drag_end failed", err);
678
+ });
633
679
  };
634
680
 
635
681
  return (
636
- // The ENTIRE wrapper catches mousedown and calls `startDragging()`
637
- // directly. No `data-tauri-drag-region` see `handleBubbleMouseDown`.
682
+ // The ENTIRE wrapper is the drag surface — pointer-down drives the manual
683
+ // clamp-before-move drag loop. No `startDragging()`, no
684
+ // `data-tauri-drag-region` — see `handleBubblePointerDown` above.
638
685
  <div
639
686
  className={`bubble-wrapper bubble-${size}`}
640
687
  onMouseEnter={handleMouseEnter}
641
688
  onMouseLeave={handleMouseLeave}
642
- onMouseDown={handleBubbleMouseDown}
689
+ onPointerDown={handleBubblePointerDown}
690
+ onPointerMove={handleBubblePointerMove}
691
+ onPointerUp={endBubbleDrag}
692
+ onPointerCancel={endBubbleDrag}
643
693
  data-path={activePath}
644
694
  >
645
695
  <div className="bubble-root">
@@ -676,7 +726,7 @@ export function Bubble() {
676
726
  }
677
727
  />
678
728
  {/* Close X — top-right of bubble, only visible on hover. Marked
679
- `data-no-drag` so mousedown here does NOT call startDragging;
729
+ `data-no-drag` so pointer-down here does NOT start a drag;
680
730
  onClick fires normally. */}
681
731
  <button
682
732
  type="button"
@@ -46,12 +46,6 @@ export function Countdown() {
46
46
  };
47
47
  }, [closeWithEvent]);
48
48
 
49
- useEffect(() => {
50
- if (n === 1) {
51
- emit("clips:countdown-one").catch(() => {});
52
- }
53
- }, [n]);
54
-
55
49
  useEffect(() => {
56
50
  if (n <= 0) {
57
51
  closeWithEvent("clips:countdown-done");
@@ -14,7 +14,10 @@ import {
14
14
  const OVERLAY_SHADOW_GUTTER = 18;
15
15
  const TOOLBAR_CONTENT_WIDTH = 72;
16
16
  const TOOLBAR_COLLAPSED_HEIGHT = 150;
17
- const TOOLBAR_EXPANDED_HEIGHT = 234;
17
+ // Collapsed content box (150 − 20 padding = 130) holds the centered primary
18
+ // zone; the expanded height must fit that fixed 130 zone + the 88px hover
19
+ // actions (+2 margin) + 20 vertical padding so nothing clips on hover.
20
+ const TOOLBAR_EXPANDED_HEIGHT = 240;
18
21
  const TOOLBAR_WINDOW_WIDTH = TOOLBAR_CONTENT_WIDTH + OVERLAY_SHADOW_GUTTER * 2;
19
22
  const TOOLBAR_COLLAPSED_WINDOW_HEIGHT =
20
23
  TOOLBAR_COLLAPSED_HEIGHT + OVERLAY_SHADOW_GUTTER * 2;
@@ -225,51 +228,58 @@ export function Toolbar() {
225
228
  onFocusCapture={() => resizeToolbarWindow(true)}
226
229
  onBlurCapture={handleToolbarBlur}
227
230
  >
228
- <button
229
- className="toolbar-v-stop"
230
- onClick={stop}
231
- disabled={!!pendingAction || !enabled}
232
- aria-label={
233
- pendingAction === "stop" ? "Stopping recording" : "Stop recording"
234
- }
235
- title={
236
- pendingAction === "stop"
237
- ? pendingActionLabel
238
- : enabled
239
- ? "Stop recording"
240
- : "Recording not started yet"
241
- }
242
- data-no-drag
243
- >
244
- {pendingAction === "stop" ? (
245
- <IconLoader2 className="toolbar-v-spinner" size={18} />
246
- ) : (
247
- <span className="toolbar-v-stop-square" />
248
- )}
249
- </button>
250
- <div className="toolbar-v-time">{formatTime(elapsed)}</div>
251
- <button
252
- className="toolbar-v-pause"
253
- onClick={togglePause}
254
- disabled={!enabled || !!pendingAction}
255
- aria-label={paused ? "Resume" : "Pause"}
256
- title={
257
- pendingAction
258
- ? pendingActionLabel
259
- : enabled
260
- ? paused
261
- ? "Resume"
262
- : "Pause"
263
- : "Recording not started yet"
264
- }
265
- data-no-drag
266
- >
267
- {paused ? (
268
- <IconPlayerPlayFilled size={18} />
269
- ) : (
270
- <IconPlayerPauseFilled size={18} />
271
- )}
272
- </button>
231
+ {/* Primary controls live in a fixed-height zone so they stay pinned
232
+ to the same vertical position whether or not the pill is hovered.
233
+ Centering happens INSIDE this zone (not on the pill), so the
234
+ collapsed→expanded `justify-content` change can't nudge the Stop
235
+ button up — only the hover actions below grow into the new space. */}
236
+ <div className="toolbar-v-primary">
237
+ <button
238
+ className="toolbar-v-stop"
239
+ onClick={stop}
240
+ disabled={!!pendingAction || !enabled}
241
+ aria-label={
242
+ pendingAction === "stop" ? "Stopping recording" : "Stop recording"
243
+ }
244
+ title={
245
+ pendingAction === "stop"
246
+ ? pendingActionLabel
247
+ : enabled
248
+ ? "Stop recording"
249
+ : "Recording not started yet"
250
+ }
251
+ data-no-drag
252
+ >
253
+ {pendingAction === "stop" ? (
254
+ <IconLoader2 className="toolbar-v-spinner" size={18} />
255
+ ) : (
256
+ <span className="toolbar-v-stop-square" />
257
+ )}
258
+ </button>
259
+ <div className="toolbar-v-time">{formatTime(elapsed)}</div>
260
+ <button
261
+ className="toolbar-v-pause"
262
+ onClick={togglePause}
263
+ disabled={!enabled || !!pendingAction}
264
+ aria-label={paused ? "Resume" : "Pause"}
265
+ title={
266
+ pendingAction
267
+ ? pendingActionLabel
268
+ : enabled
269
+ ? paused
270
+ ? "Resume"
271
+ : "Pause"
272
+ : "Recording not started yet"
273
+ }
274
+ data-no-drag
275
+ >
276
+ {paused ? (
277
+ <IconPlayerPlayFilled size={18} />
278
+ ) : (
279
+ <IconPlayerPauseFilled size={18} />
280
+ )}
281
+ </button>
282
+ </div>
273
283
  <div
274
284
  className="toolbar-v-hover-actions"
275
285
  role="group"
@@ -2106,8 +2106,18 @@ body[data-clips-route="recording-pill"] #root {
2106
2106
  font-weight: 800;
2107
2107
  font-variant-numeric: tabular-nums;
2108
2108
  color: #ffffff;
2109
- -webkit-text-stroke: 1px rgba(17, 24, 39, 0.22);
2109
+ /*
2110
+ * Do NOT use -webkit-text-stroke here: it joins with a hard miter and no way
2111
+ * to set stroke-linejoin, so at this size/weight the sharp concave corner of
2112
+ * the "2" (where the curved top meets the flat bottom bar) sprouts star-shaped
2113
+ * miter spikes. We fake a soft dark edge with a 4-direction 1px shadow halo
2114
+ * instead, which has no join geometry and cannot spike.
2115
+ */
2110
2116
  text-shadow:
2117
+ 1px 0 1px rgba(17, 24, 39, 0.22),
2118
+ -1px 0 1px rgba(17, 24, 39, 0.22),
2119
+ 0 1px 1px rgba(17, 24, 39, 0.22),
2120
+ 0 -1px 1px rgba(17, 24, 39, 0.22),
2111
2121
  0 2px 4px rgba(0, 0, 0, 0.48),
2112
2122
  0 12px 34px rgba(0, 0, 0, 0.36),
2113
2123
  0 0 2px rgba(0, 0, 0, 0.72);
@@ -2420,8 +2430,12 @@ body[data-clips-route="recording-pill"] #root {
2420
2430
  display: flex;
2421
2431
  flex-direction: column;
2422
2432
  align-items: center;
2423
- justify-content: center;
2424
- gap: 10px;
2433
+ /* Pin content to the top in BOTH states. The primary controls are
2434
+ centered inside their own fixed-height zone (.toolbar-v-primary), so
2435
+ the collapsed→expanded growth only adds room BELOW them. Without this,
2436
+ flipping justify-content center→flex-start on hover yanked the Stop
2437
+ button up toward the bar's top edge. */
2438
+ justify-content: flex-start;
2425
2439
  /* Equal inset on all four sides from the pill edge to the buttons. */
2426
2440
  padding: 10px;
2427
2441
  overflow: hidden;
@@ -2440,8 +2454,9 @@ body[data-clips-route="recording-pill"] #root {
2440
2454
 
2441
2455
  .toolbar-v:hover,
2442
2456
  .toolbar-v:focus-within {
2443
- height: 234px;
2444
- justify-content: flex-start;
2457
+ /* Matches TOOLBAR_EXPANDED_HEIGHT in toolbar.tsx — the fixed 130px primary
2458
+ zone + 88px hover actions (+2 margin) + 20px vertical padding. */
2459
+ height: 240px;
2445
2460
  border-radius: 26px;
2446
2461
  }
2447
2462
 
@@ -2449,6 +2464,19 @@ body[data-clips-route="recording-pill"] #root {
2449
2464
  cursor: grabbing;
2450
2465
  }
2451
2466
 
2467
+ /* Fixed-height home for Stop / time / Pause. Its height equals the collapsed
2468
+ pill's content box (150px − 20px vertical padding = 130px), so the trio is
2469
+ centered exactly as before when collapsed and never shifts when the pill
2470
+ grows on hover — the extra height flows into .toolbar-v-hover-actions below. */
2471
+ .toolbar-v-primary {
2472
+ flex: 0 0 130px;
2473
+ display: flex;
2474
+ flex-direction: column;
2475
+ align-items: center;
2476
+ justify-content: center;
2477
+ gap: 10px;
2478
+ }
2479
+
2452
2480
  .toolbar-v-stop {
2453
2481
  width: 30px;
2454
2482
  height: 30px;
@@ -5,6 +5,7 @@ use std::process::Command;
5
5
  #[cfg(target_os = "macos")]
6
6
  use std::process::Stdio;
7
7
  use std::sync::atomic::{AtomicBool, Ordering};
8
+ use std::sync::{Mutex, OnceLock};
8
9
  use std::thread;
9
10
  use std::time::Duration;
10
11
  use tauri::{
@@ -216,6 +217,36 @@ fn clamp_existing_bubble_window(app: &AppHandle, window: &WebviewWindow) {
216
217
  }
217
218
  }
218
219
 
220
+ /// True while the user is hand-dragging the bubble through the JS pointer
221
+ /// handlers (`bubble_drag_start` / `bubble_drag_move` / `bubble_drag_end`).
222
+ ///
223
+ /// While this is set, the bubble's `Moved` event handler skips its own clamp.
224
+ /// That handler used to re-clamp on EVERY move — including the OS-native drag's
225
+ /// interpolated moves — calling `set_position` to snap the window back inside
226
+ /// the screen. During a drag the OS keeps shoving the window back out toward the
227
+ /// cursor, so the snap-back and the OS fought each frame and the bubble
228
+ /// visibly jittered. Now the drag-move command is the sole authority on
229
+ /// position during a drag and clamps every frame itself, so the handler must
230
+ /// yield to it.
231
+ static BUBBLE_DRAGGING: AtomicBool = AtomicBool::new(false);
232
+
233
+ /// Anchor captured at the start of a hand-drag: the global cursor position and
234
+ /// the bubble window's top-left, both in physical px with a desktop top-left
235
+ /// origin (the same space `cursor_position()` / `outer_position()` report in).
236
+ /// Each move computes `win_start + (cursor_now - cursor_start)` so the bubble
237
+ /// tracks the cursor 1:1, then clamps to the monitor BEFORE moving.
238
+ struct BubbleDragAnchor {
239
+ cursor_x: i32,
240
+ cursor_y: i32,
241
+ win_x: i32,
242
+ win_y: i32,
243
+ }
244
+
245
+ fn bubble_drag_anchor() -> &'static Mutex<Option<BubbleDragAnchor>> {
246
+ static ANCHOR: OnceLock<Mutex<Option<BubbleDragAnchor>>> = OnceLock::new();
247
+ ANCHOR.get_or_init(|| Mutex::new(None))
248
+ }
249
+
219
250
  /// Path to the JSON blob that stores the last-known bubble position on disk.
220
251
  /// Lives in the Tauri app-data dir (platform-specific — `~/Library/Application
221
252
  /// Support/<bundle-id>/` on macOS). Returns None if the app-data dir cannot be
@@ -842,6 +873,13 @@ pub async fn show_bubble(app: AppHandle) -> Result<(), String> {
842
873
  | tauri::WindowEvent::Resized(_)
843
874
  | tauri::WindowEvent::ScaleFactorChanged { .. }
844
875
  ) {
876
+ // During a hand-drag the move command owns the position and clamps
877
+ // every frame; re-clamping here would race that loop and bring back
878
+ // the edge jitter, so yield until the drag ends (which runs a final
879
+ // clamp of its own).
880
+ if BUBBLE_DRAGGING.load(Ordering::SeqCst) {
881
+ return;
882
+ }
845
883
  clamp_existing_bubble_window(&app_for_bounds, &win_for_bounds);
846
884
  }
847
885
  });
@@ -1689,6 +1727,81 @@ pub async fn save_bubble_position(app: AppHandle, x: i32, y: i32) -> Result<(),
1689
1727
  Ok(())
1690
1728
  }
1691
1729
 
1730
+ /// Begin a Loom-style hand-drag of the bubble. The JS pointer handler calls
1731
+ /// this on pointer-down. We snapshot the current cursor and window position as
1732
+ /// the drag anchor and flip `BUBBLE_DRAGGING` so the bounds handler yields to
1733
+ /// the drag loop.
1734
+ ///
1735
+ /// We deliberately do NOT use Tauri's native `startDragging()`: the OS window
1736
+ /// server owns the position during a native drag, so clamping it to the screen
1737
+ /// edge means fighting the OS every frame (the jitter/snap-back the user saw).
1738
+ /// Driving the move ourselves lets us clamp BEFORE moving, so the bubble stops
1739
+ /// dead at the edge like a puck hitting a wall.
1740
+ #[tauri::command]
1741
+ pub async fn bubble_drag_start(app: AppHandle) -> Result<(), String> {
1742
+ let Some(window) = app.get_webview_window(BUBBLE_LABEL) else {
1743
+ return Ok(());
1744
+ };
1745
+ let (Ok(cursor), Ok(pos)) = (window.cursor_position(), window.outer_position()) else {
1746
+ return Ok(());
1747
+ };
1748
+ *bubble_drag_anchor().lock().unwrap_or_else(|e| e.into_inner()) = Some(BubbleDragAnchor {
1749
+ cursor_x: cursor.x.round() as i32,
1750
+ cursor_y: cursor.y.round() as i32,
1751
+ win_x: pos.x,
1752
+ win_y: pos.y,
1753
+ });
1754
+ BUBBLE_DRAGGING.store(true, Ordering::SeqCst);
1755
+ Ok(())
1756
+ }
1757
+
1758
+ /// Move the bubble to follow the cursor for the active hand-drag. The JS
1759
+ /// pointer handler calls this once per animation frame while dragging.
1760
+ ///
1761
+ /// The new top-left is `win_start + (cursor_now - cursor_start)` — a 1:1
1762
+ /// follow in physical px — then clamped to the target monitor BEFORE the move.
1763
+ /// Because we clamp first, the window never overshoots the edge, so there is
1764
+ /// nothing to snap back from: the cursor can keep travelling past the edge
1765
+ /// while the bubble sits pinned against it. No-op if no drag is in progress.
1766
+ #[tauri::command]
1767
+ pub async fn bubble_drag_move(app: AppHandle) -> Result<(), String> {
1768
+ let Some(window) = app.get_webview_window(BUBBLE_LABEL) else {
1769
+ return Ok(());
1770
+ };
1771
+ let Ok(cursor) = window.cursor_position() else {
1772
+ return Ok(());
1773
+ };
1774
+ let target = {
1775
+ let guard = bubble_drag_anchor().lock().unwrap_or_else(|e| e.into_inner());
1776
+ let Some(anchor) = guard.as_ref() else {
1777
+ return Ok(());
1778
+ };
1779
+ (
1780
+ anchor.win_x + (cursor.x.round() as i32 - anchor.cursor_x),
1781
+ anchor.win_y + (cursor.y.round() as i32 - anchor.cursor_y),
1782
+ )
1783
+ };
1784
+ let Ok(size) = window.outer_size() else {
1785
+ return Ok(());
1786
+ };
1787
+ let (x, y) = clamp_bubble_window_position(&app, target.0, target.1, size.width, size.height);
1788
+ let _ = window.set_position(PhysicalPosition::new(x, y));
1789
+ Ok(())
1790
+ }
1791
+
1792
+ /// End the hand-drag: clear the anchor, drop the dragging flag, and run one
1793
+ /// final clamp so the resting position is guaranteed in-bounds. The JS
1794
+ /// `onMoved` listener persists the final spot via `save_bubble_position`.
1795
+ #[tauri::command]
1796
+ pub async fn bubble_drag_end(app: AppHandle) -> Result<(), String> {
1797
+ *bubble_drag_anchor().lock().unwrap_or_else(|e| e.into_inner()) = None;
1798
+ BUBBLE_DRAGGING.store(false, Ordering::SeqCst);
1799
+ if let Some(window) = app.get_webview_window(BUBBLE_LABEL) {
1800
+ clamp_existing_bubble_window(&app, &window);
1801
+ }
1802
+ Ok(())
1803
+ }
1804
+
1692
1805
  #[tauri::command]
1693
1806
  pub async fn show_popover(app: AppHandle) -> Result<(), String> {
1694
1807
  if let Some(window) = app.get_webview_window("popover") {
@@ -87,6 +87,9 @@ pub fn run() {
87
87
  clips::set_recording_state,
88
88
  clips::reset_state,
89
89
  clips::save_bubble_position,
90
+ clips::bubble_drag_start,
91
+ clips::bubble_drag_move,
92
+ clips::bubble_drag_end,
90
93
  clips::set_bubble_size,
91
94
  clips::load_bubble_size,
92
95
  // config commands
@@ -38,9 +38,9 @@ const COMPRESSION_ENABLED: bool = true;
38
38
  const TRANSCODE_THRESHOLD_BYTES: u64 = 24 * 1024 * 1024;
39
39
  const TARGET_UPLOAD_BYTES: u64 = 18 * 1024 * 1024;
40
40
  // Mirror of the shared `MAX_UPLOAD_BYTES` limit (see
41
- // `templates/clips/shared/upload-limits.ts`). Same default (256 MB) and same
41
+ // `templates/clips/shared/upload-limits.ts`). Same default (2 GB) and same
42
42
  // env var (CLIPS_MAX_UPLOAD_BYTES) so desktop and web stay in lockstep.
43
- const DEFAULT_MAX_UPLOAD_BYTES: u64 = 256 * 1024 * 1024;
43
+ const DEFAULT_MAX_UPLOAD_BYTES: u64 = 2 * 1024 * 1024 * 1024;
44
44
  const MIN_TRANSCODE_VIDEO_RATE_KBPS: u32 = 350;
45
45
  const TRANSCODE_RATE_LIMIT_OVERHEAD_KBPS: f64 = 64.0;
46
46
  const TRANSCODE_FRAME_RATE_LIMIT: u32 = 30;
@@ -405,7 +405,15 @@ export default defineEventHandler(async (event: H3Event) => {
405
405
  return loomEmbedResponse(embedUrl);
406
406
  }
407
407
 
408
- const blob = await readAppState(`recording-blob-${recordingId}`);
408
+ // The `recording-blob-*` fallback only exists for local/dev recordings
409
+ // (production uses provider storage), and `readAppState` THROWS when there
410
+ // is no authenticated identity in context. An anonymous viewer of a public
411
+ // clip therefore has no blob to read anyway — swallow the missing-context
412
+ // error and fall through to the provider media URL instead of surfacing an
413
+ // unhandled 500 ("Could not start playback. Try again." in the player).
414
+ const blob = await readAppState(`recording-blob-${recordingId}`).catch(
415
+ () => null,
416
+ );
409
417
  const b64 = typeof blob?.data === "string" ? blob.data : null;
410
418
  const rangeHeader = getRequestHeader(event, "range");
411
419
 
@@ -11,8 +11,11 @@
11
11
  * `desktop/src-tauri/src/native_screen.rs` (same env var, same default).
12
12
  */
13
13
 
14
- /** Default maximum upload size: 256 MB. */
15
- export const DEFAULT_MAX_UPLOAD_BYTES = 256 * 1024 * 1024;
14
+ /** Default maximum upload size: 2 GB. The upload provider streams large files
15
+ * directly, so this is a generous safety ceiling rather than a hard product
16
+ * limit. It mainly bounds how long a high-bitrate (crisp 1080p) recording can
17
+ * run before it must be split — ~34 min at the 8 Mbps capture default. */
18
+ export const DEFAULT_MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
16
19
 
17
20
  /** Env var that overrides the upload ceiling, in bytes. */
18
21
  export const MAX_UPLOAD_BYTES_ENV = "CLIPS_MAX_UPLOAD_BYTES";
@@ -7038,7 +7038,7 @@ const PLAN_SKELETON_FILL = {
7038
7038
  function PlanCanvasSkeleton() {
7039
7039
  return (
7040
7040
  <section
7041
- className="plan-canvas relative h-[65vh] overflow-hidden border-b border-plan-line"
7041
+ className="plan-canvas relative flex min-h-[65vh] flex-col overflow-hidden border-b border-plan-line"
7042
7042
  aria-hidden="true"
7043
7043
  >
7044
7044
  <div
@@ -7058,13 +7058,17 @@ function PlanCanvasSkeleton() {
7058
7058
  <PlanSkeletonIcon />
7059
7059
  </div>
7060
7060
 
7061
- <div className="absolute inset-x-4 bottom-20 top-24 mx-auto flex max-w-5xl items-center gap-7 overflow-hidden px-1 sm:px-6">
7062
- <div className="min-w-0 flex-[1_1_44rem]">
7063
- <DesktopArtboardSkeleton />
7064
- </div>
7061
+ {/* Normal flow + flex-1 so the canvas grows to contain the artboards on
7062
+ short viewports (the surface scrolls) instead of clipping them. */}
7063
+ <div className="relative z-0 flex flex-1 items-center justify-center px-4 py-16 sm:px-6">
7064
+ <div className="mx-auto flex w-full max-w-5xl items-center gap-7">
7065
+ <div className="min-w-0 flex-[1_1_44rem]">
7066
+ <DesktopArtboardSkeleton />
7067
+ </div>
7065
7068
 
7066
- <div className="hidden w-[15rem] shrink-0 lg:block">
7067
- <PhoneArtboardSkeleton />
7069
+ <div className="hidden w-[15rem] shrink-0 lg:block">
7070
+ <PhoneArtboardSkeleton />
7071
+ </div>
7068
7072
  </div>
7069
7073
  </div>
7070
7074
 
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-23
4
+ ---
5
+
6
+ Plan loading skeleton no longer cuts off the canvas preview on short screens
package/dist/action.js CHANGED
@@ -377,16 +377,27 @@ function coerceStringToSchemaType(raw, types) {
377
377
  return NO_COERCE;
378
378
  }
379
379
  /**
380
- * Defensively coerce gateway-stringified tool arguments to the types the schema
381
- * expects. Some model gateways (notably Builder's Gemini-backed gateway) hand
382
- * back structured tool-call arguments as JSON strings — an array param arrives
383
- * as `"[{...}]"`, a boolean as `"true"`. Standard Schema (zod) `validate` does
384
- * not coerce, so these fail validation and the agent thrashes retrying shapes
385
- * (and can hang). We only touch a string value when the schema expects a
386
- * non-string type and the string parses cleanly to it; anything ambiguous
387
- * (schema also allows string) or unparseable is left as-is. Operates on
388
- * top-level properties only once an array/object param is parsed, its nested
389
- * members are already native and validate normally.
380
+ * Defensively coerce stringified action arguments to the types the schema
381
+ * expects. Two callers depend on this:
382
+ *
383
+ * 1. Model gateways (notably Builder's Gemini-backed gateway) hand back
384
+ * structured tool-call arguments as JSON strings an array param arrives
385
+ * as `"[{...}]"`, a boolean as `"true"`.
386
+ * 2. GET actions called from the browser via `useActionQuery` / `callAction`.
387
+ * Those serialize params into the query string, where `URLSearchParams`
388
+ * stringifies everythingso `includeSeries: true` arrives as the string
389
+ * `"true"` and `limit: 5` as `"5"` (see `action-routes.ts`).
390
+ *
391
+ * In both cases Standard Schema (zod) `validate` does not coerce, so the call
392
+ * fails validation ("expected boolean, received string") — the agent thrashes
393
+ * retrying shapes and the frontend query errors. We only touch a string value
394
+ * when the schema expects a non-string type and the string parses cleanly to
395
+ * it; anything ambiguous (schema also allows string) or unparseable is left
396
+ * as-is. Operates on top-level properties only — once an array/object param is
397
+ * parsed, its nested members are already native and validate normally.
398
+ *
399
+ * Do NOT narrow this to "gateway-only": the GET query-string path relies on it
400
+ * too, and `action-routes.spec.ts` guards that round-trip.
390
401
  */
391
402
  function coerceGatewayStringifiedArgs(args, parameters) {
392
403
  const properties = parameters?.properties;