@agent-native/core 0.161.22 → 0.162.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 (125) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/real-time-sync/SKILL.md +1 -1
  3. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +18 -2
  4. package/corpus/templates/assets/.agents/skills/real-time-sync/SKILL.md +1 -1
  5. package/corpus/templates/brain/.agents/skills/real-time-sync/SKILL.md +1 -1
  6. package/corpus/templates/calendar/.agents/skills/real-time-sync/SKILL.md +1 -1
  7. package/corpus/templates/calendar/server/handlers/google-auth.ts +1 -8
  8. package/corpus/templates/chat/.agents/skills/real-time-sync/SKILL.md +1 -1
  9. package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +88 -35
  10. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +36 -1
  11. package/corpus/templates/clips/app/i18n/en-US.ts +4 -3
  12. package/corpus/templates/clips/desktop/src/app.tsx +30 -40
  13. package/corpus/templates/clips/desktop/src/components/MicOffConfirmation.tsx +47 -0
  14. package/corpus/templates/clips/desktop/src/styles.css +110 -0
  15. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +114 -7
  16. package/corpus/templates/clips/shared/recording-capture.ts +18 -13
  17. package/corpus/templates/content/.agents/skills/real-time-sync/SKILL.md +1 -1
  18. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +3 -5
  19. package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +0 -3
  20. package/corpus/templates/content/app/components/editor/NotionConflictBanner.tsx +0 -4
  21. package/corpus/templates/content/app/components/editor/NotionSyncBar.tsx +4 -1
  22. package/corpus/templates/content/app/hooks/use-notion.ts +17 -15
  23. package/corpus/templates/crm/.agents/skills/real-time-sync/SKILL.md +1 -1
  24. package/corpus/templates/design/.agents/skills/real-time-sync/SKILL.md +1 -1
  25. package/corpus/templates/design/app/components/design/keyboard-shortcuts.ts +13 -7
  26. package/corpus/templates/design/app/hooks/use-navigation-state.ts +18 -21
  27. package/corpus/templates/design/app/pages/design-editor/editor-state.ts +2 -5
  28. package/corpus/templates/design/app/pages/design-editor/tool-state.ts +4 -12
  29. package/corpus/templates/design/app/pages/design-editor/types.ts +38 -1
  30. package/corpus/templates/design/changelog/2026-08-18-advanced-design-editor-panels-hidden-by-default.md +6 -0
  31. package/corpus/templates/design/playwright.config.ts +19 -2
  32. package/corpus/templates/dispatch/.agents/skills/real-time-sync/SKILL.md +1 -1
  33. package/corpus/templates/factory/.agents/skills/real-time-sync/SKILL.md +1 -1
  34. package/corpus/templates/forms/.agents/skills/real-time-sync/SKILL.md +1 -1
  35. package/corpus/templates/macros/.agents/skills/real-time-sync/SKILL.md +1 -1
  36. package/corpus/templates/mail/.agents/skills/real-time-sync/SKILL.md +1 -1
  37. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +18 -3
  38. package/corpus/templates/mail/server/handlers/google-auth.ts +1 -8
  39. package/corpus/templates/plan/.agents/skills/real-time-sync/SKILL.md +1 -1
  40. package/corpus/templates/slides/.agents/skills/create-deck/SKILL.md +4 -0
  41. package/corpus/templates/slides/.agents/skills/real-time-sync/SKILL.md +1 -1
  42. package/corpus/templates/slides/actions/_await-fit-check.ts +30 -5
  43. package/corpus/templates/slides/actions/add-slide.ts +6 -2
  44. package/corpus/templates/slides/actions/create-deck.ts +2 -1
  45. package/corpus/templates/slides/app/lib/create-deck-generation.ts +1 -0
  46. package/corpus/templates/slides/app/pages/DeckEditor.tsx +34 -1
  47. package/corpus/templates/slides/app/routes/p.$id.tsx +3 -1
  48. package/corpus/templates/slides/changelog/2026-08-18-generated-slides-preserve-speaker-notes.md +6 -0
  49. package/corpus/templates/slides/server/handlers/assets.ts +23 -12
  50. package/corpus/templates/slides/server/handlers/design-system-decode-job-status.ts +15 -6
  51. package/corpus/templates/slides/server/handlers/design-system-upload-start.ts +15 -6
  52. package/corpus/templates/slides/server/handlers/index-design-system-sources.ts +34 -19
  53. package/corpus/templates/slides/server/handlers/share.ts +1 -1
  54. package/corpus/templates/slides/server/plugins/agent-chat.ts +1 -1
  55. package/corpus/templates/slides/server/routes/api/deck-agent-context.json.get.ts +1 -1
  56. package/corpus/templates/slides/server/routes/api/exports/[filename].get.ts +9 -3
  57. package/corpus/templates/slides/server/routes/api/exports/google-slides.post.ts +12 -5
  58. package/corpus/templates/slides/server/routes/api/exports/html.post.ts +9 -7
  59. package/corpus/templates/slides/server/routes/api/exports/pptx.post.ts +9 -7
  60. package/corpus/templates/slides/shared/api.ts +7 -1
  61. package/corpus/templates/tasks/.agents/skills/real-time-sync/SKILL.md +1 -1
  62. package/dist/agent/engine/builder-engine.js +15 -8
  63. package/dist/agent/engine/translate-anthropic.js +27 -2
  64. package/dist/agent/production-agent.d.ts +17 -2
  65. package/dist/agent/production-agent.js +48 -17
  66. package/dist/agent/thread-data-builder.d.ts +1 -0
  67. package/dist/agent/thread-data-builder.js +6 -4
  68. package/dist/agent/tool-approval-store.d.ts +10 -1
  69. package/dist/agent/tool-approval-store.js +19 -2
  70. package/dist/agent/types.d.ts +9 -0
  71. package/dist/client/AssistantChat.js +14 -6
  72. package/dist/client/EnvironmentBadge.d.ts +15 -0
  73. package/dist/client/EnvironmentBadge.js +79 -0
  74. package/dist/client/MultiTabAssistantChat.js +73 -17
  75. package/dist/client/agent-chat-adapter.js +11 -1
  76. package/dist/client/api-path.js +14 -1
  77. package/dist/client/app-providers.js +2 -1
  78. package/dist/client/chat/tool-call-display.d.ts +12 -2
  79. package/dist/client/chat/tool-call-display.js +17 -7
  80. package/dist/client/chat/widgets/WorkspaceFileWidget.js +1 -1
  81. package/dist/client/chat/widgets/builtin-tool-renderers.d.ts +6 -0
  82. package/dist/client/chat/widgets/builtin-tool-renderers.js +19 -0
  83. package/dist/client/onboarding/FirstRunOnboarding.js +46 -32
  84. package/dist/client/onboarding/use-onboarding.d.ts +8 -1
  85. package/dist/client/onboarding/use-onboarding.js +22 -7
  86. package/dist/client/session-replay.js +58 -15
  87. package/dist/client/settings/SettingsTabsPage.js +19 -6
  88. package/dist/client/sse-event-processor.d.ts +7 -0
  89. package/dist/client/sse-event-processor.js +4 -1
  90. package/dist/client/ui/index.d.ts +1 -0
  91. package/dist/client/ui/index.js +1 -0
  92. package/dist/client/use-action.js +26 -0
  93. package/dist/collab/awareness.d.ts +2 -2
  94. package/dist/collab/routes.d.ts +1 -1
  95. package/dist/config.d.ts +15 -0
  96. package/dist/config.js +53 -0
  97. package/dist/extensions/fetch-tool.js +8 -2
  98. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  99. package/dist/mcp/embed-app.js +1 -1
  100. package/dist/observability/routes.d.ts +5 -5
  101. package/dist/progress/routes.d.ts +1 -1
  102. package/dist/provider-api/actions/custom-provider-registration.d.ts +13 -13
  103. package/dist/provider-api/actions/provider-api.d.ts +11 -11
  104. package/dist/provider-api/index.d.ts +10 -0
  105. package/dist/provider-api/index.js +45 -9
  106. package/dist/secrets/routes.d.ts +9 -9
  107. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  108. package/dist/server/auth.js +10 -3
  109. package/dist/server/poll.js +7 -3
  110. package/dist/server/sentry.d.ts +1 -1
  111. package/dist/templates/chat/.agents/skills/real-time-sync/SKILL.md +1 -1
  112. package/dist/templates/default/.agents/skills/real-time-sync/SKILL.md +1 -1
  113. package/dist/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +1 -1
  114. package/dist/vite/client.d.ts +5 -0
  115. package/dist/vite/client.js +64 -2
  116. package/dist/workspace-files/actions.d.ts +1 -8
  117. package/dist/workspace-files/actions.js +5 -21
  118. package/dist/workspace-files/index.d.ts +1 -1
  119. package/dist/workspace-files/index.js +1 -1
  120. package/dist/workspace-files/store.d.ts +18 -0
  121. package/dist/workspace-files/store.js +24 -3
  122. package/package.json +3 -3
  123. package/src/templates/chat/.agents/skills/real-time-sync/SKILL.md +1 -1
  124. package/src/templates/default/.agents/skills/real-time-sync/SKILL.md +1 -1
  125. package/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +1 -1
