@agent-native/core 0.164.12 → 0.164.17

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 (90) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/netlify.toml +4 -0
  3. package/corpus/templates/clips/actions/request-transcript.ts +10 -6
  4. package/corpus/templates/content/actions/_database-source-utils.ts +25 -2
  5. package/corpus/templates/content/actions/_database-utils.ts +2 -0
  6. package/corpus/templates/content/actions/_local-folder-source.ts +186 -2
  7. package/corpus/templates/content/actions/connect-local-folder-source.ts +23 -8
  8. package/corpus/templates/content/actions/disconnect-local-folder-source.ts +3 -3
  9. package/corpus/templates/content/actions/resolve-local-folder-conflict.ts +2 -2
  10. package/corpus/templates/content/actions/sync-local-folder-source.ts +130 -12
  11. package/corpus/templates/content/app/components/LocalFolderLiveSync.tsx +77 -0
  12. package/corpus/templates/content/app/components/editor/BubbleToolbar.tsx +298 -3
  13. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +354 -8
  14. package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +23 -1
  15. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +81 -1
  16. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +36 -0
  17. package/corpus/templates/content/app/components/editor/extensions/NotionExtensions.tsx +18 -4
  18. package/corpus/templates/content/app/components/editor/local-source-write-state.ts +52 -0
  19. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +244 -6
  20. package/corpus/templates/content/app/components/sidebar/local-source-hierarchy.ts +189 -0
  21. package/corpus/templates/content/app/hooks/use-create-page.ts +26 -4
  22. package/corpus/templates/content/app/hooks/use-documents.ts +83 -0
  23. package/corpus/templates/content/app/i18n-data.ts +239 -0
  24. package/corpus/templates/content/app/lib/desktop-content-files.ts +57 -1
  25. package/corpus/templates/content/app/lib/local-content-source-files.ts +210 -34
  26. package/corpus/templates/content/app/lib/local-folder-live-sync.ts +191 -0
  27. package/corpus/templates/content/app/root.tsx +2 -1
  28. package/corpus/templates/content/app/routes/_app.local-files.tsx +103 -38
  29. package/corpus/templates/content/docs/product/capabilities/content.source.file-folder.md +2 -2
  30. package/corpus/templates/content/docs/product/features/content.feature.bring-your-local-work.md +3 -3
  31. package/corpus/templates/content/docs/product/roadmap.md +2 -2
  32. package/corpus/templates/content/docs/solutions/2026-08-18-colored-text-highlighting-shape.md +693 -0
  33. package/corpus/templates/content/docs/solutions/2026-08-18-content-navigation-coherence-shape.md +482 -0
  34. package/corpus/templates/content/package.json +1 -0
  35. package/corpus/templates/content/shared/api.ts +25 -0
  36. package/corpus/templates/content/shared/nfm.ts +14 -3
  37. package/corpus/templates/factory/AGENTS.md +24 -29
  38. package/corpus/templates/factory/actions/get-slack-feedback-context.ts +8 -28
  39. package/corpus/templates/factory/actions/get-triage-config.ts +1 -0
  40. package/corpus/templates/factory/actions/govern-agent-native-pull-request.ts +67 -59
  41. package/corpus/templates/factory/actions/list-triage-items.ts +6 -1
  42. package/corpus/templates/factory/actions/reconcile-triage-run.ts +7 -1
  43. package/corpus/templates/factory/actions/save-triage-config.ts +27 -7
  44. package/corpus/templates/factory/actions/start-builder-for-item.ts +113 -31
  45. package/corpus/templates/factory/actions/view-screen.ts +12 -0
  46. package/corpus/templates/factory/app/components/factory/FactorySettingsView.tsx +457 -0
  47. package/corpus/templates/factory/app/components/factory/FactoryWorkspaceActions.tsx +53 -0
  48. package/corpus/templates/factory/app/components/layout/Layout.tsx +2 -0
  49. package/corpus/templates/factory/app/components/layout/Sidebar.tsx +3 -1
  50. package/corpus/templates/factory/app/hooks/use-navigation-state.ts +10 -7
  51. package/corpus/templates/factory/app/i18n/en-US.ts +3 -0
  52. package/corpus/templates/factory/app/routes/agents.tsx +22 -1
  53. package/corpus/templates/factory/app/routes/factory-settings.tsx +34 -0
  54. package/corpus/templates/factory/app/routes/factory.tsx +33 -323
  55. package/corpus/templates/factory/server/connectors/slack.ts +62 -0
  56. package/corpus/templates/factory/server/db/schema.ts +1 -0
  57. package/corpus/templates/factory/server/lib/slack-feedback-prompt.ts +30 -0
  58. package/corpus/templates/factory/server/plugins/agent-chat.ts +9 -8
  59. package/corpus/templates/factory/server/plugins/factory-migrations.ts +7 -0
  60. package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +43 -25
  61. package/corpus/templates/factory/server/triage/ai-services-git.ts +2 -0
  62. package/corpus/templates/factory/server/triage/pr-monitor.ts +2 -1
  63. package/corpus/templates/factory/server/triage/pr-policy.ts +197 -19
  64. package/corpus/templates/factory/server/triage/review-skill-alignment.ts +127 -0
  65. package/corpus/templates/factory/server/triage/slack-client.ts +79 -6
  66. package/dist/app-config/auth.d.ts +4 -0
  67. package/dist/app-config/auth.js +7 -0
  68. package/dist/app-config/schema.d.ts +3 -0
  69. package/dist/app-config/schema.js +2 -0
  70. package/dist/cli/code-agent-executor.js +6 -1
  71. package/dist/cli/code-agent-mcp-config.d.ts +2 -2
  72. package/dist/cli/code-agent-mcp-config.js +34 -9
  73. package/dist/client/MultiTabAssistantChat.js +28 -1
  74. package/dist/client/org/OrgSwitcher.js +8 -4
  75. package/dist/client/use-chat-threads.d.ts +1 -0
  76. package/dist/client/use-chat-threads.js +13 -3
  77. package/dist/collab/awareness.d.ts +2 -2
  78. package/dist/collab/routes.d.ts +1 -1
  79. package/dist/mcp-client/config.d.ts +11 -0
  80. package/dist/mcp-client/config.js +33 -26
  81. package/dist/notifications/routes.d.ts +3 -3
  82. package/dist/observability/routes.d.ts +5 -5
  83. package/dist/progress/routes.d.ts +1 -1
  84. package/dist/resources/handlers.d.ts +2 -2
  85. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  86. package/dist/server/auth.js +17 -3
  87. package/dist/server/realtime-token.d.ts +1 -1
  88. package/dist/server/transcribe-voice.d.ts +1 -1
  89. package/dist/vite/client.js +2 -0
  90. package/package.json +1 -1
