@agent-native/core 0.101.10 → 0.101.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/a2a/client.ts +23 -1
  5. package/corpus/core/src/a2a/handlers.ts +65 -1
  6. package/corpus/core/src/a2a/server.ts +124 -0
  7. package/corpus/core/src/a2a/task-store.ts +253 -0
  8. package/corpus/core/src/a2a/types.ts +26 -0
  9. package/corpus/core/src/agent/production-agent.ts +5 -2
  10. package/corpus/core/src/coding-tools/run-code.ts +65 -12
  11. package/corpus/core/src/extensions/actions.ts +141 -7
  12. package/corpus/core/src/mcp/builtin-tools.ts +25 -1
  13. package/corpus/core/src/scripts/call-agent.ts +22 -3
  14. package/corpus/core/src/server/agent-chat-plugin.ts +86 -1
  15. package/corpus/core/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
  16. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +59 -0
  17. package/corpus/templates/analytics/app/components/layout/NewDashboardDialog.tsx +5 -2
  18. package/corpus/templates/analytics/server/lib/agent-chat-plan-mode.ts +13 -4
  19. package/corpus/templates/analytics/server/lib/real-data-actions.ts +63 -1
  20. package/corpus/templates/analytics/server/plugins/agent-chat.ts +30 -0
  21. package/corpus/templates/content/.agents/skills/document-editing/SKILL.md +23 -0
  22. package/corpus/templates/content/app/components/editor/MathRenderer.tsx +43 -0
  23. package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +260 -4
  24. package/corpus/templates/content/app/components/editor/extensions/NotionExtensions.tsx +78 -0
  25. package/corpus/templates/content/app/global.css +53 -0
  26. package/corpus/templates/content/app/i18n/zh-TW.ts +13 -0
  27. package/corpus/templates/content/app/i18n-data.ts +163 -0
  28. package/corpus/templates/content/app/root.tsx +2 -0
  29. package/corpus/templates/content/changelog/2026-07-14-latex-equations-render-in-documents-public-pages-and-exports.md +6 -0
  30. package/corpus/templates/content/package.json +1 -0
  31. package/corpus/templates/content/shared/document-export.ts +296 -25
  32. package/corpus/templates/content/shared/inline-math.ts +128 -0
  33. package/corpus/templates/content/shared/math-rendering.ts +50 -0
  34. package/corpus/templates/content/shared/nfm.ts +17 -14
  35. package/corpus/templates/content/vite.config.ts +0 -1
  36. package/corpus/templates/mail/.agents/skills/email-drafts/SKILL.md +17 -0
  37. package/corpus/templates/mail/AGENTS.md +21 -19
  38. package/corpus/templates/mail/actions/create-attachment-upload.ts +49 -0
  39. package/corpus/templates/mail/changelog/2026-07-14-connected-agents-can-now-securely-upload-local-files-for-mai.md +6 -0
  40. package/corpus/templates/mail/server/handlers/media.ts +84 -61
  41. package/corpus/templates/mail/server/lib/attachment-upload-ticket.ts +234 -0
  42. package/corpus/templates/mail/server/lib/mail-connector-catalog.ts +8 -4
  43. package/corpus/templates/mail/server/lib/media-upload.ts +114 -0
  44. package/corpus/templates/mail/server/plugins/auth.ts +9 -1
  45. package/corpus/templates/mail/server/routes/api/media/attachment-upload/[uploadId].put.ts +1 -0
  46. package/dist/a2a/client.d.ts +5 -1
  47. package/dist/a2a/client.d.ts.map +1 -1
  48. package/dist/a2a/client.js +18 -1
  49. package/dist/a2a/client.js.map +1 -1
  50. package/dist/a2a/handlers.d.ts.map +1 -1
  51. package/dist/a2a/handlers.js +57 -8
  52. package/dist/a2a/handlers.js.map +1 -1
  53. package/dist/a2a/server.d.ts.map +1 -1
  54. package/dist/a2a/server.js +87 -1
  55. package/dist/a2a/server.js.map +1 -1
  56. package/dist/a2a/task-store.d.ts +27 -0
  57. package/dist/a2a/task-store.d.ts.map +1 -1
  58. package/dist/a2a/task-store.js +216 -0
  59. package/dist/a2a/task-store.js.map +1 -1
  60. package/dist/a2a/types.d.ts +24 -0
  61. package/dist/a2a/types.d.ts.map +1 -1
  62. package/dist/a2a/types.js.map +1 -1
  63. package/dist/agent/production-agent.d.ts +1 -0
  64. package/dist/agent/production-agent.d.ts.map +1 -1
  65. package/dist/agent/production-agent.js +5 -2
  66. package/dist/agent/production-agent.js.map +1 -1
  67. package/dist/coding-tools/run-code.js +64 -11
  68. package/dist/coding-tools/run-code.js.map +1 -1
  69. package/dist/collab/struct-routes.d.ts +1 -1
  70. package/dist/extensions/actions.d.ts.map +1 -1
  71. package/dist/extensions/actions.js +129 -7
  72. package/dist/extensions/actions.js.map +1 -1
  73. package/dist/mcp/builtin-tools.d.ts.map +1 -1
  74. package/dist/mcp/builtin-tools.js +22 -3
  75. package/dist/mcp/builtin-tools.js.map +1 -1
  76. package/dist/notifications/routes.d.ts +2 -2
  77. package/dist/observability/routes.d.ts +5 -5
  78. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  79. package/dist/resources/handlers.d.ts +3 -3
  80. package/dist/scripts/call-agent.d.ts +1 -1
  81. package/dist/scripts/call-agent.d.ts.map +1 -1
  82. package/dist/scripts/call-agent.js +19 -1
  83. package/dist/scripts/call-agent.js.map +1 -1
  84. package/dist/secrets/routes.d.ts +9 -9
  85. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  86. package/dist/server/agent-chat-plugin.js +71 -2
  87. package/dist/server/agent-chat-plugin.js.map +1 -1
  88. package/dist/server/transcribe-voice.d.ts +1 -1
  89. package/dist/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
  90. package/package.json +1 -1
  91. package/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
