wiki-viewer 1.0.0 → 1.0.1

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 (128) hide show
  1. package/.next/standalone/README.md +328 -99
  2. package/.next/standalone/agents/bootstrap-prompt.md +1 -0
  3. package/.next/standalone/agents/wiki-viewer-skill/SKILL.md +236 -0
  4. package/.next/standalone/bin/wiki-viewer.js +57 -4
  5. package/.next/standalone/docs/agent-collab-plan.md +1615 -0
  6. package/.next/standalone/docs/agent-collab-v2-plan.md +771 -0
  7. package/.next/standalone/package.json +19 -2
  8. package/.next/standalone/pnpm-lock.yaml +1368 -325
  9. package/.next/standalone/pnpm-workspace.yaml +7 -1
  10. package/.next/standalone/src/app/api/agent/activity/route.ts +58 -0
  11. package/.next/standalone/src/app/api/agent/admin/agents/[agentId]/revoke/route.ts +40 -0
  12. package/.next/standalone/src/app/api/agent/admin/agents/route.ts +33 -0
  13. package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/approve/route.ts +83 -0
  14. package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/deny/route.ts +36 -0
  15. package/.next/standalone/src/app/api/agent/admin/registrations/route.ts +27 -0
  16. package/.next/standalone/src/app/api/agent/events/[...path]/route.ts +136 -0
  17. package/.next/standalone/src/app/api/agent/files/[...path]/route.ts +202 -0
  18. package/.next/standalone/src/app/api/agent/internal/span/route.ts +117 -0
  19. package/.next/standalone/src/app/api/agent/register/[regId]/route.ts +50 -0
  20. package/.next/standalone/src/app/api/agent/register/route.ts +110 -0
  21. package/.next/standalone/src/app/api/agent/settings/route.ts +30 -0
  22. package/.next/standalone/src/app/api/agent/settings/token/regenerate/route.ts +20 -0
  23. package/.next/standalone/src/app/api/agent/sidecar/[...path]/route.ts +49 -0
  24. package/.next/standalone/src/app/api/agents/install/route.ts +83 -0
  25. package/.next/standalone/src/app/api/agents/skill/route.ts +20 -0
  26. package/.next/standalone/src/app/api/agents/skill.tar.gz/route.ts +87 -0
  27. package/.next/standalone/src/app/api/auth/[...all]/route.ts +7 -0
  28. package/.next/standalone/src/app/api/owner/init/route.ts +14 -0
  29. package/.next/standalone/src/app/api/system/auth-settings/route.ts +85 -0
  30. package/.next/standalone/src/app/api/system/browse/route.ts +4 -0
  31. package/.next/standalone/src/app/api/system/clear-root/route.ts +8 -1
  32. package/.next/standalone/src/app/api/system/config/route.ts +5 -1
  33. package/.next/standalone/src/app/api/system/pins/route.ts +7 -0
  34. package/.next/standalone/src/app/api/system/reveal/route.ts +7 -0
  35. package/.next/standalone/src/app/api/system/root-status/route.ts +5 -1
  36. package/.next/standalone/src/app/api/system/set-root/route.ts +7 -0
  37. package/.next/standalone/src/app/api/wiki/app/route.ts +15 -0
  38. package/.next/standalone/src/app/api/wiki/content/route.ts +110 -11
  39. package/.next/standalone/src/app/api/wiki/folder/route.ts +7 -0
  40. package/.next/standalone/src/app/api/wiki/move/route.ts +7 -0
  41. package/.next/standalone/src/app/api/wiki/new-file/route.ts +55 -0
  42. package/.next/standalone/src/app/api/wiki/page/route.ts +7 -0
  43. package/.next/standalone/src/app/api/wiki/route.ts +10 -0
  44. package/.next/standalone/src/app/api/wiki/slugs/route.ts +5 -1
  45. package/.next/standalone/src/app/api/wiki/upload/route.ts +7 -0
  46. package/.next/standalone/src/app/api/wiki/watch/route.ts +6 -1
  47. package/.next/standalone/src/app/globals.css +70 -0
  48. package/.next/standalone/src/app/page.tsx +461 -217
  49. package/.next/standalone/src/app/signin/page.tsx +217 -0
  50. package/.next/standalone/src/components/ai-panel/activity-row.tsx +64 -0
  51. package/.next/standalone/src/components/ai-panel/ai-panel.tsx +322 -0
  52. package/.next/standalone/src/components/ai-panel/token-section.tsx +312 -0
  53. package/.next/standalone/src/components/auth-settings-sheet.tsx +209 -0
  54. package/.next/standalone/src/components/editor/bubble-menu.tsx +41 -2
  55. package/.next/standalone/src/components/editor/comment-pip.tsx +56 -0
  56. package/.next/standalone/src/components/editor/comment-thread.tsx +252 -0
  57. package/.next/standalone/src/components/editor/editor.tsx +513 -10
  58. package/.next/standalone/src/components/editor/extensions/proof-span.ts +60 -0
  59. package/.next/standalone/src/components/editor/extensions.ts +2 -0
  60. package/.next/standalone/src/components/editor/proof-span-popover.tsx +161 -0
  61. package/.next/standalone/src/components/editor/suggest-edit-popover.tsx +228 -0
  62. package/.next/standalone/src/components/editor/suggestion-card.tsx +201 -0
  63. package/.next/standalone/src/components/view-width-toggle.tsx +47 -0
  64. package/.next/standalone/src/components/wiki/markdown-preview.tsx +120 -0
  65. package/.next/standalone/src/lib/auth/allowlist.ts +50 -0
  66. package/.next/standalone/src/lib/auth/client.ts +4 -0
  67. package/.next/standalone/src/lib/auth/csrf.ts +68 -0
  68. package/.next/standalone/src/lib/auth/server.ts +164 -0
  69. package/.next/standalone/src/lib/config.ts +4 -0
  70. package/.next/standalone/src/lib/markdown/parse-frontmatter.ts +20 -0
  71. package/.next/standalone/src/lib/markdown/sanitize-schema.ts +106 -0
  72. package/.next/standalone/src/lib/markdown/to-html.ts +46 -8
  73. package/.next/standalone/src/lib/markdown/to-markdown.ts +14 -0
  74. package/.next/standalone/src/lib/proof/activity-shared.ts +31 -0
  75. package/.next/standalone/src/lib/proof/activity.ts +74 -0
  76. package/.next/standalone/src/lib/proof/auth.ts +132 -0
  77. package/.next/standalone/src/lib/proof/block-refs.ts +133 -0
  78. package/.next/standalone/src/lib/proof/blocks.ts +73 -0
  79. package/.next/standalone/src/lib/proof/client-auth.ts +23 -0
  80. package/.next/standalone/src/lib/proof/event-bus.ts +47 -0
  81. package/.next/standalone/src/lib/proof/file-lock.ts +38 -0
  82. package/.next/standalone/src/lib/proof/glob.ts +51 -0
  83. package/.next/standalone/src/lib/proof/idempotency.ts +32 -0
  84. package/.next/standalone/src/lib/proof/mutex.ts +32 -0
  85. package/.next/standalone/src/lib/proof/ops-applier.ts +678 -0
  86. package/.next/standalone/src/lib/proof/owner-auth.ts +2 -0
  87. package/.next/standalone/src/lib/proof/pending.ts +97 -0
  88. package/.next/standalone/src/lib/proof/proof-span.ts +141 -0
  89. package/.next/standalone/src/lib/proof/rate-limit.ts +53 -0
  90. package/.next/standalone/src/lib/proof/register-rate-limit.ts +53 -0
  91. package/.next/standalone/src/lib/proof/registry.ts +190 -0
  92. package/.next/standalone/src/lib/proof/sidecar.ts +66 -0
  93. package/.next/standalone/src/lib/proof/suggest-capture.ts +69 -0
  94. package/.next/standalone/src/lib/proof/types.ts +170 -0
  95. package/.next/standalone/src/lib/proof-config.ts +14 -0
  96. package/.next/standalone/src/middleware.ts +38 -0
  97. package/.next/standalone/src/stores/ai-panel-store.ts +58 -3
  98. package/.next/standalone/src/stores/editor-store.ts +115 -9
  99. package/.next/standalone/src/stores/proof-store.ts +123 -0
  100. package/.next/standalone/src/stores/view-width-store.ts +62 -0
  101. package/.next/standalone/src/tests/proof/activity-aggregator.test.ts +146 -0
  102. package/.next/standalone/src/tests/proof/agent-ownership.test.ts +140 -0
  103. package/.next/standalone/src/tests/proof/agents-install.test.ts +57 -0
  104. package/.next/standalone/src/tests/proof/better-auth.test.ts +68 -0
  105. package/.next/standalone/src/tests/proof/block-refs.test.ts +108 -0
  106. package/.next/standalone/src/tests/proof/blocks.test.ts +92 -0
  107. package/.next/standalone/src/tests/proof/comments-ops.test.ts +177 -0
  108. package/.next/standalone/src/tests/proof/editor-roundtrip.test.ts +85 -0
  109. package/.next/standalone/src/tests/proof/external-edit.test.ts +58 -0
  110. package/.next/standalone/src/tests/proof/file-lock.test.ts +80 -0
  111. package/.next/standalone/src/tests/proof/glob.test.ts +82 -0
  112. package/.next/standalone/src/tests/proof/helpers/auth-session.ts +27 -0
  113. package/.next/standalone/src/tests/proof/markdown-to-html.test.ts +46 -0
  114. package/.next/standalone/src/tests/proof/ops-applier.test.ts +368 -0
  115. package/.next/standalone/src/tests/proof/owner-init.test.ts +2 -0
  116. package/.next/standalone/src/tests/proof/parse-frontmatter.test.ts +60 -0
  117. package/.next/standalone/src/tests/proof/proof-span.test.ts +98 -0
  118. package/.next/standalone/src/tests/proof/rate-limit.test.ts +54 -0
  119. package/.next/standalone/src/tests/proof/registration-flow.test.ts +330 -0
  120. package/.next/standalone/src/tests/proof/registry.test.ts +169 -0
  121. package/.next/standalone/src/tests/proof/routes.test.ts +516 -0
  122. package/.next/standalone/src/tests/proof/sidecar-trim.test.ts +58 -0
  123. package/.next/standalone/src/tests/proof/suggestion-ops.test.ts +280 -0
  124. package/.next/standalone/src/tests/proof/wiki-content-put.test.ts +140 -0
  125. package/.next/standalone/src/tests/proof/wiki-routes-auth.test.ts +208 -0
  126. package/README.md +328 -99
  127. package/bin/wiki-viewer.js +57 -4
  128. package/package.json +19 -2