@@ -19,7 +19,13 @@ import { ensureDocumentsFilesMembership } from "./_content-files.js";
19
19
  import { resolveContentSpaceAccess } from "./_content-space-access.js";
20
20
  import { provisionContentSpaces } from "./_content-spaces.js";
21
21
  import { lockDatabaseMemberships } from "./_database-membership-lock.js";
22
- import { LOCAL_FOLDER_SOURCE_TYPE } from "./_local-folder-source.js";
22
+ import {
23
+ LOCAL_FOLDER_SOURCE_TYPE,
24
+ localFolderObservedRevision,
25
+ localFolderSourceFileIdentity,
26
+ localFolderSourceIdentityFromMetadata,
27
+ normalizeLocalFolderSourceIdentity,
28
+ } from "./_local-folder-source.js";
23
29
 
24
30
  const MAX_SOURCE_FILES = 500;
25
31
  const MAX_SOURCE_FILE_BYTES = 2 * 1024 * 1024;
@@ -75,13 +81,28 @@ function sourceValues(args: {
75
81
  title: string;
76
82
  hash: string;
77
83
  metadataHash: string;
84
+ workingCopyId: string;
85
+ bridgeFileIdentity?: string;
78
86
  }) {
87
+ const observedRevision = localFolderObservedRevision({
88
+ contentHash: args.hash,
89
+ metadataHash: args.metadataHash,
90
+ });
79
91
  return JSON.stringify({
80
92
  relativePath: args.path,
81
93
  extension: args.path.toLowerCase().endsWith(".mdx") ? ".mdx" : ".md",
82
94
  title: args.title,
83
95
  contentHash: args.hash,
84
96
  metadataHash: args.metadataHash,
97
+ observedRevision,
98
+ sourceFileIdentity: localFolderSourceFileIdentity({
99
+ workingCopyId: args.workingCopyId,
100
+ relativePath: args.path,
101
+ observedRevision,
102
+ }),
103
+ ...(args.bridgeFileIdentity
104
+ ? { bridgeFileIdentity: args.bridgeFileIdentity }
105
+ : {}),
85
106
  });
86
107
  }
87
108
 