@@ -630,6 +630,60 @@ mod macos {
630
630
  }
631
631
  }
632
632
 
633
+ /// RNNoise's voice-activity output is more useful than a fixed amplitude
634
+ /// threshold for the meeting microphone. A muted call still leaves Clips
635
+ /// with the local microphone, and fans, keyboard noise, or room noise can
636
+ /// be loud enough to cross the old RMS-only gate. Keep this state on the
637
+ /// worker thread so the realtime capture callback stays allocation-free.
638
+ struct MicVoiceActivity {
639
+ state: Box<nnnoiseless::DenoiseState<'static>>,
640
+ pending: Vec<f32>,
641
+ }
642
+
643
+ impl MicVoiceActivity {
644
+ const SAMPLE_RATE: f32 = 48_000.0;
645
+ const INPUT_SCALE: f32 = 32_768.0;
646
+
647
+ fn new() -> Self {
648
+ Self {
649
+ state: nnnoiseless::DenoiseState::new(),
650
+ pending: Vec::new(),
651
+ }
652
+ }
653
+
654
+ fn reset(&mut self) {
655
+ self.state = nnnoiseless::DenoiseState::new();
656
+ self.pending.clear();
657
+ }
658
+
659
+ /// Return the highest RNNoise speech probability in `samples`.
660
+ /// `None` means this capture format is not the 48 kHz PCM that
661
+ /// RNNoise expects; callers retain the existing RMS fallback for
662
+ /// those legacy devices.
663
+ fn observe(&mut self, samples: &[f32], sample_rate: f32) -> Option<f32> {
664
+ if (sample_rate - Self::SAMPLE_RATE).abs() > 1.0 {
665
+ self.reset();
666
+ return None;
667
+ }
668
+
669
+ self.pending.extend(samples.iter().map(|sample| {
670
+ (sample * Self::INPUT_SCALE).clamp(-Self::INPUT_SCALE, Self::INPUT_SCALE)
671
+ }));
672
+
673
+ let frame_size = nnnoiseless::DenoiseState::FRAME_SIZE;
674
+ let mut highest_probability: f32 = 0.0;
675
+ while self.pending.len() >= frame_size {
676
+ let mut denoised = [0.0_f32; nnnoiseless::DenoiseState::FRAME_SIZE];
677
+ let probability = self
678
+ .state
679
+ .process_frame(&mut denoised, &self.pending[..frame_size]);
680
+ highest_probability = highest_probability.max(probability);
681
+ self.pending.drain(..frame_size);
682
+ }
683
+ Some(highest_probability)
684
+ }
685
+ }
686
+
633
687
  /// Run whisper over `samples` (16 kHz mono f32), returning each speech