@@ -3,19 +3,27 @@
3
3
  import { cellAround, isInTable } from "@tiptap/pm/tables";
4
4
  import { EditorContent, useEditor } from "@tiptap/react";
5
5
  import { Code2, FilePlus, Loader2, Sparkles } from "lucide-react";
6
- import { useCallback, useEffect, useRef, useState } from "react";
6
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
7
  import { findNodeByPath } from "@/lib/cabinets/tree";
8
8
  import { markdownToHtml } from "@/lib/markdown/to-html";
9
9
  import { htmlToMarkdown } from "@/lib/markdown/to-markdown";
10
10
  import { useAIPanelStore } from "@/stores/ai-panel-store";
11
11
  import { useEditorStore } from "@/stores/editor-store";
12
12
  import { useTreeStore } from "@/stores/tree-store";
13
+ import { useViewWidthStore, VIEW_WIDTH_CSS } from "@/stores/view-width-store";
13
14
  import { useWikiSlugsStore } from "@/stores/wiki-slugs-store";
14
15
  import type { TreeNode } from "@/types";
16
+ import { useProofStore } from "@/stores/proof-store";
17
+ import { captureSuggestion } from "@/lib/proof/suggest-capture";
15
18
  import { EditorBubbleMenu } from "./bubble-menu";
