wiki-viewer 1.8.2 → 1.8.3

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 (98) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +5 -5
  3. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
  4. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  5. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
  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/app-proxy/[...path]/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/system/browse/route.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/wiki/app/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/wiki/upload/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/index.html +1 -1
  26. package/.next/standalone/.next/server/app/index.rsc +3 -3
  27. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
  29. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  30. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +2 -2
  31. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  32. package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
  33. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  35. package/.next/standalone/.next/server/app/signin/page/build-manifest.json +2 -2
  36. package/.next/standalone/.next/server/app/signin/page_client-reference-manifest.js +1 -1
  37. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-inabx._.js +1 -1
  38. package/.next/standalone/.next/server/chunks/[root-of-the-server]__00lep-u._.js +1 -1
  39. package/.next/standalone/.next/server/chunks/ssr/{0~w0_katex_dist_katex_mjs_04mhawr._.js → 0~w0_katex_dist_katex_mjs_0hyln.b._.js} +1 -1
  40. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__09~x2v~._.js → [root-of-the-server]__0du58sw._.js} +2 -2
  41. package/.next/standalone/.next/server/chunks/ssr/_0pqaawz._.js +19 -19
  42. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0shlang._.js → node_modules__pnpm_0.kyqfq._.js} +1 -1
  43. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_04n0h6.._.js → node_modules__pnpm_0.~goq~._.js} +1 -1
  44. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_011vy_q._.js → node_modules__pnpm_00y7foi._.js} +2 -2
  45. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0yiz.-t._.js → node_modules__pnpm_03.1jli._.js} +1 -1
  46. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_093dp2q._.js → node_modules__pnpm_03z3na-._.js} +1 -1
  47. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_01o_5qt._.js → node_modules__pnpm_05~w-ok._.js} +1 -1
  48. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0b8qx0g._.js → node_modules__pnpm_09d5yyu._.js} +1 -1
  49. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0wmx9kq._.js → node_modules__pnpm_09eo8jh._.js} +2 -2
  50. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0nw5ou2._.js → node_modules__pnpm_0adm8h6._.js} +1 -1
  51. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0bbc---._.js → node_modules__pnpm_0arbjru._.js} +1 -1
  52. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0t9jb7d._.js → node_modules__pnpm_0b5fla7._.js} +1 -1
  53. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_124u4o2._.js → node_modules__pnpm_0beormu._.js} +2 -2
  54. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_13tajgx._.js → node_modules__pnpm_0c7u76a._.js} +2 -2
  55. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_09ej62_._.js → node_modules__pnpm_0dbr7c2._.js} +1 -1
  56. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0s0ur~d._.js → node_modules__pnpm_0d~ida~._.js} +1 -1
  57. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_09ljbdz._.js → node_modules__pnpm_0elpfsb._.js} +2 -2
  58. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0hlzy8f._.js → node_modules__pnpm_0fh.6ir._.js} +1 -1
  59. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_09~_ob2._.js → node_modules__pnpm_0fm6wcx._.js} +1 -1
  60. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0o.dn8o._.js → node_modules__pnpm_0iikbcy._.js} +1 -1
  61. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0hk6xys._.js → node_modules__pnpm_0mvih7b._.js} +2 -2
  62. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0a01ng7._.js → node_modules__pnpm_0qd2ovj._.js} +2 -2
  63. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0c.309.._.js → node_modules__pnpm_0s4yn43._.js} +1 -1
  64. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0sq3c2r._.js → node_modules__pnpm_0s8fvbn._.js} +1 -1
  65. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0xfx0sf._.js → node_modules__pnpm_0tq8zs6._.js} +1 -1
  66. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_11sut1u._.js → node_modules__pnpm_0vyv.lz._.js} +1 -1
  67. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0c8aunj._.js → node_modules__pnpm_0xa.ko1._.js} +1 -1
  68. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0.96tmo._.js → node_modules__pnpm_12.ymhl._.js} +1 -1
  69. package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
  70. package/.next/standalone/.next/server/middleware-manifest.json +1 -1
  71. package/.next/standalone/.next/server/pages/404.html +1 -1
  72. package/.next/standalone/.next/server/pages/500.html +1 -1
  73. package/.next/standalone/.next/static/chunks/{0vp-ahhagxptc.js → 06iw_iy5e90d4.js} +1 -1
  74. package/.next/standalone/.next/static/chunks/0d~o0h0ssgra7.js +129 -0
  75. package/.next/standalone/.next/static/chunks/167ph4xws_-61.css +2 -0
  76. package/.next/standalone/.next/static/chunks/{turbopack-12~z-mr7b9b50.js → turbopack-0lf4~b-ofzbm6.js} +1 -1
  77. package/.next/standalone/AGENTS.md +104 -0
  78. package/.next/standalone/package.json +1 -1
  79. package/.next/standalone/src/app/api/wiki/app/route.ts +7 -4
  80. package/.next/standalone/src/app/page.tsx +580 -192
  81. package/.next/standalone/src/components/editor/backlinks-panel.tsx +152 -0
  82. package/.next/standalone/src/components/editor/document-outline.tsx +221 -0
  83. package/.next/standalone/src/components/editor/editor.tsx +145 -48
  84. package/.next/standalone/src/components/editor/node-app-viewer.tsx +65 -7
  85. package/.next/standalone/src/components/editor/slash-commands.tsx +216 -41
  86. package/.next/standalone/src/components/editor/source-viewer.tsx +29 -2
  87. package/.next/standalone/src/components/search/search-command-dialog.tsx +183 -46
  88. package/.next/standalone/src/lib/app-runner.ts +57 -16
  89. package/.next/standalone/src/stores/pin-store.ts +55 -0
  90. package/.next/standalone/src/stores/recent-store.ts +50 -0
  91. package/.next/standalone/tsconfig.tsbuildinfo +1 -1
  92. package/package.json +1 -1
  93. package/.next/standalone/.next/static/chunks/0c_~zevf2n0r4.css +0 -2
  94. package/.next/standalone/.next/static/chunks/16r--8~rpehcz.js +0 -129
  95. package/.next/standalone/src/components/wiki/markdown-preview.tsx +0 -120
  96. /package/.next/standalone/.next/static/{t_k0ztuf0V-iA-kgeez18 → Pkj31aCfRVk0Zy2rKA1wf}/_buildManifest.js +0 -0
  97. /package/.next/standalone/.next/static/{t_k0ztuf0V-iA-kgeez18 → Pkj31aCfRVk0Zy2rKA1wf}/_clientMiddlewareManifest.js +0 -0
  98. /package/.next/standalone/.next/static/{t_k0ztuf0V-iA-kgeez18 → Pkj31aCfRVk0Zy2rKA1wf}/_ssgManifest.js +0 -0