634
688
  /// segment as `(start_ms, end_ms, text)` with buffer-relative timestamps.
635
689
  /// `language` is the forced language code (e.g. "en"); `None` lets whisper
@@ -698,6 +752,10 @@ mod macos {
698
752
  const VOICE_RMS_THRESHOLD: f32 = 0.006;
699
753
  /// A second, model-level gate for ambient/no-speech Whisper segments.
700
754
  const MAX_NO_SPEECH_PROBABILITY: f32 = 0.72;
755
+ /// RNNoise speech probability required for a 48 kHz microphone frame to
756
+ /// count as voice. System audio and non-48 kHz legacy mic captures keep
757
+ /// the RMS path because this VAD is trained for 48 kHz microphone PCM.
758
+ const MIN_MIC_VAD_PROBABILITY: f32 = 0.6;
701
759
  /// Minimum average per-token probability for a segment to count as real
702
760
  /// speech. On noisy/near-silent audio whisper mis-detects the language and
703
761
  /// decodes fluent-looking gibberish in another language — but those tokens
@@ -733,6 +791,11 @@ mod macos {
733
791
  sum / count as f32
734
792
  }
735
793
 
794
+ fn mic_voice_detected(rms: f32, vad_probability: Option<f32>) -> bool {
795
+ rms > VOICE_RMS_THRESHOLD
796
+ && vad_probability.is_none_or(|probability| probability >= MIN_MIC_VAD_PROBABILITY)
797
+ }
798
+
736
799
  fn buffer_start_after_drain(now: Instant, pending: Duration) -> Instant {
737
800
  now.checked_sub(pending).unwrap_or(now)
738
801
  }
@@ -788,6 +851,10 @@ mod macos {
788
851
  let mut seen_reset_generation = stream.reset_generation.load(Ordering::SeqCst);
789
852
  // Growing-utterance resample cache — see `IncrementalResample`.
790
853
  let mut resample_state = IncrementalResample::new();
854
+ // Only the microphone stream needs a speech/noise classifier. The
855
+ // system stream must preserve remote speech even when it is mixed
856
+ // with music or other non-voice audio.
857
+ let mut mic_voice_activity = (stream.source == "mic").then(MicVoiceActivity::new);
791
858
 
792
859
  while stream.running.load(Ordering::SeqCst) {
793
860
  std::thread::sleep(Duration::from_millis(250));
@@ -800,6 +867,9 @@ mod macos {
800
867
  last_infer = Instant::now() - Duration::from_secs(10);
801
868
  had_voice = false;
802
869
  resample_state.drop_all();
870
+ if let Some(vad) = mic_voice_activity.as_mut() {
871
+ vad.reset();
872
+ }
803
873
  continue;
804
874
  }
805
875
 
@@ -807,31 +877,40 @@ mod macos {
807
877
  // poll. Inspect that tail in place instead of cloning + resampling
808
878
  // the entire growing utterance four times per second. Full snapshots
809
879
  // are reserved for an inference that is actually due.
810
- let (raw_len, new_rms) = match stream.buf.lock() {
880
+ let (raw_len, new_rms, new_mic_samples) = match stream.buf.lock() {
811
881
  Ok(b) => {
812
882
  let raw_len = b.len();
813
- let new_rms = if raw_len > last_raw_len {
883
+ let (new_rms, new_mic_samples) = if raw_len > last_raw_len {
814
884
  let new = &b[last_raw_len..];
815
- Some((new.iter().map(|x| x * x).sum::<f32>() / new.len() as f32).sqrt())
885
+ (
886
+ Some(
887
+ (new.iter().map(|x| x * x).sum::<f32>() / new.len() as f32).sqrt(),
888
+ ),
889
+ (stream.source == "mic").then(|| new.to_vec()),
890
+ )
816
891
  } else {
817
- None
892
+ (None, None)
818
893
  };
819
- (raw_len, new_rms)
894
+ (raw_len, new_rms, new_mic_samples)
820
895
  }
821
896
  Err(_) => continue,
822
897
  };
823
898
  if stream.reset_generation.load(Ordering::SeqCst) != seen_reset_generation {
824
899
  continue;
825
900
  }
901
+ let src_rate = (stream.src_rate.load(Ordering::SeqCst) as f32).max(1.0);
826
902
  if let Some(rms) = new_rms {
827
- if rms > VOICE_RMS_THRESHOLD {
903
+ let vad_probability = mic_voice_activity
904
+ .as_mut()
905
+ .zip(new_mic_samples.as_deref())
906
+ .and_then(|(vad, samples)| vad.observe(samples, src_rate));
907
+ if mic_voice_detected(rms, vad_probability) {
828
908
  last_voice = Instant::now();
829
909
  had_voice = true;
830
910
  }
831
911
  }
832
912
  last_raw_len = raw_len;
833
913
 
834
- let src_rate = (stream.src_rate.load(Ordering::SeqCst) as f32).max(1.0);
835
914
  let have_secs = raw_len as f32 / src_rate;
836
915
  let silence = last_voice.elapsed();
837
916
 
@@ -878,6 +957,9 @@ mod macos {
878
957
  // so the resample cache is invalid regardless of whether this
879
958
  // utterance ran inference — rebuild fresh from whatever's left.
880
959
  resample_state.drop_all();
960
+ if let Some(vad) = mic_voice_activity.as_mut() {
961
+ vad.reset();
962
+ }
881
963
  // Advance the timeline offset so the next utterance's whisper
882
964
  // timestamps map correctly. Inference can take seconds, and
883
965
  // audio kept arriving throughout it, so the new buffer starts
@@ -1568,6 +1650,31 @@ mod macos {
1568
1650
  );
1569
1651
  }
1570
1652
 
1653
+ #[test]
1654
+ fn mic_voice_gate_requires_speech_probability_on_supported_captures() {
1655
+ assert!(!super::mic_voice_detected(0.02, Some(0.59)));
1656
+ assert!(!super::mic_voice_detected(0.004, Some(0.99)));
1657
+ assert!(super::mic_voice_detected(0.02, Some(0.6)));
1658
+ assert!(super::mic_voice_detected(0.02, None));
1659
+ }
1660
+
1661
+ #[test]
1662
+ fn rnnoise_vad_rejects_steady_ambient_noise() {
1663
+ let mut vad = super::MicVoiceActivity::new();
1664
+ let samples: Vec<f32> = (0..(nnnoiseless::DenoiseState::FRAME_SIZE * 8))
1665
+ .map(|index| {
1666
+ let value = index.wrapping_mul(1_103_515_245usize).wrapping_add(12_345);
1667
+ (((value >> 16) & 0x7fff) as f32 / 16_384.0 - 1.0) * 0.012
1668
+ })
1669
+ .collect();
1670
+
1671
+ let probability = vad.observe(&samples, 48_000.0).unwrap();
1672
+ assert!(
1673
+ probability < super::MIN_MIC_VAD_PROBABILITY,
1674
+ "ambient noise was classified as speech: {probability:.3}"
1675
+ );
1676
+ }
1677
+
1571
1678
  #[test]
1572
1679
  fn filters_multilingual_caption_hallucinations() {
1573
1680
  assert_eq!(clean_transcript("ご視聴ありがとうございました"), None);
@@ -47,29 +47,34 @@ export type ScreenCaptureDisplayOptions = {
47
47
  /**
48
48
  * The `getDisplayMedia` options for a screen recording.
49
49
  *
50
- * Screen/tab audio is requested ALWAYS, and deliberately does not depend on
51
- * whether the microphone is on. They are two different sources, and one toggle
52
- * used to govern both: with the mic off, Chrome was never even offered the
53
- * "share tab audio" checkbox, so a screen recording of a call or a demo
54
- * captured zero audio tracks. Measured in production, 345 recordings landed
55
- * with `has_audio = false` — 95 over a minute, 17 over five — and each was told
56
- * "No speech was detected because this recording was saved without audio",
57
- * blaming the recording for a capture setting. That was 64% of recent
58
- * transcript failures. The Chrome extension always requested display audio and
59
- * never had the problem.
50
+ * Screen/tab audio is requested whenever the microphone is enabled, and
51
+ * withheld when it's off. The mic toggle is the only audio control the
52
+ * recorder UI shows, so a user who turns it off reasonably expects the
53
+ * recording to capture no audio at all — not just their voice. Requesting
54
+ * system audio anyway relies on the user separately noticing and unchecking
55
+ * the browser's native "share tab audio" checkbox, which they may not even
56
+ * know exists. See Slack thread 1786086902028429: a user turned the mic off
57
+ * and still got system audio in the recording; the agreed fix is to gate
58
+ * system audio on the same toggle.
60
59
  *
61
- * Declining the checkbox is still respected; the user simply gets the choice.
60
+ * When the mic IS on, audio is still requested unconditionally (not made
61
+ * conditional on some separate "include system audio" flag) — that used to be
62
+ * one toggle governing both, and with the mic off Chrome never even offered
63
+ * the checkbox, so 345 production recordings landed with `has_audio = false`
64
+ * while recording a call/demo. Declining the checkbox is still respected; the
65
+ * user simply gets the choice whenever the mic is on.
62
66
  */
63
67
  export function screenCaptureDisplayOptions(
64
68
  displaySurface: ScreenCaptureSurface,
69
+ wantsMic: boolean,
65
70
  ): ScreenCaptureDisplayOptions {
66
71
  return {
67
72
  video: screenCaptureVideoConstraints(displaySurface),
68
- audio: true,
73
+ audio: wantsMic,
69
74
  // Let "Browser tab" open the tab picker. preferCurrentTab turns it into a
70
75
  // current-tab shortcut, which makes choosing another tab harder.
71
76
  selfBrowserSurface: displaySurface === "browser" ? "include" : "exclude",
72
77
  surfaceSwitching: "include",
73
- systemAudio: "include",
78
+ systemAudio: wantsMic ? "include" : "exclude",
74
79
  };
75
80
  }
@@ -50,7 +50,7 @@ The agent modifies data in SQL, but the UI runs in the browser. SSE bridges same
50
50
 
51
51
  For list/sidebar queries, use the same pattern — pass the counter into the queryKey of every list query you want to keep fresh.
52
52
 
53
- 4. **Fallback** polling calls `/_agent-native/poll?since=N`. It runs every 2 seconds until SSE is connected, then relaxes to 15 seconds (`SSE_FALLBACK_INTERVAL_MS`). If SSE is disabled or unavailable (e.g., edge/serverless deployments), polling continues at the 2 s cadence. Polling is the universal serverless fallback: new framework writes are read from the durable `sync_events` log, while the older DB timestamp scan remains as a slower safety net for direct SQL writes and older processes.
53
+ 4. **Fallback** polling calls `/_agent-native/poll?since=N`. It polls every 2 seconds only while an agent run is in flight; otherwise it idles at 60 seconds whether or not SSE is connected (`SSE_FALLBACK_INTERVAL_MS` and `IDLE_POLL_INTERVAL_MS`, both 60 s). A hidden tab polls no faster than every 10 seconds (`HIDDEN_POLL_INTERVAL_MS`). Polling is the universal serverless fallback: new framework writes are read from the durable `sync_events` log, while the older DB timestamp scan remains as a slower safety net for direct SQL writes and older processes.
54
54
 
55
55
  5. When the agent writes to the database, the version increments, SSE/polling detects it, and React Query refetches the affected queries.
56
56
 
@@ -597,9 +597,7 @@ function DocumentEditorBody({
597
597
  );
598
598
  // Polls Notion sync status to drive the conflict banner / sync bar and the
599
599
  // push-on-save path below (read via the query cache, not this return value).
600
- useDocumentSyncStatus(canEdit && !isLocalFileDocument ? documentId : null, {
601
- autoSync,
602
- });
600
+ useDocumentSyncStatus(canEdit && !isLocalFileDocument ? documentId : null);
603
601
  const pushDocumentToNotion = usePushDocumentToNotion(documentId);
604
602
  const [localTitle, setLocalTitle] = useState("");
605
603
  const [localContent, setLocalContent] = useState("");
@@ -1088,7 +1086,7 @@ function DocumentEditorBody({
1088
1086
  // Pulls remain driven by the polling refetch in useDocumentSyncStatus.
1089
1087
  if (autoSync) {
1090
1088
  const status = queryClient.getQueryData<DocumentSyncStatus>(
1091
- documentSyncStatusQueryKey(documentId, { autoSync }),
1089
+ documentSyncStatusQueryKey(documentId),
1092
1090
  );
1093
1091
  if (status?.pageId && !status.hasConflict) {
1094
1092
  try {
@@ -1100,7 +1098,7 @@ function DocumentEditorBody({
1100
1098
  flushOpenEditor: false,
1101
1099
  });
1102
1100
  queryClient.setQueryData(
1103
- documentSyncStatusQueryKey(documentId, { autoSync }),
1101
+ documentSyncStatusQueryKey(documentId),
1104
1102
  next,
1105
1103
  );
1106
1104
  } catch {
@@ -510,9 +510,6 @@ export function DocumentToolbar({
510
510
  const { data: connection } = useNotionConnection();
511
511
  const { data: syncStatus } = useDocumentSyncStatus(
512
512
  canEdit && !isLocalFileDocument ? documentId : null,
513
- {
514
- autoSync,
515
- },
516
513
  );
517
514
  const linkDocument = useLinkDocumentToNotion(documentId);
518
515
  const unlinkDocument = useUnlinkDocumentFromNotion(documentId);
@@ -4,7 +4,6 @@ import { useState } from "react";
4
4
  import { toast } from "sonner";
5
5
 
6
6
  import { Button } from "@/components/ui/button";
7
- import { useLocalStorage } from "@/hooks/use-local-storage";
8
7
  import {
9
8
  useDocumentSyncStatus,
10
9
  useResolveDocumentSyncConflict,
@@ -20,11 +19,8 @@ export function NotionConflictBanner({
20
19
  canEdit = true,
21
20
  }: NotionConflictBannerProps) {
22
21
  const t = useT();
23
- // Share autoSync state (and React Query cache) with DocumentToolbar.
24
- const [autoSync] = useLocalStorage(`notion-auto-sync:${documentId}`, false);
25
22
  const { data: syncStatus } = useDocumentSyncStatus(
26
23
  canEdit ? documentId : null,
27
- { autoSync },
28
24
  );
29
25
  const resolveConflict = useResolveDocumentSyncConflict(documentId);
30
26
  const [direction, setDirection] = useState<"pull" | "push" | null>(null);
@@ -15,6 +15,7 @@ import { toast } from "sonner";
15
15
  import { Button } from "@/components/ui/button";
16
16
  import { Input } from "@/components/ui/input";
17
17
  import {
18
+ invalidateDocumentQueries,
18
19
  useDisconnectNotion,
19
20
  useDocumentSyncStatus,
20
21
  useLinkDocumentToNotion,
@@ -51,7 +52,9 @@ export function NotionSyncBar({ documentId }: NotionSyncBarProps) {
51
52
  lastSyncedRef.current &&
52
53
  lastSyncedRef.current !== syncStatus.lastSyncedAt
53
54
  ) {
54
- queryClient.invalidateQueries({ queryKey: ["action"] });
55
+ // Bare ["action"] refetches every mounted query app-wide (sidebar tree,
56
+ // comments, database views, search, connection status) on each sync tick.
57
+ invalidateDocumentQueries(queryClient, documentId);
55
58
  }
56
59
  lastSyncedRef.current = syncStatus.lastSyncedAt;
57
60
  }, [syncStatus?.lastSyncedAt, queryClient, documentId]);
@@ -16,6 +16,8 @@ import type {
16
16
  import { useQuery, useQueryClient } from "@tanstack/react-query";
17
17
  import { useEffect, useRef } from "react";
18
18
 
19
+ import { useLocalStorage } from "@/hooks/use-local-storage";
20
+
19
21
  import { documentQueryFilter } from "./use-documents";
20
22
 
21
23
  // The server signs a `redirect` query param into the OAuth `state` and the
@@ -64,20 +66,16 @@ export function invalidateDocumentQueries(
64
66
  queryClient.invalidateQueries({
65
67
  queryKey: documentSyncStatusQueryKey(documentId),
66
68
  });
67
- queryClient.invalidateQueries({
68
- queryKey: documentSyncStatusQueryKey(documentId, { autoSync: true }),
69
- });
70
69
  }
71
70
 
72
- export function documentSyncStatusQueryKey(
73
- documentId: string,
74
- options?: { autoSync?: boolean },
75
- ) {
76
- const normalizedDocumentId = documentId.trim();
71
+ // `autoSync` only decides how often to refetch, so keying on it split one
72
+ // document's status across two cache entries and two independent poll loops —
73
+ // the toolbar polling at 2s while the sync bar polled the same action at 30s.
74
+ export function documentSyncStatusQueryKey(documentId: string) {
77
75
  return [
78
76
  "action",
79
77
  "refresh-notion-sync-status",
80
- { documentId: normalizedDocumentId, autoSync: !!options?.autoSync },
78
+ { documentId: documentId.trim() },
81
79
  ] as const;
82
80
  }
83
81
 
@@ -119,17 +117,21 @@ export function documentSyncRefetchIntervalMs(
119
117
  return autoSync ? 2_000 : 30_000;
120
118
  }
121
119
 
122
- export function useDocumentSyncStatus(
123
- documentId: string | null,
124
- options?: { autoSync?: boolean },
125
- ) {
120
+ export function useDocumentSyncStatus(documentId: string | null) {
126
121
  const queryClient = useQueryClient();
127
122
  const lastObservedSyncedAtRef = useRef<string | null>(null);
128
123
  const normalizedDocumentId = documentId?.trim() || null;
129
- const autoSync = !!options?.autoSync;
124
+ // `autoSync` changes what the server does, so every observer of the shared
125
+ // query key has to agree on it. Read the same per-document toggle the
126
+ // toolbar writes instead of taking it per mount, where one component could
127
+ // suppress or enable another's auto-sync through the shared query function.
128
+ const [autoSync] = useLocalStorage(
129
+ `notion-auto-sync:${normalizedDocumentId ?? ""}`,
130
+ false,
131
+ );
130
132
  const query = useQuery<DocumentSyncStatus>({
131
133
  queryKey: normalizedDocumentId
132
- ? documentSyncStatusQueryKey(normalizedDocumentId, options)
134
+ ? documentSyncStatusQueryKey(normalizedDocumentId)
133
135
  : ["action", "refresh-notion-sync-status", null],
134
136
  queryFn: () => {
135
137
  if (!normalizedDocumentId) throw new Error("documentId is required");
@@ -50,7 +50,7 @@ The agent modifies data in SQL, but the UI runs in the browser. SSE bridges same
50
50
 
51
51
  For list/sidebar queries, use the same pattern — pass the counter into the queryKey of every list query you want to keep fresh.
52
52
 
53
- 4. **Fallback** polling calls `/_agent-native/poll?since=N`. It runs every 2 seconds until SSE is connected, then relaxes to 15 seconds (`SSE_FALLBACK_INTERVAL_MS`). If SSE is disabled or unavailable (e.g., edge/serverless deployments), polling continues at the 2 s cadence. Polling is the universal serverless fallback: new framework writes are read from the durable `sync_events` log, while the older DB timestamp scan remains as a slower safety net for direct SQL writes and older processes.
53
+ 4. **Fallback** polling calls `/_agent-native/poll?since=N`. It polls every 2 seconds only while an agent run is in flight; otherwise it idles at 60 seconds whether or not SSE is connected (`SSE_FALLBACK_INTERVAL_MS` and `IDLE_POLL_INTERVAL_MS`, both 60 s). A hidden tab polls no faster than every 10 seconds (`HIDDEN_POLL_INTERVAL_MS`). Polling is the universal serverless fallback: new framework writes are read from the durable `sync_events` log, while the older DB timestamp scan remains as a slower safety net for direct SQL writes and older processes.
54
54
 
55
55
  5. When the agent writes to the database, the version increments, SSE/polling detects it, and React Query refetches the affected queries.
56
56
 
@@ -50,7 +50,7 @@ The agent modifies data in SQL, but the UI runs in the browser. SSE bridges same
50
50
 
51
51
  For list/sidebar queries, use the same pattern — pass the counter into the queryKey of every list query you want to keep fresh.
52
52
 
53
- 4. **Fallback** polling calls `/_agent-native/poll?since=N`. It runs every 2 seconds until SSE is connected, then relaxes to 15 seconds (`SSE_FALLBACK_INTERVAL_MS`). If SSE is disabled or unavailable (e.g., edge/serverless deployments), polling continues at the 2 s cadence. Polling is the universal serverless fallback: new framework writes are read from the durable `sync_events` log, while the older DB timestamp scan remains as a slower safety net for direct SQL writes and older processes.
53
+ 4. **Fallback** polling calls `/_agent-native/poll?since=N`. It polls every 2 seconds only while an agent run is in flight; otherwise it idles at 60 seconds whether or not SSE is connected (`SSE_FALLBACK_INTERVAL_MS` and `IDLE_POLL_INTERVAL_MS`, both 60 s). A hidden tab polls no faster than every 10 seconds (`HIDDEN_POLL_INTERVAL_MS`). Polling is the universal serverless fallback: new framework writes are read from the durable `sync_events` log, while the older DB timestamp scan remains as a slower safety net for direct SQL writes and older processes.
54
54
 
55
55
  5. When the agent writes to the database, the version increments, SSE/polling detects it, and React Query refetches the affected queries.
56
56
 
@@ -1,4 +1,5 @@
1
1
  import type { UseDesignHotkeysProps } from "@/hooks/useDesignHotkeys";
2
+ import { SHOW_DESIGN_SECONDARY_LEFT_PANELS } from "@/pages/design-editor/types";
2
3
 
3
4
  export const DESIGN_SHORTCUT_CATEGORIES = [
4
5
  "essential",
@@ -136,13 +137,18 @@ export const DESIGN_SHORTCUTS: readonly DesignShortcutDefinition[] = [
136
137
  labelKey: "designEditor.keyboardShortcuts.commands.showLayers",
137
138
  handler: "onShowLayersPanel",
138
139
  }),
139
- shortcut({
140
- id: "show-assets",
141
- category: "view",
142
- bindings: ["alt+2"],
143
- labelKey: "designEditor.keyboardShortcuts.commands.showAssets",
144
- handler: "onShowAssetsPanel",
145
- }),
140
+ ...(SHOW_DESIGN_SECONDARY_LEFT_PANELS
141
+ ? [
142
+ shortcut({
143
+ id: "show-assets",
144
+ category: "view" as const,
145
+ bindings: ["alt+2"],
146
+ labelKey:
147
+ "designEditor.keyboardShortcuts.commands.showAssets" as const,
148
+ handler: "onShowAssetsPanel" as const,
149
+ }),
150
+ ]
151
+ : []),
146
152
  shortcut({
147
153
  id: "toggle-ui",
148
154
  category: "view",
@@ -6,6 +6,8 @@ import { useAgentRouteState } from "@agent-native/core/client/navigation";
6
6
  import { useEffect } from "react";
7
7
  import { useLocation, useParams } from "react-router";
8
8
 
9
+ import { normalizeDesignLeftPanel } from "@/pages/design-editor/tool-state";
10
+
9
11
  export interface NavigationState {
10
12
  view: string;
11
13
  designId?: string;
@@ -120,12 +122,11 @@ function normalizeEditorView(
120
122
 
121
123
  function normalizeInspectorTab(
122
124
  value: unknown,
123
- ): "design" | "comments" | "tweaks" | "code" | "extensions" | undefined {
125
+ ): "design" | "comments" | "tweaks" | "code" | undefined {
124
126
  return value === "design" ||
125
127
  value === "comments" ||
126
128
  value === "tweaks" ||
127
- value === "code" ||
128
- value === "extensions"
129
+ value === "code"
129
130
  ? value
130
131
  : undefined;
131
132
  }
@@ -141,16 +142,7 @@ function normalizeLeftPanel(
141
142
  | "import"
142
143
  | "code"
143
144
  | undefined {
144
- if (value === "extensions") return "tools";
145
- return value === "file" ||
146
- value === "agent" ||
147
- value === "assets" ||
148
- value === "tools" ||
149
- value === "tokens" ||
150
- value === "import" ||
151
- value === "code"
152
- ? value
153
- : undefined;
145
+ return normalizeDesignLeftPanel(value);
154
146
  }
155
147
 
156
148
  function normalizeEditorMode(
@@ -176,9 +168,12 @@ export function editorPathFromCommand(cmd: NavigationState): string | null {
176
168
  const editorView = normalizeEditorView(cmd.editorView);
177
169
  if (editorView) params.set("view", editorView);
178
170
  if (editorView === "single") params.set("mode", cmd.mode ?? "interact");
179
- const inspectorTab = normalizeInspectorTab(cmd.inspectorTab ?? cmd.inspector);
171
+ const rawInspectorTab = cmd.inspectorTab ?? cmd.inspector;
172
+ const inspectorTab = normalizeInspectorTab(rawInspectorTab);
180
173
  if (inspectorTab) params.set("inspector", inspectorTab);
181
- const leftPanel = normalizeLeftPanel(cmd.leftPanel ?? cmd.panel);
174
+ const leftPanel = normalizeLeftPanel(
175
+ cmd.leftPanel ?? cmd.panel ?? rawInspectorTab,
176
+ );
182
177
  if (leftPanel) params.set("panel", leftPanel);
183
178
  const screen = cmd.fileId ?? cmd.screenId ?? cmd.filename ?? cmd.screen;
184
179
  if (screen) params.set("screen", screen);
@@ -201,10 +196,11 @@ export function editorCommandFromNavigate(
201
196
  ): DesignEditorCommand | null {
202
197
  if (cmd.view !== "editor" || !cmd.designId) return null;
203
198
  const editorView = normalizeEditorView(cmd.editorView);
204
- const inspectorTab = normalizeInspectorTab(cmd.inspectorTab ?? cmd.inspector);
199
+ const rawInspectorTab = cmd.inspectorTab ?? cmd.inspector;
200
+ const inspectorTab = normalizeInspectorTab(rawInspectorTab);
205
201
  const leftPanel =
206
202
  normalizeLeftPanel(cmd.leftPanel ?? cmd.panel) ??
207
- normalizeLeftPanel(cmd.inspectorTab ?? cmd.inspector);
203
+ normalizeLeftPanel(rawInspectorTab);
208
204
  const command: DesignEditorCommand = {
209
205
  designId: cmd.designId,
210
206
  issuedAt: Date.now(),
@@ -255,11 +251,12 @@ export function useNavigationState(enabled = true) {
255
251
  if (editorView) state.editorView = editorView;
256
252
  const mode = normalizeEditorMode(searchParams.get("mode"));
257
253
  if (mode) state.mode = mode;
258
- const inspectorTab = normalizeInspectorTab(
259
- searchParams.get("inspector"),
260
- );
254
+ const rawInspectorTab = searchParams.get("inspector");
255
+ const inspectorTab = normalizeInspectorTab(rawInspectorTab);
261
256
  if (inspectorTab) state.inspectorTab = inspectorTab;
262
- const leftPanel = normalizeLeftPanel(searchParams.get("panel"));
257
+ const leftPanel = normalizeLeftPanel(
258
+ searchParams.get("panel") ?? rawInspectorTab,
259
+ );
263
260
  if (leftPanel) state.leftPanel = leftPanel;
264
261
  const screen = searchParams.get("screen");
265
262
  if (screen) state.screen = screen;
@@ -1,11 +1,11 @@
1
1
  import { isStandaloneHttpUrl } from "@shared/html-content";
2
2
 
3
+ import { normalizeDesignLeftPanel } from "./tool-state";
3
4
  import {
4
5
  type DesignFile,
5
6
  type DesignLeftPanel,
6
7
  type DesignTool,
7
8
  type EditorMode,
8
- SHOW_DESIGN_CODE_LEFT_PANEL,
9
9
  } from "./types";
10
10
 
11
11
  // Definition moved to shared/ so the server write path and code-layer's
@@ -127,10 +127,7 @@ export function getDesignEditorStateUrlSearch(args: {
127
127
  mode?: EditorMode | null;
128
128
  }) {
129
129
  const params = new URLSearchParams(args.currentSearch);
130
- const leftPanel =
131
- args.leftPanel === "code" && !SHOW_DESIGN_CODE_LEFT_PANEL
132
- ? null
133
- : args.leftPanel;
130
+ const leftPanel = normalizeDesignLeftPanel(args.leftPanel) ?? null;
134
131
  params.set("view", args.viewMode);
135
132
  if (leftPanel && leftPanel !== "file") {
136
133
  params.set("panel", leftPanel);
@@ -1,7 +1,7 @@
1
1
  import type { CreationTool } from "@/components/design/design-canvas/creation";
2
2
 
3
3
  import {
4
- SHOW_DESIGN_CODE_LEFT_PANEL,
4
+ isDesignLeftPanelEnabled,
5
5
  type DesignLeftPanel,
6
6
  type DesignTool,
7
7
  type EditorMode,
@@ -10,18 +10,10 @@ import {
10
10
  export function normalizeDesignLeftPanel(
11
11
  value: unknown,
12
12
  ): DesignLeftPanel | undefined {
13
- if (value === "extensions") return "tools";
14
- if (value === "code") {
15
- return SHOW_DESIGN_CODE_LEFT_PANEL ? "code" : undefined;
13
+ if (value === "extensions") {
14
+ return isDesignLeftPanelEnabled("tools") ? "tools" : undefined;
16
15
  }
17
- return value === "file" ||
18
- value === "agent" ||
19
- value === "assets" ||
20
- value === "tools" ||
21
- value === "tokens" ||
22
- value === "import"
23
- ? value
24
- : undefined;
16
+ return isDesignLeftPanelEnabled(value) ? value : undefined;
25
17
  }
26
18
 
27
19
  export const MOVE_GROUP_TOOL_PRESENTATIONS = {
@@ -33,7 +33,44 @@ export type DesignLeftPanel =
33
33
  | "import"
34
34
  | "code";
35
35
 
36
- export const SHOW_DESIGN_CODE_LEFT_PANEL = true;
36
+ /**
37
+ * Keep the still-evolving Design editor side panels out of the default
38
+ * surface until their Figma parity work is ready. Flip this switch to expose
39
+ * Assets, Tools, Tokens, and Code together while iterating on them. The E2E
40
+ * harness sets the Vite flag so those existing advanced-surface tests keep
41
+ * exercising the gated panels.
42
+ */
43
+ const designSecondaryLeftPanelsSetting =
44
+ typeof import.meta !== "undefined"
45
+ ? (import.meta as { env?: Record<string, string> }).env
46
+ ?.VITE_SHOW_DESIGN_SECONDARY_LEFT_PANELS
47
+ : undefined;
48
+ export const SHOW_DESIGN_SECONDARY_LEFT_PANELS =
49
+ designSecondaryLeftPanelsSetting === "1" ||
50
+ designSecondaryLeftPanelsSetting === "true";
51
+ export const SHOW_DESIGN_CODE_LEFT_PANEL = SHOW_DESIGN_SECONDARY_LEFT_PANELS;
52
+
53
+ export function isDesignLeftPanelEnabled(
54
+ value: unknown,
55
+ ): value is DesignLeftPanel {
56
+ if (
57
+ value !== "file" &&
58
+ value !== "agent" &&
59
+ value !== "assets" &&
60
+ value !== "tools" &&
61
+ value !== "tokens" &&
62
+ value !== "import" &&
63
+ value !== "code"
64
+ ) {
65
+ return false;
66
+ }
67
+
68
+ if (value === "code") return SHOW_DESIGN_CODE_LEFT_PANEL;
69
+ if (value === "assets" || value === "tools" || value === "tokens") {
70
+ return SHOW_DESIGN_SECONDARY_LEFT_PANELS;
71
+ }
72
+ return true;
73
+ }
37
74
 
38
75
  /** Zoom percentage applied when entering single-screen (focused) editor mode. */
39
76
  export const FOCUSED_SCREEN_ZOOM = 100;
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: changed
3
+ date: 2026-08-18
4
+ ---
5
+
6
+ Advanced Design editor panels are hidden by default while they are refined.