@plannotator/ui 0.54.0 → 0.56.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 (59) hide show
  1. package/HANDOFF.md +102 -6
  2. package/README.md +128 -3
  3. package/components/AnnotationPanel.tsx +2 -1
  4. package/components/AttachmentsButton.tsx +1 -1
  5. package/components/BlockRenderer.tsx +4 -1
  6. package/components/CodeFilePopout.tsx +6 -4
  7. package/components/CodeSnippetPreview.tsx +174 -0
  8. package/components/ComposerTokenTextarea.tsx +43 -2
  9. package/components/DeferredMarkdownContentsRail.tsx +38 -0
  10. package/components/DiffFileTree.tsx +7 -6
  11. package/components/DocBadges.tsx +47 -35
  12. package/components/DocumentContents.tsx +74 -0
  13. package/components/ImageLightbox.tsx +121 -63
  14. package/components/ImageZoomStage.tsx +32 -4
  15. package/components/InlineMarkdown.tsx +15 -150
  16. package/components/MarkdownContentsRail.tsx +777 -0
  17. package/components/Settings.tsx +2 -1
  18. package/components/TableOfContents.tsx +22 -7
  19. package/components/Viewer.tsx +51 -19
  20. package/components/blocks/QuestionBlock.tsx +14 -4
  21. package/components/chat/ChatComposer.tsx +192 -0
  22. package/components/chat/ChatFrame.tsx +524 -0
  23. package/components/chat/ChatTranscript.tsx +422 -0
  24. package/components/chat/chatMarkdown.ts +436 -0
  25. package/components/chat/chatRecipes.tsx +39 -0
  26. package/components/chat/chatWindowStore.ts +196 -0
  27. package/components/chat/index.ts +72 -0
  28. package/components/diagram/DiagramCanvas.tsx +7 -0
  29. package/components/diagram/DiagramComposer.tsx +15 -3
  30. package/components/diagram/DiagramViewer.tsx +1 -0
  31. package/components/docBadgesWidth.ts +29 -0
  32. package/components/html-viewer/HtmlViewer.tsx +36 -1
  33. package/components/imageZoom.ts +21 -0
  34. package/components/inbox-thread/FileName.tsx +118 -0
  35. package/components/inbox-thread/GuideCard.tsx +39 -0
  36. package/components/inbox-thread/ReaskNote.tsx +25 -0
  37. package/components/inbox-thread/SkillsLine.tsx +40 -0
  38. package/components/inbox-thread/Tip.tsx +105 -0
  39. package/components/inbox-thread/icons.tsx +262 -0
  40. package/components/inbox-thread/reask.ts +222 -0
  41. package/contents-rail.css +337 -0
  42. package/hooks/useActiveSection.ts +46 -63
  43. package/hooks/useAnnotationHighlighter.ts +40 -1
  44. package/hooks/useFileBrowser.ts +4 -3
  45. package/hooks/useHeadingScrollSpy.ts +77 -0
  46. package/hooks/useHoverPreview.ts +94 -0
  47. package/hooks/usePrefersReducedMotion.ts +23 -0
  48. package/hooks/useSwipeStep.ts +597 -0
  49. package/inbox-thread.css +4542 -0
  50. package/package.json +13 -4
  51. package/styles.css +1 -1
  52. package/theme.css +1 -0
  53. package/themes/tokyo-night-moon.css +34 -0
  54. package/utils/aiChatFormat.ts +32 -11
  55. package/utils/annotationHelpers.ts +32 -4
  56. package/utils/blockParse.ts +45 -0
  57. package/utils/headingScrollSpy.ts +105 -0
  58. package/utils/syntaxTheme.ts +1 -0
  59. package/utils/themeRegistry.ts +11 -0