16
19
  import { EditorToolbar } from "./editor-toolbar";
17
20
  import { editorExtensions } from "./extensions";
18
21
  import { FolderIndex } from "./folder-index";
22
+ import { CommentPip } from "./comment-pip";
23
+ import { CommentThread } from "./comment-thread";
24
+ import { ProofSpanPopover } from "./proof-span-popover";
25
+ import { SuggestionCard } from "./suggestion-card";
26
+ import { SuggestEditPopover } from "./suggest-edit-popover";
19
27
  import { SlashCommands } from "./slash-commands";
20
28
  import { TableMenu } from "./table-menu";
21
29
  import {
@@ -136,8 +144,11 @@ export function KBEditor() {
136
144
  isLoading,
137
145
  loadStatus,
138
146
  createMissingPage,
147
+ editMode,
148
+ setEditMode,
139
149
  } = useEditorStore();
140
150
  const nodes = useTreeStore((s) => s.nodes);
151
+ const editorMaxW = useViewWidthStore((s) => VIEW_WIDTH_CSS[s.width]);
141
152
  const isRtl = frontmatter?.dir === "rtl";
142
153
  const { open: openAI, clearMessages } = useAIPanelStore();
143
154
  const { open: openWikiCreate, Dialog: WikiCreateDialog } =
@@ -167,9 +178,323 @@ export function KBEditor() {
167
178
  void useWikiSlugsStore.getState().load();
168
179
  }, []);
169
180
 
