wiki-viewer 1.13.2 → 1.14.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 (148) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +1 -0
  3. package/.next/standalone/.next/build-manifest.json +3 -3
  4. package/.next/standalone/.next/routes-manifest.json +6 -0
  5. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  12. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  13. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  14. package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
  15. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  16. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  18. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  21. package/.next/standalone/.next/server/app/api/agent/activity/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/agent/events/[...path]/route.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/agent/files/[...path]/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/agent/fs/file/[...path]/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/agent/fs/ls/[[...path]]/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/agent/fs/move/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/agent/fs/search/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/agent/internal/span/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/agent/settings/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/agent/sidecar/[...path]/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js +1 -1
  32. package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/assets/[...path]/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/share/[token]/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/share/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/system/browse/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/system/reveal/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/api/system/set-root/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/api/system/workspaces/[id]/open/route.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/api/system/workspaces/[id]/refresh/route.js.nft.json +1 -1
  41. package/.next/standalone/.next/server/app/api/system/workspaces/[id]/route.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/api/system/workspaces/route.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/api/upload/[...path]/route.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/api/wiki/app/route.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/api/wiki/backlinks/route.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/api/wiki/content/route.js.nft.json +1 -1
  47. package/.next/standalone/.next/server/app/api/wiki/download/route.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/api/wiki/file/route.js.nft.json +1 -1
  49. package/.next/standalone/.next/server/app/api/wiki/folder/route.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/api/wiki/git-branches/route.js.nft.json +1 -1
  51. package/.next/standalone/.next/server/app/api/wiki/git-checkout/route.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/api/wiki/git-diff/route.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/api/wiki/git-file-info/route.js.nft.json +1 -1
  54. package/.next/standalone/.next/server/app/api/wiki/git-history/route.js.nft.json +1 -1
  55. package/.next/standalone/.next/server/app/api/wiki/git-pull/route.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/api/wiki/move/route.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/api/wiki/new-file/route.js.nft.json +1 -1
  58. package/.next/standalone/.next/server/app/api/wiki/outlinks/route/app-paths-manifest.json +3 -0
  59. package/.next/standalone/.next/server/app/api/wiki/outlinks/route/build-manifest.json +9 -0
  60. package/.next/standalone/.next/server/app/api/wiki/outlinks/route/server-reference-manifest.json +4 -0
  61. package/.next/standalone/.next/server/app/api/wiki/outlinks/route.js +16 -0
  62. package/.next/standalone/.next/server/app/api/wiki/outlinks/route.js.map +5 -0
  63. package/.next/standalone/.next/server/app/api/wiki/outlinks/route.js.nft.json +1 -0
  64. package/.next/standalone/.next/server/app/api/wiki/outlinks/route_client-reference-manifest.js +3 -0
  65. package/.next/standalone/.next/server/app/api/wiki/page/route.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/api/wiki/presence/route.js.nft.json +1 -1
  67. package/.next/standalone/.next/server/app/api/wiki/route.js.nft.json +1 -1
  68. package/.next/standalone/.next/server/app/api/wiki/search/route.js.nft.json +1 -1
  69. package/.next/standalone/.next/server/app/api/wiki/slugs/route.js.nft.json +1 -1
  70. package/.next/standalone/.next/server/app/api/wiki/upload/route.js.nft.json +1 -1
  71. package/.next/standalone/.next/server/app/api/wiki/watch/route.js.nft.json +1 -1
  72. package/.next/standalone/.next/server/app/index.html +1 -1
  73. package/.next/standalone/.next/server/app/index.rsc +3 -3
  74. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  75. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
  76. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  77. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +2 -2
  78. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  79. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  80. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  81. package/.next/standalone/.next/server/app/s/[token]/page.js.nft.json +1 -1
  82. package/.next/standalone/.next/server/app/s/[token]/page_client-reference-manifest.js +1 -1
  83. package/.next/standalone/.next/server/app/signin/page_client-reference-manifest.js +1 -1
  84. package/.next/standalone/.next/server/app-paths-manifest.json +1 -0
  85. package/.next/standalone/.next/server/chunks/[root-of-the-server]__01h996g._.js +3 -0
  86. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0oruwos._.js → [root-of-the-server]__0stkifk._.js} +2 -2
  87. package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_wiki_outlinks_route_actions_0v-5x5r.js +3 -0
  88. package/.next/standalone/.next/server/chunks/ssr/{0~w0_katex_dist_katex_mjs_0100dfi._.js → 0~w0_katex_dist_katex_mjs_029cdbg._.js} +1 -1
  89. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0~9qfcs._.js → [root-of-the-server]__0z41nrr._.js} +2 -2
  90. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0osa2xb._.js → [root-of-the-server]__10m6c40._.js} +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/_0yevh-d._.js +2 -2
  92. package/.next/standalone/.next/server/chunks/ssr/_0~a9dyk._.js +3 -3
  93. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_01df~jf._.js → node_modules__pnpm_0-_58b8._.js} +1 -1
  94. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0u0.e4r._.js → node_modules__pnpm_0026mgr._.js} +1 -1
  95. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0uo7x8s._.js → node_modules__pnpm_02bje_g._.js} +1 -1
  96. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0z~fyt8._.js → node_modules__pnpm_04v1_ty._.js} +2 -2
  97. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_04l9bx6._.js → node_modules__pnpm_0607~kg._.js} +2 -2
  98. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0lf1dtw._.js → node_modules__pnpm_08edl1n._.js} +1 -1
  99. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0i8hw7q._.js → node_modules__pnpm_08xpp3w._.js} +1 -1
  100. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0nt8do2._.js → node_modules__pnpm_09o_spj._.js} +1 -1
  101. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0a2d3t0._.js → node_modules__pnpm_0aha1~u._.js} +1 -1
  102. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0zgqorw._.js → node_modules__pnpm_0ajqqlq._.js} +1 -1
  103. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0pu-2sk._.js → node_modules__pnpm_0b8odbc._.js} +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0hrj.2x._.js → node_modules__pnpm_0e0r_qk._.js} +2 -2
  105. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0a71te0._.js → node_modules__pnpm_0f_n_zp._.js} +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0mg7y.1._.js → node_modules__pnpm_0gos._c._.js} +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_02_h794._.js → node_modules__pnpm_0hzzx9d._.js} +1 -1
  108. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0m-sqwd._.js → node_modules__pnpm_0i2m0b.._.js} +2 -2
  109. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0k.5pst._.js → node_modules__pnpm_0i6_kfe._.js} +2 -2
  110. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0suw78t._.js → node_modules__pnpm_0k-7uzm._.js} +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0mmji-1._.js → node_modules__pnpm_0ru1i2-._.js} +2 -2
  112. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0_4caj_._.js → node_modules__pnpm_0t4l89_._.js} +1 -1
  113. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0wqzfz7._.js → node_modules__pnpm_0w.kwue._.js} +1 -1
  114. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0gqcxiq._.js → node_modules__pnpm_0xk042~._.js} +1 -1
  115. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0ca5pep._.js → node_modules__pnpm_0xr.ztu._.js} +1 -1
  116. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0w_zv~3._.js → node_modules__pnpm_0y2h_cz._.js} +1 -1
  117. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0o.ke1-._.js → node_modules__pnpm_0~i3a.i._.js} +1 -1
  118. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_109~spe._.js → node_modules__pnpm_11ede0p._.js} +2 -2
  119. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0mn3jk_._.js → node_modules__pnpm_13p348f._.js} +1 -1
  120. package/.next/standalone/.next/server/chunks/ssr/src_app_page_tsx_0ss2.w7._.js +2 -2
  121. package/.next/standalone/.next/server/functions-config-manifest.json +1 -0
  122. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  123. package/.next/standalone/.next/server/middleware-manifest.json +1 -1
  124. package/.next/standalone/.next/server/pages/404.html +1 -1
  125. package/.next/standalone/.next/server/pages/500.html +1 -1
  126. package/.next/standalone/.next/static/chunks/02kg.lbkfi1yc.css +5 -0
  127. package/.next/standalone/.next/static/chunks/{0._793qr3oswh.js → 0ey6h_rkpuzxs.js} +8 -8
  128. package/.next/standalone/.next/static/chunks/{08bdsfl84~3l..js → 0jt1z17mjmjde.js} +7 -7
  129. package/.next/standalone/.next/static/chunks/{0wnjskl5t63nx.js → 0rcwd6-.iu~nd.js} +3 -3
  130. package/.next/standalone/.pi/ownership.json +3 -0
  131. package/.next/standalone/TODO.md +16 -0
  132. package/.next/standalone/package.json +2 -1
  133. package/.next/standalone/src/app/api/wiki/outlinks/route.ts +76 -0
  134. package/.next/standalone/src/app/globals.css +13 -15
  135. package/.next/standalone/src/components/ai-panel/activity-row.tsx +268 -53
  136. package/.next/standalone/src/components/ai-panel/ai-panel.tsx +3 -9
  137. package/.next/standalone/src/components/editor/backlinks-panel.tsx +47 -17
  138. package/.next/standalone/src/components/editor/bubble-menu.tsx +22 -1
  139. package/.next/standalone/src/components/editor/comment-thread.tsx +3 -2
  140. package/.next/standalone/src/components/editor/editor.tsx +9 -1
  141. package/.next/standalone/src/components/editor/suggestion-card.tsx +4 -0
  142. package/.next/standalone/src/components/editor/view-mode-comment-button.tsx +77 -0
  143. package/.next/standalone/tsconfig.tsbuildinfo +1 -1
  144. package/package.json +2 -1
  145. package/.next/standalone/.next/static/chunks/0v9z3r.aiug_u.css +0 -5
  146. /package/.next/standalone/.next/static/{Fe9EDoNlINdV6yroHKsnE → WMOTgfV82apmdovxzgbMx}/_buildManifest.js +0 -0
  147. /package/.next/standalone/.next/static/{Fe9EDoNlINdV6yroHKsnE → WMOTgfV82apmdovxzgbMx}/_clientMiddlewareManifest.js +0 -0
  148. /package/.next/standalone/.next/static/{Fe9EDoNlINdV6yroHKsnE → WMOTgfV82apmdovxzgbMx}/_ssgManifest.js +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ "claims": []