@@ -2,11 +2,12 @@
2
2
 
3
3
  import { cellAround, isInTable } from "@tiptap/pm/tables";
4
4
  import { EditorContent, useEditor } from "@tiptap/react";
5
- import { Code2, FilePlus, Loader2, Sparkles } from "lucide-react";
5
+ import { AlertCircle, Check, Code2, FilePlus, Loader2, Sparkles } from "lucide-react";
6
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
+ import { parseFrontmatter } from "@/lib/markdown/parse-frontmatter";
10
11
  import { useAIPanelStore } from "@/stores/ai-panel-store";
11
12
  import { useEditorStore } from "@/stores/editor-store";
12
13
  import { useTreeStore } from "@/stores/tree-store";
@@ -16,6 +17,7 @@ import type { TreeNode } from "@/types";
16
17
  import { useProofStore } from "@/stores/proof-store";
17
18
  import { captureSuggestion } from "@/lib/proof/suggest-capture";
18
19
  import { wsFetch, withWs } from "@/lib/workspace-client";
20
+ import { showError } from "@/lib/toast";
19
21
  import { EditorBubbleMenu } from "./bubble-menu";
20
22
  import { EditorToolbar } from "./editor-toolbar";
21
23
  import { editorExtensions } from "./extensions";
@@ -26,12 +28,15 @@ import { ProofSpanPopover } from "./proof-span-popover";
26
28
  import { SuggestionCard } from "./suggestion-card";
