@plannotator/ui 0.45.0 → 0.46.0

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 (36) hide show
  1. package/HANDOFF.md +53 -1
  2. package/README.md +2 -2
  3. package/components/AgentsTab.tsx +108 -108
  4. package/components/BlockRenderer.tsx +9 -5
  5. package/components/ExportModal.tsx +8 -4
  6. package/components/ImageAnnotator/Toolbar.tsx +13 -3
  7. package/components/ImageAnnotator/index.tsx +6 -2
  8. package/components/InlineMarkdown.tsx +14 -7
  9. package/components/MarkdownEditor/embedPicker.ts +16 -3
  10. package/components/RenderedMarkdown.tsx +3 -1
  11. package/components/Settings.tsx +38 -15
  12. package/components/Viewer.tsx +76 -18
  13. package/components/blocks/AlertBlock.tsx +3 -2
  14. package/components/blocks/Callout.tsx +3 -0
  15. package/components/blocks/CodeBlock.tsx +6 -2
  16. package/components/blocks/TableBlock.tsx +4 -0
  17. package/components/blocks/TablePopout.tsx +6 -2
  18. package/components/blocks/proseBody.tsx +3 -0
  19. package/components/plan-diff/PlanCleanDiffView.tsx +6 -2
  20. package/config/reviewView.ts +8 -3
  21. package/config/settings.ts +10 -4
  22. package/hooks/useAgentSettings.ts +108 -123
  23. package/hooks/useAnnotationHighlighter.ts +109 -1
  24. package/hooks/useCodeAnnotationDraft.ts +196 -3
  25. package/hooks/useModelCatalogs.ts +116 -0
  26. package/package.json +2 -2
  27. package/styles.css +1 -1
  28. package/types.ts +31 -0
  29. package/utils/aiProvider.ts +8 -16
  30. package/utils/annotateAgentInstructions.ts +162 -0
  31. package/utils/codeHighlight.ts +18 -0
  32. package/utils/parser.ts +141 -31
  33. package/utils/terminalToolsAnnouncement.ts +2 -2
  34. package/assets/review-sections.png +0 -0
  35. package/assets/review-tree.png +0 -0
  36. package/utils/codexModels.ts +0 -79
@@ -1,6 +1,7 @@
1
1
  import React, { useState, useRef, useCallback, useEffect, useMemo } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePath } from "@plannotator/core/code-file";
4
+ import { forgeRefLinks } from "@plannotator/core/forge-refs";
4
5
  import { ensureHighlight, highlightToHtml } from "../utils/codeHighlight";
5
6
  import { useFenceTheme } from "../hooks/useFenceTheme";
6
7
  import { transformPlainText } from "../utils/inlineTransforms";