@@ -0,0 +1,174 @@
1
+ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { ensureHighlight, highlightToHtml } from "../utils/codeHighlight";
4
+ import { useFenceTheme } from "../hooks/useFenceTheme";
5
+
6
+ /**
7
+ * The floating code window a code-file reference opens on hover: a few lines
8
+ * of one file, syntax highlighted, with line numbers. Portalled to the body
9
+ * and positioned under (or above) its anchor.
10
+ *
11
+ * Used by plan / annotate code-file links (`CodeFileLink` in InlineMarkdown,
12
+ * the exact range only) and by code review's Call Flow locations
13
+ * (`CallFlowTreeView`, with `contextLines` around the target).
14
+ */
15
+
16
+ /** Lines highlighted above the shown ones so a range opening inside a block comment or string tokenises as one. */
17
+ const HIGHLIGHT_LEAD_IN_LINES = 40;
18
+ /** Gap kept between the window and the viewport edge. */
19
+ const VIEWPORT_MARGIN = 8;
20
+
21
+ /** The highlighter language for a file path, by extension; undefined renders plain text. */
22
+ export function codeSnippetLanguage(filepath: string): string | undefined {
23
+ const ext = filepath.split('.').pop()?.toLowerCase();
24
+ const map: Record<string, string> = {
25
+ ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
26
+ mts: 'typescript', cts: 'typescript', mjs: 'javascript', cjs: 'javascript',
27
+ py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',
28
+ css: 'css', scss: 'scss', json: 'json', yml: 'yaml', yaml: 'yaml',
29
+ sql: 'sql', sh: 'bash', bash: 'bash', zsh: 'bash', md: 'markdown',
30
+ html: 'html', xml: 'xml', toml: 'toml', swift: 'swift', kt: 'kotlin',
31
+ lua: 'lua', c: 'c', h: 'c', cpp: 'cpp', hpp: 'cpp', cs: 'csharp',
32
+ scala: 'scala', dart: 'dart', ex: 'elixir', exs: 'elixir', zig: 'zig',
33
+ vue: 'vue', svelte: 'svelte', astro: 'astro', php: 'php',
34
+ };
35
+ return ext ? map[ext] : undefined;
36
+ }
37
+
38
+ export interface CodeSnippetPreviewProps {
39
+ anchorEl: HTMLElement | null;
40
+ /** The whole file; the preview slices the lines it shows. */
41
+ contents: string;
42
+ filepath: string;
43
+ /** One-based first line of the target range. */
44
+ line: number;
45
+ lineEnd?: number;
46
+ /** Lines shown above and below the target range, the target rows marked. 0 (default) shows the range alone. */
47
+ contextLines?: number;
48
+ /** Header label; defaults to the file's basename. */
49
+ title?: string;
50
+ /** Called when the page or a scroller scrolls: the window is placed once and would drift from its anchor. */
51
+ onDismiss?: () => void;
52
+ onMouseEnter?: () => void;
53
+ onMouseLeave?: () => void;
54
+ }
55
+
56
+ export const CodeSnippetPreview: React.FC<CodeSnippetPreviewProps> = ({
57
+ anchorEl,
58
+ contents,
59
+ filepath,
60
+ line,
61
+ lineEnd,
62
+ contextLines = 0,
63
+ title,
64
+ onDismiss,
65
+ onMouseEnter,
66
+ onMouseLeave,
67
+ }) => {
68
+ const allLines = contents.split('\n');
69
+ const targetStart = Math.max(0, line - 1);
70
+ const targetEnd = Math.min(allLines.length, lineEnd ?? line);
71
+ const start = Math.max(0, targetStart - contextLines);
72
+ const end = Math.min(allLines.length, targetEnd + contextLines);
73
+ const snippet = allLines.slice(start, end).join('\n');
74
+ // Highlight from a little earlier than the first shown line: a range that
75
+ // starts inside a block comment or template literal would otherwise colour
76
+ // that text as code.
77
+ const highlightStart = Math.max(0, start - HIGHLIGHT_LEAD_IN_LINES);
78
+ const highlightSource = allLines.slice(highlightStart, end).join('\n');
79
+ const marksTarget = contextLines > 0;
80
+
81
+ const fenceTheme = useFenceTheme();
82
+ // Bumped once the grammar is attached, to re-run the memo below with the
83
+ // highlighter warm. Until then the snippet renders as plain text — no
84
+ // auto-detection, and an unknown extension simply stays plain.
85
+ const [highlighterGeneration, setHighlighterGeneration] = useState(0);
86
+ const lang = codeSnippetLanguage(filepath);
87
+
88
+ useEffect(() => {
89
+ if (!lang) return;
90
+ let cancelled = false;
91
+ void ensureHighlight(lang, fenceTheme).then((ok) => {
92
+ if (ok && !cancelled) setHighlighterGeneration((n) => n + 1);
93
+ });
94
+ return () => { cancelled = true; };
95
+ }, [lang, fenceTheme]);
96
+
97
+ useEffect(() => {
98
+ if (!onDismiss) return;
99
+ const dismiss = () => onDismiss();
100
+ window.addEventListener('scroll', dismiss, { capture: true, passive: true });
101
+ return () => window.removeEventListener('scroll', dismiss, { capture: true });
102
+ }, [onDismiss]);
103
+
104
+ const highlightedLines = useMemo(() => {
105
+ // Highlight the snippet as one unit so multi-line constructs (block
106
+ // comments, template literals) tokenise correctly, then split back into
107
+ // rows: the highlighter joins lines with "\n" and never emits one inside a
108
+ // span, so the split is exact.
109
+ const html = lang ? highlightToHtml(highlightSource, lang, fenceTheme) : null;
110
+ if (html !== null) return html.split('\n').slice(start - highlightStart);
111
+ return snippet.split('\n').map(text =>
112
+ text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'));
113
+ // highlighterGeneration is the "grammar just became available" signal.
114
+ // eslint-disable-next-line react-hooks/exhaustive-deps
115
+ }, [snippet, highlightSource, start, highlightStart, lang, fenceTheme, highlighterGeneration]);
116
+
117
+ // Clamp against the window's rendered width (it sizes to its longest line,
118
+ // up to its max width) so a wide snippet never runs off the right edge.
119
+ const windowRef = useRef<HTMLDivElement | null>(null);
120
+ const [renderedWidth, setRenderedWidth] = useState<number | null>(null);
121
+ useLayoutEffect(() => {
122
+ const width = windowRef.current?.offsetWidth ?? null;
123
+ if (width !== renderedWidth) setRenderedWidth(width);
124
+ });
125
+
126
+ if (!anchorEl) return null;
127
+
128
+ const rect = anchorEl.getBoundingClientRect();
129
+ const spaceBelow = window.innerHeight - rect.bottom;
130
+ const showAbove = spaceBelow < 200 && rect.top > spaceBelow;
131
+ const top = showAbove ? undefined : rect.bottom + 4;
132
+ const bottom = showAbove ? window.innerHeight - rect.top + 4 : undefined;
133
+ const left = Math.max(VIEWPORT_MARGIN, Math.min(rect.left, window.innerWidth - (renderedWidth ?? 520) - VIEWPORT_MARGIN));
134
+
135
+ return createPortal(
136
+ <div
137
+ ref={windowRef}
138
+ className="code-snippet-preview-window fixed z-[9999] rounded-lg border border-border bg-card shadow-xl flex flex-col"
139
+ style={{ top, bottom, left, maxWidth: `min(600px, calc(100vw - ${VIEWPORT_MARGIN * 2}px))`, maxHeight: '300px' }}
140
+ data-code-snippet-preview={filepath}
141
+ onMouseEnter={onMouseEnter}
142
+ onMouseLeave={onMouseLeave}
143
+ >
144
+ <div className="px-3 py-1.5 border-b border-border/50 text-[10px] text-muted-foreground font-mono flex items-center justify-between gap-4 flex-shrink-0">
145
+ <span>{title ?? filepath.split('/').pop()}</span>
146
+ <span className="opacity-60">{lineEnd && lineEnd !== line ? `lines ${line}–${lineEnd}` : `line ${line}`}</span>
147
+ </div>
148
+ <div className="code-snippet-preview overflow-auto text-[12px] leading-5 min-h-0" style={{ padding: 0, background: 'var(--code-bg, #1e293b)' }}>
149
+ <table className="border-collapse w-full">
150
+ <tbody>
151
+ {snippet.split('\n').map((_, i) => {
152
+ const index = start + i;
153
+ const target = marksTarget && index >= targetStart && index < targetEnd;
154
+ return (
155
+ <tr
156
+ key={index}
157
+ className={target ? 'bg-primary/15' : 'hover:bg-white/5'}
158
+ data-target-line={target ? 'true' : undefined}
159
+ >
160
+ <td className={`select-none text-right pr-3 pl-3 py-0 align-top font-mono w-8 whitespace-nowrap ${target ? 'text-primary' : 'text-muted-foreground/40'}`} style={{ userSelect: 'none' }}>{index + 1}</td>
161
+ <td
162
+ className="font-mono pr-3 py-0 whitespace-pre"
163
+ dangerouslySetInnerHTML={{ __html: highlightedLines[i] ?? '' }}
164
+ />
165
+ </tr>
166
+ );
167
+ })}
168
+ </tbody>
169
+ </table>
170
+ </div>
171
+ </div>,
172
+ document.body,
173
+ );
174
+ };
@@ -207,6 +207,16 @@ export interface ComposerTokenTextareaProps {
207
207
  React.TextareaHTMLAttributes<HTMLTextAreaElement>,
208
208
  'value' | 'onChange' | 'onKeyDown' | 'onSelect' | 'onScroll' | 'className' | 'placeholder'
209
209
  >;
210
+ /** Paint on the mirror that is not in the value. Same metric rule as the token spans: it must add no width. */
211
+ mirrorPaint?: ComposerMirrorPaint;
212
+ }
213
+
214
+ export interface ComposerMirrorPaint {
215
+ /** Drawn at this offset (past the end of a token it falls inside). */
216
+ at?: number;
217
+ node?: React.ReactNode;
218
+ /** A range of plain text behind a mark. A token inside it keeps its own paint. */
219
+ wash?: { start: number; end: number; className: string };
210
220
  }
211
221
 
212
222
  /**
@@ -241,6 +251,7 @@ export const ComposerTokenTextarea: React.FC<ComposerTokenTextareaProps> = ({
241
251
  inputClassName,
242
252
  mirrorClassName,
243
253
  inputProps,
254
+ mirrorPaint,
244
255
  }) => {
245
256
  const overlayRef = useRef<HTMLDivElement>(null);
246
257
  const [composing, setComposing] = useState(false);
@@ -325,15 +336,45 @@ export const ComposerTokenTextarea: React.FC<ComposerTokenTextareaProps> = ({
325
336
  }
326
337
 
327
338
  const segments: React.ReactNode[] = [];
339
+ let paintDrawn = false;
340
+ /** One run of plain text, cut at the paint's offsets. With no paint, the plain string as ever. */
341
+ const pushPlain = (start: number, end: number) => {
342
+ if (!mirrorPaint) {
343
+ segments.push(value.slice(start, end));
344
+ return;
345
+ }
346
+ const { at, node, wash } = mirrorPaint;
347
+ const cuts = [start, end, ...[at, wash?.start, wash?.end].filter((cut): cut is number => cut !== undefined && cut > start && cut < end)].sort((a, b) => a - b);
348
+ for (let i = 0; i + 1 < cuts.length; i++) {
349
+ const from = cuts[i]!;
350
+ const to = cuts[i + 1]!;
351
+ if (!paintDrawn && at !== undefined && from >= at) {
352
+ paintDrawn = true;
353
+ segments.push(<React.Fragment key="mirror-paint">{node}</React.Fragment>);
354
+ }
355
+ if (from === to) continue;
356
+ const text = value.slice(from, to);
357
+ segments.push(
358
+ wash && from >= wash.start && to <= wash.end ? (
359
+ <mark key={`mirror-wash-${from}`} className={wash.className}>
360
+ {text}
361
+ </mark>
362
+ ) : (
363
+ text
364
+ ),
365
+ );
366
+ }
367
+ };
328
368
  let pos = 0;
329
369
  for (const range of ranges) {
330
- if (range.start > pos) segments.push(value.slice(pos, range.start));
370
+ if (range.start > pos) pushPlain(pos, range.start);
331
371
  segments.push(
332
372
  renderTokenSpan(range, value.slice(range.start, range.end), mentionChips?.tokenClassName),
333
373
  );
334
374
  pos = range.end;
335
375
  }
336
- segments.push(value.slice(pos));
376
+ pushPlain(pos, value.length);
377
+ if (mirrorPaint?.at !== undefined && !paintDrawn) segments.push(<React.Fragment key="mirror-paint">{mirrorPaint.node}</React.Fragment>);
337
378
 
338
379
  return (
339
380
  <div className="relative">
@@ -0,0 +1,38 @@
1
+ // Moved from the Workspaces app's DeferredMarkdownContentsRail with its
2
+ // contents rail (MarkdownContentsRail, approved 2026-10-03). Port: the props
3
+ // are the rail's own (`MarkdownContentsRailProps`), passed through.
4
+
5
+ import { lazy, Suspense, useEffect, useState } from 'react';
6
+
7
+ import type { MarkdownContentsRailProps } from './MarkdownContentsRail';
8
+
9
+ const MarkdownContentsRail = lazy(() =>
10
+ import('./MarkdownContentsRail').then((module) => ({
11
+ default: module.MarkdownContentsRail,
12
+ })),
13
+ );
14
+
15
+ export function scheduleMarkdownContentsRail(callback: () => void): () => void {
16
+ if (typeof window.requestIdleCallback === 'function') {
17
+ const handle = window.requestIdleCallback(callback, { timeout: 2000 });
18
+ return () => window.cancelIdleCallback(handle);
19
+ }
20
+ const handle = window.setTimeout(callback, 0);
21
+ return () => window.clearTimeout(handle);
22
+ }
23
+
24
+ /** Load the reading aid after the document is interactive, never in the
25
+ * critical document-paint path. The timeout still guarantees it appears on
26
+ * a busy page; Safari and tests use the zero-delay fallback. */
27
+ export function DeferredMarkdownContentsRail(props: MarkdownContentsRailProps) {
28
+ const [ready, setReady] = useState(false);
29
+
30
+ useEffect(() => scheduleMarkdownContentsRail(() => setReady(true)), []);
31
+
32
+ if (!ready) return null;
33
+ return (
34
+ <Suspense fallback={null}>
35
+ <MarkdownContentsRail {...props} />
36
+ </Suspense>
37
+ );
38
+ }
@@ -11,12 +11,13 @@ export type { DiffFileTreeFile, DiffFileTreeNode, DiffFileTreeStatus } from '../
11
11
  export type { DiffFileTreeDefaultExpanded } from '../hooks/useDiffFileTreeExpansion';
12
12
 
13
13
  /*
14
- * The diff file tree's presentational parts. Plannotator's code review renders
15
- * its tree rows from the atoms below (folder row, file-row content, change
16
- * letter, +/- counts, indentation), adding its review-only decorations
17
- * (viewed/stage controls, annotation badge, context menu) around them; the
18
- * `DiffFileTree` component at the bottom is the read-only, embeddable tree a
19
- * host drops beside its own diff list.
14
+ * The diff file tree's presentational parts: the atoms below (folder row,
15
+ * file-row content, change letter, +/- counts, indentation) for a host's own
16
+ * rows, and the `DiffFileTree` component at the bottom, the read-only,
17
+ * embeddable tree a host drops beside its own diff list. Plannotator's code
18
+ * review shares `ChangeTypeLetter` and `diffFileTreeIndent` (its All files
19
+ * tree) but draws its own Changes rows since its aligned sidebar (see
20
+ * HANDOFF.md § "Diff file tree").
20
21
  */
21
22
 
22
23
  /** Left padding of a row at `depth` (px). */
@@ -19,7 +19,9 @@ import React from 'react';
19
19
  import { PlanDiffBadge } from './plan-diff/PlanDiffBadge';
20
20
  import type { PlanDiffStats } from '../utils/planDiffEngine';
21
21
  import { hostnameOrFallback } from '@plannotator/core/project';
22
+ import { basenameFromPath } from '@plannotator/core/source-save';
22
23
  import { OpenInAppButton } from './OpenInAppButton';
24
+ import { HostTitle } from './HostTitle';
23
25
 
24
26
  export interface LinkedDocBadgeInfo {
25
27
  filepath: string;
@@ -74,8 +76,11 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
74
76
  const isHorizontal = layout !== 'column';
75
77
  const canOpenInApp =
76
78
  !!openInAppPath && !/^https?:\/\//i.test(openInAppPath);
79
+ // Never shrinks: a file name chip beside it gives up its width first.
77
80
  const openInButton = canOpenInApp ? (
78
- <OpenInAppButton filePath={openInAppPath} base={null} />
81
+ <span className="flex flex-shrink-0">
82
+ <OpenInAppButton filePath={openInAppPath} base={null} />
83
+ </span>
79
84
  ) : null;
80
85
 
81
86
  // In row layout, only PlanDiffBadge (when it has stats to show) and
@@ -100,24 +105,32 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
100
105
  under the action buttons. The Viewer-owned header measures and wraps
101
106
  the complete badge set, so its `header` layout keeps this context. */}
102
107
  {repoInfo && !linkedDocInfo && !isRow && (
103
- <div className="flex items-center gap-1.5">
104
- <span
105
- className="px-1.5 py-0.5 bg-muted/50 rounded truncate max-w-[140px]"
106
- title={repoInfo.display}
107
- >
108
- {repoInfo.display}
109
- </span>
110
- {repoInfo.branch && (
111
- <span
112
- className="px-1.5 py-0.5 bg-muted/30 rounded max-w-[120px] flex items-center gap-1 overflow-hidden"
113
- title={repoInfo.branch}
114
- >
115
- <svg className="w-2.5 h-2.5 flex-shrink-0" viewBox="0 0 16 16" fill="currentColor">
116
- <path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
117
- </svg>
118
- <span className="truncate">{repoInfo.branch}</span>
119
- </span>
120
- )}
108
+ <div className="flex min-w-0 max-w-full items-center gap-1.5">
109
+ {/* No fixed caps: the chips take the room the cluster has. When it runs
110
+ out, the branch gives way first, down to its icon and a letter or
111
+ two, and only then the repository: the repository never shrinks,
112
+ but stops short of the branch's floor (4.5em) and the gap. */}
113
+ <div className="flex min-w-0 items-center gap-1.5">
114
+ <HostTitle title={repoInfo.display} cut>
115
+ <span
116
+ className={`truncate px-1.5 py-0.5 bg-muted/50 rounded ${
117
+ repoInfo.branch ? 'flex-shrink-0 max-w-[calc(100%_-_4.5em_-_0.375rem)]' : 'min-w-0'
118
+ }`}
119
+ >
120
+ {repoInfo.display}
121
+ </span>
122
+ </HostTitle>
123
+ {repoInfo.branch && (
124
+ <HostTitle title={repoInfo.branch} cut>
125
+ <span className="min-w-[4.5em] px-1.5 py-0.5 bg-muted/30 rounded flex items-center gap-1 overflow-hidden">
126
+ <svg className="w-2.5 h-2.5 flex-shrink-0" viewBox="0 0 16 16" fill="currentColor">
127
+ <path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
128
+ </svg>
129
+ <span className="truncate">{repoInfo.branch}</span>
130
+ </span>
131
+ </HostTitle>
132
+ )}
133
+ </div>
121
134
  {/* Markdown/text annotate sessions have no separate source badge, so
122
135
  keep the open-in control attached to the repository context. */}
123
136
  {canOpenInApp && !sourceInfo && openInButton}
@@ -199,7 +212,7 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
199
212
  {/* Linked-doc breadcrumb: only in column layout (sticky lane is hidden in linked-doc mode) */}
200
213
  {!isRow && linkedDocInfo && (
201
214
  linkedDocInfo.variant === 'folder-file' ? (
202
- <div className="flex items-center gap-2">
215
+ <div className="flex min-w-0 max-w-full items-center gap-2">
203
216
  {linkedDocInfo.onBack && (
204
217
  <button
205
218
  type="button"
@@ -209,19 +222,19 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
209
222
  Close
210
223
  </button>
211
224
  )}
212
- <span
213
- className="truncate rounded bg-muted/50 px-1.5 py-0.5 text-[9px] text-muted-foreground max-w-[220px]"
214
- title={linkedDocInfo.filepath}
215
- >
216
- {linkedDocInfo.filepath.split('/').pop()}
217
- </span>
225
+ {/* The hover text is the whole path, so it shows whether or not the name is cut. */}
226
+ <HostTitle title={linkedDocInfo.filepath}>
227
+ <span className="min-w-0 truncate rounded bg-muted/50 px-1.5 py-0.5 text-[9px] text-muted-foreground">
228
+ {basenameFromPath(linkedDocInfo.filepath)}
229
+ </span>
230
+ </HostTitle>
218
231
  {openInButton}
219
232
  </div>
220
233
  ) : (
221
- <div className="flex items-center gap-1.5">
234
+ <div className="flex min-w-0 max-w-full items-center gap-1.5">
222
235
  <button
223
236
  onClick={linkedDocInfo.onBack}
224
- className="px-1.5 py-0.5 bg-primary/10 text-primary rounded hover:bg-primary/20 transition-colors flex items-center gap-1"
237
+ className="px-1.5 py-0.5 bg-primary/10 text-primary rounded hover:bg-primary/20 transition-colors flex flex-shrink-0 items-center gap-1"
225
238
  >
226
239
  <svg
227
240
  className="w-2.5 h-2.5"
@@ -238,15 +251,14 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
238
251
  </svg>
239
252
  {linkedDocInfo.backLabel || 'plan'}
240
253
  </button>
241
- <span className="px-1.5 py-0.5 bg-primary/10 text-primary/80 rounded">
254
+ <span className="px-1.5 py-0.5 bg-primary/10 text-primary/80 rounded flex-shrink-0">
242
255
  {linkedDocInfo.label || 'Linked File'}
243
256
  </span>
244
- <span
245
- className="px-1.5 py-0.5 bg-muted/50 text-muted-foreground rounded truncate max-w-[200px]"
246
- title={linkedDocInfo.filepath}
247
- >
248
- {linkedDocInfo.filepath.split('/').pop()}
249
- </span>
257
+ <HostTitle title={linkedDocInfo.filepath}>
258
+ <span className="min-w-0 px-1.5 py-0.5 bg-muted/50 text-muted-foreground rounded truncate">
259
+ {basenameFromPath(linkedDocInfo.filepath)}
260
+ </span>
261
+ </HostTitle>
250
262
  {openInButton}
251
263
  </div>
252
264
  )
@@ -0,0 +1,74 @@
1
+ import { useLayoutEffect, useMemo, useRef } from 'react';
2
+ import type { Annotation, Block } from '../types';
3
+ import { CONTENTS_RAIL_MAX_LEVEL, tocHeadings } from '../utils/annotationHelpers';
4
+ import { useHeadingScrollSpy } from '../hooks/useHeadingScrollSpy';
5
+ import { TableOfContents } from './TableOfContents';
6
+
7
+ interface DocumentContentsProps {
8
+ blocks: Block[];
9
+ annotations: Annotation[];
10
+ /**
11
+ * The element the document scrolls in. The current heading is read from it
12
+ * and a click scrolls it. `null` until it has mounted.
13
+ */
14
+ scrollViewport: HTMLElement | null;
15
+ /** Called with the heading's block id after a click (a sheet closes itself here). */
16
+ onNavigate?: (blockId: string) => void;
17
+ className?: string;
18
+ }
19
+
20
+ /**
21
+ * A document's Contents list for a host without plan review's sidebar, on the
22
+ * contents rail's surface (the Inbox's sheet, where the rail yields): the same
23
+ * `TableOfContents` (click to scroll, the current heading highlighted) with
24
+ * the rail's headings (levels 1 to 4) and the rail's scroll math
25
+ * (`useHeadingScrollSpy`), so the list and the rail never disagree. It draws
26
+ * no chrome; the host places it and titles it.
27
+ *
28
+ * The host mounts it afresh (a sheet that opens), often mid-document: the
29
+ * current heading is read at once. The marked entry is kept in view inside the
30
+ * list's own scroll box (a sheet).
31
+ */
32
+ export function DocumentContents({ blocks, annotations, scrollViewport, onNavigate, className = '' }: DocumentContentsProps) {
33
+ const headingIds = useMemo(
34
+ () => tocHeadings(blocks, CONTENTS_RAIL_MAX_LEVEL).map((heading) => heading.id),
35
+ [blocks],
36
+ );
37
+ const activeId = useHeadingScrollSpy(scrollViewport, headingIds);
38
+
39
+ const wrapRef = useRef<HTMLDivElement>(null);
40
+ useLayoutEffect(() => {
41
+ const item = wrapRef.current?.querySelector<HTMLElement>('[aria-current="location"]');
42
+ if (item) revealInList(item);
43
+ }, [activeId]);
44
+
45
+ return (
46
+ <div ref={wrapRef} style={{ display: 'contents' }}>
47
+ <TableOfContents
48
+ blocks={blocks}
49
+ annotations={annotations}
50
+ activeId={activeId}
51
+ onNavigate={onNavigate ?? noop}
52
+ scrollViewport={scrollViewport}
53
+ maxLevel={CONTENTS_RAIL_MAX_LEVEL}
54
+ className={className}
55
+ />
56
+ </div>
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Scroll the list's own scroll box (the nearest ancestor that scrolls) just
62
+ * enough to show `item`, at once. Never the page or the document behind it.
63
+ */
64
+ function revealInList(item: HTMLElement) {
65
+ let box = item.parentElement;
66
+ while (box && !/(auto|scroll)/.test(getComputedStyle(box).overflowY)) box = box.parentElement;
67
+ if (!box || box.scrollHeight <= box.clientHeight) return;
68
+ const area = box.getBoundingClientRect();
69
+ const rect = item.getBoundingClientRect();
70
+ if (rect.top < area.top) box.scrollTop -= area.top - rect.top;
71
+ else if (rect.bottom > area.bottom) box.scrollTop += Math.min(rect.bottom - area.bottom, rect.top - area.top);
72
+ }
73
+
74
+ function noop() {}