@@ -142,9 +163,19 @@ export default defineAction({
142
163
  .refine((value) => Object.keys(value).length <= MAX_SOURCE_FILES, {
143
164
  message: `Sync is limited to ${MAX_SOURCE_FILES} files.`,
144
165
  }),
166
+ fileIdentities: z.record(z.string(), z.string().min(1).max(256)).optional(),
167
+ observedRevisions: z
168
+ .record(z.string(), z.string().regex(/^[a-f0-9]{64}$/i))
169
+ .optional(),
145
170
  dryRun: z.boolean().optional().default(false),
146
171
  }),
147
- run: async ({ sourceId, files, dryRun }) => {
172
+ run: async ({
173
+ sourceId,
174
+ files,
175
+ fileIdentities = {},
176
+ observedRevisions,
177
+ dryRun,
178
+ }) => {
148
179
  const userEmail = getRequestUserEmail();
149
180
  if (!userEmail) throw new Error("no authenticated user");
150
181
  const builderPaths = Object.keys(files).filter(isBuilderMdxSourcePath);
@@ -154,6 +185,15 @@ export default defineAction({
154
185
  );
155
186
  }
156
187
  const entries = normalizedEntries(files);
188
+ if (observedRevisions) {
189
+ for (const [filePath, content] of entries) {
190
+ if (observedRevisions[filePath] !== contentHash(content)) {
191
+ throw new Error(
192
+ `Local file revision changed while reading "${filePath}"`,
193
+ );
194
+ }
195
+ }
196
+ }
157
197
  const db = getDb();
158
198
  const [target] = await db
159
199
  .select({
@@ -263,6 +303,12 @@ export default defineAction({
263
303
 
264
304
  const metadata = parseJson(target.source.metadataJson);
265
305
  const policy = truthPolicy(metadata.truthPolicy);
306
+ const localIdentity =
307
+ localFolderSourceIdentityFromMetadata(metadata.localIdentity) ??
308
+ normalizeLocalFolderSourceIdentity({
309
+ connectionId: target.source.sourceTable,
310
+ label: target.source.sourceName,
311
+ });
266
312
  const now = new Date().toISOString();
267
313
  const created: Array<{ id: string; path: string; title: string }> = [];
268
314
  const updated: Array<{ id: string; path: string; title: string }> = [];
@@ -271,16 +317,42 @@ export default defineAction({
271
317
  const conflicts: Array<{ id: string; path: string; title: string }> = [];
272
318
  const outbound: Array<{ id: string; path: string; title: string }> = [];
273
319
 
274
- const buildPlans = (snapshot: Awaited<ReturnType<typeof loadSnapshot>>) =>
275
- valid.map((file, index) => {
276
- const pathRow = snapshot.rowByPath.get(file.path);
320
+ const buildPlans = (snapshot: Awaited<ReturnType<typeof loadSnapshot>>) => {
321
+ const rowByBridgeIdentity = new Map<
322
+ string,
323
+ (typeof snapshot.storedRows)[number]
324
+ >();
325
+ for (const row of snapshot.storedRows) {
326
+ const values = parseJson(row.sourceValuesJson);
327
+ if (typeof values.bridgeFileIdentity === "string") {
328
+ rowByBridgeIdentity.set(values.bridgeFileIdentity, row);
329
+ }
330
+ }
331
+
332
+ return valid.map((file, index) => {
333
+ const directPathRow = snapshot.rowByPath.get(file.path);
334
+ const incomingBridgeFileIdentity = fileIdentities[file.path];
335
+ const identityRow = incomingBridgeFileIdentity
336
+ ? rowByBridgeIdentity.get(incomingBridgeFileIdentity)
337
+ : undefined;
338
+ const incomingHash = contentHash(file.content);
339
+ const pathRow = directPathRow ?? identityRow;
340
+ const explicitId =
341
+ file.id && localIdentity.workingCopy.kind === "temporary"
342
+ ? opaqueId("content_local_file", `${sourceId}:${file.id}`)
343
+ : file.id;
277
344
  const id =
278
- file.id ??
345
+ explicitId ??
279
346
  pathRow?.documentId ??
280
347
  opaqueId("content_local_file", `${sourceId}:${file.path}`);
281
348
  const existing = snapshot.documentById.get(id);
282
349
  const previousRow = snapshot.rowByDocumentId.get(id) ?? pathRow;
283
350
  const previousValues = parseJson(previousRow?.sourceValuesJson);
351
+ const bridgeFileIdentity =
352
+ incomingBridgeFileIdentity ??
353
+ (typeof previousValues.bridgeFileIdentity === "string"
354
+ ? previousValues.bridgeFileIdentity
355
+ : undefined);
284
356
  const previousHash =
285
357
  typeof previousValues.contentHash === "string"
286
358
  ? previousValues.contentHash
@@ -289,7 +361,6 @@ export default defineAction({
289
361
  typeof previousValues.metadataHash === "string"
290
362
  ? previousValues.metadataHash
291
363
  : null;
292
- const incomingHash = contentHash(file.content);
293
364
  const incomingMetadataHash = metadataHash(file);
294
365
  const localHash = existing ? contentHash(existing.content) : null;
295
366
  const localMetadataHash = existing ? metadataHash(existing) : null;
@@ -332,8 +403,10 @@ export default defineAction({
332
403
  conflict,
333
404
  keepContent,
334
405
  applyIncoming,
406
+ bridgeFileIdentity,
335
407
  };
336
408
  });
409
+ };
337
410
 
338
411
  let plans = buildPlans(initialSnapshot);
339
412
  const initialDocumentIds = new Set(plans.map((plan) => plan.id));
@@ -348,7 +421,7 @@ export default defineAction({
348
421
  unchanged.length = 0;
349
422
  conflicts.length = 0;
350
423
  outbound.length = 0;
351
- for (const row of missingRows) {
424
+ for (const row of policy === "source_primary" ? [] : missingRows) {
352
425
  const values = parseJson(row.sourceValuesJson);
353
426
  const path = String(values.relativePath ?? row.sourceDisplayKey ?? "");
354
427
  const document = snapshot.documentById.get(row.documentId);
@@ -375,7 +448,8 @@ export default defineAction({
375
448
  } else if (
376
449
  plan.applyIncoming ||
377
450
  plan.existing.title !== plan.file.title ||
378
- plan.existing.sourcePath !== plan.file.path
451
+ plan.existing.sourcePath !== plan.file.path ||
452
+ plan.existing.sourceRootPath !== target.source.sourceTable
379
453
  ) {
380
454
  updated.push({
381
455
  id: plan.id,
@@ -518,7 +592,7 @@ export default defineAction({
518
592
  sourceMode: "local-files",
519
593
  sourceKind: "file",
520
594
  sourcePath: plan.file.path,
521
- sourceRootPath: target.source.sourceName,
595
+ sourceRootPath: target.source.sourceTable,
522
596
  sourceUpdatedAt: now,
523
597
  visibility: target.database.orgId ? "org" : "private",
524
598
  createdAt: now,
@@ -526,7 +600,8 @@ export default defineAction({
526
600
  });
527
601
  } else if (
528
602
  plan.applyIncoming ||
529
- plan.existing.sourcePath !== plan.file.path
603
+ plan.existing.sourcePath !== plan.file.path ||
604
+ plan.existing.sourceRootPath !== target.source.sourceTable
530
605
  ) {
531
606
  await tx
532
607
  .insert(schema.documentVersions)
@@ -557,7 +632,7 @@ export default defineAction({
557
632
  sourceMode: "local-files",
558
633
  sourceKind: "file",
559
634
  sourcePath: plan.file.path,
560
- sourceRootPath: target.source.sourceName,
635
+ sourceRootPath: target.source.sourceTable,
561
636
  sourceUpdatedAt: now,
562
637
  updatedAt: now,
563
638
  })
@@ -647,6 +722,8 @@ export default defineAction({
647
722
  title: plan.file.title,
648
723
  hash: plan.incomingHash,
649
724
  metadataHash: plan.incomingMetadataHash,
725
+ workingCopyId: localIdentity.workingCopy.id,
726
+ bridgeFileIdentity: plan.bridgeFileIdentity,
650
727
  }),
651
728
  provenance: "trusted local-folder bridge",
652
729
  syncState: "linked",
@@ -670,6 +747,47 @@ export default defineAction({
670
747
  .where(eq(schema.contentDatabaseSourceRows.id, rowId));
671
748
  }
672
749
  for (const row of missingRows) {
750
+ if (policy === "source_primary") {
751
+ await tx
752
+ .delete(schema.contentDatabaseSourceRows)
753
+ .where(eq(schema.contentDatabaseSourceRows.id, row.id));
754
+ const [remainingSourceRow] = await tx
755
+ .select({ id: schema.contentDatabaseSourceRows.id })
756
+ .from(schema.contentDatabaseSourceRows)
757
+ .where(
758
+ eq(
759
+ schema.contentDatabaseSourceRows.databaseItemId,
760
+ row.databaseItemId,
761
+ ),
762
+ )
763
+ .limit(1);
764
+ if (!remainingSourceRow) {
765
+ await tx
766
+ .delete(schema.contentDatabaseItems)
767
+ .where(eq(schema.contentDatabaseItems.id, row.databaseItemId));
768
+ await tx
769
+ .update(schema.documents)
770
+ .set({
771
+ sourceMode: null,
772
+ sourceKind: null,
773
+ sourcePath: null,
774
+ sourceRootPath: null,
775
+ sourceUpdatedAt: now,
776
+ updatedAt: now,
777
+ })
778
+ .where(
779
+ and(
780
+ eq(schema.documents.id, row.documentId),
781
+ eq(schema.documents.spaceId, targetSpaceId),
782
+ eq(
783
+ schema.documents.sourceRootPath,
784
+ target.source.sourceTable,
785
+ ),
786
+ ),
787
+ );
788
+ }
789
+ continue;
790
+ }
673
791
  const values = parseJson(row.sourceValuesJson);
674
792
  const path = String(
675
793
  values.relativePath ?? row.sourceDisplayKey ?? row.sourceRowId,
@@ -0,0 +1,77 @@
1
+ import { useQueryClient } from "@tanstack/react-query";
2
+ import { useEffect } from "react";
3
+
4
+ import { getDesktopContentFiles } from "../lib/desktop-content-files";
5
+ import { syncLiveLocalFolder } from "../lib/local-folder-live-sync";
6
+ import { connectTemporaryLocalFolder } from "../lib/local-folder-live-sync";
7
+
8
+ const RECONCILE_DELAY_MS = 120;
9
+
10
+ export function LocalFolderLiveSync() {
11
+ const queryClient = useQueryClient();
12
+
13
+ useEffect(() => {
14
+ const desktop = getDesktopContentFiles();
15
+ if (!desktop?.onChange || !desktop.subscribeChanges) return;
16
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
17
+ let active = true;
18
+
19
+ const reconcile = (folderId: string) => {
20
+ const current = timers.get(folderId);
21
+ if (current) clearTimeout(current);
22
+ timers.set(
23
+ folderId,
24
+ setTimeout(() => {
25
+ timers.delete(folderId);
26
+ void desktop
27
+ .getFolder({ folderId })
28
+ .then(async (folderResult) => {
29
+ if (!folderResult.ok) return { synced: false as const };
30
+ await connectTemporaryLocalFolder(folderResult.folder);
31
+ return syncLiveLocalFolder(folderId);
32
+ })
33
+ .then((outcome) => {
34
+ if (!active || !outcome.synced) return;
35
+ queryClient.invalidateQueries({
36
+ queryKey: ["action", "list-documents"],
37
+ });
38
+ queryClient.invalidateQueries({
39
+ queryKey: ["action", "get-document"],
40
+ });
41
+ queryClient.invalidateQueries({
42
+ queryKey: ["action", "get-content-database"],
43
+ });
44
+ })
45
+ .catch(() => {
46
+ // A later watcher event or ordinary launch reconciliation retries.
47
+ });
48
+ }, RECONCILE_DELAY_MS),
49
+ );
50
+ };
51
+
52
+ const removeListener = desktop.onChange((event) =>
53
+ reconcile(event.folderId),
54
+ );
55
+ const reconcileAll = () =>
56
+ void desktop.getFolder().then((result) => {
57
+ if (!active || !result.ok) return;
58
+ for (const folder of result.folders ?? [result.folder]) {
59
+ if (!folder.id) continue;
60
+ void desktop.subscribeChanges?.({ folderId: folder.id });
61
+ reconcile(folder.id);
62
+ }
63
+ });
64
+ reconcileAll();
65
+ const boundedReconciliation = window.setInterval(reconcileAll, 5_000);
66
+
67
+ return () => {
68
+ active = false;
69
+ window.clearInterval(boundedReconciliation);
70
+ removeListener();
71
+ for (const timer of timers.values()) clearTimeout(timer);
72
+ timers.clear();
73
+ };
74
+ }, [queryClient]);
75
+
76
+ return null;
77
+ }
@@ -60,6 +60,109 @@ type SelectionFillRange = {
60
60
  };
61
61
 
62
62
  type TextStyle = "paragraph" | 1 | 2 | 3 | 4 | 5 | 6;
63
+ type ColorAttribute = "color" | "bgColor";
64
+ type ColorName =
65
+ | "gray"
66
+ | "brown"
67
+ | "orange"
68
+ | "yellow"
69
+ | "green"
70
+ | "blue"
71
+ | "purple"
72
+ | "pink"
73
+ | "red";
74
+
75
+ const COLOR_NAMES: ColorName[] = [
76
+ "gray",
77
+ "brown",
78
+ "orange",
79
+ "yellow",
80
+ "green",
81
+ "blue",
82
+ "purple",
83
+ "pink",
84
+ "red",
85
+ ];
86
+
87
+ export function getSelectionNotionSpanAttribute(
88
+ editor: Editor,
89
+ attribute: ColorAttribute,
90
+ ): string | null | "mixed" {
91
+ const { from, to } = editor.state.selection;
92
+ const markType = editor.state.schema.marks.notionSpan;
93
+ if (!markType) return null;
94
+
95
+ let observed: string | null = null;
96
+ let hasObserved = false;
97
+ let mixed = false;
98
+
99
+ editor.state.doc.nodesBetween(from, to, (node, _position, parent) => {
100
+ if (!node.isText || !parent?.type.allowsMarkType(markType)) return;
101
+ const mark = node.marks.find((candidate) => candidate.type === markType);
102
+ const value = (mark?.attrs[attribute] as string | null | undefined) ?? null;
103
+ if (!hasObserved) {
104
+ observed = value;
105
+ hasObserved = true;
106
+ } else if (observed !== value) {
107
+ mixed = true;
108
+ }
109
+ });
110
+
111
+ return mixed ? "mixed" : observed;
112
+ }
113
+
114
+ export function selectionHasColorableText(
115
+ state: EditorState,
116
+ from: number,
117
+ to: number,
118
+ ) {
119
+ const markType = state.schema.marks.notionSpan;
120
+ if (!markType) return false;
121
+
122
+ let hasText = false;
123
+ state.doc.nodesBetween(from, to, (node, _position, parent) => {
124
+ if (node.isText && parent?.type.allowsMarkType(markType)) hasText = true;
125
+ return !hasText;
126
+ });
127
+ return hasText;
128
+ }
129
+
130
+ export function setSelectionNotionSpanAttribute(
131
+ editor: Editor,
132
+ attribute: ColorAttribute,
133
+ value: string | null,
134
+ ) {
135
+ const { state } = editor;
136
+ const { from, to } = state.selection;
137
+ const markType = state.schema.marks.notionSpan;
138
+ if (!markType || from === to) return false;
139
+
140
+ const transaction = state.tr;
141
+ state.doc.nodesBetween(from, to, (node, position, parent) => {
142
+ if (!node.isText || !parent?.type.allowsMarkType(markType)) return;
143
+ const start = Math.max(from, position);
144
+ const end = Math.min(to, position + node.nodeSize);
145
+ if (start >= end) return;
146
+
147
+ const existing = node.marks.find((mark) => mark.type === markType);
148
+ const attrs = { ...existing?.attrs, [attribute]: value };
149
+ transaction.removeMark(start, end, markType);
150
+ if (
151
+ attrs.color ||
152
+ attrs.bgColor ||
153
+ attrs.underline ||
154
+ attrs.href ||
155
+ (attrs.attrsJson && attrs.attrsJson !== "{}")
156
+ ) {
157
+ transaction.addMark(start, end, markType.create(attrs));
158
+ }
159
+ });
160
+
161
+ if (!transaction.docChanged) return false;
162
+ editor.view.dispatch(transaction);
163
+ editor.commands.focus();
164
+ return true;
165
+ }
63
166
 
64
167
  function activeTextStyle(editor: Editor): TextStyle {
65
168
  for (const level of [1, 2, 3, 4, 5, 6] as const) {
@@ -119,8 +222,16 @@ export function BubbleToolbar({ editor, onComment }: BubbleToolbarProps) {
119
222
  const [showLinkInput, setShowLinkInput] = useState(false);
120
223
  const [linkUrl, setLinkUrl] = useState("");
121
224
  const [textStyleOpen, setTextStyleOpen] = useState(false);
225
+ const [colorOpen, setColorOpen] = useState(false);
226
+ const [colorRevision, setColorRevision] = useState(0);
227
+ const [recentColor, setRecentColor] = useState<{
228
+ attribute: ColorAttribute;
229
+ value: string;
230
+ } | null>(null);
122
231
  const textStyleSelection = useRef<{ from: number; to: number } | null>(null);
232
+ const colorSelection = useRef<{ from: number; to: number } | null>(null);
123
233
  const textStyleApplied = useRef(false);
234
+ const colorApplied = useRef(false);
124
235
  const restoreEditorFocusOnClose = useRef(false);
125
236
  const [textStyle, setTextStyle] = useState<TextStyle>(() =>
126
237
  activeTextStyle(editor),
@@ -129,8 +240,12 @@ export function BubbleToolbar({ editor, onComment }: BubbleToolbarProps) {
129
240
  useEffect(() => {
130
241
  const syncTextStyle = () => {
131
242
  setTextStyle(activeTextStyle(editor));
243
+ setColorRevision((revision) => revision + 1);
132
244
  const { from, to } = editor.state.selection;
133
- if (from !== to) textStyleSelection.current = { from, to };
245
+ if (from !== to) {
246
+ textStyleSelection.current = { from, to };
247
+ colorSelection.current = { from, to };
248
+ }
134
249
  };
135
250
  editor.on("selectionUpdate", syncTextStyle);
136
251
  editor.on("transaction", syncTextStyle);
@@ -199,6 +314,82 @@ export function BubbleToolbar({ editor, onComment }: BubbleToolbarProps) {
199
314
  setTextStyleOpen(false);
200
315
  };
201
316
 
317
+ const applyColor = (attribute: ColorAttribute, value: string | null) => {
318
+ if (colorSelection.current) {
319
+ editor.commands.setTextSelection(colorSelection.current);
320
+ }
321
+ if (!setSelectionNotionSpanAttribute(editor, attribute, value)) return;
322
+ if (value) setRecentColor({ attribute, value });
323
+ colorApplied.current = true;
324
+ setColorOpen(false);
325
+ };
326
+
327
+ const activeTextColor = getSelectionNotionSpanAttribute(editor, "color");
328
+ const activeBackgroundColor = getSelectionNotionSpanAttribute(
329
+ editor,
330
+ "bgColor",
331
+ );
332
+ void colorRevision;
333
+
334
+ const renderColorChoice = (
335
+ attribute: ColorAttribute,
336
+ value: string | null,
337
+ ) => {
338
+ const sectionLabel = t(
339
+ attribute === "color" ? "editor.textColor" : "editor.backgroundColor",
340
+ );
341
+ const colorName = value?.replace(/_bg$/, "") as ColorName | undefined;
342
+ const choiceLabel = colorName
343
+ ? t(`editor.color.${colorName}`)
344
+ : t("editor.defaultColor");
345
+ const activeValue =
346
+ attribute === "color" ? activeTextColor : activeBackgroundColor;
347
+ const isActive = activeValue !== "mixed" && activeValue === value;
348
+
349
+ return (
350
+ <button
351
+ key={`${attribute}-${value ?? "default"}`}
352
+ type="button"
353
+ role="menuitemradio"
354
+ aria-checked={isActive}
355
+ aria-label={`${sectionLabel}: ${choiceLabel}`}
356
+ onPointerDown={(event) => {
357
+ event.preventDefault();
358
+ event.stopPropagation();
359
+ applyColor(attribute, value);
360
+ }}
361
+ onClick={(event) => {
362
+ if (event.detail === 0) applyColor(attribute, value);
363
+ }}
364
+ className={cn(
365
+ "relative flex size-8 items-center justify-center rounded-md border border-border bg-background text-sm font-semibold hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
366
+ isActive && "ring-2 ring-foreground",
367
+ )}
368
+ >
369
+ {attribute === "color" ? (
370
+ <span className={colorName ? `notion-block-color--${colorName}` : ""}>
371
+ A
372
+ </span>
373
+ ) : (
374
+ <span
375
+ className={cn(
376
+ "size-5 rounded border border-border",
377
+ colorName && `notion-block-bg--${colorName}`,
378
+ )}
379
+ />
380
+ )}
381
+ {isActive ? (
382
+ <IconCheck
383
+ aria-hidden="true"
384
+ className="absolute -end-1 -top-1 rounded-full bg-foreground p-0.5 text-background"
385
+ size={12}
386
+ strokeWidth={3}
387
+ />
388
+ ) : null}
389
+ </button>
390
+ );
391
+ };
392
+
202
393
  const openLinkInput = useCallback(() => {
203
394
  setLinkUrl(editor.getAttributes("link").href || "");
204
395
  setShowLinkInput(true);
@@ -318,6 +509,15 @@ export function BubbleToolbar({ editor, onComment }: BubbleToolbarProps) {
318
509
  };
319
510
 
320
511
  const items = [
512
+ { type: "text-style" as const },
513
+ ...(selectionHasColorableText(
514
+ editor.state,
515
+ editor.state.selection.from,
516
+ editor.state.selection.to,
517
+ )
518
+ ? [{ type: "color" as const }]
519
+ : []),
520
+ { type: "divider" as const },
321
521
  {
322
522
  icon: IconBold,
323
523
  title: t("editor.bold"),
@@ -343,8 +543,6 @@ export function BubbleToolbar({ editor, onComment }: BubbleToolbarProps) {
343
543
  isActive: () => editor.isActive("code"),
344
544
  },
345
545
  { type: "divider" as const },
346
- { type: "text-style" as const },
347
- { type: "divider" as const },
348
546
  {
349
547
  icon: IconLink,
350
548
  title: t("editor.link"),
@@ -529,6 +727,103 @@ export function BubbleToolbar({ editor, onComment }: BubbleToolbarProps) {
529
727
  </Popover>
530
728
  );
531
729
  }
730
+ if ("type" in item && item.type === "color") {
731
+ return (
732
+ <Popover
733
+ key="color"
734
+ open={colorOpen}
735
+ onOpenChange={(open) => {
736
+ if (open) {
737
+ colorApplied.current = false;
738
+ restoreEditorFocusOnClose.current = false;
739
+ const { from, to } = editor.state.selection;
740
+ if (from !== to) colorSelection.current = { from, to };
741
+ }
742
+ setColorOpen(open);
743
+ }}
744
+ >
745
+ <PopoverTrigger asChild>
746
+ <button
747
+ type="button"
748
+ aria-label={t("editor.color.label")}
749
+ className={cn(
750
+ "flex size-8 items-center justify-center rounded text-sm font-semibold text-popover-foreground/85 hover:bg-accent hover:text-accent-foreground",
751
+ colorOpen && "bg-accent text-accent-foreground",
752
+ )}
753
+ >
754
+ A
755
+ </button>
756
+ </PopoverTrigger>
757
+ <PopoverContent
758
+ portalled={false}
759
+ align="start"
760
+ sideOffset={24}
761
+ className="w-52 p-2"
762
+ onEscapeKeyDown={() => {
763
+ restoreEditorFocusOnClose.current = true;
764
+ window.setTimeout(() => editor.commands.focus(), 0);
765
+ }}
766
+ onCloseAutoFocus={(event) => {
767
+ if (
768
+ colorApplied.current ||
769
+ restoreEditorFocusOnClose.current
770
+ ) {
771
+ event.preventDefault();
772
+ }
773
+ if (restoreEditorFocusOnClose.current) {
774
+ editor.commands.focus();
775
+ }
776
+ colorApplied.current = false;
777
+ restoreEditorFocusOnClose.current = false;
778
+ }}
779
+ >
780
+ {recentColor ? (
781
+ <div className="mb-2">
782
+ <div className="mb-1 text-xs font-medium text-muted-foreground">
783
+ {t("editor.color.recentlyUsed")}
784
+ </div>
785
+ <div role="menu">
786
+ {renderColorChoice(
787
+ recentColor.attribute,
788
+ recentColor.value,
789
+ )}
790
+ </div>
791
+ </div>
792
+ ) : null}
793
+ <div className="mb-2">
794
+ <div className="mb-1 text-xs font-medium text-muted-foreground">
795
+ {t("editor.textColor")}
796
+ </div>
797
+ <div
798
+ role="menu"
799
+ aria-label={t("editor.textColor")}
800
+ className="grid grid-cols-5 gap-1"
801
+ >
802
+ {renderColorChoice("color", null)}
803
+ {COLOR_NAMES.map((name) =>
804
+ renderColorChoice("color", name),
805
+ )}
806
+ </div>
807
+ </div>
808
+ <div>
809
+ <div className="mb-1 text-xs font-medium text-muted-foreground">
810
+ {t("editor.backgroundColor")}
811
+ </div>
812
+ <div
813
+ role="menu"
814
+ aria-label={t("editor.backgroundColor")}
815
+ className="grid grid-cols-5 gap-1"
816
+ >
817
+ {renderColorChoice("bgColor", null)}
818
+ {COLOR_NAMES.map((name) =>
819
+ renderColorChoice("bgColor", `${name}_bg`),
820
+ )}
821
+ </div>
822
+ </div>
823
+ </PopoverContent>
824
+ </Popover>
825
+ );
826
+ }
532
827
  const {
533
828
  icon: Icon,
534
829
  title,