@@ -501,9 +502,13 @@ export const InlineMarkdown: React.FC<{
501
502
  onNavigateAnchor?: (hash: string) => void;
502
503
  imageBaseDir?: string;
503
504
  onImageClick?: (src: string, alt: string) => void;
505
+ /** Repo path (`owner/repo`, `group/sub/project`) bare `#123` / `@user` refs link into. */
504
506
  githubRepo?: string;
505
- }> = ({ text, onOpenLinkedDoc, resolveLinkedDoc, onOpenCodeFile, onNavigateAnchor, imageBaseDir, onImageClick, githubRepo }) => {
507
+ /** Host of that repo's git remote; picks the forge refs link to. Absent means github.com. */
508
+ repoHost?: string;
509
+ }> = ({ text, onOpenLinkedDoc, resolveLinkedDoc, onOpenCodeFile, onNavigateAnchor, imageBaseDir, onImageClick, githubRepo, repoHost }) => {
506
510
  const validation = useCodePathValidation();
511
+ const refLinks = forgeRefLinks({ display: githubRepo, host: repoHost });
507
512
  const parts: React.ReactNode[] = [];
508
513
  let remaining = text;
509
514
  let key = 0;
@@ -672,6 +677,7 @@ export const InlineMarkdown: React.FC<{
672
677
  onOpenCodeFile={onOpenCodeFile}
673
678
  onNavigateAnchor={onNavigateAnchor}
674
679
  githubRepo={githubRepo}
680
+ repoHost={repoHost}
675
681
  />
676
682
  </del>,
677
683
  );
@@ -695,6 +701,7 @@ export const InlineMarkdown: React.FC<{
695
701
  onOpenCodeFile={onOpenCodeFile}
696
702
  onNavigateAnchor={onNavigateAnchor}
697
703
  githubRepo={githubRepo}
704
+ repoHost={repoHost}
698
705
  />
699
706
  </em>
700
707
  </strong>,
@@ -718,6 +725,7 @@ export const InlineMarkdown: React.FC<{
718
725
  onOpenCodeFile={onOpenCodeFile}
719
726
  onNavigateAnchor={onNavigateAnchor}
720
727
  githubRepo={githubRepo}
728
+ repoHost={repoHost}
721
729
  />
722
730
  </strong>,
723
731
  );
@@ -740,6 +748,7 @@ export const InlineMarkdown: React.FC<{
740
748
  onOpenCodeFile={onOpenCodeFile}
741
749
  onNavigateAnchor={onNavigateAnchor}
742
750
  githubRepo={githubRepo}
751
+ repoHost={repoHost}
743
752
  />
744
753
  </em>,
745
754
  );
@@ -763,6 +772,7 @@ export const InlineMarkdown: React.FC<{
763
772
  onOpenCodeFile={onOpenCodeFile}
764
773
  onNavigateAnchor={onNavigateAnchor}
765
774
  githubRepo={githubRepo}
775
+ repoHost={repoHost}
766
776
  />
767
777
  </em>,
768
778
  );
@@ -834,9 +844,7 @@ export const InlineMarkdown: React.FC<{
834
844
  match = remaining.match(/^#(\d+)(?!\w)/);
835
845
  if (match) {
836
846
  const num = match[1];
837
- const href = githubRepo && githubRepo.includes('/')
838
- ? `https://github.com/${githubRepo}/issues/${num}`
839
- : null;
847
+ const href = refLinks?.issue(num) ?? null;
840
848
  const label = `#${num}`;
841
849
  parts.push(
842
850
  href ? (
@@ -864,9 +872,7 @@ export const InlineMarkdown: React.FC<{
864
872
  match = remaining.match(/^@([a-zA-Z][a-zA-Z0-9_-]{0,38})(?!\w)/);
865
873
  if (match) {
866
874
  const handle = match[1];
867
- const href = githubRepo && githubRepo.includes('/')
868
- ? `https://github.com/${handle}`
869
- : null;
875
+ const href = refLinks?.user(handle) ?? null;
870
876
  const label = `@${handle}`;
871
877
  parts.push(
872
878
  href ? (
@@ -1140,6 +1146,7 @@ export const InlineMarkdown: React.FC<{
1140
1146
  onOpenCodeFile={onOpenCodeFile}
1141
1147
  onNavigateAnchor={onNavigateAnchor}
1142
1148
  githubRepo={githubRepo}
1149
+ repoHost={repoHost}
1143
1150
  imageBaseDir={imageBaseDir}
1144
1151
  onImageClick={onImageClick}
1145
1152
  />,
@@ -42,6 +42,19 @@ export interface EmbedPickerConfig {
42
42
  readonly uploadTarget?: (kind: EmbedKind) => Promise<EmbedTarget | null>;
43
43
  /** Return an optional informational row for the current document text. */
44
44
  readonly getNotice?: (docBody: string) => string | null;
45
+ /**
46
+ * Host copy for the three rows that name the target type (0.45.1). A host
47
+ * that feeds the picker more than HTML files passes its own wording; each
48
+ * absent key keeps the built-in text, so Plannotator's menu is unchanged.
49
+ */
50
+ readonly labels?: {
51
+ /** The upload row. Default: "Upload HTML..." */
52
+ readonly upload?: string;
53
+ /** The row shown when there are no targets at all. Default: "No HTML files in this workspace" */
54
+ readonly empty?: string;
55
+ /** The row shown when the query matches nothing. Default: "No HTML files match “<query>”" */
56
+ readonly noMatch?: (query: string) => string;
57
+ };
45
58
  }
46
59
 
47
60
  type IconCompletion = Completion & {
@@ -175,8 +188,8 @@ function createEmbedPickerSource(
175
188
  options.push({
176
189
  label:
177
190
  targets.length === 0
178
- ? 'No HTML files in this workspace'
179
- : `No HTML files match “${query.trim()}”`,
191
+ ? config.labels?.empty ?? 'No HTML files in this workspace'
192
+ : config.labels?.noMatch?.(query.trim()) ?? `No HTML files match “${query.trim()}”`,
180
193
  apply: (view, completion, _from, to) => {
181
194
  view.dispatch({
182
195
  changes: { from: line.from, to, insert: '' },
@@ -209,7 +222,7 @@ function createEmbedPickerSource(
209
222
  },
210
223
  }
211
224
  : {
212
- label: 'Upload HTML...',
225
+ label: config.labels?.upload ?? 'Upload HTML...',
213
226
  slashCommandIcon: UPLOAD_ICON,
214
227
  apply: (view, completion, from, to) => {
215
228
  startUpload(
@@ -18,6 +18,7 @@ export interface RenderedMarkdownProps {
18
18
  onOpenLinkedDoc?: (path: string) => void;
19
19
  onOpenCodeFile?: (path: string) => void;
20
20
  githubRepo?: string;
21
+ repoHost?: string;
21
22
  }
22
23
 
23
24
  export const RenderedMarkdown: React.FC<RenderedMarkdownProps> = ({
@@ -27,9 +28,10 @@ export const RenderedMarkdown: React.FC<RenderedMarkdownProps> = ({
27
28
  onOpenLinkedDoc,
28
29
  onOpenCodeFile,
29
30
  githubRepo,
31
+ repoHost,
30
32
  }) => {
31
33
  const groups = useMemo(() => groupBlocks(parseMarkdownToBlocks(markdown)), [markdown]);
32
- const cb = { onImageClick, onOpenLinkedDoc, onOpenCodeFile, githubRepo };
34
+ const cb = { onImageClick, onOpenLinkedDoc, onOpenCodeFile, githubRepo, repoHost };
33
35
 
34
36
  return (
35
37
  <div className={className}>
@@ -87,6 +87,13 @@ interface SettingsProps {
87
87
  onIdentityChange?: (oldIdentity: string, newIdentity: string) => void;
88
88
  origin?: Origin | null;
89
89
  mode?: 'plan' | 'annotate' | 'review';
90
+ /**
91
+ * `mode="annotate"` only: offer the document tabs plan review has (Display,
92
+ * Saving, Labels, and the Obsidian / Bear / Octarine integrations) and hide
93
+ * the plan-only OpenCode agent-switch row. Plannotator's annotate app passes
94
+ * true; a host that omits it keeps the pre-parity annotate tab set.
95
+ */
96
+ annotateParity?: boolean;
90
97
  onUIPreferencesChange?: (prefs: UIPreferences) => void;
91
98
  /** Externally controlled open state (for mobile menu integration) */
92
99
  externalOpen?: boolean;
@@ -930,7 +937,7 @@ const CommentsTab: React.FC = () => {
930
937
  );
931
938
  };
932
939
 
933
- export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, sinceBaseUnavailable, isCompactTouchLayout = false, onDetectObsidianVaults, agentTerminalAvailable = false, webmcpAvailable = false }) => {
940
+ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', annotateParity = false, onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, sinceBaseUnavailable, isCompactTouchLayout = false, onDetectObsidianVaults, agentTerminalAvailable = false, webmcpAvailable = false }) => {
934
941
  const webmcpTools = useWebMcpToolsEnabled();
935
942
  const [showDialog, setShowDialog] = useState(false);
936
943
  const settingsWasOpenRef = useRef(false);
@@ -981,6 +988,8 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
981
988
  // The agent switch default depends on the surface: plan approval hands off to
982
989
  // the build agent, review feedback stays on the current agent.
983
990
  const agentSurface: AgentSwitchSurface = mode === 'review' ? 'review' : 'plan';
991
+ // Annotate with parity on gets the same document tabs as plan review.
992
+ const annotateDocumentTabs = mode === 'annotate' && annotateParity;
984
993
  const [agent, setAgent] = useState<AgentSwitchSettings>(() => getAgentSwitchDefaults(agentSurface));
985
994
  const [planSave, setPlanSave] = useState<PlanSaveSettings>({ enabled: true, customPath: null });
986
995
  const [uiPrefs, setUiPrefs] = useState<UIPreferences>({ tocEnabled: true, stickyActionsEnabled: true, planWidth: 'compact' });
@@ -1001,7 +1010,11 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1001
1010
  const mainTabs = useMemo(() => {
1002
1011
  const t: { id: SettingsTab; label: string }[] = [{ id: 'general', label: 'General' }];
1003
1012
  t.push({ id: 'theme', label: 'Theme' });
1004
- if (mode === 'plan') {
1013
+ // Annotate renders the same document viewer as plan review, so the
1014
+ // document-level tabs apply there too. Only rows that describe a plan
1015
+ // decision (plan snapshots, plan-arrival auto-save, plan-time hooks,
1016
+ // post-approval permission mode / agent switch) stay plan-only.
1017
+ if (mode === 'plan' || annotateDocumentTabs) {
1005
1018
  t.push({ id: 'display', label: 'Display' });
1006
1019
  t.push({ id: 'saving', label: 'Saving' });
1007
1020
  t.push({ id: 'labels', label: 'Labels' });
@@ -1023,11 +1036,11 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1023
1036
  t.push({ id: 'hooks', label: 'Hooks' });
1024
1037
  }
1025
1038
  return t;
1026
- }, [mode, aiProviders.length]);
1039
+ }, [mode, annotateDocumentTabs, aiProviders.length]);
1027
1040
 
1028
1041
  const integrationTabs: { id: SettingsTab; label: string }[] = [
1029
1042
  { id: 'files', label: 'Files' },
1030
- ...(mode === 'plan'
1043
+ ...(mode === 'plan' || annotateDocumentTabs
1031
1044
  ? [{ id: 'obsidian' as SettingsTab, label: 'Obsidian' }, { id: 'bear' as SettingsTab, label: 'Bear' }, { id: 'octarine' as SettingsTab, label: 'Octarine' }]
1032
1045
  : []),
1033
1046
  ];
@@ -1428,8 +1441,9 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1428
1441
  </>
1429
1442
  )}
1430
1443
 
1431
- {/* Agent Switching (OpenCode only) */}
1432
- {origin === 'opencode' && (
1444
+ {/* Agent Switching (OpenCode only). Applied on plan
1445
+ approval only; annotate decisions never switch agents. */}
1446
+ {origin === 'opencode' && !annotateDocumentTabs && (
1433
1447
  <>
1434
1448
  <div className="border-t border-border" />
1435
1449
  <div className="space-y-2">
@@ -1689,9 +1703,9 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1689
1703
  {/* Plan Width */}
1690
1704
  <div className="space-y-3">
1691
1705
  <div>
1692
- <div className="text-sm font-medium flex items-center gap-2">Plan Width</div>
1706
+ <div className="text-sm font-medium flex items-center gap-2">{mode === 'annotate' ? 'Document Width' : 'Plan Width'}</div>
1693
1707
  <div className="text-xs text-muted-foreground">
1694
- Maximum width of the plan card
1708
+ {mode === 'annotate' ? 'Maximum width of the document card' : 'Maximum width of the plan card'}
1695
1709
  </div>
1696
1710
  </div>
1697
1711
  <div className="flex items-center gap-1 bg-muted/50 rounded-lg p-0.5">
@@ -1815,7 +1829,9 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1815
1829
  {/* === SAVING TAB === */}
1816
1830
  {activeTab === 'saving' && (
1817
1831
  <>
1818
- {/* Plan Saving */}
1832
+ {/* Plan Saving — decision snapshots in plans/ are written
1833
+ on plan approve/deny only. */}
1834
+ {mode === 'plan' && (<>
1819
1835
  <div className="space-y-3">
1820
1836
  <div className="flex items-center justify-between">
1821
1837
  <div>
@@ -1858,6 +1874,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1858
1874
  </div>
1859
1875
 
1860
1876
  <div className="border-t border-border" />
1877
+ </>)}
1861
1878
 
1862
1879
  {/* Default Notes App */}
1863
1880
  <div className="space-y-2">
@@ -2256,7 +2273,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
2256
2273
  <div>
2257
2274
  <div className="text-sm font-medium">Obsidian Integration</div>
2258
2275
  <div className="text-xs text-muted-foreground">
2259
- Auto-save approved plans to your vault
2276
+ {mode === 'annotate' ? 'Save documents to your vault from the Options menu. When on, plan review also saves each approved plan here.' : 'Auto-save approved plans to your vault'}
2260
2277
  </div>
2261
2278
  </div>
2262
2279
  <button
@@ -2385,7 +2402,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
2385
2402
  </div>
2386
2403
 
2387
2404
  <div className="text-[10px] text-muted-foreground/70">
2388
- Plans saved to: {obsidian.vaultPath === CUSTOM_PATH_SENTINEL
2405
+ {mode === 'annotate' ? 'Saved to' : 'Plans saved to'}: {obsidian.vaultPath === CUSTOM_PATH_SENTINEL
2389
2406
  ? (obsidian.customPath || '...')
2390
2407
  : (obsidian.vaultPath || '...')}/{obsidian.folder || 'plannotator'}/
2391
2408
  </div>
@@ -2396,13 +2413,14 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
2396
2413
  {`---
2397
2414
  created: ${new Date().toISOString().slice(0, 19)}Z
2398
2415
  source: plannotator
2399
- tags: [plan, ...]
2416
+ tags: [${mode === 'annotate' ? 'plannotator' : 'plan'}, ...]
2400
2417
  ---`}
2401
2418
  </pre>
2402
2419
  </div>
2403
2420
 
2404
2421
  <div className="border-t border-border/30" />
2405
2422
 
2423
+ {mode === 'plan' && (<>
2406
2424
  <div className="flex items-center justify-between">
2407
2425
  <div>
2408
2426
  <div className="text-xs font-medium">Auto-save on Plan Arrival</div>
@@ -2423,6 +2441,7 @@ tags: [plan, ...]
2423
2441
  }`} />
2424
2442
  </button>
2425
2443
  </div>
2444
+ </>)}
2426
2445
 
2427
2446
  <div className="flex items-center justify-between">
2428
2447
  <div>
@@ -2457,7 +2476,7 @@ tags: [plan, ...]
2457
2476
  <div>
2458
2477
  <div className="text-sm font-medium">Bear Notes</div>
2459
2478
  <div className="text-xs text-muted-foreground">
2460
- Auto-save approved plans to Bear
2479
+ {mode === 'annotate' ? 'Save documents to Bear from the Options menu. When on, plan review also saves each approved plan here.' : 'Auto-save approved plans to Bear'}
2461
2480
  </div>
2462
2481
  </div>
2463
2482
  <button
@@ -2503,6 +2522,7 @@ tags: [plan, ...]
2503
2522
  </select>
2504
2523
  </div>
2505
2524
 
2525
+ {mode === 'plan' && (<>
2506
2526
  <div className="border-t border-border/30" />
2507
2527
 
2508
2528
  <div className="flex items-center justify-between">
@@ -2525,6 +2545,7 @@ tags: [plan, ...]
2525
2545
  }`} />
2526
2546
  </button>
2527
2547
  </div>
2548
+ </>)}
2528
2549
  </div>
2529
2550
  )}
2530
2551
  </>
@@ -2537,7 +2558,7 @@ tags: [plan, ...]
2537
2558
  <div>
2538
2559
  <div className="text-sm font-medium">Octarine</div>
2539
2560
  <div className="text-xs text-muted-foreground">
2540
- Auto-save approved plans to Octarine
2561
+ {mode === 'annotate' ? 'Save documents to Octarine from the Options menu. When on, plan review also saves each approved plan here.' : 'Auto-save approved plans to Octarine'}
2541
2562
  </div>
2542
2563
  </div>
2543
2564
  <button
@@ -2585,9 +2606,10 @@ tags: [plan, ...]
2585
2606
  </div>
2586
2607
 
2587
2608
  <div className="text-[10px] text-muted-foreground/70">
2588
- Plans saved to: {octarine.workspace || '...'} / {octarine.folder || 'plannotator'}/
2609
+ {mode === 'annotate' ? 'Saved to' : 'Plans saved to'}: {octarine.workspace || '...'} / {octarine.folder || 'plannotator'}/
2589
2610
  </div>
2590
2611
 
2612
+ {mode === 'plan' && (<>
2591
2613
  <div className="border-t border-border/30" />
2592
2614
 
2593
2615
  <div className="flex items-center justify-between">
@@ -2610,6 +2632,7 @@ tags: [plan, ...]
2610
2632
  }`} />
2611
2633
  </button>
2612
2634
  </div>
2635
+ </>)}
2613
2636
  </div>
2614
2637
  )}
2615
2638
  </>
@@ -5,7 +5,7 @@ import { AnnotationType, type Block, type Annotation, type EditorMode, type Inpu
5
5
  import { applyHighlight, codeBlockClassName, onCodeHighlightSwap } from '../utils/codeHighlight';
6
6
  import { paintCodeBlockMark } from '../utils/codeBlockMark';
7
7
  import { useFenceTheme } from '../hooks/useFenceTheme';
8
- import { computeListIndices, groupBlocks, type Frontmatter } from '../utils/parser';
8
+ import { computeListIndices, groupBlocks, type Frontmatter, type FrontmatterValue } from '../utils/parser';
9
9
  import { buildHeadingSlugMap } from '../utils/slugify';
10
10
  import { copyTextToClipboard } from '../utils/clipboard';
11
11
  import { BlockRenderer } from './BlockRenderer';
@@ -222,6 +222,76 @@ interface CodeBlockToolbarTarget {
222
222
  readonly activation: 'pointer' | 'keyboard';
223
223
  }
224
224
 
225
+ // Named type guard so both taken and fallthrough branches narrow.
226
+ function isFrontmatterMap(value: FrontmatterValue): value is { [key: string]: FrontmatterValue } {
227
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
228
+ }
229
+
230
+ /**
231
+ * Renders a single frontmatter field or recursive sub-structure.
232
+ */
233
+ const FrontmatterRow: React.FC<{ field: string; value: FrontmatterValue }> = ({ field, value }) => {
234
+ if (isFrontmatterMap(value)) {
235
+ const subEntries = Object.entries(value);
236
+ return (
237
+ <div className="flex flex-col gap-1.5">
238
+ <span className="font-medium text-muted-foreground">{field}:</span>
239
+ <div className="pl-4 grid gap-1.5">
240
+ {subEntries.map(([k, v]) => (
241
+ <FrontmatterRow key={k} field={k} value={v} />
242
+ ))}
243
+ </div>
244
+ </div>
245
+ );
246
+ }
247
+
248
+ if (Array.isArray(value)) {
249
+ const isArrayOfMaps = value.some((v) => typeof v === 'object' && v !== null);
250
+ if (isArrayOfMaps) {
251
+ return (
252
+ <div className="flex flex-col gap-1.5">
253
+ <span className="font-medium text-muted-foreground">{field}:</span>
254
+ <div className="pl-4 grid gap-2">
255
+ {value.map((item, i) => (
256
+ <div key={i} className="p-2 bg-muted/40 border border-border/40 rounded grid gap-1.5">
257
+ {isFrontmatterMap(item) ? (
258
+ Object.entries(item).map(([k, v]) => (
259
+ <FrontmatterRow key={k} field={k} value={v} />
260
+ ))
261
+ ) : (
262
+ <span className="text-foreground">{typeof item === 'string' ? item : String(item)}</span>
263
+ )}
264
+ </div>
265
+ ))}
266
+ </div>
267
+ </div>
268
+ );
269
+ }
270
+
271
+ return (
272
+ <div className="flex gap-2">
273
+ <span className="font-medium text-muted-foreground min-w-[80px]">{field}:</span>
274
+ <span className="text-foreground">
275
+ <span className="flex flex-wrap gap-1">
276
+ {value.map((v, i) => (
277
+ <span key={i} className="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-xs">
278
+ {typeof v === 'string' ? v : String(v)}
279
+ </span>
280
+ ))}
281
+ </span>
282
+ </span>
283
+ </div>
284
+ );
285
+ }
286
+
287
+ return (
288
+ <div className="flex gap-2">
289
+ <span className="font-medium text-muted-foreground min-w-[80px]">{field}:</span>
290
+ <span className="text-foreground">{value}</span>
291
+ </div>
292
+ );
293
+ };
294
+
225
295
  /**
226
296
  * Renders YAML frontmatter as a styled metadata card.
227
297
  */
@@ -233,22 +303,7 @@ const FrontmatterCard: React.FC<{ frontmatter: Frontmatter }> = ({ frontmatter }
233
303
  <div className="mt-4 mb-6 p-4 bg-muted/30 border border-border/50 rounded-lg">
234
304
  <div className="grid gap-2 text-sm">
235
305
  {entries.map(([key, value]) => (
236
- <div key={key} className="flex gap-2">
237
- <span className="font-medium text-muted-foreground min-w-[80px]">{key}:</span>
238
- <span className="text-foreground">
239
- {Array.isArray(value) ? (
240
- <span className="flex flex-wrap gap-1">
241
- {value.map((v, i) => (
242
- <span key={i} className="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-xs">
243
- {v}
244
- </span>
245
- ))}
246
- </span>
247
- ) : (
248
- value
249
- )}
250
- </span>
251
- </div>
306
+ <FrontmatterRow key={key} field={key} value={value} />
252
307
  ))}
253
308
  </div>
254
309
  </div>
@@ -1222,6 +1277,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1222
1277
  onToggleCheckbox={readOnly ? undefined : onToggleCheckbox}
1223
1278
  checkboxOverrides={checkboxOverrides}
1224
1279
  githubRepo={repoInfo?.display}
1280
+ repoHost={repoInfo?.host}
1225
1281
  headingAnchorId={headingSlugMap.get(block.id)}
1226
1282
  onNavigateAnchor={scrollToAnchor}
1227
1283
  />
@@ -1262,6 +1318,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1262
1318
  onOpenLinkedDoc={onOpenLinkedDoc}
1263
1319
  onOpenCodeFile={onOpenCodeFile}
1264
1320
  githubRepo={repoInfo?.display}
1321
+ repoHost={repoInfo?.host}
1265
1322
  onNavigateAnchor={scrollToAnchor}
1266
1323
  onHover={(element) => {
1267
1324
  if (tableHoverTimeoutRef.current) {
@@ -1328,7 +1385,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1328
1385
  }
1329
1386
  />
1330
1387
  ) : (
1331
- <BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} onNavigateAnchor={scrollToAnchor} onToggleCheckbox={readOnly ? undefined : onToggleCheckbox} checkboxOverrides={checkboxOverrides} githubRepo={repoInfo?.display} headingAnchorId={headingSlugMap.get(group.block.id)} />
1388
+ <BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} onNavigateAnchor={scrollToAnchor} onToggleCheckbox={readOnly ? undefined : onToggleCheckbox} checkboxOverrides={checkboxOverrides} githubRepo={repoInfo?.display} repoHost={repoInfo?.host} headingAnchorId={headingSlugMap.get(group.block.id)} />
1332
1389
  )
1333
1390
  )}
1334
1391
 
@@ -1438,6 +1495,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1438
1495
  onOpenLinkedDoc={onOpenLinkedDoc}
1439
1496
  onOpenCodeFile={onOpenCodeFile}
1440
1497
  githubRepo={repoInfo?.display}
1498
+ repoHost={repoInfo?.host}
1441
1499
  onNavigateAnchor={scrollToAnchor}
1442
1500
  />
1443
1501
  )}
@@ -13,6 +13,7 @@ interface AlertBlockProps {
13
13
  imageBaseDir?: string;
14
14
  onImageClick?: (src: string, alt: string) => void;
15
15
  githubRepo?: string;
16
+ repoHost?: string;
16
17
  onNavigateAnchor?: (hash: string) => void;
17
18
  }
18
19
 
@@ -60,9 +61,9 @@ const Icon: React.FC<{ kind: AlertKind }> = ({ kind }) => {
60
61
  };
61
62
 
62
63
  export const AlertBlock: React.FC<AlertBlockProps> = ({
63
- blockId, kind, body, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, githubRepo, onNavigateAnchor,
64
+ blockId, kind, body, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, githubRepo, repoHost, onNavigateAnchor,
64
65
  }) => {
65
- const proseProps = { imageBaseDir, onImageClick, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor, githubRepo };
66
+ const proseProps = { imageBaseDir, onImageClick, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor, githubRepo, repoHost };
66
67
 
67
68
  // A bold-only first line (optionally led by one emoji, optionally trailed by
68
69
  // an `<!-- icon: name -->` comment) is the alert's title and rides the icon
@@ -13,6 +13,7 @@ interface CalloutProps {
13
13
  imageBaseDir?: string;
14
14
  onImageClick?: (src: string, alt: string) => void;
15
15
  githubRepo?: string;
16
+ repoHost?: string;
16
17
  onNavigateAnchor?: (hash: string) => void;
17
18
  }
18
19
 
@@ -28,6 +29,7 @@ export const Callout: React.FC<CalloutProps> = ({
28
29
  imageBaseDir,
29
30
  onImageClick,
30
31
  githubRepo,
32
+ repoHost,
31
33
  onNavigateAnchor,
32
34
  }) => {
33
35
  const kindAttr =
@@ -54,6 +56,7 @@ export const Callout: React.FC<CalloutProps> = ({
54
56
  onOpenCodeFile,
55
57
  onNavigateAnchor,
56
58
  githubRepo,
59
+ repoHost,
57
60
  })}
58
61
  </div>
59
62
  );
@@ -1,7 +1,7 @@
1
1
  import React, { useState, useRef, useEffect, useCallback } from 'react';
2
2
  import type { Block } from '../../types';
3
3
  import { copyTextToClipboard } from '../../utils/clipboard';
4
- import { applyHighlight, codeBlockClassName } from '../../utils/codeHighlight';
4
+ import { applyHighlight, codeBlockClassName, codeBlockKeepsLayout } from '../../utils/codeHighlight';
5
5
  import { useFenceTheme } from '../../hooks/useFenceTheme';
6
6
 
7
7
  interface CodeBlockProps {
@@ -68,7 +68,11 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({ block, onHover, onLeave })
68
68
  )}
69
69
  </button>
70
70
  <pre className="rounded-lg text-[13px] overflow-x-auto bg-muted/50 border border-border/30">
71
- <code ref={codeRef} className={codeClassName}>{block.content}</code>
71
+ <code
72
+ ref={codeRef}
73
+ className={codeClassName}
74
+ data-keep-layout={codeBlockKeepsLayout(block.content) ? '' : undefined}
75
+ >{block.content}</code>
72
76
  </pre>
73
77
  </div>
74
78
  );
@@ -12,6 +12,7 @@ interface TableBlockProps {
12
12
  imageBaseDir?: string;
13
13
  onImageClick?: (src: string, alt: string) => void;
14
14
  githubRepo?: string;
15
+ repoHost?: string;
15
16
  }
16
17
 
17
18
  // Parse pipe-delimited markdown table content into headers + rows.
@@ -87,6 +88,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
87
88
  imageBaseDir,
88
89
  onImageClick,
89
90
  githubRepo,
91
+ repoHost,
90
92
  }) => {
91
93
  const containerRef = useRef<HTMLDivElement>(null);
92
94
  const { headers, rows } = parseTableContent(block.content);
@@ -116,6 +118,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
116
118
  onOpenCodeFile={onOpenCodeFile}
117
119
  onNavigateAnchor={onNavigateAnchor}
118
120
  githubRepo={githubRepo}
121
+ repoHost={repoHost}
119
122
  />
120
123
  </th>
121
124
  ))}
@@ -134,6 +137,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
134
137
  onOpenCodeFile={onOpenCodeFile}
135
138
  onNavigateAnchor={onNavigateAnchor}
136
139
  githubRepo={githubRepo}
140
+ repoHost={repoHost}
137
141
  />
138
142
  </td>
139
143
  ))}
@@ -28,6 +28,7 @@ interface TablePopoutProps {
28
28
  imageBaseDir?: string;
29
29
  onImageClick?: (src: string, alt: string) => void;
30
30
  githubRepo?: string;
31
+ repoHost?: string;
31
32
  }
32
33
 
33
34
  type Row = Record<string, string>;
@@ -43,6 +44,7 @@ const TablePopoutImpl: React.FC<TablePopoutProps> = ({
43
44
  imageBaseDir,
44
45
  onImageClick,
45
46
  githubRepo,
47
+ repoHost,
46
48
  }) => {
47
49
  const { headers, rows } = useMemo(() => parseTableContent(block.content), [block.content]);
48
50
 
@@ -83,11 +85,12 @@ const TablePopoutImpl: React.FC<TablePopoutProps> = ({
83
85
  onOpenCodeFile={onOpenCodeFile}
84
86
  onNavigateAnchor={onNavigateAnchor}
85
87
  githubRepo={githubRepo}
88
+ repoHost={repoHost}
86
89
  />
87
90
  ),
88
91
  }),
89
92
  );
90
- }, [columnIds, headers, imageBaseDir, onImageClick, onOpenLinkedDoc, onNavigateAnchor, githubRepo]);
93
+ }, [columnIds, headers, imageBaseDir, onImageClick, onOpenLinkedDoc, onNavigateAnchor, githubRepo, repoHost]);
91
94
 
92
95
  const [sorting, setSorting] = useState<SortingState>([]);
93
96
  const [globalFilter, setGlobalFilter] = useState('');
@@ -263,7 +266,8 @@ export const TablePopout = React.memo(
263
266
  prev.open === next.open &&
264
267
  prev.container === next.container &&
265
268
  prev.imageBaseDir === next.imageBaseDir &&
266
- prev.githubRepo === next.githubRepo,
269
+ prev.githubRepo === next.githubRepo &&
270
+ prev.repoHost === next.repoHost,
267
271
  );
268
272
 
269
273
  const SortIndicator: React.FC<{ dir: false | 'asc' | 'desc' }> = ({ dir }) => {
@@ -19,6 +19,7 @@ export function renderProseBody(args: {
19
19
  onOpenCodeFile?: (path: string) => void;
20
20
  onNavigateAnchor?: (hash: string) => void;
21
21
  githubRepo?: string;
22
+ repoHost?: string;
22
23
  }): React.ReactNode {
23
24
  const {
24
25
  body,
@@ -30,6 +31,7 @@ export function renderProseBody(args: {
30
31
  onOpenCodeFile,
31
32
  onNavigateAnchor,
32
33
  githubRepo,
34
+ repoHost,
33
35
  } = args;
34
36
 
35
37
  const inline = (text: string) => (
@@ -41,6 +43,7 @@ export function renderProseBody(args: {
41
43
  onOpenCodeFile={onOpenCodeFile}
42
44
  onNavigateAnchor={onNavigateAnchor}
43
45
  githubRepo={githubRepo}
46
+ repoHost={repoHost}
44
47
  />
45
48
  );
46
49