27
29
  import { SuggestEditPopover } from "./suggest-edit-popover";
28
30
  import { SlashCommands } from "./slash-commands";
31
+ import { DocumentOutline } from "./document-outline";
32
+ import { BacklinksPanel } from "./backlinks-panel";
29
33
  import { TableMenu } from "./table-menu";
30
34
  import {
31
35
  useWikiLinkCreate,
32
36
  type WikiCreateResult,
33
37
  } from "./wiki-link-create-dialog";
34
38
  import { WikiLinkPicker } from "./wiki-link-picker";
39
+ import { FrontmatterHeader } from "@/components/wiki/frontmatter-header";
35
40
 
36
41
  async function uploadFile(
37
42
  pagePath: string,
@@ -44,10 +49,14 @@ async function uploadFile(
44
49
  method: "POST",
45
50
  body: formData,
46
51
  });
47
- if (!res.ok) return null;
52
+ if (!res.ok) {
53
+ showError(`Upload failed: ${file.name}`);
54
+ return null;
55
+ }
48
56
  const data = await res.json();
49
57
  return data.url;
50
58
  } catch {
59
+ showError(`Upload failed: ${file.name}`);
51
60
  return null;
52
61
  }
53
62
  }
@@ -136,7 +145,13 @@ function resolveInternalLink(
136
145
  return findPageBySlug(slug, currentPath, nodes);
137
146
  }
138
147
 