3
+ }
@@ -2,3 +2,19 @@
2
2
  <!-- pi-todo-md:schema=1 -->
3
3
 
4
4
  ## Tasks
5
+
6
+ ## Agent Presence (Theme 2)
7
+ - [ ] Live agent orb — drifting pastel orb in editor margin when agent holds Tier-2 write lock on current doc. Poll /api/agent/files/{path} collab-state or reuse presence heartbeat; render orb anchored to the active block position. Use gradient-orb pastel tokens (mint/peach/lavender). Hover = agent id + op type + elapsed time. <!-- pi-todo-md:id=1 -->
8
+ - note: Source of truth for "is agent active here": X-Collab-State header from /api/agent/files/{path} (active/tracked/untracked). Owner UI should poll this for currently-open file. No new backend needed, just a frontend polling hook + orb component. Wow ★★★★ / effort M.
9
+ - [ ] Watch-mode — when another agent is editing the open doc, auto-poll the event-stream (SSE or poll /api/agent/activity?file=) and apply incoming proof-span diffs to the TipTap view in near-real-time. Toggle: <!-- pi-todo-md:id=2 -->
10
+ - note: Toggle button in editor toolbar: "Watch live" on/off. SSE from /api/agent/events or poll /api/agent/activity?file=&since=. Render incoming proof-spans as fading highlights before they settle. Prereq: verify event-log completeness (not GC'd mid-session). Wow ★★★★ / effort M.
11
+ - [x] Activity newsfeed — replace the current raw activity panel with an editorial changelog. Each entry: agent avatar chip + human-readable summary ("Claude appended 3 blocks to notes.md · 2 pending your review") + relative timestamp. Group by file. Link to pending proof-spans. Data already exists in /api/agent/activity. Wow ★★★ / effort S. <!-- pi-todo-md:id=3 -->
12
+
13
+ ## Local Wiki Graph
14
+ - [ ] Mini force-graph in backlinks panel — add a "Graph" tab alongside the existing backlinks list. Render a small D3/vis-network force graph: current doc = center node, backlinkers + outgoing [[links]] = surrounding nodes, edges = link direction. Click node = navigate. Backlinks panel + wikilink parser both already exist; missing piece = graph renderer + outgoing-links API endpoint. Wow ★★★★ / effort M. <!-- pi-todo-md:id=4 -->
15
+ - [x] Outgoing links API — new /api/wiki/outlinks?path= endpoint: parse [[wikilinks]] from a file using remark-wikilinks, return list of {slug, resolved_path, exists} entries. Needed by the graph tab. remark-wikilinks.ts already parses them; need to expose via HTTP + resolve slugs against the file index. Effort S. <!-- pi-todo-md:id=5 -->
16
+
17
+ ## Comment on Share
18
+ - [ ] Share comment DB schema — add share_comments table to shared.db: id, share_token, body (text), author_name (nullable, visitor-supplied), created_at, status (pending|approved|rejected). Owner-only read/approve/reject via /api/share/[token]/comments (auth-gated). Visitors POST via public /api/share/[token]/comment (rate-limited, no auth). Wow ★★★★ / effort M. <!-- pi-todo-md:id=6 -->
19
+ - [ ] Share page comment UI — comment box at the bottom of the share viewer page. Name field (optional) + body textarea + submit. Pending state: 'Sent for review'. Rate-limit feedback. No JS framework gymnastics — simple form submit or fetch POST. <!-- pi-todo-md:id=7 -->
20
+ - [ ] Owner comment inbox — notification badge on shared-links panel when there are pending comments. Inline approve/reject with the comment body shown. On approve: comment becomes visible on the share page to all future visitors. On reject: silently dropped. <!-- pi-todo-md:id=8 -->
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-viewer",
3
- "version": "1.13.2",
3
+ "version": "1.14.1",
4
4
  "description": "Local file viewer and wiki — view markdown, HTML, PDFs, notebooks, office docs, and more from any directory",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "wiki": "node bin/wiki-viewer.js",
20
20
  "postbuild": "node bin/postbuild.js",
21
21
  "prepublishOnly": "npm run build",
22
+ "prepack": "node -e \"require('fs').existsSync('.next/standalone/server.js')||(console.error('ERROR: .next/standalone/server.js missing, run pnpm build before publishing'),process.exit(1))\"",
22
23
  "test": "tsx --import ./src/tests/proof/preload.ts --test src/tests/proof/*.test.ts"
23
24
  },
