@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/analytics/netlify.toml +4 -0
- package/corpus/templates/clips/actions/request-transcript.ts +10 -6
- package/corpus/templates/content/actions/_database-source-utils.ts +25 -2
- package/corpus/templates/content/actions/_database-utils.ts +2 -0
- package/corpus/templates/content/actions/_local-folder-source.ts +186 -2
- package/corpus/templates/content/actions/connect-local-folder-source.ts +23 -8
- package/corpus/templates/content/actions/disconnect-local-folder-source.ts +3 -3
- package/corpus/templates/content/actions/resolve-local-folder-conflict.ts +2 -2
- package/corpus/templates/content/actions/sync-local-folder-source.ts +130 -12
- package/corpus/templates/content/app/components/LocalFolderLiveSync.tsx +77 -0
- package/corpus/templates/content/app/components/editor/BubbleToolbar.tsx +298 -3
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +354 -8
- package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +23 -1
- package/corpus/templates/content/app/components/editor/VisualEditor.tsx +81 -1
- package/corpus/templates/content/app/components/editor/database/sidebar.tsx +36 -0
- package/corpus/templates/content/app/components/editor/extensions/NotionExtensions.tsx +18 -4
- package/corpus/templates/content/app/components/editor/local-source-write-state.ts +52 -0
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +244 -6
- package/corpus/templates/content/app/components/sidebar/local-source-hierarchy.ts +189 -0
- package/corpus/templates/content/app/hooks/use-create-page.ts +26 -4
- package/corpus/templates/content/app/hooks/use-documents.ts +83 -0
- package/corpus/templates/content/app/i18n-data.ts +239 -0
- package/corpus/templates/content/app/lib/desktop-content-files.ts +57 -1
- package/corpus/templates/content/app/lib/local-content-source-files.ts +210 -34
- package/corpus/templates/content/app/lib/local-folder-live-sync.ts +191 -0
- package/corpus/templates/content/app/root.tsx +2 -1
- package/corpus/templates/content/app/routes/_app.local-files.tsx +103 -38
- package/corpus/templates/content/docs/product/capabilities/content.source.file-folder.md +2 -2
- package/corpus/templates/content/docs/product/features/content.feature.bring-your-local-work.md +3 -3
- package/corpus/templates/content/docs/product/roadmap.md +2 -2
- package/corpus/templates/content/docs/solutions/2026-08-18-colored-text-highlighting-shape.md +693 -0
- package/corpus/templates/content/docs/solutions/2026-08-18-content-navigation-coherence-shape.md +482 -0
- package/corpus/templates/content/package.json +1 -0
- package/corpus/templates/content/shared/api.ts +25 -0
- package/corpus/templates/content/shared/nfm.ts +14 -3
- package/corpus/templates/factory/AGENTS.md +24 -29
- package/corpus/templates/factory/actions/get-slack-feedback-context.ts +8 -28
- package/corpus/templates/factory/actions/get-triage-config.ts +1 -0
- package/corpus/templates/factory/actions/govern-agent-native-pull-request.ts +67 -59
- package/corpus/templates/factory/actions/list-triage-items.ts +6 -1
- package/corpus/templates/factory/actions/reconcile-triage-run.ts +7 -1
- package/corpus/templates/factory/actions/save-triage-config.ts +27 -7
- package/corpus/templates/factory/actions/start-builder-for-item.ts +113 -31
- package/corpus/templates/factory/actions/view-screen.ts +12 -0
- package/corpus/templates/factory/app/components/factory/FactorySettingsView.tsx +457 -0
- package/corpus/templates/factory/app/components/factory/FactoryWorkspaceActions.tsx +53 -0
- package/corpus/templates/factory/app/components/layout/Layout.tsx +2 -0
- package/corpus/templates/factory/app/components/layout/Sidebar.tsx +3 -1
- package/corpus/templates/factory/app/hooks/use-navigation-state.ts +10 -7
- package/corpus/templates/factory/app/i18n/en-US.ts +3 -0
- package/corpus/templates/factory/app/routes/agents.tsx +22 -1
- package/corpus/templates/factory/app/routes/factory-settings.tsx +34 -0
- package/corpus/templates/factory/app/routes/factory.tsx +33 -323
- package/corpus/templates/factory/server/connectors/slack.ts +62 -0
- package/corpus/templates/factory/server/db/schema.ts +1 -0
- package/corpus/templates/factory/server/lib/slack-feedback-prompt.ts +30 -0
- package/corpus/templates/factory/server/plugins/agent-chat.ts +9 -8
- package/corpus/templates/factory/server/plugins/factory-migrations.ts +7 -0
- package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +43 -25
- package/corpus/templates/factory/server/triage/ai-services-git.ts +2 -0
- package/corpus/templates/factory/server/triage/pr-monitor.ts +2 -1
- package/corpus/templates/factory/server/triage/pr-policy.ts +197 -19
- package/corpus/templates/factory/server/triage/review-skill-alignment.ts +127 -0
- package/corpus/templates/factory/server/triage/slack-client.ts +79 -6
- package/dist/app-config/auth.d.ts +4 -0
- package/dist/app-config/auth.js +7 -0
- package/dist/app-config/schema.d.ts +3 -0
- package/dist/app-config/schema.js +2 -0
- package/dist/cli/code-agent-executor.js +6 -1
- package/dist/cli/code-agent-mcp-config.d.ts +2 -2
- package/dist/cli/code-agent-mcp-config.js +34 -9
- package/dist/client/MultiTabAssistantChat.js +28 -1
- package/dist/client/org/OrgSwitcher.js +8 -4
- package/dist/client/use-chat-threads.d.ts +1 -0
- package/dist/client/use-chat-threads.js +13 -3
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/routes.d.ts +1 -1
- package/dist/mcp-client/config.d.ts +11 -0
- package/dist/mcp-client/config.js +33 -26
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +5 -5
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/auth.js +17 -3
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/vite/client.js +2 -0
- 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 {
|
|
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 ({
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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)
|
|
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,
|