139
- export function KBEditor() {
148
+ type KBEditorMode = "viewing" | "editing" | "suggesting";
149
+
150
+ interface KBEditorProps {
151
+ mode?: KBEditorMode;
152
+ }
153
+
154
+ export function KBEditor({ mode }: KBEditorProps = {}) {
140
155
  const {
141
156
  currentPath,
142
157
  content,
@@ -148,9 +163,18 @@ export function KBEditor() {
148
163
  editMode,
149
164
  setEditMode,
150
165
  } = useEditorStore();
166
+ const effectiveMode = mode ?? editMode;
167
+ const isViewing = effectiveMode === "viewing";
168
+ const isSuggesting = effectiveMode === "suggesting";
169
+ const parsedViewingContent = useMemo(
170
+ () => (isViewing ? parseFrontmatter(content) : { data: {}, body: content }),
171
+ [content, isViewing],
172
+ );
151
173
  const nodes = useTreeStore((s) => s.nodes);
152
174
  const editorMaxW = useViewWidthStore((s) => VIEW_WIDTH_CSS[s.width]);
153
- const isRtl = frontmatter?.dir === "rtl";
175
+ const isRtl = isViewing
176
+ ? parsedViewingContent.data.dir === "rtl"
177
+ : frontmatter?.dir === "rtl";
154
178
  const { open: openAI, clearMessages } = useAIPanelStore();
155
179
  const { open: openWikiCreate, Dialog: WikiCreateDialog } =
156
180
  useWikiLinkCreate();
@@ -161,6 +185,8 @@ export function KBEditor() {
161
185
  openWikiCreateRef.current = openWikiCreate;
162
186
 
163
187
  const isLoadingRef = useRef(false);
188
+ const isViewingRef = useRef(isViewing);
189
+ isViewingRef.current = isViewing;
164
190
  const [sourceMode, setSourceMode] = useState(false);
165
191
  const [sourceText, setSourceText] = useState("");
166
192
  // Reset the tab to "page" whenever the path changes — opening a new folder
@@ -193,6 +219,7 @@ export function KBEditor() {
193
219
  useEffect(() => {
194
220
  if (typeof window === "undefined") return;
195
221
  if (!currentPath) return;
222
+ if (isViewing) return;
196
223
  if (!/\.(md|markdown)$/i.test(currentPath)) return;
197
224
 
198
225
  const path = currentPath;
@@ -221,7 +248,7 @@ export function KBEditor() {
221
248
  document.removeEventListener("visibilitychange", onHidden);
222
249
  ping("close");
223
250
  };
224
- }, [currentPath]);
251
+ }, [currentPath, isViewing]);
225
252
 
226
253
  // Subscribe to chokidar SSE: when current file changes on disk, reload sidecar.
227
254
  useEffect(() => {
@@ -243,6 +270,9 @@ export function KBEditor() {
243
270
  .getState()
244
271
  .loadSnapshot(activePath)
245
272
  .then(() => useProofStore.getState().loadSidecar(activePath));
273
+ if (isViewingRef.current) {
274
+ void useEditorStore.getState().loadPage(activePath);
275
+ }
246
276
  }
247
277
  } catch {
248
278
  // ignore malformed events
@@ -400,6 +430,7 @@ export function KBEditor() {
400
430
 
401
431
  const flushSuggestions = useCallback(async () => {
402
432
  if (flushingRef.current) return;
433
+ if (isViewingRef.current) return;
403
434
  if (useEditorStore.getState().editMode !== "suggesting") return;
404
435
  if (!suggestDirtyRef.current) return;
405
436
  const ed = editorRef.current;
@@ -527,7 +558,7 @@ export function KBEditor() {
527
558
 
528
559
  const handleUpdate = useCallback(
529
560
  ({ editor }: { editor: ReturnType<typeof useEditor> }) => {
530
- if (isLoadingRef.current || !editor) return;
561
+ if (isLoadingRef.current || isViewingRef.current || !editor) return;
531
562
  // In suggesting mode, mark the edit dirty so the next block-change or
532
563
  // blur flushes it into suggestions. Still push content to the store so
533
564
  // the store guard (no autosave in suggesting mode) keeps it in sync.
@@ -544,6 +575,7 @@ export function KBEditor() {
544
575
  const editor = useEditor({
545
576
  extensions: editorExtensions,
546
577
  content: "",
578
+ editable: !isViewing,
547
579
  onUpdate: handleUpdate,
548
580
  onBlur: () => {
549
581
  void flushSuggestions();
@@ -622,6 +654,8 @@ export function KBEditor() {
622
654
  ?.scrollIntoView({ behavior: "smooth" });
623
655
  }, 200);
624
656
  }
657
+ } else if (isViewingRef.current) {
658
+ return true;
625
659
  } else {
626
660
  void openWikiCreateRef.current(slug).then((result) => {
627
661
  if (result.ok) {
@@ -667,6 +701,7 @@ export function KBEditor() {
667
701
  return true;
668
702
  },
669
703
  handlePaste: (_view, event) => {
704
+ if (isViewingRef.current) return false;
670
705
  const files = event.clipboardData?.files;
671
706
  const pagePath = useEditorStore.getState().currentPath;
672
707
 
@@ -696,6 +731,7 @@ export function KBEditor() {
696
731
  return false;
697
732
  },
698
733
  handleDrop: (_view, event) => {
734
+ if (isViewingRef.current) return false;
699
735
  const files = event.dataTransfer?.files;
700
736
  if (!files || files.length === 0) return false;
701
737
 
@@ -731,6 +767,11 @@ export function KBEditor() {
731
767
  const editorRef = useRef<typeof editor>(editor);
732
768
  editorRef.current = editor;
733
769
 
770
+ useEffect(() => {
771
+ editor?.setEditable(!isViewing);
772
+ if (isViewing) setSourceMode(false);
773
+ }, [editor, isViewing]);
774
+
734
775
  // When content updates from store (after loadPage), set it in editor
735
776
  const prevPathRef = useRef<string | null>(null);
736
777
  const renderedKeyRef = useRef<string | null>(null);
@@ -750,7 +791,8 @@ export function KBEditor() {
750
791
  if (isLoading && content === "") return;
751
792
  // Dedupe identical (path, content) renders — e.g. cached paint followed
752
793
  // by a fresh fetch that returned the same markdown.
753
- const key = `${currentPath} ${content}`;
794
+ const renderMarkdown = parsedViewingContent.body;
795
+ const key = `${currentPath} ${renderMarkdown}`;
754
796
  if (renderedKeyRef.current === key) {
755
797
  if (renderedPath !== currentPath) setRenderedPath(currentPath);
756
798
  return;
@@ -759,7 +801,10 @@ export function KBEditor() {
759
801
 
760
802
  const setContent = async () => {
761
803
  isLoadingRef.current = true;
762
- const html = await markdownToHtml(content, currentPath);
804
+ const html = await markdownToHtml(
805
+ renderMarkdown,
806
+ isViewing ? { pagePath: currentPath, sanitize: true } : currentPath,
807
+ );
763
808
  editor.commands.setContent(html);
764
809
  renderedKeyRef.current = key;
765
810
  setRenderedPath(currentPath);
@@ -769,7 +814,18 @@ export function KBEditor() {
769
814
  };
770
815
 
771
816
  setContent();
772
- }, [editor, content, currentPath, isLoading, renderedPath]);
817
+ }, [editor, content, currentPath, isLoading, renderedPath, parsedViewingContent.body, isViewing]);
818
+
819
+ useEffect(() => {
820
+ if (!isViewing || !renderedPath) return;
821
+ const container = scrollContainerRef.current;
822
+ if (!container) return;
823
+ container
824
+ .querySelectorAll<HTMLInputElement>('input[type="checkbox"]')
825
+ .forEach((input) => {
826
+ input.disabled = true;
827
+ });
828
+ }, [isViewing, renderedPath, parsedViewingContent.body]);
773
829
 
774
830
  const showLoadingOverlay =
775
831
  currentPath !== null && (isLoading || renderedPath !== currentPath);
@@ -915,22 +971,24 @@ export function KBEditor() {
915
971
  </div>
916
972
  ) : (
917
973
  <>
918
- <div className="flex items-center min-w-0">
919
- <div className="flex-1 min-w-0">
920
- {!sourceMode && <EditorToolbar editor={editor} />}
974
+ {!isViewing && (
975
+ <div className="flex items-center min-w-0">
976
+ <div className="flex-1 min-w-0">
977
+ {!sourceMode && <EditorToolbar editor={editor} />}
978
+ </div>
979
+ <button
980
+ onClick={toggleSourceMode}
981
+ className={`flex items-center gap-1.5 px-3 py-1 mr-2 text-[11px] rounded-md transition-colors border border-border ${
982
+ sourceMode
983
+ ? "bg-primary text-primary-foreground"
984
+ : "text-muted-foreground hover:bg-accent"
985
+ }`}
986
+ >
987
+ <Code2 className="h-3 w-3" />
988
+ {sourceMode ? "Preview" : "Markdown"}
989
+ </button>
921
990
  </div>
922
- <button
923
- onClick={toggleSourceMode}
924
- className={`flex items-center gap-1.5 px-3 py-1 mr-2 text-[11px] rounded-md transition-colors border border-border ${
925
- sourceMode
926
- ? "bg-primary text-primary-foreground"
927
- : "text-muted-foreground hover:bg-accent"
928
- }`}
929
- >
930
- <Code2 className="h-3 w-3" />
931
- {sourceMode ? "Preview" : "Markdown"}
932
- </button>
933
- </div>
991
+ )}
934
992
 
935
993
  {sourceMode ? (
936
994
  <div
@@ -946,15 +1004,16 @@ export function KBEditor() {
946
1004
  </div>
947
1005
  ) : (
948
1006
  <div className="flex-1 relative" dir={isRtl ? "rtl" : undefined}>
949
- {editMode === "suggesting" && (
1007
+ {isSuggesting && (
950
1008
  <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">
951
1009
  Suggesting mode · your edits become suggestions for review
952
1010
  </div>
953
1011
  )}
1012
+ <DocumentOutline editor={editor} scrollContainerRef={scrollContainerRef} />
954
1013
  <div
955
1014
  ref={scrollContainerRef}
956
1015
  className={`absolute inset-0 overflow-y-auto ${
957
- editMode === "suggesting" ? "pt-7" : ""
1016
+ isSuggesting ? "pt-7" : ""
958
1017
  }`}
959
1018
  style={{ ["--editor-max-w" as string]: editorMaxW }}
960
1019
  data-editor-scroll
@@ -1043,7 +1102,17 @@ export function KBEditor() {
1043
1102
  />
1044
1103
  )}
1045
1104
 
1105
+ {isViewing && Object.keys(parsedViewingContent.data).length > 0 && (
1106
+ <div className="max-w-[var(--editor-max-w,48rem)] mx-auto px-4 sm:px-8 pt-6">
1107
+ <FrontmatterHeader
1108
+ data={parsedViewingContent.data as Record<string, never>}
1109
+ />
1110
+ </div>
1111
+ )}
1046
1112
  <EditorContent editor={editor} />
1113
+ {currentPath && /\.(md|markdown)$/i.test(currentPath) && (
1114
+ <BacklinksPanel currentPath={currentPath} />
1115
+ )}
1047
1116
  {/* Proof-span hover delegation */}
1048
1117
  <div
1049
1118
  aria-hidden="true"
@@ -1071,17 +1140,21 @@ export function KBEditor() {
1071
1140
  }}
1072
1141
  />
1073
1142
  )}
1074
- <EditorBubbleMenu
1075
- editor={editor}
1076
- onSuggestEdit={openSuggestForSelection}
1077
- onComment={openCommentForSelection}
1078
- />
1079
- <TableMenu editor={editor} />
1080
- <SlashCommands editor={editor} />
1081
- <WikiLinkPicker
1082
- editor={editor}
1083
- onCreateRequest={openWikiCreateRef.current}
1084
- />
1143
+ {!isViewing && (
1144
+ <>
1145
+ <EditorBubbleMenu
1146
+ editor={editor}
1147
+ onSuggestEdit={openSuggestForSelection}
1148
+ onComment={openCommentForSelection}
1149
+ />
1150
+ <TableMenu editor={editor} />
1151
+ <SlashCommands editor={editor} />
1152
+ <WikiLinkPicker
1153
+ editor={editor}
1154
+ onCreateRequest={openWikiCreateRef.current}
1155
+ />
1156
+ </>
1157
+ )}
1085
1158
 
1086
1159
  {/* AI Edit Prompt + slash hint */}
1087
1160
  <div className="max-w-[var(--editor-max-w,48rem)] mx-auto px-8 pb-8 flex items-center gap-4">
@@ -1092,12 +1165,14 @@ export function KBEditor() {
1092
1165
  <Sparkles className="h-3.5 w-3.5 group-hover:text-primary transition-colors" />
1093
1166
  <span>Edit with AI</span>
1094
1167
  </button>
1095
- <span className="text-[11px] text-muted-foreground/30 select-none">
1096
- <kbd className="rounded px-1 py-0.5 font-mono text-[10px] ring-1 ring-foreground/10">
1097
- /
1098
- </kbd>{" "}
1099
- for commands
1100
- </span>
1168
+ {!isViewing && (
1169
+ <span className="text-[11px] text-muted-foreground/30 select-none">
1170
+ <kbd className="rounded px-1 py-0.5 font-mono text-[10px] ring-1 ring-foreground/10">
1171
+ /
1172
+ </kbd>{" "}
1173
+ for commands
1174
+ </span>
1175
+ )}
1101
1176
  </div>
1102
1177
  </div>
1103
1178
 
@@ -1112,7 +1187,8 @@ export function KBEditor() {
1112
1187
  </div>
1113
1188
  )}
1114
1189
 
1115
- {/* Status bar */}
1190
+ {!isViewing && (
1191
+ /* Status bar */
1116
1192
  <div className="flex items-center justify-between px-4 py-1 border-t border-border text-xs text-muted-foreground/60">
1117
1193
  <span className="text-[10.5px] text-muted-foreground/30 select-none hidden sm:block">
1118
1194
  <kbd className="rounded px-1 font-mono text-[9.5px] ring-1 ring-foreground/10">
@@ -1159,13 +1235,34 @@ export function KBEditor() {
1159
1235
  Suggesting
1160
1236
  </button>
1161
1237
  </div>
1162
- <span>
1163
- {saveStatus === "saving" && "Saving..."}
1164
- {saveStatus === "saved" && "Saved"}
1165
- {saveStatus === "error" && "Save failed"}
1238
+ <span
1239
+ className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10.5px] transition-all duration-300 ${
1240
+ saveStatus === "idle"
1241
+ ? "opacity-0 pointer-events-none"
1242
+ : "opacity-100"
1243
+ } ${
1244
+ saveStatus === "saving"
1245
+ ? "bg-muted text-muted-foreground"
1246
+ : saveStatus === "saved"
1247
+ ? "bg-green-500/10 text-green-600 dark:text-green-400"
1248
+ : saveStatus === "error"
1249
+ ? "bg-destructive/10 text-destructive"
1250
+ : ""
1251
+ }`}
1252
+ >
1253
+ {saveStatus === "saving" && (
1254
+ <><Loader2 className="h-2.5 w-2.5 animate-spin" />Saving…</>
1255
+ )}
1256
+ {saveStatus === "saved" && (
1257
+ <><Check className="h-2.5 w-2.5" />Saved</>
1258
+ )}
1259
+ {saveStatus === "error" && (
1260
+ <><AlertCircle className="h-2.5 w-2.5" />Save failed</>
1261
+ )}
1166
1262
  </span>
1167
1263
  </div>
1168
1264
  </div>
1265
+ )}
1169
1266
  </>
1170
1267
  )}
1171
1268
  </div>
@@ -2,6 +2,7 @@
2
2
 
3
3
  import {
4
4
  AlertCircle,
5
+ ChevronDown,
5
6
  ExternalLink,
6
7
  Loader2,
7
8
  Play,
@@ -12,6 +13,14 @@ import {
12
13
  import { useCallback, useEffect, useRef, useState } from "react";
13
14
  import { ViewerToolbar } from "@/components/layout/viewer-toolbar";
14
15
  import { Button } from "@/components/ui/button";
16
+ import {
17
+ DropdownMenu,
18
+ DropdownMenuContent,
19
+ DropdownMenuItem,
20
+ DropdownMenuLabel,
21
+ DropdownMenuSeparator,
22
+ DropdownMenuTrigger,
23
+ } from "@/components/ui/dropdown-menu";
15
24
  import type { AppStatus } from "@/lib/app-runner";
16
25
  import { wsFetch } from "@/lib/workspace-client";
17
26
 
@@ -25,6 +34,8 @@ interface StatusResponse {
25
34
  port?: number;
26
35
  error?: string;
27
36
  logs: string[];
37
+ scripts?: string[];
38
+ defaultScript?: string | null;
28
39
  }
29
40
 
30
41
  const STATUS_LABEL: Record<AppStatus, string> = {
@@ -42,6 +53,8 @@ export function NodeAppViewer({ path, title }: Props) {
42
53
  const [error, setError] = useState<string | null>(null);
43
54
  const [showLogs, setShowLogs] = useState(false);
44
55
  const [iframeKey, setIframeKey] = useState(0);
56
+ const [scripts, setScripts] = useState<string[]>([]);
57
+ const [defaultScript, setDefaultScript] = useState<string | null>(null);
45
58
  const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
46
59
  const logsEndRef = useRef<HTMLDivElement>(null);
47
60
 
@@ -62,6 +75,8 @@ export function NodeAppViewer({ path, title }: Props) {
62
75
  const data: StatusResponse = await res.json();
63
76
  setStatus(data.status);
64
77
  setLogs(data.logs ?? []);
78
+ if (data.scripts) setScripts(data.scripts);
79
+ if (data.defaultScript !== undefined) setDefaultScript(data.defaultScript);
65
80
  if (data.port) setPort(data.port);
66
81
  if (data.error) setError(data.error);
67
82
  if (data.status === "running" || data.status === "stopped" || data.status === "error") {
@@ -92,7 +107,7 @@ export function NodeAppViewer({ path, title }: Props) {
92
107
  fetchStatus();
93
108
  }, [fetchStatus]);
94
109
 
95
- const handleLaunch = async () => {
110
+ const handleLaunch = async (script?: string) => {
96
111
  setError(null);
97
112
  setLogs([]);
98
113
  setStatus("starting");
@@ -100,7 +115,7 @@ export function NodeAppViewer({ path, title }: Props) {
100
115
  const res = await wsFetch("/api/wiki/app", {
101
116
  method: "POST",
102
117
  headers: { "Content-Type": "application/json" },
103
- body: JSON.stringify({ path }),
118
+ body: JSON.stringify({ path, script }),
104
119
  });
105
120
  const data: { port?: number; error?: string } = await res.json();
106
121
  if (!res.ok || data.error) {
@@ -134,6 +149,8 @@ export function NodeAppViewer({ path, title }: Props) {
134
149
  };
135
150
 
136
151
  const isTransient = status === "installing" || status === "starting";
152
+ // Scripts other than the default — offered in the combo dropdown.
153
+ const altScripts = scripts.filter((s) => s !== defaultScript);
137
154
 
138
155
  return (
139
156
  <div className="flex-1 flex flex-col overflow-hidden">
@@ -216,10 +233,51 @@ export function NodeAppViewer({ path, title }: Props) {
216
233
  Node.js app — will be started on a local port
217
234
  </p>
218
235
  </div>
219
- <Button onClick={handleLaunch} className="gap-2">
220
- <Play className="h-4 w-4" />
221
- Launch app
222
- </Button>
236
+ <div className="inline-flex items-stretch overflow-hidden rounded-full bg-primary text-primary-foreground shadow-e-1">
237
+ <button
238
+ type="button"
239
+ onClick={() => handleLaunch()}
240
+ className="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors hover:bg-primary-foreground/10 focus-visible:outline-none"
241
+ >
242
+ <Play className="h-4 w-4" />
243
+ Launch app
244
+ {defaultScript && (
245
+ <span className="opacity-70">({defaultScript})</span>
246
+ )}
247
+ </button>
248
+ {altScripts.length > 0 && (
249
+ <DropdownMenu>
250
+ <DropdownMenuTrigger asChild>
251
+ <button
252
+ type="button"
253
+ className="inline-flex items-center justify-center border-l border-l-primary-foreground/20 px-2 transition-colors hover:bg-primary-foreground/10 focus-visible:outline-none"
254
+ aria-label="Choose script to launch"
255
+ >
256
+ <ChevronDown className="h-4 w-4" />
257
+ </button>
258
+ </DropdownMenuTrigger>
259
+ <DropdownMenuContent align="end">
260
+ <DropdownMenuLabel>Run script</DropdownMenuLabel>
261
+ <DropdownMenuSeparator />
262
+ {scripts.map((s) => (
263
+ <DropdownMenuItem
264
+ key={s}
265
+ onSelect={() => handleLaunch(s)}
266
+ className="gap-2 font-mono text-xs"
267
+ >
268
+ <Play className="h-3.5 w-3.5" />
269
+ {s}
270
+ {s === defaultScript && (
271
+ <span className="ml-auto text-[10px] text-muted-foreground">
272
+ default
273
+ </span>
274
+ )}
275
+ </DropdownMenuItem>
276
+ ))}
277
+ </DropdownMenuContent>
278
+ </DropdownMenu>
279
+ )}
280
+ </div>
223
281
  </div>
224
282
  )}
225
283
 
@@ -248,7 +306,7 @@ export function NodeAppViewer({ path, title }: Props) {
248
306
  {error ?? "Unknown error"}
249
307
  </p>
250
308
  </div>
251
- <Button onClick={handleLaunch} variant="outline" className="gap-2">
309
+ <Button onClick={() => handleLaunch()} variant="outline" className="gap-2">
252
310
  <RefreshCw className="h-4 w-4" />
253
311
  Try again
254
312
  </Button>