24
25
  "engines": {
@@ -0,0 +1,76 @@
1
+ export const runtime = "nodejs";
2
+
3
+ import { NextResponse } from "next/server";
4
+ import { resolveWorkspaceForUser } from "@/lib/workspace-context";
5
+ import { getSearchDb } from "@/lib/search/search-db";
6
+ import { ensureIndexer } from "@/lib/search/indexer";
7
+
8
+ // Matches [[slug]], [[slug|alias]], [[slug#anchor]] — slug is [a-z0-9-]+
9
+ const WIKILINK_RE = /\[\[([a-z0-9-]+)(?:\|[^\]#|]+|#[a-z0-9-]+)?\]\]/gi;
10
+
11
+ function slugFromPath(filePath: string): string {
12
+ const base = filePath.split("/").pop() ?? filePath;
13
+ return base.replace(/\.md$/i, "").toLowerCase();
14
+ }
15
+
16
+ export interface OutlinkEntry {
17
+ slug: string;
18
+ resolved_path: string | null;
19
+ exists: boolean;
20
+ }
21
+
22
+ export async function GET(request: Request) {
23
+ const ctx = await resolveWorkspaceForUser(request);
24
+ if (!ctx.ok) return NextResponse.json({ error: ctx.code }, { status: ctx.status });
25
+
26
+ const url = new URL(request.url);
27
+ const filePath = url.searchParams.get("path") ?? "";
28
+ if (!filePath) {
29
+ return NextResponse.json({ error: "path required" }, { status: 400 });
30
+ }
31
+
32
+ // Warm indexer in background (same pattern as backlinks route)
33
+ ensureIndexer(ctx.ws.id, ctx.rootDir).catch((e) =>
34
+ console.error("[outlinks] ensureIndexer failed", e),
35
+ );
36
+
37
+ const db = getSearchDb();
38
+
39
+ // Read body from search index (avoids a file-system read)
40
+ const row = db
41
+ .prepare(`SELECT body FROM docs WHERE ws = ? AND path = ?`)
42
+ .get(ctx.ws.id, filePath) as { body: string } | undefined;
43
+
44
+ if (!row) {
45
+ return NextResponse.json({ error: "file not indexed" }, { status: 404 });
46
+ }
47
+
48
+ // Extract unique slugs from [[wikilinks]]
49
+ const slugs = new Set<string>();
50
+ const re = new RegExp(WIKILINK_RE.source, "gi");
51
+ let match: RegExpExecArray | null;
52
+ while ((match = re.exec(row.body)) !== null) {
53
+ if (match[1]) slugs.add(match[1].toLowerCase());
54
+ }
55
+
56
+ if (slugs.size === 0) {
57
+ return NextResponse.json({ links: [] });
58
+ }
59
+
60
+ // Build slug → path map from indexed .md files (single DB pass)
61
+ const allMdPaths = db
62
+ .prepare(`SELECT path FROM docs WHERE ws = ? AND path LIKE '%.md'`)
63
+ .all(ctx.ws.id) as { path: string }[];
64
+
65
+ const pathBySlug = new Map<string, string>();
66
+ for (const { path } of allMdPaths) {
67
+ pathBySlug.set(slugFromPath(path), path);
68
+ }
69
+
70
+ const links: OutlinkEntry[] = Array.from(slugs).map((slug) => {
71
+ const resolved_path = pathBySlug.get(slug) ?? null;
72
+ return { slug, resolved_path, exists: resolved_path !== null };
73
+ });
74
+
75
+ return NextResponse.json({ links });
76
+ }
@@ -743,12 +743,21 @@ nextjs-portal {
743
743
  background: color-mix(in srgb, var(--accent-foreground) 10%, transparent);
744
744
  border-bottom-style: solid;
745
745
  }
746
- .wiki-link[data-broken="true"] {
746
+ .wiki-link[data-broken="true"],
747
+ .wiki-link:has([data-broken="true"]) {
748
+ color: var(--destructive);
749
+ opacity: 0.6;
750
+ border-bottom: none;
751
+ text-decoration: line-through;
752
+ }
753
+ /* ProseMirror wraps text in a span — also target it directly */
754
+ .wiki-link [data-broken="true"] {
755
+ text-decoration: line-through;
747
756
  color: var(--destructive);
748
- border-bottom-color: var(--destructive);
749
- border-bottom-style: dashed;
750
757
  }
751
- .wiki-link[data-broken="true"]:hover {
758
+ .wiki-link[data-broken="true"]:hover,
759
+ .wiki-link:has([data-broken="true"]):hover {
760
+ opacity: 1;
752
761
  background: color-mix(in srgb, var(--destructive) 10%, transparent);
753
762
  }
754
763
 
@@ -967,17 +976,6 @@ proof-span.proof-span:hover,
967
976
  margin: 1.4em 0 0.35rem;
968
977
  }
969
978
 
970
- /* Drop cap on first paragraph */
971
- [data-skin="editorial"] .tiptap > p:first-child::first-letter {
972
- float: left;
973
- font-family: var(--font-fraunces), Georgia, serif;
974
- font-size: 3.4em;
975
- line-height: 0.72;
976
- padding: 0.05em 0.12em 0 0;
977
- color: var(--primary);
978
- font-weight: 600;
979
- }
980
-
981
979
  [data-skin="editorial"] .tiptap p {
982
980
  margin-bottom: 0.75rem;
983
981
  }
@@ -1,69 +1,284 @@
1
1
  "use client";
2
2
 
3
- import { memo } from "react";
3
+ import { memo, useEffect, useMemo, useState, type CSSProperties } from "react";
4
+ import { useEditorStore } from "@/stores/editor-store";
5
+ import { wsFetch } from "@/lib/workspace-client";
4
6
  import type { ActivityEvent } from "@/lib/proof/activity-shared";
5
7
 
6
- function timeAgo(iso: string): string {
7
- const diff = Math.max(0, Date.now() - new Date(iso).getTime());
8
- const s = Math.floor(diff / 1000);
9
- if (s < 60) return `${s}s ago`;
10
- const m = Math.floor(s / 60);
11
- if (m < 60) return `${m}m ago`;
12
- const h = Math.floor(m / 60);
13
- if (h < 24) return `${h}h ago`;
14
- return `${Math.floor(h / 24)}d ago`;
8
+ type ReviewCounts = Record<string, number>;
9
+
10
+ type ActivityGroup = {
11
+ path: string;
12
+ filename: string;
13
+ latestAt: string;
14
+ events: ActivityEvent[];
15
+ };
16
+
17
+ function hashString(input: string): number {
18
+ let hash = 0;
19
+ for (let i = 0; i < input.length; i += 1) {
20
+ hash = (hash * 31 + input.charCodeAt(i)) | 0;
21
+ }
22
+ return Math.abs(hash);
23
+ }
24
+
25
+ function agentLabel(agentId: string): string {
26
+ const raw = agentId.split(/[:/]/).pop() ?? agentId;
27
+ const compact = raw.replace(/[^a-z0-9]/gi, "");
28
+ return (compact.slice(0, 2) || raw.slice(0, 2) || "??").toUpperCase();
29
+ }
30
+
31
+ function agentName(agentId: string): string {
32
+ const raw = agentId.split(/[:/]/).pop() ?? agentId;
33
+ return raw.replace(/[-_]+/g, " ").replace(/^./, (c) => c.toUpperCase());
15
34
  }
16
35
 
17
- function verbFor(type: string): string {
18
- const map: Record<string, string> = {
19
- "block.replace": "replaced",
20
- "block.insertAfter": "inserted after",
21
- "block.insertBefore": "inserted before",
22
- "block.delete": "deleted",
23
- "block.append": "appended",
24
- "block.prepend": "prepended",
25
- "comment.add": "commented on",
26
- "comment.reply": "replied to comment",
27
- "comment.resolve": "resolved comment",
28
- "comment.reopen": "reopened comment",
29
- "suggestion.add": "suggested",
30
- "suggestion.accept": "accepted suggestion",
31
- "suggestion.reject": "rejected suggestion",
36
+ function agentChipStyle(agentId: string): CSSProperties {
37
+ const hue = hashString(agentId) % 360;
38
+ return {
39
+ backgroundColor: `hsl(${hue} 72% 42%)`,
40
+ color: "white",
32
41
  };
33
- return map[type] ?? type;
34
42
  }
35
43
 
36
- function ActivityRowImpl({ event }: { event: ActivityEvent }) {
37
- const filename = event.path.split("/").pop() ?? event.path;
38
- const ref = typeof event["ref"] === "string" ? (event["ref"] as string) : "";
44
+ function timeAgo(iso: string): string {
45
+ const diffSeconds = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000));
46
+ if (diffSeconds < 45) return "just now";
47
+
48
+ const steps: Array<[number, Intl.RelativeTimeFormatUnit]> = [
49
+ [60, "second"],
50
+ [60, "minute"],
51
+ [24, "hour"],
52
+ [7, "day"],
53
+ ];
54
+ let value = diffSeconds;
55
+ let unit: Intl.RelativeTimeFormatUnit = "second";
56
+ for (const [step, nextUnit] of steps) {
57
+ if (value < step) break;
58
+ value = Math.floor(value / step);
59
+ unit = nextUnit;
60
+ }
61
+
62
+ const rtf = new Intl.RelativeTimeFormat("en", { numeric: "always", style: "short" });
63
+ return rtf.format(-value, unit).replace(/\s+/g, " ").replace(/\./g, "");
64
+ }
65
+
66
+ function blockCount(event: ActivityEvent): number {
67
+ if (Array.isArray((event as { refs?: unknown }).refs)) {
68
+ return ((event as { refs?: unknown[] }).refs ?? []).length;
69
+ }
70
+ return typeof (event as { ref?: unknown }).ref === "string" ? 1 : 0;
71
+ }
72
+
73
+ function summaryFor(event: ActivityEvent): string {
74
+ const actor = agentName(event.by);
75
+ const kind = event.type;
76
+ const count = blockCount(event);
77
+
78
+ if (kind === "block.append" || (kind === "block.inserted" && (event as { position?: string }).position === "end")) {
79
+ return `${actor} appended ${count || 1} block${(count || 1) === 1 ? "" : "s"}`;
80
+ }
81
+ if (kind === "block.prepend" || (kind === "block.inserted" && (event as { position?: string }).position === "start")) {
82
+ return `${actor} prepended ${count || 1} block${(count || 1) === 1 ? "" : "s"}`;
83
+ }
84
+ if (kind === "block.inserted") {
85
+ return `${actor} inserted ${count || 1} block${(count || 1) === 1 ? "" : "s"}`;
86
+ }
87
+ if (kind === "block.replaced" || kind === "file.rawWritten") {
88
+ return `${actor} edited`;
89
+ }
90
+ if (kind === "block.deleted") {
91
+ return `${actor} deleted ${count || 1} block${(count || 1) === 1 ? "" : "s"}`;
92
+ }
93
+ if (kind === "suggestion.added") {
94
+ return `${actor} suggested a change`;
95
+ }
96
+ if (kind === "suggestion.accepted") {
97
+ return `${actor} accepted a suggestion`;
98
+ }
99
+ if (kind === "suggestion.rejected") {
100
+ return `${actor} rejected a suggestion`;
101
+ }
102
+ if (kind === "comment.added") {
103
+ return `${actor} commented`;
104
+ }
105
+ if (kind === "comment.replied") {
106
+ return `${actor} replied`;
107
+ }
108
+ if (kind === "comment.resolved") {
109
+ return `${actor} resolved a comment`;
110
+ }
111
+ if (kind === "comment.reopened") {
112
+ return `${actor} reopened a comment`;
113
+ }
114
+ if (kind === "file.externallyEdited") {
115
+ return `${actor} changed file externally`;
116
+ }
117
+ return `${actor} ${kind.replace(/^\w+\./, "").replace(/([a-z])([A-Z])/g, "$1 $2")}`;
118
+ }
119
+
120
+ function groupActivity(activity: ActivityEvent[]): ActivityGroup[] {
121
+ const groups = new Map<string, ActivityEvent[]>();
122
+ for (const event of activity) {
123
+ const list = groups.get(event.path) ?? [];
124
+ list.push(event);
125
+ groups.set(event.path, list);
126
+ }
127
+
128
+ return Array.from(groups.entries())
129
+ .map(([path, events]) => ({
130
+ path,
131
+ filename: path.split("/").pop() ?? path,
132
+ latestAt: events[0]?.at ?? "",
133
+ events,
134
+ }))
135
+ .sort((a, b) => (a.latestAt < b.latestAt ? 1 : -1));
136
+ }
137
+
138
+ function isMarkdownPath(path: string): boolean {
139
+ return /\.(md|markdown)$/i.test(path);
140
+ }
141
+
142
+ async function fetchReviewCount(path: string): Promise<number> {
143
+ if (!isMarkdownPath(path)) return 0;
144
+
145
+ const endpoints = [`/api/agent/sidecar/${path}`, `/api/agent/files/${path}/pending`, `/api/agent/files/${path}`];
146
+ for (const endpoint of endpoints) {
147
+ try {
148
+ const res = await wsFetch(endpoint);
149
+ if (!res.ok) continue;
150
+ const data = (await res.json()) as Record<string, unknown>;
39
151
 
152
+ if (Array.isArray(data.pending)) return data.pending.length;
153
+ if (Array.isArray(data.events) && endpoint.includes("/pending")) return data.events.length;
154
+ if (typeof data.pendingCount === "number") return data.pendingCount;
155
+ if (typeof data.count === "number") return data.count;
156
+ if (typeof data.total === "number") return data.total;
157
+ if (Array.isArray(data.suggestions) || Array.isArray(data.comments) || data.blockProvenance) {
158
+ const suggestions = Array.isArray(data.suggestions)
159
+ ? data.suggestions.filter(
160
+ (item) => item && typeof item === "object" && (item as { status?: string }).status === "pending",
161
+ ).length
162
+ : 0;
163
+ const comments = Array.isArray(data.comments)
164
+ ? data.comments.filter(
165
+ (item) => item && typeof item === "object" && !(item as { resolved?: boolean }).resolved,
166
+ ).length
167
+ : 0;
168
+ const proofSpans = data.blockProvenance && typeof data.blockProvenance === "object"
169
+ ? Object.keys(data.blockProvenance as Record<string, unknown>).length
170
+ : 0;
171
+ return suggestions + comments + proofSpans;
172
+ }
173
+ } catch {
174
+ // Ignore fetch errors; review badge stays hidden.
175
+ }
176
+ }
177
+ return 0;
178
+ }
179
+
180
+ function ActivityItemImpl({ event }: { event: ActivityEvent }) {
40
181
  return (
41
- <div className="flex flex-col gap-0.5 py-1.5 border-b border-border/50 last:border-0">
42
- <div className="flex items-baseline justify-between gap-2">
43
- <span
44
- className="text-xs font-mono text-foreground/80 truncate"
45
- title={event.path}
46
- >
47
- {filename}
48
- </span>
49
- <span className="text-[10px] text-muted-foreground shrink-0">
50
- {timeAgo(event.at)}
51
- </span>
182
+ <div className="flex items-start gap-2.5 rounded-md px-2 py-2 hover:bg-background/50">
183
+ <div
184
+ className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold shadow-sm ring-1 ring-black/5"
185
+ style={agentChipStyle(event.by)}
186
+ title={event.by}
187
+ aria-hidden
188
+ >
189
+ {agentLabel(event.by)}
190
+ </div>
191
+ <div className="min-w-0 flex-1">
192
+ <p className="truncate text-xs text-foreground/85">
193
+ {summaryFor(event)}
194
+ </p>
195
+ <p className="truncate text-[10px] text-muted-foreground">{timeAgo(event.at)}</p>
52
196
  </div>
53
- <p className="text-[11px] text-muted-foreground truncate">
54
- <span className="text-foreground/60">{event.by}</span>{" "}
55
- {verbFor(event.type)}
56
- {ref && (
57
- <>
58
- {" "}
59
- <span className="font-mono text-[10px] text-muted-foreground/60">{ref}</span>
60
- </>
61
- )}
62
- </p>
63
197
  </div>
64
198
  );
65
199
  }
66
200
 
67
- // Memoised: the activity list re-renders on every 10s poll and on any AI-panel
68
- // state change. Rows only change when their event object changes.
69
- export const ActivityRow = memo(ActivityRowImpl);
201
+ const ActivityItem = memo(ActivityItemImpl);
202
+
203
+ function ActivityGroupView({ group, reviewCount }: { group: ActivityGroup; reviewCount: number }) {
204
+ const loadPage = useEditorStore((s) => s.loadPage);
205
+ const latest = group.events[0];
206
+
207
+ return (
208
+ <section className="space-y-1.5 rounded-md border border-border/70 bg-background/50 p-2">
209
+ <div className="flex items-start justify-between gap-2">
210
+ <button
211
+ type="button"
212
+ onClick={() => void loadPage(group.path)}
213
+ className="min-w-0 text-left"
214
+ title={group.path}
215
+ >
216
+ <p className="truncate text-xs font-medium text-foreground/90">{group.filename}</p>
217
+ <p className="truncate text-[10px] text-muted-foreground">{group.path}</p>
218
+ </button>
219
+ <div className="flex shrink-0 items-center gap-1.5">
220
+ {reviewCount > 0 && (
221
+ <button
222
+ type="button"
223
+ onClick={() => void loadPage(group.path)}
224
+ className="inline-flex items-center rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-700 hover:bg-amber-500/15"
225
+ title="Open file for review"
226
+ >
227
+ review {reviewCount}
228
+ </button>
229
+ )}
230
+ <span className="text-[10px] text-muted-foreground">{timeAgo(latest.at)}</span>
231
+ </div>
232
+ </div>
233
+ <div className="space-y-0.5">
234
+ {group.events.map((event) => (
235
+ <ActivityItem key={`${event.path}-${event.id}`} event={event} />
236
+ ))}
237
+ </div>
238
+ </section>
239
+ );
240
+ }
241
+
242
+ export function ActivityFeed({ activity }: { activity: ActivityEvent[] }) {
243
+ const groups = useMemo(() => groupActivity(activity), [activity]);
244
+ const [reviewCounts, setReviewCounts] = useState<ReviewCounts>({});
245
+
246
+ useEffect(() => {
247
+ let cancelled = false;
248
+ const paths = Array.from(new Set(groups.map((group) => group.path)));
249
+ if (paths.length === 0) {
250
+ setReviewCounts({});
251
+ return () => {
252
+ cancelled = true;
253
+ };
254
+ }
255
+
256
+ void (async () => {
257
+ const pairs = await Promise.all(
258
+ paths.map(async (path) => [path, await fetchReviewCount(path)] as const),
259
+ );
260
+ if (cancelled) return;
261
+ setReviewCounts(Object.fromEntries(pairs.filter(([, count]) => count > 0)));
262
+ })();
263
+
264
+ return () => {
265
+ cancelled = true;
266
+ };
267
+ }, [groups]);
268
+
269
+ if (groups.length === 0) {
270
+ return <p className="py-2 text-xs text-muted-foreground/60">No events recorded</p>;
271
+ }
272
+
273
+ return (
274
+ <div className="space-y-2">
275
+ {groups.map((group) => (
276
+ <ActivityGroupView
277
+ key={group.path}
278
+ group={group}
279
+ reviewCount={reviewCounts[group.path] ?? 0}
280
+ />
281
+ ))}
282
+ </div>
283
+ );
284
+ }
@@ -5,7 +5,7 @@ import { X, Copy, Check, Bot, Wifi, ExternalLink } from "lucide-react";
5
5
  import { Button } from "@/components/ui/button";
6
6
  import { useAIPanelStore } from "@/stores/ai-panel-store";
7
7
  import { TokenSection } from "./token-section";
8
- import { ActivityRow } from "./activity-row";
8
+ import { ActivityFeed } from "./activity-row";
9
9
  const SKILL_CLI = "npx skills add anh-chu/wiki-viewer/agents/wiki-viewer-skill";
10
10
 
11
11
  function useCopyCurl(currentPath: string | null) {
@@ -288,14 +288,8 @@ export function AIPanel({ currentPath }: { currentPath?: string | null }) {
288
288
  <h3 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
289
289
  Recent Activity
290
290
  </h3>
291
- <div className="rounded-md border border-border bg-muted/40 px-3 py-1 max-h-72 overflow-y-auto">
292
- {activity.length === 0 ? (
293
- <p className="text-xs text-muted-foreground/60 py-2">No events recorded</p>
294
- ) : (
295
- activity.map((ev) => (
296
- <ActivityRow key={`${ev.path}-${ev.id}`} event={ev} />
297
- ))
298
- )}
291
+ <div className="rounded-md border border-border bg-muted/40 px-3 py-2 max-h-72 overflow-y-auto">
292
+ <ActivityFeed activity={activity} />
299
293
  </div>
300
294
  </section>
301
295
 
@@ -1,9 +1,10 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useState } from "react";
4
- import { ChevronDown, ChevronRight, Link2 } from "lucide-react";
4
+ import { ChevronDown, ChevronRight, Link2, Unlink } from "lucide-react";
5
5
  import { wsFetch } from "@/lib/workspace-client";
6
6
  import { useEditorStore } from "@/stores/editor-store";
7
+ import { useWikiSlugsStore } from "@/stores/wiki-slugs-store";
7
8
 
8
9
  interface BacklinkEntry {
9
10
  path: string;
@@ -16,6 +17,12 @@ function displayPath(filePath: string): string {
16
17
  return parts.length > 1 ? parts.slice(-2).join(" / ") : parts[0] ?? filePath;
17
18
  }
18
19
 
20
+ /** Last segment sans .md — the wiki slug used for existence checks. */
21
+ function slugFromPath(filePath: string): string {
22
+ const base = filePath.split("/").pop() ?? filePath;
23
+ return base.replace(/\.md$/i, "");
24
+ }
25
+
19
26
  interface BacklinksPanelProps {
20
27
  currentPath: string;
21
28
  }
@@ -24,6 +31,7 @@ export function BacklinksPanel({ currentPath }: BacklinksPanelProps) {
24
31
  const [backlinks, setBacklinks] = useState<BacklinkEntry[]>([]);
25
32
  const [loading, setLoading] = useState(false);
26
33
  const [collapsed, setCollapsed] = useState(false);
34
+ const hasSlug = useWikiSlugsStore((s) => s.has);
27
35
 
28
36
  useEffect(() => {
29
37
  if (!currentPath) return;
@@ -85,22 +93,44 @@ export function BacklinksPanel({ currentPath }: BacklinksPanelProps) {
85
93
 
86
94
  {!collapsed && backlinks.length > 0 && (
87
95
  <ul className="flex flex-col gap-1.5">
88
- {backlinks.map((bl) => (
89
- <li key={bl.path} className="flex flex-col">
90
- <button
91
- onClick={() => useEditorStore.getState().loadPage(bl.path)}
92
- className="text-left text-[12px] text-primary/70 hover:text-primary hover:underline underline-offset-2 truncate transition-colors"
93
- title={bl.path}
94
- >
95
- {displayPath(bl.path)}
96
- </button>
97
- {bl.snippet && (
98
- <p className="text-[10.5px] text-muted-foreground/50 line-clamp-1 mt-0.5">
99
- {bl.snippet}
100
- </p>
101
- )}
102
- </li>
103
- ))}
96
+ {backlinks.map((bl) => {
97
+ const exists = hasSlug(slugFromPath(bl.path));
98
+ return (
99
+ <li key={bl.path} className="flex flex-col">
100
+ <button
101
+ onClick={() =>
102
+ exists && useEditorStore.getState().loadPage(bl.path)
103
+ }
104
+ className={
105
+ exists
106
+ ? "text-left text-[12px] text-primary/70 hover:text-primary hover:underline underline-offset-2 truncate transition-colors flex items-center gap-1"
107
+ : "text-left text-[12px] text-muted-foreground/40 truncate cursor-default flex items-center gap-1"
108
+ }
109
+ title={
110
+ exists
111
+ ? bl.path
112
+ : `${bl.path} (file not found)`
113
+ }
114
+ >
115
+ {!exists && (
116
+ <Unlink className="h-2.5 w-2.5 shrink-0 text-muted-foreground/30" />
117
+ )}
118
+ {displayPath(bl.path)}
119
+ </button>
120
+ {bl.snippet && (
121
+ <p
122
+ className={
123
+ exists
124
+ ? "text-[10.5px] text-muted-foreground/50 line-clamp-1 mt-0.5"
125
+ : "text-[10.5px] text-muted-foreground/25 line-clamp-1 mt-0.5"
126
+ }
127
+ >
128
+ {bl.snippet}
129
+ </p>
130
+ )}
131
+ </li>
132
+ );
133
+ })}
104
134
  </ul>
105
135
  )}
106
136
  </div>
@@ -28,6 +28,8 @@ interface Props {
28
28
  onSuggestEdit?: () => void;
29
29
  /** Open a comment thread on the current selection's block. */
30
30
  onComment?: () => void;
31
+ /** Hide all formatting controls; show only comment button. */
32
+ readOnly?: boolean;
31
33
  }
32
34
 
33
35
  type OpenPopover =
@@ -40,7 +42,7 @@ type OpenPopover =
40
42
  anchor: { top: number; left: number };
41
43
  };
42
44
 
43
- export function EditorBubbleMenu({ editor, onSuggestEdit, onComment }: Props) {
45
+ export function EditorBubbleMenu({ editor, onSuggestEdit, onComment, readOnly }: Props) {
44
46
  const [popover, setPopover] = useState<OpenPopover>(null);
45
47
 
46
48
  useEffect(() => {
@@ -127,6 +129,8 @@ export function EditorBubbleMenu({ editor, onSuggestEdit, onComment }: Props) {
127
129
  options={{ placement: "top", offset: 8 }}
128
130
  className="flex items-center gap-0.5 px-1 py-1 bg-popover border border-border rounded-sm shadow-lg"
129
131
  >
132
+ {!readOnly && (
133
+ <>
130
134
  <button
131
135
  type="button"
132
136
  className={btn(editor.isActive("bold"))}
@@ -299,6 +303,23 @@ export function EditorBubbleMenu({ editor, onSuggestEdit, onComment }: Props) {
299
303
  </div>
300
304
  )}
301
305
  </div>
306
+ </>
307
+ )}
308
+ {readOnly && onComment && (
309
+ <button
310
+ type="button"
311
+ className={btn(false)}
312
+ onMouseDown={(e) => e.preventDefault()}
313
+ onClick={(e) => {
314
+ e.preventDefault();
315
+ onComment();
316
+ }}
317
+ aria-label="Comment"
318
+ title="Add comment"
319
+ >
320
+ <MessageCircle className="w-3.5 h-3.5" />
321
+ </button>
322
+ )}
302
323
  </BubbleMenu>
303
324
 
304
325
  {popover?.type === "link" && (