@@ -16,23 +16,32 @@ export const INITIAL_TOOL_NAMES = [
16
16
  "list-analyses",
17
17
  "get-analysis",
18
18
  "save-analysis",
19
+ // Dashboard/extension INSPECTION stays on the initial surface so a
20
+ // template-clone request can resolve and inspect the source on the first
21
+ // turn. The MUTATING writers (update-dashboard, mutate-dashboard,
22
+ // create-extension, update-extension) are intentionally left off: the
23
+ // dashboard-construction final-response guard retries with
24
+ // `expandToolSurface: true` (see server/plugins/agent-chat.ts), which opens
25
+ // the full run registry exactly when a save is needed, and tool-search can
26
+ // surface them otherwise. This keeps the first-request surface under the
27
+ // 40-tool ceiling enforced by scripts/guard-agent-chat-context.ts.
19
28
  "get-sql-dashboard",
20
- "mutate-dashboard",
29
+ "list-sql-dashboards",
30
+ "list-dashboard-templates",
31
+ "list-extensions",
32
+ "get-extension",
21
33
  "generate-chart",
22
34
  "query-agent-native-analytics",
23
35
  "bigquery",
24
36
  "search-bigquery-schema",
25
- "bigquery-table-info",
26
37
  "provider-api-catalog",
27
38
  "provider-api-docs",
28
39
  "provider-api-request",
29
40
  "run-code",
30
41
  "get-code-execution",
31
42
  "provider-corpus-job",
32
- "provider-corpus-jobs",
33
43
  "query-staged-dataset",
34
44
  "list-staged-datasets",
35
- "delete-staged-dataset",
36
45
  "account-deep-dive",
37
46
  "hubspot-deals",
38
47
  "hubspot-records",
@@ -57,6 +57,25 @@ export const CORPUS_SOURCE_ACTIONS = new Set([
57
57
 
58
58
  export const CORPUS_REDUCTION_ACTIONS = new Set(["run-code"]);
59
59
 
60
+ // Inspecting or cloning an existing dashboard/extension template is
61
+ // construction progress, not a metric query. These do not satisfy
62
+ // hasDataQueryAttempt, but they should stop the guard from steering a
63
+ // template-clone turn into "connect a missing source". Deliberately limited
64
+ // to read/inspection actions: update-dashboard/mutate-dashboard/
65
+ // compose-dashboard/install-dashboard-template/create-extension/
66
+ // update-extension can all author brand-new SQL or extension content, so
67
+ // calling one of those alone is not proof the turn actually inspected a
68
+ // template rather than inventing it from scratch. If the tool run also
69
+ // includes one of these read actions, the bypass still applies even when an
70
+ // authoring/save action ran alongside it.
71
+ export const DASHBOARD_CONSTRUCTION_ACTIONS = new Set([
72
+ "get-sql-dashboard",
73
+ "list-sql-dashboards",
74
+ "list-dashboard-templates",
75
+ "list-extensions",
76
+ "get-extension",
77
+ ]);
78
+
60
79
  const RUN_CODE_BRIDGE_TOOLS_USED = /^bridgeToolsUsed:\s*(.+)$/im;
61
80
 
62
81
  const MCP_DATA_SOURCE_TOKENS = [
@@ -97,6 +116,41 @@ function isDataQueryActionName(name: string): boolean {
97
116
  return DATA_QUERY_ACTIONS.has(normalizeActionToolName(name));
98
117
  }
99
118
 
119
+ function isDashboardConstructionActionName(name: string): boolean {
120
+ return DASHBOARD_CONSTRUCTION_ACTIONS.has(normalizeActionToolName(name));
121
+ }
122
+
123
+ // "Build/clone/template" language targeting a dashboard/extension/panel is
124
+ // dashboard construction, distinct from an analytics-result question. Turns
125
+ // like this may inspect and clone a template without running a metric query.
126
+ const DASHBOARD_CONSTRUCTION_INTENT_TERMS =
127
+ /\b(build|create|make|clone|copy|duplicate|adapt|template|based (?:off|on)|using .{1,80}? as a template)\b/i;
128
+
129
+ const DASHBOARD_CONSTRUCTION_TARGET_TERMS =
130
+ /\b(dashboard|extension|panel|widget)\b/i;
131
+
132
+ export function looksLikeDashboardConstructionRequest(text: string): boolean {
133
+ const requestText = stripInjectedAnalyticsGuardContext(text);
134
+ const lower = requestText.toLowerCase();
135
+ if (!lower) return false;
136
+ const wantsBuild = DASHBOARD_CONSTRUCTION_INTENT_TERMS.test(lower);
137
+ const targetsDashboard =
138
+ DASHBOARD_CONSTRUCTION_TARGET_TERMS.test(lower) ||
139
+ lower.includes(REAL_DATA_REQUIRED_MARKER.toLowerCase());
140
+ return wantsBuild && targetsDashboard;
141
+ }
142
+
143
+ export function hasDashboardConstructionAttempt(
144
+ toolResults:
145
+ | Array<{ name?: string; isError?: boolean; content?: string }>
146
+ | undefined,
147
+ ): boolean {
148
+ return (toolResults ?? []).some((result) => {
149
+ if (result.isError) return false;
150
+ return isDashboardConstructionActionName(String(result.name ?? ""));
151
+ });
152
+ }
153
+
100
154
  function isCorpusSourceActionName(name: string): boolean {
101
155
  return CORPUS_SOURCE_ACTIONS.has(normalizeActionToolName(name));
102
156
  }
@@ -253,7 +307,15 @@ export function looksLikeAnalyticsDataRequest(text: string): boolean {
253
307
  }
254
308
 
255
309
  const UNSUPPORTED_RESULT_CLAIM =
256
- /(?:\b\d[\d,.]*(?:\.\d+)?\s*(?:%|percent|users?|customers?|accounts?|sessions?|events?|deals?|tickets?|issues?|calls?|messages?|signups?|pageviews?)\b|\$\s*\d|\b(?:data|query|results?)\s+(?:shows?|showed|indicates?|returned|found)\b|\b(?:i found|the top|the bottom|highest|lowest|increased|decreased|grew|declined|converted|churned|retained|averaged|total(?:ed)?|count(?:ed)?)\b)/i;
310
+ /(?:\b\d[\d,.]*(?:\.\d+)?\s*(?:%|percent|users?|customers?|accounts?|sessions?|events?|deals?|tickets?|issues?|calls?|messages?|signups?|pageviews?)\b|\$\s*\d|\b(?:zero|no|none)\s+(?:users?|customers?|accounts?|sessions?|events?|deals?|tickets?|issues?|calls?|messages?|signups?|pageviews?)\b|\b(?:data|query|results?)\s+(?:shows?|showed|indicates?|returned|found)\b|\b(?:i found|the top|the bottom|highest|lowest|increased|decreased|grew|declined|converted|churned|retained|averaged|total(?:ed)?|count(?:ed)?)\b)/i;
311
+
312
+ // Reuse the same broad unsupported-result-claim vocabulary that gates
313
+ // isSafeNoDataAnalyticsResponse so a dashboard-construction turn cannot
314
+ // bypass the no-query fallback just by avoiding the narrower set of units a
315
+ // dashboard-specific regex would otherwise miss (e.g. "signups", "accounts").
316
+ export function draftClaimsAnalyticsMetrics(text: string): boolean {
317
+ return UNSUPPORTED_RESULT_CLAIM.test(String(text ?? "").trim());
318
+ }
257
319
 
258
320
  export const GENERIC_NO_DATA_FALLBACK_MESSAGE =
259
321
  "I can't provide a grounded analytics result yet because no real data-source query ran successfully. Tell me which source to use or connect the missing source, and I'll run it before giving numbers or source-record conclusions.";
@@ -14,7 +14,9 @@ import {
14
14
  import { ANALYTICS_CONNECTOR_CATALOG } from "../lib/analytics-connector-catalog";
15
15
  import { credentialProviderConfigs } from "../lib/credential-keys";
16
16
  import {
17
+ draftClaimsAnalyticsMetrics,
17
18
  failedDataQueryAttemptMessage,
19
+ hasDashboardConstructionAttempt,
18
20
  hasExplicitPartialDisclosure,
19
21
  hasFailedCorpusWorkflowEvidence,
20
22
  hasDataQueryAttempt,
@@ -23,6 +25,7 @@ import {
23
25
  isSafeNoDataAnalyticsResponse,
24
26
  hasOverstatedCoverageConfidenceClaim,
25
27
  looksLikeCoverageSensitiveAnalyticsRequest,
28
+ looksLikeDashboardConstructionRequest,
26
29
  looksLikeStrongCoverageClaim,
27
30
  looksLikeAnalyticsDataRequest,
28
31
  needsCorpusWorkflowForCoverageSensitiveRequest,
@@ -522,6 +525,33 @@ export function realDataFinalGuard(
522
525
  "I can't make a confident exhaustive analytics claim yet because part of the source evidence was aborted, truncated, or still paginated. I need to recover the missing coverage or state the answer as partial with the inspected sample size.",
523
526
  };
524
527
  }
528
+ // Dashboard construction/template-clone turns may inspect and clone an
529
+ // existing dashboard/extension without running a metric query, as long as
530
+ // the draft does not invent numbers. Check this before the generic
531
+ // "no data query ran" fallback so a template-based extension clone is not
532
+ // treated the same as an unanswerable analytics-result question.
533
+ if (
534
+ looksLikeDashboardConstructionRequest(userText) &&
535
+ !draftClaimsAnalyticsMetrics(context.text)
536
+ ) {
537
+ if (
538
+ hasDashboardConstructionAttempt(context.toolResults) ||
539
+ isSafeNoDataAnalyticsResponse(context.text)
540
+ ) {
541
+ return null;
542
+ }
543
+ return {
544
+ retryMessage:
545
+ 'This is a dashboard construction/template-clone request. Resolve the named template\'s id (use `list-sql-dashboards` if you only have a title) and call `get-sql-dashboard` with `includeConfig: true` first. If its panels are `chartType: "extension"`, use `get-extension` then `create-extension` to clone/adapt it, then `update-dashboard` to save the new dashboard. Do not invent SQL panels for an extension-backed template. Ask one clarifying filter question if needed. Only run a data-source query before presenting numbers or authoring invented SQL.',
546
+ fallbackMessage:
547
+ "I need to inspect the template dashboard (and its extension, if it uses one) before creating the new one. Tell me the template dashboard name, or confirm the org/account filter, and I'll clone it without inventing metrics.",
548
+ // list-sql-dashboards/list-dashboard-templates are on the initial
549
+ // surface, but expand anyway so a corrective retry can always reach
550
+ // the lookup/inspection tools this message asks for.
551
+ expandToolSurface: true,
552
+ };
553
+ }
554
+
525
555
  if (dataQueryAttempted) return null;
526
556
  if (isSafeNoDataAnalyticsResponse(context.text)) {
527
557
  if (firstPartySourceShouldBeTried) {
@@ -145,6 +145,29 @@ Always run this after any document modification to update the sidebar.
145
145
 
146
146
  Documents use **markdown** for content. The editor renders markdown in real time.
147
147
 
148
+ ### Math
149
+
150
+ Content renders LaTeX with KaTeX while preserving the source in NFM. Use the
151
+ canonical Content delimiters when an agent creates or edits math:
152
+
153
+ ```md
154
+ Inline math: The relationship is $`E = mc^2`$ in this example.
155
+
156
+ Block math:
157
+
158
+ $$
159
+ \int_0^1 x^2 dx = \frac{1}{3}
160
+ $$
161
+ ```
162
+
163
+ - Inline math uses ``$`...`$``. Do not substitute ordinary `$...$`; dollar
164
+ amounts in prose are intentionally not interpreted as equations.
165
+ - Block equations use `$$` on their own lines before and after the LaTeX.
166
+ - Keep the raw LaTeX intact when editing around an equation. Invalid or
167
+ unsupported expressions remain visible as source so they can be repaired.
168
+ - Markdown exports preserve these delimiters. HTML and PDF-ready exports render
169
+ the equation.
170
+
148
171
  ## Parent-Child Hierarchy
149
172
 
150
173
  Documents form a tree via `parent_id`:
@@ -0,0 +1,43 @@
1
+ import { renderMathToHtml } from "@shared/math-rendering";
2
+ import { useMemo } from "react";
3
+
4
+ interface MathRendererProps {
5
+ latex: string;
6
+ displayMode: boolean;
7
+ }
8
+
9
+ export function MathRenderer({ latex, displayMode }: MathRendererProps) {
10
+ const rendered = useMemo(
11
+ () => renderMathToHtml(latex, displayMode),
12
+ [displayMode, latex],
13
+ );
14
+
15
+ if (!rendered.ok) {
16
+ return (
17
+ <code
18
+ className={
19
+ displayMode
20
+ ? "content-math-error content-math-error--block"
21
+ : "content-math-error content-math-error--inline"
22
+ }
23
+ title={rendered.error}
24
+ >
25
+ {latex || "Empty equation"}
26
+ </code>
27
+ );
28
+ }
29
+
30
+ return displayMode ? (
31
+ <span
32
+ className="content-math content-math--block"
33
+ contentEditable={false}
34
+ dangerouslySetInnerHTML={{ __html: rendered.html }}
35
+ />
36
+ ) : (
37
+ <span
38
+ className="content-math content-math--inline"
39
+ contentEditable={false}
40
+ dangerouslySetInnerHTML={{ __html: rendered.html }}
41
+ />
42
+ );
43
+ }
@@ -1,8 +1,10 @@
1
1
  import { useSendToAgentChat, useT } from "@agent-native/core/client";
2
2
  import type { CreateInlineDatabaseResponse } from "@shared/api";
3
+ import { renderMathToHtml } from "@shared/math-rendering";
3
4
  import { collapseExactRepeatedNfm, docToNfm } from "@shared/nfm";
4
5
  import { serializeRegistryBlockToMdx } from "@shared/nfm-registry";
5
6
  import {
7
+ IconCheck,
6
8
  IconTypography,
7
9
  IconH1,
8
10
  IconH2,
@@ -23,6 +25,8 @@ import {
23
25
  IconFileText,
24
26
  IconDatabase,
25
27
  IconVideo,
28
+ IconMathFunction,
29
+ IconSquareRoot2,
26
30
  } from "@tabler/icons-react";
27
31
  import { Editor } from "@tiptap/react";
28
32
  import { useState, useEffect, useCallback, useRef, useMemo } from "react";
@@ -30,6 +34,7 @@ import { useNavigate } from "react-router";
30
34
  import { toast } from "sonner";
31
35
 
32
36
  import { contentBlockRegistry } from "@/blocks/contentBlockRegistry";
37
+ import { Button } from "@/components/ui/button";
33
38
  import {
34
39
  Popover,
35
40
  PopoverContent,
@@ -42,6 +47,7 @@ import { localContentComponents } from "@/local-components";
42
47
 
43
48
  import { focusMostRecentEmptyToggleSummary } from "./extensions/NotionExtensions";
44
49
  import { buildLocalComponentSlashItems } from "./localComponentSlashItems";
50
+ import { MathRenderer } from "./MathRenderer";
45
51
  import { buildRegistrySlashItems } from "./registrySlashItems";
46
52
 
47
53
  interface SlashCommandMenuProps {
@@ -64,6 +70,13 @@ interface EditorMenuPosition {
64
70
  left: number;
65
71
  }
66
72
 
73
+ interface EquationDraft {
74
+ displayMode: boolean;
75
+ insertionRange: { from: number; to: number };
76
+ slashRange: { from: number; to: number };
77
+ position: EditorMenuPosition;
78
+ }
79
+
67
80
  interface CommandItem {
68
81
  title: string;
69
82
  description: string;
@@ -178,6 +191,47 @@ export function insertInlineDatabaseBlock(
178
191
  : chain.insertContent(content).run();
179
192
  }
180
193
 
194
+ export function equationNodeContent(latex: string, displayMode: boolean) {
195
+ return displayMode
196
+ ? {
197
+ type: "notionBlockAtom",
198
+ attrs: { tagName: "equation", attrsJson: "{}", label: latex },
199
+ }
200
+ : {
201
+ type: "notionInlineAtom",
202
+ attrs: { tagName: "math", attrsJson: "{}", label: latex },
203
+ };
204
+ }
205
+
206
+ export function insertEquation(
207
+ editor: Editor,
208
+ latex: string,
209
+ displayMode: boolean,
210
+ range: { from: number; to: number },
211
+ ) {
212
+ const content = equationNodeContent(latex, displayMode);
213
+ return editor
214
+ .chain()
215
+ .focus()
216
+ .insertContentAt(
217
+ range,
218
+ displayMode ? [content, { type: "paragraph" }] : content,
219
+ )
220
+ .run();
221
+ }
222
+
223
+ export function getEquationInsertionRange(
224
+ editor: Editor,
225
+ slashRange: { from: number; to: number },
226
+ displayMode: boolean,
227
+ ) {
228
+ if (!displayMode) return slashRange;
229
+ const resolved = editor.state.doc.resolve(slashRange.from);
230
+ return resolved.parent.isTextblock
231
+ ? { from: resolved.before(), to: resolved.after() }
232
+ : slashRange;
233
+ }
234
+
181
235
  const commands: CommandTemplate[] = [
182
236
  {
183
237
  titleKey: "editor.slash.text",
@@ -460,6 +514,16 @@ export function SlashCommandMenu({
460
514
  );
461
515
  const generateTextareaRef = useRef<HTMLTextAreaElement>(null);
462
516
 
517
+ const [equationDraft, setEquationDraft] = useState<EquationDraft | null>(
518
+ null,
519
+ );
520
+ const [equationLatex, setEquationLatex] = useState("");
521
+ const equationInputRef = useRef<HTMLTextAreaElement>(null);
522
+ const equationResult = useMemo(
523
+ () => renderMathToHtml(equationLatex, equationDraft?.displayMode ?? false),
524
+ [equationDraft?.displayMode, equationLatex],
525
+ );
526
+
463
527
  const submitGeneratePrompt = useCallback(
464
528
  (prompt: string) => {
465
529
  const trimmed = prompt.trim();
@@ -684,6 +748,80 @@ export function SlashCommandMenu({
684
748
  },
685
749
  };
686
750
 
751
+ const openEquationComposer = useCallback(
752
+ (displayMode: boolean, slashRange: { from: number; to: number } | null) => {
753
+ const menuPosition = position ?? getSelectionMenuPosition();
754
+ if (!slashRange || !menuPosition) return false;
755
+ setEquationLatex("");
756
+ setEquationDraft({
757
+ displayMode,
758
+ slashRange,
759
+ insertionRange: getEquationInsertionRange(
760
+ editor,
761
+ slashRange,
762
+ displayMode,
763
+ ),
764
+ position: menuPosition,
765
+ });
766
+ setTimeout(() => equationInputRef.current?.focus(), 0);
767
+ return true;
768
+ },
769
+ [editor, getSelectionMenuPosition, position],
770
+ );
771
+
772
+ const cancelEquation = useCallback(() => {
773
+ const draft = equationDraft;
774
+ setEquationDraft(null);
775
+ setEquationLatex("");
776
+ if (draft) {
777
+ editor.chain().focus().deleteRange(draft.slashRange).run();
778
+ }
779
+ }, [editor, equationDraft]);
780
+
781
+ const submitEquation = useCallback(() => {
782
+ if (!equationDraft || !equationResult.ok) return;
783
+ const latex = equationLatex.trim();
784
+ const { displayMode, insertionRange } = equationDraft;
785
+ setEquationDraft(null);
786
+ setEquationLatex("");
787
+ const inserted = insertEquation(editor, latex, displayMode, insertionRange);
788
+ if (!inserted) {
789
+ toast.error(t("editor.slash.equationInsertFailed"));
790
+ return;
791
+ }
792
+ void onDraftCommitted?.();
793
+ }, [
794
+ editor,
795
+ equationDraft,
796
+ equationLatex,
797
+ equationResult.ok,
798
+ onDraftCommitted,
799
+ t,
800
+ ]);
801
+
802
+ const equationCommands: CommandItem[] = isTurnInto
803
+ ? []
804
+ : [
805
+ {
806
+ title: t("editor.slash.blockEquation"),
807
+ description: t("editor.slash.blockEquationDescription"),
808
+ searchText: "latex katex math formula",
809
+ icon: IconMathFunction,
810
+ preserveSlashRange: true,
811
+ action: (_editor, { slashRange }) =>
812
+ openEquationComposer(true, slashRange),
813
+ },
814
+ {
815
+ title: t("editor.slash.inlineEquation"),
816
+ description: t("editor.slash.inlineEquationDescription"),
817
+ searchText: "latex katex math formula",
818
+ icon: IconSquareRoot2,
819
+ preserveSlashRange: true,
820
+ action: (_editor, { slashRange }) =>
821
+ openEquationComposer(false, slashRange),
822
+ },
823
+ ];
824
+
687
825
  // Registry-derived block items (the shared dev-doc / OpenAPI / structured
688
826
  // library). Filtered to Notion-compatible specs when the document is linked to
689
827
  // a Notion page. "Turn into" only converts the current text block, so these
@@ -713,9 +851,10 @@ export function SlashCommandMenu({
713
851
  title: t(cmd.titleKey),
714
852
  description: t(cmd.descriptionKey),
715
853
  });
716
- const blockCommands = (isTurnInto ? turnIntoCommands : commands).map(
717
- localizeCommand,
718
- );
854
+ const blockCommands = [
855
+ ...(isTurnInto ? turnIntoCommands : commands).map(localizeCommand),
856
+ ...equationCommands,
857
+ ];
719
858
  const pageCommands = isTurnInto ? [] : [pageCommand, databaseCommand];
720
859
  const mediaCommands = isTurnInto
721
860
  ? []
@@ -1042,7 +1181,7 @@ export function SlashCommandMenu({
1042
1181
  align="start"
1043
1182
  side="bottom"
1044
1183
  className="w-[calc(100vw-2rem)] max-w-80 rounded-xl p-0"
1045
- onOpenAutoFocus={(e) => {
1184
+ onOpenAutoFocus={(e: Event) => {
1046
1185
  e.preventDefault();
1047
1186
  generateTextareaRef.current?.focus();
1048
1187
  }}
@@ -1086,6 +1225,123 @@ export function SlashCommandMenu({
1086
1225
  </PopoverContent>
1087
1226
  </Popover>
1088
1227
  )}
1228
+
1229
+ {equationDraft && (
1230
+ <Popover
1231
+ open
1232
+ onOpenChange={(open: boolean) => {
1233
+ if (!open) cancelEquation();
1234
+ }}
1235
+ >
1236
+ <PopoverTrigger asChild>
1237
+ <span
1238
+ className="pointer-events-none absolute size-0"
1239
+ style={{
1240
+ top: equationDraft.position.top,
1241
+ left: Math.min(equationDraft.position.left, 16),
1242
+ }}
1243
+ />
1244
+ </PopoverTrigger>
1245
+ <PopoverContent
1246
+ align="start"
1247
+ side="bottom"
1248
+ className="w-[calc(100vw-2rem)] max-w-md rounded-xl p-0"
1249
+ onOpenAutoFocus={(event: Event) => {
1250
+ event.preventDefault();
1251
+ equationInputRef.current?.focus();
1252
+ }}
1253
+ >
1254
+ <div className="p-4">
1255
+ <div className="flex items-center gap-2 text-sm font-semibold">
1256
+ {equationDraft.displayMode ? (
1257
+ <IconMathFunction
1258
+ className="text-muted-foreground"
1259
+ size={16}
1260
+ />
1261
+ ) : (
1262
+ <IconSquareRoot2
1263
+ className="text-muted-foreground"
1264
+ size={16}
1265
+ />
1266
+ )}
1267
+ {equationDraft.displayMode
1268
+ ? t("editor.slash.blockEquation")
1269
+ : t("editor.slash.inlineEquation")}
1270
+ </div>
1271
+ <textarea
1272
+ ref={equationInputRef}
1273
+ value={equationLatex}
1274
+ onChange={(event) => setEquationLatex(event.target.value)}
1275
+ onKeyDown={(event) => {
1276
+ if (
1277
+ event.key === "Enter" &&
1278
+ (event.metaKey || event.ctrlKey) &&
1279
+ equationResult.ok
1280
+ ) {
1281
+ event.preventDefault();
1282
+ submitEquation();
1283
+ }
1284
+ if (event.key === "Escape") {
1285
+ event.preventDefault();
1286
+ cancelEquation();
1287
+ }
1288
+ }}
1289
+ rows={equationDraft.displayMode ? 3 : 2}
1290
+ placeholder={t("editor.slash.equationPlaceholder")}
1291
+ aria-label={t("editor.slash.equationInputLabel")}
1292
+ aria-invalid={equationLatex.length > 0 && !equationResult.ok}
1293
+ aria-describedby="equation-preview-status"
1294
+ className="mt-3 w-full resize-y rounded-lg border border-input bg-background px-3 py-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
1295
+ />
1296
+ <div className="mt-3 min-h-20 rounded-lg border border-border bg-muted/30 p-3">
1297
+ <div className="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
1298
+ {t("editor.slash.equationPreview")}
1299
+ </div>
1300
+ <div className="flex min-h-9 items-center justify-center overflow-x-auto text-foreground">
1301
+ {equationResult.ok ? (
1302
+ <MathRenderer
1303
+ latex={equationLatex}
1304
+ displayMode={equationDraft.displayMode}
1305
+ />
1306
+ ) : (
1307
+ <span className="text-sm text-muted-foreground">
1308
+ {equationLatex
1309
+ ? t("editor.slash.equationNeedsRepair")
1310
+ : t("editor.slash.equationPreviewEmpty")}
1311
+ </span>
1312
+ )}
1313
+ </div>
1314
+ </div>
1315
+ <p
1316
+ id="equation-preview-status"
1317
+ className={cn(
1318
+ "mt-2 min-h-5 text-xs",
1319
+ equationLatex && !equationResult.ok
1320
+ ? "text-destructive"
1321
+ : "text-muted-foreground",
1322
+ )}
1323
+ >
1324
+ {equationLatex && !equationResult.ok
1325
+ ? equationResult.error
1326
+ : t("editor.slash.equationSubmitHint")}
1327
+ </p>
1328
+ </div>
1329
+ <div className="flex items-center justify-end gap-2 border-t border-border px-4 py-3">
1330
+ <Button variant="ghost" size="sm" onClick={cancelEquation}>
1331
+ {t("editor.slash.cancelEquation")}
1332
+ </Button>
1333
+ <Button
1334
+ size="sm"
1335
+ onClick={submitEquation}
1336
+ disabled={!equationResult.ok}
1337
+ >
1338
+ <IconCheck />
1339
+ {t("editor.slash.insertEquation")}
1340
+ </Button>
1341
+ </div>
1342
+ </PopoverContent>
1343
+ </Popover>
1344
+ )}
1089
1345
  </>
1090
1346
  );
1091
1347
  }