181
+ // Load sidecar when the current path changes.
182
+ useEffect(() => {
183
+ if (!currentPath) return;
184
+ void useProofStore.getState().loadSidecar(currentPath);
185
+ }, [currentPath]);
186
+
187
+ // Subscribe to chokidar SSE: when current file changes on disk, reload sidecar.
188
+ useEffect(() => {
189
+ if (typeof window === "undefined") return;
190
+ const es = new EventSource("/api/wiki/watch");
191
+ es.onmessage = (evt: MessageEvent<string>) => {
192
+ try {
193
+ const data = JSON.parse(evt.data) as { type: string; path: string };
194
+ const activePath = useEditorStore.getState().currentPath;
195
+ if (
196
+ (data.type === "change" || data.type === "add") &&
197
+ activePath &&
198
+ data.path === activePath
199
+ ) {
200
+ // loadSnapshot first so server-side readSnapshot detects
201
+ // fingerprint mismatch, emits file.externallyEdited, and persists
202
+ // the sidecar. Then loadSidecar to refresh comments/suggestions.
203
+ void useProofStore
204
+ .getState()
205
+ .loadSnapshot(activePath)
206
+ .then(() => useProofStore.getState().loadSidecar(activePath));
207
+ }
208
+ } catch {
209
+ // ignore malformed events
210
+ }
211
+ };
212
+ return () => {
213
+ es.close();
214
+ };
215
+ }, []);
216
+
217
+ // Proof-span popover state.
218
+ const [proofTarget, setProofTarget] = useState<HTMLElement | null>(null);
219
+
220
+ /**
221
+ * Ref to the editor scroll container. Used to compute block positions
222
+ * relative to the scrollable area for suggestion cards and comment pips.
223
+ *
224
+ * Phase D coordination: comment-pip positioning uses this same ref and the
225
+ * same blockRefPositions map computed below.
226
+ */
227
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
228
+
229
+ /** Map of block ref → position relative to scroll container */
230
+ const [blockRefPositions, setBlockRefPositions] = useState<
231
+ Map<string, { top: number; left: number; width: number; bottom: number }>
232
+ >(new Map());
233
+
234
+ // Subscribe to snapshot data for suggestion cards.
235
+ // NOTE: select the RAW stored references here — returning a freshly built
236
+ // array (e.g. `?? []` or `.filter(...)`) on every call makes
237
+ // useSyncExternalStore think the snapshot changed each render, which spins
238
+ // into a "Maximum update depth exceeded" loop. Derive defaults/filters below.
239
+ const snapshotBlocksRaw = useProofStore((s) =>
240
+ currentPath ? s.byPath[currentPath]?.snapshotBlocks : undefined
241
+ );
242
+ const suggestionsRaw = useProofStore((s) =>
243
+ currentPath ? s.byPath[currentPath]?.sidecar?.suggestions : undefined
244
+ );
245
+ const snapshotRevision = useProofStore((s) =>
246
+ currentPath ? (s.byPath[currentPath]?.snapshotRevision ?? 0) : 0
247
+ );
248
+ const commentsRaw = useProofStore((s) =>
249
+ currentPath ? s.byPath[currentPath]?.sidecar?.comments : undefined
250
+ );
251
+
252
+ const snapshotBlocks = useMemo(() => snapshotBlocksRaw ?? [], [snapshotBlocksRaw]);
253
+ const comments = useMemo(() => commentsRaw ?? [], [commentsRaw]);
254
+ const pendingSuggestions = useMemo(
255
+ () => suggestionsRaw?.filter((sg) => sg.status === "pending") ?? [],
256
+ [suggestionsRaw],
257
+ );
258
+
259
+ /** Group comments by block ref for pip rendering. */
260
+ const commentsByRef = useMemo(() => {
261
+ const map: Record<string, typeof comments> = {};
262
+ for (const c of comments) {
263
+ (map[c.ref] ??= []).push(c);
264
+ }
265
+ return map;
266
+ }, [comments]);
267
+
268
+ /** Tracks which block's comment thread is open and its anchor element. */
269
+ const [threadTarget, setThreadTarget] = useState<{ blockRef: string; el: HTMLElement } | null>(null);
270
+
271
+ /** Tracks the open human "suggest edit" popover (block + anchor + content). */
272
+ const [suggestTarget, setSuggestTarget] = useState<
273
+ { blockRef: string; markdown: string; anchor: { top: number; left: number } } | null
274
+ >(null);
275
+
276
+ /**
277
+ * Resolve the current editor selection to a top-level block.
278
+ *
279
+ * Primary strategy: map the selection to its top-level ProseMirror child
280
+ * INDEX, then look up snapshotBlocks[index] — the same index-based mapping
281
+ * used by the position-tracker effect. This is robust even when the DOM
282
+ * `data-block-ref` annotation has not been applied yet (e.g. snapshot still
283
+ * loading), which previously made the suggest/comment buttons silently
284
+ * no-op. Falls back to walking the DOM for an existing [data-block-ref].
285
+ */
286
+ const resolveSelectionBlock = useCallback((): {
287
+ blockRef: string;
288
+ blockEl: HTMLElement;
289
+ markdown: string;
290
+ } | null => {
291
+ if (!editorRef.current) return null;
292
+ const view = editorRef.current.view;
293
+ const { from } = view.state.selection;
294
+ const path = useEditorStore.getState().currentPath ?? "";
295
+ const blocks = useProofStore.getState().byPath[path]?.snapshotBlocks ?? [];
296
+
297
+ // Find the top-level child index containing the selection head.
298
+ const $pos = view.state.doc.resolve(from);
299
+ const topIndex = $pos.depth > 0 ? $pos.index(0) : 0;
300
+
301
+ const proseMirror = scrollContainerRef.current?.querySelector(".ProseMirror");
302
+ const children = proseMirror
303
+ ? (Array.from(proseMirror.children) as HTMLElement[])
304
+ : [];
305
+ const blockEl = children[topIndex] ?? null;
306
+
307
+ // Prefer the index-aligned snapshot block; fall back to the DOM attr.
308
+ const block = blocks[topIndex];
309
+ let blockRef: string | null =
310
+ block?.ref ?? blockEl?.getAttribute("data-block-ref") ?? null;
311
+ const markdown = block?.markdown ?? "";
312
+
313
+ if (!blockRef && blockEl) {
314
+ // Last-resort: DOM walk from selection anchor.
315
+ const domAt = view.domAtPos(from);
316
+ const node: HTMLElement | null =
317
+ domAt.node.nodeType === Node.ELEMENT_NODE
318
+ ? (domAt.node as HTMLElement)
319
+ : domAt.node.parentElement;
320
+ const found = node?.closest<HTMLElement>("[data-block-ref]") ?? null;
321
+ blockRef = found?.getAttribute("data-block-ref") ?? null;
322
+ }
323
+
324
+ if (!blockRef || !blockEl) return null;
325
+ return { blockRef, blockEl, markdown };
326
+ }, []);
327
+
328
+ const openSuggestForSelection = useCallback(() => {
329
+ const resolved = resolveSelectionBlock();
330
+ if (!resolved) return;
331
+ const rect = resolved.blockEl.getBoundingClientRect();
332
+ setSuggestTarget({
333
+ blockRef: resolved.blockRef,
334
+ markdown: resolved.markdown,
335
+ anchor: { top: rect.bottom + 4, left: rect.left },
336
+ });
337
+ }, [resolveSelectionBlock]);
338
+
339
+ const openCommentForSelection = useCallback(() => {
340
+ const resolved = resolveSelectionBlock();
341
+ if (!resolved) return;
342
+ setThreadTarget({ blockRef: resolved.blockRef, el: resolved.blockEl });
343
+ }, [resolveSelectionBlock]);
344
+
345
+ // ── Suggesting mode: capture human block edits as suggestions ──────────────
346
+ //
347
+ // In suggesting mode the editor stays editable but edits never touch the
348
+ // file. On flush (leaving a block or blurring the editor) we diff each
349
+ // top-level block against the snapshot, emit a human `suggestion.add` for
350
+ // every changed/added/removed block, then revert the editor to the snapshot
351
+ // so the pending suggestion cards render over the original content.
352
+
353
+ /** Set true whenever the user edits while in suggesting mode. */
354
+ const suggestDirtyRef = useRef(false);
355
+ /** Guards against re-entrant flushes (capture is async). */
356
+ const flushingRef = useRef(false);
357
+ /** Top-level block index that currently holds the selection. */
358
+ const activeBlockIndexRef = useRef<number | null>(null);
359
+
360
+ const normalizeMd = (s: string): string => s.replace(/\s+$/g, "").trimStart();
361
+
362
+ const flushSuggestions = useCallback(async () => {
363
+ if (flushingRef.current) return;
364
+ if (useEditorStore.getState().editMode !== "suggesting") return;
365
+ if (!suggestDirtyRef.current) return;
366
+ const ed = editorRef.current;
367
+ const path = useEditorStore.getState().currentPath;
368
+ if (!ed || !path) return;
369
+
370
+ const proseMirror = scrollContainerRef.current?.querySelector(".ProseMirror");
371
+ if (!proseMirror) return;
372
+ const children = Array.from(proseMirror.children) as HTMLElement[];
373
+ const snapBlocks =
374
+ useProofStore.getState().byPath[path]?.snapshotBlocks ?? [];
375
+ if (snapBlocks.length === 0) return;
376
+
377
+ flushingRef.current = true;
378
+ suggestDirtyRef.current = false;
379
+ try {
380
+ const getRevision = () =>
381
+ useProofStore.getState().byPath[path]?.snapshotRevision ?? 0;
382
+ const refresh = async () => {
383
+ await useProofStore.getState().loadSnapshot(path);
384
+ await useProofStore.getState().loadSidecar(path);
385
+ };
386
+
387
+ const count = Math.max(children.length, snapBlocks.length);
388
+ let captured = false;
389
+ for (let i = 0; i < count; i++) {
390
+ const el = children[i];
391
+ const snap = snapBlocks[i];
392
+ const curMd = el ? htmlToMarkdown(el.outerHTML).trim() : null;
393
+
394
+ if (snap && curMd !== null) {
395
+ if (normalizeMd(curMd) !== normalizeMd(snap.markdown)) {
396
+ const ok = await captureSuggestion({
397
+ path,
398
+ ref: snap.ref,
399
+ kind: "replace",
400
+ markdown: curMd,
401
+ getRevision,
402
+ refresh,
403
+ });
404
+ captured = captured || ok;
405
+ }
406
+ } else if (snap && curMd === null) {
407
+ const ok = await captureSuggestion({
408
+ path,
409
+ ref: snap.ref,
410
+ kind: "delete",
411
+ getRevision,
412
+ refresh,
413
+ });
414
+ captured = captured || ok;
415
+ } else if (!snap && curMd !== null && curMd.length > 0) {
416
+ // New trailing block: suggest inserting after the last known block.
417
+ const lastRef = snapBlocks[snapBlocks.length - 1]?.ref;
418
+ if (lastRef) {
419
+ const ok = await captureSuggestion({
420
+ path,
421
+ ref: lastRef,
422
+ kind: "insertAfter",
423
+ markdown: curMd,
424
+ getRevision,
425
+ refresh,
426
+ });
427
+ captured = captured || ok;
428
+ }
429
+ }
430
+ }
431
+
432
+ if (captured) {
433
+ // Reload sidecar so the new pending suggestion cards appear, then
434
+ // revert the editor to the snapshot (file unchanged).
435
+ await refresh();
436
+ const freshSnap =
437
+ useProofStore.getState().byPath[path]?.snapshotBlocks ?? snapBlocks;
438
+ const snapshotMarkdown = freshSnap.map((b) => b.markdown).join("\n\n");
439
+ isLoadingRef.current = true;
440
+ const html = await markdownToHtml(snapshotMarkdown, path);
441
+ ed.commands.setContent(html);
442
+ setTimeout(() => {
443
+ isLoadingRef.current = false;
444
+ }, 50);
445
+ }
446
+ } finally {
447
+ flushingRef.current = false;
448
+ }
449
+ }, []);
450
+
451
+ // Load snapshot (ordered block list) when path changes so suggestion cards
452
+ // can look up block content by ref.
453
+ useEffect(() => {
454
+ if (!currentPath) return;
455
+ void useProofStore.getState().loadSnapshot(currentPath);
456
+ }, [currentPath]);
457
+
458
+ /**
459
+ * After content renders, walk `.ProseMirror > *` to build ref→position map.
460
+ * Matches by index: the i-th ProseMirror child = snapshotBlocks[i].
461
+ *
462
+ * Phase D coordination: this effect also annotates each child element with
463
+ * data-block-ref for any consumer that needs CSS/query-based lookup.
464
+ */
465
+ useEffect(() => {
466
+ if (!currentPath || snapshotBlocks.length === 0 || !scrollContainerRef.current) return;
467
+ const container = scrollContainerRef.current;
468
+ const proseMirror = container.querySelector(".ProseMirror");
469
+ if (!proseMirror) return;
470
+ const children = Array.from(proseMirror.children) as HTMLElement[];
471
+ const containerRect = container.getBoundingClientRect();
472
+ const next = new Map<string, { top: number; left: number; width: number; bottom: number }>();
473
+ for (let i = 0; i < Math.min(children.length, snapshotBlocks.length); i++) {
474
+ const el = children[i];
475
+ const block = snapshotBlocks[i];
476
+ // Annotate DOM element — Phase D comment-pip and other consumers read this
477
+ el.setAttribute("data-block-ref", block.ref);
478
+ const rect = el.getBoundingClientRect();
479
+ next.set(block.ref, {
480
+ top: rect.top - containerRect.top + container.scrollTop,
481
+ left: rect.left - containerRect.left,
482
+ width: rect.width,
483
+ bottom: rect.bottom - containerRect.top + container.scrollTop,
484
+ });
485
+ }
486
+ setBlockRefPositions(next);
487
+ }, [currentPath, snapshotBlocks]);
488
+
170
489
  const handleUpdate = useCallback(
171
490
  ({ editor }: { editor: ReturnType<typeof useEditor> }) => {
172
491
  if (isLoadingRef.current || !editor) return;
492
+ // In suggesting mode, mark the edit dirty so the next block-change or
493
+ // blur flushes it into suggestions. Still push content to the store so
494
+ // the store guard (no autosave in suggesting mode) keeps it in sync.
495
+ if (useEditorStore.getState().editMode === "suggesting") {
496
+ suggestDirtyRef.current = true;
497
+ }
173
498
  const html = editor.getHTML();
174
499
  const md = htmlToMarkdown(html);
175
500
  useEditorStore.getState().updateContent(md);
@@ -181,10 +506,25 @@ export function KBEditor() {
181
506
  extensions: editorExtensions,
182
507
  content: "",
183
508
  onUpdate: handleUpdate,
509
+ onBlur: () => {
510
+ void flushSuggestions();
511
+ },
512
+ onSelectionUpdate: ({ editor: ed }) => {
513
+ if (useEditorStore.getState().editMode !== "suggesting") return;
514
+ const { from } = ed.state.selection;
515
+ const $pos = ed.state.doc.resolve(from);
516
+ const idx = $pos.depth > 0 ? $pos.index(0) : 0;
517
+ const prev = activeBlockIndexRef.current;
518
+ activeBlockIndexRef.current = idx;
519
+ // Moved to a different top-level block — flush edits to the prior one.
520
+ if (prev !== null && prev !== idx && suggestDirtyRef.current) {
521
+ void flushSuggestions();
522
+ }
523
+ },
184
524
  editorProps: {
185
525
  attributes: {
186
526
  class:
187
- "focus:outline-none min-h-[calc(100vh-12rem)] px-4 sm:px-8 py-6 max-w-3xl mx-auto",
527
+ "focus:outline-none min-h-[calc(100vh-12rem)] px-4 sm:px-8 py-6 max-w-[var(--editor-max-w,48rem)] mx-auto",
188
528
  },
189
529
  handleKeyDown: (view, event) => {
190
530
  if (
@@ -348,6 +688,10 @@ export function KBEditor() {
348
688
  immediatelyRender: false,
349
689
  });
350
690
 
691
+ // Stable ref to the editor so callbacks with empty deps reach the live instance.
692
+ const editorRef = useRef<typeof editor>(editor);
693
+ editorRef.current = editor;
694
+
351
695
  // When content updates from store (after loadPage), set it in editor
352
696
  const prevPathRef = useRef<string | null>(null);
353
697
  const renderedKeyRef = useRef<string | null>(null);
@@ -563,12 +907,136 @@ export function KBEditor() {
563
907
  </div>
564
908
  ) : (
565
909
  <div className="flex-1 relative" dir={isRtl ? "rtl" : undefined}>
910
+ {editMode === "suggesting" && (
911
+ <div className="absolute top-0 inset-x-0 z-20 flex items-center justify-center gap-2 px-3 py-1 bg-primary/10 border-b border-primary/20 text-[11px] text-primary pointer-events-none">
912
+ Suggesting mode · your edits become suggestions for review
913
+ </div>
914
+ )}
566
915
  <div
567
- className="absolute inset-0 overflow-y-auto"
916
+ ref={scrollContainerRef}
917
+ className={`absolute inset-0 overflow-y-auto ${
918
+ editMode === "suggesting" ? "pt-7" : ""
919
+ }`}
920
+ style={{ ["--editor-max-w" as string]: editorMaxW }}
568
921
  data-editor-scroll
569
922
  >
923
+ {/* Absolutely-positioned overlay for comment pips and suggestion cards.
924
+ height:0 so it doesn't push content; children overflow freely.
925
+ Positions from blockRefPositions are relative to scroll container top. */}
926
+ <div
927
+ aria-hidden="true"
928
+ className="relative pointer-events-none"
929
+ style={{ height: 0 }}
930
+ >
931
+ {/* Comment pips — one per block with at least one comment */}
932
+ {Object.entries(commentsByRef).map(([blockRef, blockComments]) => {
933
+ const pos = blockRefPositions.get(blockRef);
934
+ if (!pos) return null;
935
+ return (
936
+ <div key={`pip-${blockRef}`} style={{ pointerEvents: "auto" }}>
937
+ <CommentPip
938
+ blockRef={blockRef}
939
+ comments={blockComments}
940
+ top={pos.top + 4}
941
+ left={Math.max(0, pos.left - 20)}
942
+ onClick={() => {
943
+ const el = scrollContainerRef.current?.querySelector(
944
+ `[data-block-ref="${blockRef}"]`,
945
+ ) as HTMLElement | null;
946
+ if (el) setThreadTarget({ blockRef, el });
947
+ }}
948
+ />
949
+ </div>
950
+ );
951
+ })}
952
+
953
+ {/* Suggestion cards — one per pending suggestion */}
954
+ {currentPath && pendingSuggestions.map((sg) => {
955
+ const pos = blockRefPositions.get(sg.ref);
956
+ const currentBlock = snapshotBlocks.find((b) => b.ref === sg.ref);
957
+ if (!pos || !currentBlock) return null;
958
+ const cardTop =
959
+ sg.kind === "insertAfter" ? pos.bottom + 4 :
960
+ sg.kind === "insertBefore" ? Math.max(0, pos.top - 80) :
961
+ pos.top;
962
+ return (
963
+ <div key={`sug-${sg.id}`} style={{ pointerEvents: "auto" }}>
964
+ <SuggestionCard
965
+ path={currentPath}
966
+ suggestion={sg}
967
+ currentMarkdown={currentBlock.markdown}
968
+ baseRevision={snapshotRevision}
969
+ getLatestRevision={() =>
970
+ useProofStore.getState().byPath[currentPath]?.snapshotRevision ?? 0
971
+ }
972
+ top={cardTop}
973
+ left={pos.left}
974
+ width={pos.width}
975
+ onSettled={() => {
976
+ void useProofStore.getState().loadSidecar(currentPath);
977
+ void useProofStore.getState().loadSnapshot(currentPath);
978
+ }}
979
+ />
980
+ </div>
981
+ );
982
+ })}
983
+ </div>
984
+
985
+ {/* Comment thread — Portal-rendered, driven by threadTarget */}
986
+ {threadTarget && currentPath && (
987
+ <CommentThread
988
+ path={currentPath}
989
+ blockRef={threadTarget.blockRef}
990
+ comments={commentsByRef[threadTarget.blockRef] ?? []}
991
+ anchorEl={threadTarget.el}
992
+ onClose={() => setThreadTarget(null)}
993
+ />
994
+ )}
995
+
996
+ {/* Human suggest-edit popover — driven by suggestTarget */}
997
+ {suggestTarget && currentPath && (
998
+ <SuggestEditPopover
999
+ path={currentPath}
1000
+ blockRef={suggestTarget.blockRef}
1001
+ currentMarkdown={suggestTarget.markdown}
1002
+ anchor={suggestTarget.anchor}
1003
+ onClose={() => setSuggestTarget(null)}
1004
+ />
1005
+ )}
1006
+
570
1007
  <EditorContent editor={editor} />
571
- <EditorBubbleMenu editor={editor} />
1008
+ {/* Proof-span hover delegation */}
1009
+ <div
1010
+ aria-hidden="true"
1011
+ className="contents"
1012
+ onMouseOver={(e) => {
1013
+ const span = (e.target as HTMLElement).closest<HTMLElement>(".proof-span");
1014
+ if (span && span !== proofTarget) setProofTarget(span);
1015
+ }}
1016
+ onMouseOut={(e) => {
1017
+ const related = e.relatedTarget as HTMLElement | null;
1018
+ if (!related?.closest(".proof-span")) setProofTarget(null);
1019
+ }}
1020
+ />
1021
+ {currentPath && (
1022
+ <ProofSpanPopover
1023
+ targetEl={proofTarget}
1024
+ path={currentPath}
1025
+ onClose={() => setProofTarget(null)}
1026
+ onComment={() => {
1027
+ if (!proofTarget) return;
1028
+ const blockEl = proofTarget.closest<HTMLElement>("[data-block-ref]");
1029
+ if (!blockEl) return;
1030
+ const blockRef = blockEl.getAttribute("data-block-ref");
1031
+ if (blockRef) setThreadTarget({ blockRef, el: blockEl });
1032
+ }}
1033
+ />
1034
+ )}
1035
+ <EditorBubbleMenu
1036
+ editor={editor}
1037
+ onSuggestEdit={openSuggestForSelection}
1038
+ onComment={openCommentForSelection}
1039
+ />
572
1040
  <TableMenu editor={editor} />
573
1041
  <SlashCommands editor={editor} />
574
1042
  <WikiLinkPicker
@@ -577,7 +1045,7 @@ export function KBEditor() {
577
1045
  />
578
1046
 
579
1047
  {/* AI Edit Prompt + slash hint */}
580
- <div className="max-w-3xl mx-auto px-8 pb-8 flex items-center gap-4">
1048
+ <div className="max-w-[var(--editor-max-w,48rem)] mx-auto px-8 pb-8 flex items-center gap-4">
581
1049
  <button
582
1050
  onClick={handleOpenAI}
583
1051
  className="group flex items-center gap-2 text-[13px] text-muted-foreground/50 hover:text-muted-foreground transition-colors cursor-pointer"
@@ -618,11 +1086,46 @@ export function KBEditor() {
618
1086
  </kbd>{" "}
619
1087
  commands
620
1088
  </span>
621
- <span>
622
- {saveStatus === "saving" && "Saving..."}
623
- {saveStatus === "saved" && "Saved"}
624
- {saveStatus === "error" && "Save failed"}
625
- </span>
1089
+ <div className="flex items-center gap-3">
1090
+ {/* Mode toggle */}
1091
+ <div
1092
+ className="flex items-center rounded-md border border-border overflow-hidden text-[10.5px]"
1093
+ role="radiogroup"
1094
+ aria-label="Edit mode"
1095
+ >
1096
+ <button
1097
+ type="button"
1098
+ role="radio"
1099
+ aria-checked={editMode === "editing"}
1100
+ onClick={() => setEditMode("editing")}
1101
+ className={`px-2 py-0.5 transition-colors ${
1102
+ editMode === "editing"
1103
+ ? "bg-primary text-primary-foreground"
1104
+ : "text-muted-foreground hover:bg-accent"
1105
+ }`}
1106
+ >
1107
+ Editing
1108
+ </button>
1109
+ <button
1110
+ type="button"
1111
+ role="radio"
1112
+ aria-checked={editMode === "suggesting"}
1113
+ onClick={() => setEditMode("suggesting")}
1114
+ className={`px-2 py-0.5 transition-colors ${
1115
+ editMode === "suggesting"
1116
+ ? "bg-primary text-primary-foreground"
1117
+ : "text-muted-foreground hover:bg-accent"
1118
+ }`}
1119
+ >
1120
+ Suggesting
1121
+ </button>
1122
+ </div>
1123
+ <span>
1124
+ {saveStatus === "saving" && "Saving..."}
1125
+ {saveStatus === "saved" && "Saved"}
1126
+ {saveStatus === "error" && "Save failed"}
1127
+ </span>
1128
+ </div>
626
1129
  </div>
627
1130
  </>
628
1131
  )}
@@ -0,0 +1,60 @@
1
+ import { Mark, mergeAttributes } from "@tiptap/core";
2
+
3
+ export const ProofSpan = Mark.create({
4
+ name: "proofSpan",
5
+ priority: 900,
6
+ inclusive: false,
7
+ keepOnSplit: false,
8
+
9
+ addAttributes() {
10
+ return {
11
+ spanId: {
12
+ default: null,
13
+ parseHTML: (el) => el.getAttribute("id"),
14
+ renderHTML: (a) => ({ id: a.spanId }),
15
+ },
16
+ origin: {
17
+ default: "ai",
18
+ parseHTML: (el) => el.getAttribute("origin"),
19
+ renderHTML: (a) => ({ origin: a.origin }),
20
+ },
21
+ basis: {
22
+ default: null,
23
+ parseHTML: (el) => el.getAttribute("basis"),
24
+ renderHTML: (a) => ({ basis: a.basis }),
25
+ },
26
+ basisDetail: {
27
+ default: null,
28
+ parseHTML: (el) => el.getAttribute("basis-detail"),
29
+ renderHTML: (a) => ({ "basis-detail": a.basisDetail }),
30
+ },
31
+ by: {
32
+ default: null,
33
+ parseHTML: (el) => el.getAttribute("by"),
34
+ renderHTML: (a) => ({ by: a.by }),
35
+ },
36
+ at: {
37
+ default: null,
38
+ parseHTML: (el) => el.getAttribute("at"),
39
+ renderHTML: (a) => ({ at: a.at }),
40
+ },
41
+ inResponseTo: {
42
+ default: null,
43
+ parseHTML: (el) => el.getAttribute("in-response-to"),
44
+ renderHTML: (a) => ({ "in-response-to": a.inResponseTo }),
45
+ },
46
+ };
47
+ },
48
+
49
+ parseHTML() {
50
+ return [{ tag: "proof-span" }];
51
+ },
52
+
53
+ renderHTML({ HTMLAttributes }) {
54
+ return [
55
+ "proof-span",
56
+ mergeAttributes(HTMLAttributes, { class: "proof-span" }),
57
+ 0,
58
+ ];
59
+ },
60
+ });
@@ -28,6 +28,7 @@ import { createLowlight } from "lowlight";
28
28
  import { CalloutExtension } from "./callout-extension";
29
29
  import { DragHandle } from "./extensions/drag-handle";
30
30
  import { HeadingAnchors } from "./extensions/heading-anchors";
31
+ import { ProofSpan } from "./extensions/proof-span";
31
32
  import { ResizableImage } from "./extensions/resizable-image";
32
33
  import { WikiLink } from "./wiki-link-extension";
33
34
 
@@ -139,6 +140,7 @@ export const editorExtensions = [
139
140
  },
140
141
  }),
141
142
  CalloutExtension,
143
+ ProofSpan,
142
144
  TextAlign.configure({ types: ["heading", "paragraph"] }),
143
145
  Subscript,
144
146
  Superscript,