md-redline 0.1.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 (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/bin/md-redline +255 -0
  4. package/bin/test-windows.ps1 +70 -0
  5. package/dist/assets/_baseFor-Ck08IaSF.js +1 -0
  6. package/dist/assets/arc-DI2g9LXK.js +1 -0
  7. package/dist/assets/architecture-YZFGNWBL-BDgMfc-b.js +1 -0
  8. package/dist/assets/architectureDiagram-Q4EWVU46-Dg1hcUEa.js +36 -0
  9. package/dist/assets/array-DOVTz2Mq.js +1 -0
  10. package/dist/assets/blockDiagram-DXYQGD6D-BAXkTCAk.js +132 -0
  11. package/dist/assets/c4Diagram-AHTNJAMY-BIkgwQSx.js +10 -0
  12. package/dist/assets/channel-DPCihw7y.js +1 -0
  13. package/dist/assets/chunk-2KRD3SAO-Dc_tBGsw.js +1 -0
  14. package/dist/assets/chunk-336JU56O-Dhi-ID9Y.js +2 -0
  15. package/dist/assets/chunk-426QAEUC-DnFdrNMW.js +1 -0
  16. package/dist/assets/chunk-4BX2VUAB-Z63FkGov.js +1 -0
  17. package/dist/assets/chunk-4TB4RGXK-BAiBlfyy.js +206 -0
  18. package/dist/assets/chunk-55IACEB6-BXDWXbxy.js +1 -0
  19. package/dist/assets/chunk-5FUZZQ4R-C72e1c_O.js +62 -0
  20. package/dist/assets/chunk-5PVQY5BW-BBHW_uCu.js +2 -0
  21. package/dist/assets/chunk-67CJDMHE-3Cf_D9m6.js +1 -0
  22. package/dist/assets/chunk-7N4EOEYR-DAXUXJ2c.js +1 -0
  23. package/dist/assets/chunk-AA7GKIK3-Dr7fOryc.js +1 -0
  24. package/dist/assets/chunk-BSJP7CBP-BmsSs1Nt.js +1 -0
  25. package/dist/assets/chunk-CIAEETIT-QDzV-X_Y.js +1 -0
  26. package/dist/assets/chunk-EDXVE4YY-C25WFHxY.js +1 -0
  27. package/dist/assets/chunk-ENJZ2VHE-_OzxcZOU.js +10 -0
  28. package/dist/assets/chunk-FMBD7UC4-CjsTKY4u.js +15 -0
  29. package/dist/assets/chunk-FOC6F5B3-g-xaH5nc.js +1 -0
  30. package/dist/assets/chunk-ICPOFSXX-iKiUSjDK.js +121 -0
  31. package/dist/assets/chunk-K5T4RW27-CKR-lPBN.js +94 -0
  32. package/dist/assets/chunk-KGLVRYIC-DRccT-B_.js +1 -0
  33. package/dist/assets/chunk-LIHQZDEY-DTbMwMXj.js +1 -0
  34. package/dist/assets/chunk-ORNJ4GCN-DlerdcWX.js +1 -0
  35. package/dist/assets/chunk-OYMX7WX6-Dekv1on2.js +231 -0
  36. package/dist/assets/chunk-QZHKN3VN-BHu0RdKl.js +1 -0
  37. package/dist/assets/chunk-U2HBQHQK-BvtlVHAg.js +70 -0
  38. package/dist/assets/chunk-X2U36JSP-BI_g8mub.js +1 -0
  39. package/dist/assets/chunk-XPW4576I-B39JkmSE.js +32 -0
  40. package/dist/assets/chunk-YZCP3GAM-BfPcXRm2.js +1 -0
  41. package/dist/assets/chunk-ZZ45TVLE-Bg4q68wZ.js +1 -0
  42. package/dist/assets/classDiagram-6PBFFD2Q-p73p727_.js +1 -0
  43. package/dist/assets/classDiagram-v2-HSJHXN6E-C4Ftpivp.js +1 -0
  44. package/dist/assets/clone-CI9aUwHe.js +1 -0
  45. package/dist/assets/cose-bilkent-S5V4N54A-7BpAeDh5.js +1 -0
  46. package/dist/assets/cytoscape.esm-DoTFyJaN.js +321 -0
  47. package/dist/assets/dagre-CilMRazv.js +1 -0
  48. package/dist/assets/dagre-KV5264BT-DDMqpjkB.js +4 -0
  49. package/dist/assets/defaultLocale-Ck2Xxk-C.js +1 -0
  50. package/dist/assets/diagram-5BDNPKRD-BFeyfnCx.js +10 -0
  51. package/dist/assets/diagram-G4DWMVQ6-DoqT-PtF.js +24 -0
  52. package/dist/assets/diagram-MMDJMWI5-BPV6KADk.js +43 -0
  53. package/dist/assets/diagram-TYMM5635-okvcTBtl.js +24 -0
  54. package/dist/assets/dist-C_eddq6m.js +1 -0
  55. package/dist/assets/erDiagram-SMLLAGMA-Dl-Ixy8n.js +85 -0
  56. package/dist/assets/flatten-B8XIuT0x.js +1 -0
  57. package/dist/assets/flowDiagram-DWJPFMVM-CsqWAx5r.js +162 -0
  58. package/dist/assets/ganttDiagram-T4ZO3ILL-mIt6zVeF.js +292 -0
  59. package/dist/assets/gitGraph-7Q5UKJZL-COXHGMvj.js +1 -0
  60. package/dist/assets/gitGraphDiagram-UUTBAWPF-syVqZJX_.js +106 -0
  61. package/dist/assets/graphlib-Bpd0q3yO.js +1 -0
  62. package/dist/assets/index-BoggyWS0.css +2 -0
  63. package/dist/assets/index-aLvjHQW4.js +104 -0
  64. package/dist/assets/info-OMHHGYJF-B-0wfxwL.js +1 -0
  65. package/dist/assets/infoDiagram-42DDH7IO-C0_uqsVa.js +2 -0
  66. package/dist/assets/init-Bft5Ffpj.js +1 -0
  67. package/dist/assets/isEmpty-BrFi5AqV.js +1 -0
  68. package/dist/assets/ishikawaDiagram-UXIWVN3A-CTjFbDBV.js +70 -0
  69. package/dist/assets/journeyDiagram-VCZTEJTY-BDBcej1q.js +139 -0
  70. package/dist/assets/kanban-definition-6JOO6SKY-Ylgzakw7.js +89 -0
  71. package/dist/assets/katex-Uj9wLT16.js +265 -0
  72. package/dist/assets/line-CRxEwpOv.js +1 -0
  73. package/dist/assets/linear-PDPfFByd.js +1 -0
  74. package/dist/assets/mermaid-parser.core-CY-XNOOy.js +4 -0
  75. package/dist/assets/mermaid.core-BPlTADIX.js +11 -0
  76. package/dist/assets/mindmap-definition-QFDTVHPH-TefzJnBM.js +96 -0
  77. package/dist/assets/ordinal-DIg8h6NI.js +1 -0
  78. package/dist/assets/packet-4T2RLAQJ-BW1T_A-C.js +1 -0
  79. package/dist/assets/path-DfRbCp9y.js +1 -0
  80. package/dist/assets/pie-ZZUOXDRM-DkKU-SFu.js +1 -0
  81. package/dist/assets/pieDiagram-DEJITSTG-BCXuaeEy.js +30 -0
  82. package/dist/assets/quadrantDiagram-34T5L4WZ-VSBAicWL.js +7 -0
  83. package/dist/assets/radar-PYXPWWZC-CYvTacKJ.js +1 -0
  84. package/dist/assets/reduce-CV2X8n1a.js +1 -0
  85. package/dist/assets/requirementDiagram-MS252O5E-4NeL9Z6J.js +84 -0
  86. package/dist/assets/rough.esm-Bbn_-PMU.js +1 -0
  87. package/dist/assets/sankeyDiagram-XADWPNL6-DMBSDnrH.js +10 -0
  88. package/dist/assets/sequenceDiagram-FGHM5R23-DVpzcZUi.js +157 -0
  89. package/dist/assets/src-PKe5NtkK.js +1 -0
  90. package/dist/assets/stateDiagram-FHFEXIEX-BkHTlCjL.js +1 -0
  91. package/dist/assets/stateDiagram-v2-QKLJ7IA2-nMeWu9fP.js +1 -0
  92. package/dist/assets/timeline-definition-GMOUNBTQ-CyLt92nf.js +120 -0
  93. package/dist/assets/treeView-SZITEDCU-BUgcJ4eR.js +1 -0
  94. package/dist/assets/treemap-W4RFUUIX-BIWGQ4Pw.js +1 -0
  95. package/dist/assets/vennDiagram-DHZGUBPP-BCK0xB_m.js +34 -0
  96. package/dist/assets/wardley-RL74JXVD-DMZZRlby.js +1 -0
  97. package/dist/assets/wardleyDiagram-NUSXRM2D-BisBgfsF.js +20 -0
  98. package/dist/assets/xychartDiagram-5P7HB3ND-D_REDciv.js +7 -0
  99. package/dist/favicon.svg +15 -0
  100. package/dist/index.html +14 -0
  101. package/dist/screenshot.png +0 -0
  102. package/index.html +13 -0
  103. package/package.json +105 -0
  104. package/public/favicon.svg +15 -0
  105. package/public/screenshot.png +0 -0
  106. package/server/index.test.ts +814 -0
  107. package/server/index.ts +736 -0
  108. package/server/preferences.test.ts +126 -0
  109. package/server/preferences.ts +76 -0
  110. package/src/App.tsx +1620 -0
  111. package/src/components/ActionButton.tsx +41 -0
  112. package/src/components/CommandPalette.tsx +191 -0
  113. package/src/components/CommentCard.tsx +556 -0
  114. package/src/components/CommentForm.tsx +285 -0
  115. package/src/components/CommentSidebar.tsx +428 -0
  116. package/src/components/ConfirmDialog.tsx +64 -0
  117. package/src/components/ContextMenu.tsx +220 -0
  118. package/src/components/DragHandles.tsx +48 -0
  119. package/src/components/FileExplorer.tsx +251 -0
  120. package/src/components/FileOpener.tsx +304 -0
  121. package/src/components/IconButton.tsx +32 -0
  122. package/src/components/KeyboardShortcutsPanel.tsx +136 -0
  123. package/src/components/MarkdownViewer.tsx +682 -0
  124. package/src/components/RawView.tsx +798 -0
  125. package/src/components/SearchBar.tsx +129 -0
  126. package/src/components/Separator.tsx +7 -0
  127. package/src/components/SettingsPanel.tsx +813 -0
  128. package/src/components/SplitIconButton.tsx +133 -0
  129. package/src/components/TabBar.tsx +594 -0
  130. package/src/components/TableOfContents.tsx +70 -0
  131. package/src/components/ThemeSelector.tsx +159 -0
  132. package/src/components/Toast.tsx +99 -0
  133. package/src/components/Toolbar.tsx +161 -0
  134. package/src/components/iconButtonVariants.ts +19 -0
  135. package/src/components/rawView.test.ts +291 -0
  136. package/src/contexts/SettingsContext.tsx +120 -0
  137. package/src/hooks/useAuthor.test.ts +58 -0
  138. package/src/hooks/useAuthor.ts +69 -0
  139. package/src/hooks/useAutoResize.ts +20 -0
  140. package/src/hooks/useCommentCardTriggers.ts +20 -0
  141. package/src/hooks/useComments.test.ts +773 -0
  142. package/src/hooks/useComments.ts +332 -0
  143. package/src/hooks/useContextMenu.ts +48 -0
  144. package/src/hooks/useContextMenuItems.ts +392 -0
  145. package/src/hooks/useDiffSnapshot.test.ts +130 -0
  146. package/src/hooks/useDiffSnapshot.ts +67 -0
  147. package/src/hooks/useDragHandles.ts +417 -0
  148. package/src/hooks/useFileWatcher.ts +45 -0
  149. package/src/hooks/useHeadingTracking.ts +84 -0
  150. package/src/hooks/useMermaidRenderer.ts +75 -0
  151. package/src/hooks/useModalState.ts +22 -0
  152. package/src/hooks/usePageVisible.test.ts +69 -0
  153. package/src/hooks/usePageVisible.ts +19 -0
  154. package/src/hooks/usePaneLayout.test.ts +108 -0
  155. package/src/hooks/usePaneLayout.ts +102 -0
  156. package/src/hooks/useRecentFiles.test.ts +103 -0
  157. package/src/hooks/useRecentFiles.ts +99 -0
  158. package/src/hooks/useResizablePanel.test.ts +84 -0
  159. package/src/hooks/useResizablePanel.ts +118 -0
  160. package/src/hooks/useSearch.test.ts +72 -0
  161. package/src/hooks/useSearch.ts +53 -0
  162. package/src/hooks/useSelection.ts +48 -0
  163. package/src/hooks/useSessionPersistence.test.ts +59 -0
  164. package/src/hooks/useSessionPersistence.ts +43 -0
  165. package/src/hooks/useTabs.test.ts +127 -0
  166. package/src/hooks/useTabs.ts +561 -0
  167. package/src/hooks/useThemePersistence.ts +41 -0
  168. package/src/hooks/useToast.ts +27 -0
  169. package/src/index.css +1047 -0
  170. package/src/lib/agent-prompts.test.ts +34 -0
  171. package/src/lib/agent-prompts.ts +68 -0
  172. package/src/lib/comment-editor-state.ts +6 -0
  173. package/src/lib/comment-parser.test.ts +1959 -0
  174. package/src/lib/comment-parser.ts +1021 -0
  175. package/src/lib/diff.test.ts +164 -0
  176. package/src/lib/diff.ts +139 -0
  177. package/src/lib/heading-slugs.test.ts +85 -0
  178. package/src/lib/heading-slugs.ts +44 -0
  179. package/src/lib/http.test.ts +43 -0
  180. package/src/lib/http.ts +29 -0
  181. package/src/lib/mermaid-highlights.test.ts +517 -0
  182. package/src/lib/mermaid-highlights.ts +936 -0
  183. package/src/lib/mermaid-renderer.test.ts +114 -0
  184. package/src/lib/mermaid-renderer.ts +89 -0
  185. package/src/lib/path-utils.test.ts +17 -0
  186. package/src/lib/path-utils.ts +7 -0
  187. package/src/lib/platform.test.ts +58 -0
  188. package/src/lib/platform.ts +14 -0
  189. package/src/lib/preferences-client.test.ts +177 -0
  190. package/src/lib/preferences-client.ts +94 -0
  191. package/src/lib/selection-resolver.test.ts +118 -0
  192. package/src/lib/selection-resolver.ts +37 -0
  193. package/src/lib/settings.test.ts +152 -0
  194. package/src/lib/settings.ts +78 -0
  195. package/src/lib/shortcut-label.tsx +18 -0
  196. package/src/lib/themes.ts +21 -0
  197. package/src/lib/visible-text.test.ts +86 -0
  198. package/src/lib/visible-text.ts +77 -0
  199. package/src/main.tsx +22 -0
  200. package/src/markdown/pipeline.test.ts +82 -0
  201. package/src/markdown/pipeline.ts +33 -0
  202. package/src/types.test.ts +43 -0
  203. package/src/types.ts +46 -0
  204. package/tsconfig.app.json +28 -0
  205. package/tsconfig.json +7 -0
  206. package/tsconfig.node.json +26 -0
  207. package/vite.config.ts +50 -0
@@ -0,0 +1,594 @@
1
+ import { useState, useRef, useEffect, useCallback } from 'react';
2
+ import type { ViewMode } from './Toolbar';
3
+ import { getPrimaryModifierLabel } from '../lib/platform';
4
+ import { IconButton } from './IconButton';
5
+ import { SplitIconButton } from './SplitIconButton';
6
+ import { getPathBasename } from '../lib/path-utils';
7
+
8
+ interface Tab {
9
+ filePath: string;
10
+ error: string | null;
11
+ }
12
+
13
+ export interface TabContextMenuInfo {
14
+ filePath: string;
15
+ x: number;
16
+ y: number;
17
+ }
18
+
19
+ interface Props {
20
+ tabs: Tab[];
21
+ activeFilePath: string | null;
22
+ commentCounts: Map<string, number>;
23
+ resolvedCommentCounts?: Map<string, number>;
24
+ onSwitchTab: (path: string) => void;
25
+ onCloseTab: (path: string) => void;
26
+ onOpenFile: () => void;
27
+ onTabContextMenu?: (info: TabContextMenuInfo) => void;
28
+ // Document actions (moved from Toolbar)
29
+ viewMode: ViewMode;
30
+ diffPending?: boolean;
31
+ commentCount: number;
32
+ enableResolve?: boolean;
33
+ onViewModeChange: (mode: ViewMode) => void;
34
+ onSearch: () => void;
35
+ searchActive: boolean;
36
+ onCopyAgentPrompt?: (filePaths: string[]) => void;
37
+ }
38
+
39
+ const tabControlButtonClass =
40
+ 'flex h-full w-8 items-center justify-center shrink-0 border-l border-border text-content-muted transition-colors hover:bg-tint hover:text-content-secondary disabled:pointer-events-none disabled:opacity-35';
41
+
42
+ const tabActionButtonClass =
43
+ 'sticky right-0 z-10 flex h-full items-center justify-center bg-surface-secondary px-2.5 shrink-0 border-r border-border text-content-muted transition-colors hover:bg-tint hover:text-content';
44
+
45
+ const handOffIcon = (
46
+ <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
47
+ {/* antenna */}
48
+ <line x1="12" y1="1.5" x2="12" y2="4.5" strokeLinecap="round" />
49
+ <circle cx="12" cy="1.5" r="1" fill="currentColor" stroke="none" />
50
+ {/* head */}
51
+ <rect x="3" y="4.5" width="18" height="17" rx="3" strokeLinejoin="round" />
52
+ {/* eyes */}
53
+ <circle cx="8" cy="11.5" r="2" fill="currentColor" stroke="none" />
54
+ <circle cx="16" cy="11.5" r="2" fill="currentColor" stroke="none" />
55
+ {/* mouth */}
56
+ <line x1="8.5" y1="17.5" x2="15.5" y2="17.5" strokeLinecap="round" />
57
+ {/* ears */}
58
+ <line x1="0.5" y1="13" x2="3" y2="13" strokeLinecap="round" />
59
+ <line x1="21" y1="13" x2="23.5" y2="13" strokeLinecap="round" />
60
+ </svg>
61
+ );
62
+
63
+ function HandOffButton({
64
+ activeFilePath,
65
+ commentCounts,
66
+ onCopyAgentPrompt,
67
+ }: {
68
+ activeFilePath: string;
69
+ commentCounts: Map<string, number>;
70
+ onCopyAgentPrompt: (filePaths: string[]) => void;
71
+ }) {
72
+ const [selected, setSelected] = useState<Set<string>>(new Set());
73
+
74
+ // All files with comments
75
+ const filesWithComments = Array.from(commentCounts.entries())
76
+ .filter(([, count]) => count > 0)
77
+ .map(([path, count]) => ({ path, count }));
78
+
79
+ const hasMultipleFiles = filesWithComments.length > 1;
80
+
81
+ const toggle = (path: string) => {
82
+ setSelected((prev) => {
83
+ const next = new Set(prev);
84
+ if (next.has(path)) next.delete(path);
85
+ else next.add(path);
86
+ return next;
87
+ });
88
+ };
89
+
90
+ if (!hasMultipleFiles) {
91
+ return (
92
+ <div className="relative flex items-center" data-testid="handoff-group">
93
+ <IconButton
94
+ variant="neutral"
95
+ onClick={() => onCopyAgentPrompt([activeFilePath])}
96
+ title="Hand off to agent — copy instructions for this file"
97
+ data-testid="handoff-button"
98
+ >
99
+ {handOffIcon}
100
+ </IconButton>
101
+ <button
102
+ type="button"
103
+ title="Hand off multiple files"
104
+ data-testid="handoff-chevron"
105
+ aria-hidden="true"
106
+ tabIndex={-1}
107
+ className="pl-0 pr-0.5 self-stretch flex items-center rounded-r opacity-0 pointer-events-none text-content-muted"
108
+ >
109
+ <svg
110
+ className="w-2 h-2"
111
+ fill="none"
112
+ viewBox="0 0 24 24"
113
+ stroke="currentColor"
114
+ strokeWidth={3}
115
+ >
116
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
117
+ </svg>
118
+ </button>
119
+ </div>
120
+ );
121
+ }
122
+
123
+ return (
124
+ <div className="relative flex items-center" data-testid="handoff-group">
125
+ <SplitIconButton
126
+ icon={handOffIcon}
127
+ onClick={() => onCopyAgentPrompt([activeFilePath])}
128
+ title="Hand off to agent — copy instructions for this file"
129
+ testId="handoff-button"
130
+ chevronTestId="handoff-chevron"
131
+ chevronTitle="Hand off multiple files"
132
+ onOpen={() => setSelected(new Set(filesWithComments.map((f) => f.path)))}
133
+ dropdown={(close) => {
134
+ const handleCopySelected = () => {
135
+ const paths = Array.from(selected);
136
+ if (paths.length === 0) return;
137
+ onCopyAgentPrompt(paths);
138
+ close();
139
+ };
140
+
141
+ return (
142
+ <div className="absolute right-0 top-full mt-1 z-50 bg-surface border border-border rounded-lg shadow-lg py-1.5 min-w-[240px]">
143
+ {filesWithComments.map(({ path, count }) => {
144
+ const isSelected = selected.has(path);
145
+ const isActive = path === activeFilePath;
146
+ return (
147
+ <button
148
+ key={path}
149
+ onClick={() => toggle(path)}
150
+ className="w-full px-3 py-1.5 flex items-center gap-2 hover:bg-tint transition-colors"
151
+ title={path}
152
+ >
153
+ <span className="w-3 h-3 shrink-0 flex items-center justify-center">
154
+ {isSelected && (
155
+ <svg
156
+ className="w-3 h-3 text-primary-text"
157
+ fill="none"
158
+ viewBox="0 0 24 24"
159
+ stroke="currentColor"
160
+ strokeWidth={2.5}
161
+ >
162
+ <path
163
+ strokeLinecap="round"
164
+ strokeLinejoin="round"
165
+ d="M4.5 12.75l6 6 9-13.5"
166
+ />
167
+ </svg>
168
+ )}
169
+ </span>
170
+ <span
171
+ className={`text-xs truncate flex-1 text-left ${isActive ? 'text-content font-medium' : 'text-content'}`}
172
+ >
173
+ {getPathBasename(path)}
174
+ </span>
175
+ <span className="text-[10px] text-content-muted">{count}</span>
176
+ </button>
177
+ );
178
+ })}
179
+
180
+ {/* Action button */}
181
+ <div className="border-t border-border-subtle mt-1.5 pt-1.5 px-3 pb-1">
182
+ <button
183
+ onClick={handleCopySelected}
184
+ disabled={selected.size === 0}
185
+ className={`w-full text-xs px-2 py-1.5 rounded-md font-medium transition-opacity ${
186
+ selected.size > 0
187
+ ? 'bg-primary-bg-strong text-primary-text hover:opacity-90'
188
+ : 'bg-surface-inset text-content-muted cursor-not-allowed'
189
+ }`}
190
+ >
191
+ {selected.size === 0
192
+ ? 'Select files to hand off'
193
+ : `Copy handoff for ${selected.size} file${selected.size !== 1 ? 's' : ''}`}
194
+ </button>
195
+ </div>
196
+ </div>
197
+ );
198
+ }}
199
+ />
200
+ </div>
201
+ );
202
+ }
203
+
204
+ export function TabBar({
205
+ tabs,
206
+ activeFilePath,
207
+ commentCounts,
208
+ resolvedCommentCounts,
209
+ onSwitchTab,
210
+ onCloseTab,
211
+ onOpenFile,
212
+ onTabContextMenu,
213
+ viewMode,
214
+ diffPending,
215
+ commentCount,
216
+ onViewModeChange,
217
+ onSearch,
218
+ searchActive,
219
+ onCopyAgentPrompt,
220
+ }: Props) {
221
+ const modLabel = getPrimaryModifierLabel();
222
+ const tabsViewportRef = useRef<HTMLDivElement>(null);
223
+ const tabsContentRef = useRef<HTMLDivElement>(null);
224
+ const tabListRef = useRef<HTMLDivElement>(null);
225
+ const tabButtonRefs = useRef(new Map<string, HTMLButtonElement>());
226
+ const [tabListOpen, setTabListOpen] = useState(false);
227
+ const [isOverflowing, setIsOverflowing] = useState(false);
228
+ const [canScrollLeft, setCanScrollLeft] = useState(false);
229
+ const [canScrollRight, setCanScrollRight] = useState(false);
230
+
231
+ const updateTabOverflow = useCallback(() => {
232
+ const viewport = tabsViewportRef.current;
233
+ if (!viewport) return;
234
+
235
+ const maxScrollLeft = Math.max(viewport.scrollWidth - viewport.clientWidth, 0);
236
+ setIsOverflowing(maxScrollLeft > 1);
237
+ setCanScrollLeft(viewport.scrollLeft > 1);
238
+ setCanScrollRight(viewport.scrollLeft < maxScrollLeft - 1);
239
+ }, []);
240
+
241
+ const scrollTabsBy = useCallback((direction: 'left' | 'right') => {
242
+ const viewport = tabsViewportRef.current;
243
+ if (!viewport) return;
244
+
245
+ const distance = Math.max(Math.round(viewport.clientWidth * 0.6), 160);
246
+ viewport.scrollBy({
247
+ left: direction === 'left' ? -distance : distance,
248
+ behavior: 'smooth',
249
+ });
250
+ }, []);
251
+
252
+ useEffect(() => {
253
+ const viewport = tabsViewportRef.current;
254
+ if (!viewport) return;
255
+
256
+ updateTabOverflow();
257
+
258
+ const handleScroll = () => updateTabOverflow();
259
+ viewport.addEventListener('scroll', handleScroll, { passive: true });
260
+
261
+ const resizeObserver =
262
+ typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => updateTabOverflow()) : null;
263
+
264
+ resizeObserver?.observe(viewport);
265
+ if (tabsContentRef.current) resizeObserver?.observe(tabsContentRef.current);
266
+
267
+ return () => {
268
+ viewport.removeEventListener('scroll', handleScroll);
269
+ resizeObserver?.disconnect();
270
+ };
271
+ }, [tabs.length, updateTabOverflow]);
272
+
273
+ useEffect(() => {
274
+ if (!activeFilePath) return;
275
+
276
+ const frame = window.requestAnimationFrame(() => {
277
+ tabButtonRefs.current
278
+ .get(activeFilePath)
279
+ ?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
280
+ updateTabOverflow();
281
+ });
282
+
283
+ return () => window.cancelAnimationFrame(frame);
284
+ }, [activeFilePath, updateTabOverflow]);
285
+
286
+ useEffect(() => {
287
+ if (!tabListOpen) return;
288
+
289
+ const handlePointerDown = (event: MouseEvent) => {
290
+ if (tabListRef.current && !tabListRef.current.contains(event.target as Node)) {
291
+ setTabListOpen(false);
292
+ }
293
+ };
294
+
295
+ const handleEscape = (event: KeyboardEvent) => {
296
+ if (event.key === 'Escape') {
297
+ setTabListOpen(false);
298
+ }
299
+ };
300
+
301
+ document.addEventListener('mousedown', handlePointerDown);
302
+ document.addEventListener('keydown', handleEscape);
303
+ return () => {
304
+ document.removeEventListener('mousedown', handlePointerDown);
305
+ document.removeEventListener('keydown', handleEscape);
306
+ };
307
+ }, [tabListOpen]);
308
+
309
+ useEffect(() => {
310
+ if (!isOverflowing) setTabListOpen(false);
311
+ }, [isOverflowing]);
312
+
313
+ return (
314
+ <div className="h-9 bg-surface-secondary border-b border-border flex items-stretch shrink-0">
315
+ <div className="min-w-0 flex-1 flex items-stretch">
316
+ {isOverflowing && (
317
+ <button
318
+ type="button"
319
+ onClick={() => scrollTabsBy('left')}
320
+ disabled={!canScrollLeft}
321
+ className={`${tabControlButtonClass} border-r border-l-0`}
322
+ title="Scroll tabs left"
323
+ aria-label="Scroll tabs left"
324
+ >
325
+ <svg
326
+ className="w-3.5 h-3.5"
327
+ fill="none"
328
+ viewBox="0 0 24 24"
329
+ stroke="currentColor"
330
+ strokeWidth={2}
331
+ >
332
+ <path strokeLinecap="round" strokeLinejoin="round" d="m15.75 19.5-7.5-7.5 7.5-7.5" />
333
+ </svg>
334
+ </button>
335
+ )}
336
+
337
+ <div ref={tabsViewportRef} className="min-w-0 flex-1 h-full overflow-x-auto no-scrollbar">
338
+ <div ref={tabsContentRef} className="flex h-full items-stretch min-w-max">
339
+ {tabs.map((tab) => {
340
+ const isActive = tab.filePath === activeFilePath;
341
+ const fileName = getPathBasename(tab.filePath) || tab.filePath;
342
+ const count = commentCounts.get(tab.filePath) ?? 0;
343
+ const resolvedCount = resolvedCommentCounts?.get(tab.filePath) ?? 0;
344
+ return (
345
+ <button
346
+ key={tab.filePath}
347
+ ref={(node) => {
348
+ if (node) tabButtonRefs.current.set(tab.filePath, node);
349
+ else tabButtonRefs.current.delete(tab.filePath);
350
+ }}
351
+ onClick={() => onSwitchTab(tab.filePath)}
352
+ onMouseDown={(e) => {
353
+ if (e.button === 1) {
354
+ e.preventDefault();
355
+ onCloseTab(tab.filePath);
356
+ }
357
+ }}
358
+ onContextMenu={(e) => {
359
+ if (onTabContextMenu) {
360
+ e.preventDefault();
361
+ onTabContextMenu({ filePath: tab.filePath, x: e.clientX, y: e.clientY });
362
+ }
363
+ }}
364
+ className={`group flex h-full items-center gap-1.5 px-3 text-xs leading-none border-r border-border border-b-2 shrink-0 max-w-[200px] transition-colors ${
365
+ isActive
366
+ ? 'bg-surface text-content font-medium border-b-primary'
367
+ : 'border-b-transparent text-content-secondary hover:text-content hover:bg-tint'
368
+ }`}
369
+ title={tab.filePath}
370
+ >
371
+ <span className="truncate">{fileName}</span>
372
+ {count > 0 ? (
373
+ <span
374
+ className={`text-[10px] font-medium px-1 min-w-[16px] text-center rounded-full shrink-0 ${
375
+ isActive
376
+ ? 'bg-primary-bg-strong text-primary-text'
377
+ : 'bg-surface-inset text-content-secondary'
378
+ }`}
379
+ >
380
+ {count}
381
+ </span>
382
+ ) : resolvedCount > 0 ? (
383
+ <span
384
+ className="text-[10px] font-medium px-1 min-w-[16px] text-center rounded-full shrink-0 border border-border-subtle text-content-muted"
385
+ title={`${resolvedCount} resolved`}
386
+ >
387
+ {resolvedCount}
388
+ </span>
389
+ ) : null}
390
+ {tab.error && <span className="w-1.5 h-1.5 rounded-full bg-danger shrink-0" />}
391
+ <span
392
+ role="button"
393
+ onClick={(e) => {
394
+ e.stopPropagation();
395
+ onCloseTab(tab.filePath);
396
+ }}
397
+ className={`ml-1 p-0.5 rounded hover:bg-tint shrink-0 transition-opacity ${
398
+ isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
399
+ }`}
400
+ aria-label={`Close ${getPathBasename(tab.filePath)}`}
401
+ tabIndex={-1}
402
+ >
403
+ <svg
404
+ className="w-3 h-3"
405
+ viewBox="0 0 24 24"
406
+ fill="none"
407
+ stroke="currentColor"
408
+ strokeWidth={2}
409
+ >
410
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
411
+ </svg>
412
+ </span>
413
+ </button>
414
+ );
415
+ })}
416
+
417
+ <button
418
+ type="button"
419
+ onClick={onOpenFile}
420
+ className={tabActionButtonClass}
421
+ title="Open file"
422
+ aria-label="Open file"
423
+ >
424
+ <svg
425
+ className="w-4 h-4"
426
+ viewBox="0 0 24 24"
427
+ fill="none"
428
+ stroke="currentColor"
429
+ strokeWidth={2}
430
+ >
431
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
432
+ </svg>
433
+ </button>
434
+ </div>
435
+ </div>
436
+
437
+ {isOverflowing && (
438
+ <>
439
+ <button
440
+ type="button"
441
+ onClick={() => scrollTabsBy('right')}
442
+ disabled={!canScrollRight}
443
+ className={tabControlButtonClass}
444
+ title="Scroll tabs right"
445
+ aria-label="Scroll tabs right"
446
+ >
447
+ <svg
448
+ className="w-3.5 h-3.5"
449
+ fill="none"
450
+ viewBox="0 0 24 24"
451
+ stroke="currentColor"
452
+ strokeWidth={2}
453
+ >
454
+ <path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
455
+ </svg>
456
+ </button>
457
+
458
+ <div ref={tabListRef} className="relative flex items-stretch shrink-0">
459
+ <button
460
+ type="button"
461
+ onClick={() => setTabListOpen((open) => !open)}
462
+ className={`${tabControlButtonClass} ${
463
+ tabListOpen ? 'bg-surface-inset text-content-secondary' : ''
464
+ }`}
465
+ title="Show all open tabs"
466
+ aria-label="Show all open tabs"
467
+ aria-haspopup="menu"
468
+ aria-expanded={tabListOpen}
469
+ data-testid="tab-list-button"
470
+ >
471
+ <svg
472
+ className="w-3.5 h-3.5"
473
+ fill="none"
474
+ viewBox="0 0 24 24"
475
+ stroke="currentColor"
476
+ strokeWidth={2}
477
+ >
478
+ <path
479
+ strokeLinecap="round"
480
+ strokeLinejoin="round"
481
+ d="M5.25 6.75h13.5M5.25 12h13.5M5.25 17.25h13.5"
482
+ />
483
+ </svg>
484
+ </button>
485
+
486
+ {tabListOpen && (
487
+ <div
488
+ className="absolute right-0 top-full z-50 mt-1 w-72 rounded-lg border border-border bg-surface shadow-lg py-1.5"
489
+ data-testid="tab-list-menu"
490
+ >
491
+ {tabs.map((tab) => {
492
+ const isActive = tab.filePath === activeFilePath;
493
+ const fileName = getPathBasename(tab.filePath) || tab.filePath;
494
+ const count = commentCounts.get(tab.filePath) ?? 0;
495
+ const resolvedCount = resolvedCommentCounts?.get(tab.filePath) ?? 0;
496
+ return (
497
+ <button
498
+ key={tab.filePath}
499
+ type="button"
500
+ onClick={() => {
501
+ onSwitchTab(tab.filePath);
502
+ setTabListOpen(false);
503
+ }}
504
+ className={`w-full px-3 py-2 flex items-center gap-2 text-left transition-colors ${
505
+ isActive ? 'bg-surface-inset' : 'hover:bg-tint'
506
+ }`}
507
+ title={tab.filePath}
508
+ >
509
+ <span
510
+ className={`w-1.5 h-1.5 rounded-full shrink-0 ${isActive ? 'bg-primary' : 'bg-transparent'}`}
511
+ />
512
+ <span
513
+ className={`text-xs truncate flex-1 ${isActive ? 'text-content font-medium' : 'text-content'}`}
514
+ >
515
+ {fileName}
516
+ </span>
517
+ {count > 0 ? (
518
+ <span
519
+ className={`text-[10px] font-medium px-1 min-w-[16px] text-center rounded-full shrink-0 ${
520
+ isActive
521
+ ? 'bg-primary-bg-strong text-primary-text'
522
+ : 'bg-surface-inset text-content-secondary'
523
+ }`}
524
+ >
525
+ {count}
526
+ </span>
527
+ ) : resolvedCount > 0 ? (
528
+ <span
529
+ className="text-[10px] font-medium px-1 min-w-[16px] text-center rounded-full shrink-0 border border-border-subtle text-content-muted"
530
+ title={`${resolvedCount} resolved`}
531
+ >
532
+ {resolvedCount}
533
+ </span>
534
+ ) : null}
535
+ {tab.error && (
536
+ <span className="w-1.5 h-1.5 rounded-full bg-danger shrink-0" />
537
+ )}
538
+ </button>
539
+ );
540
+ })}
541
+ </div>
542
+ )}
543
+ </div>
544
+ </>
545
+ )}
546
+ </div>
547
+
548
+ {/* Document actions (right side) */}
549
+ <div className="flex items-center gap-0.5 px-2 shrink-0 border-l border-border">
550
+ {/* Search */}
551
+ <IconButton
552
+ variant="active"
553
+ active={searchActive}
554
+ onClick={onSearch}
555
+ title={`Find in document (${modLabel}+F)`}
556
+ >
557
+ <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
558
+ <circle cx="11" cy="11" r="8" />
559
+ <path strokeLinecap="round" d="m21 21-4.35-4.35" />
560
+ </svg>
561
+ </IconButton>
562
+
563
+ <IconButton
564
+ variant="active"
565
+ active={viewMode === 'raw'}
566
+ onClick={() => onViewModeChange(viewMode === 'raw' ? 'rendered' : 'raw')}
567
+ title={viewMode === 'raw' ? 'Switch to rendered view' : 'View raw markdown'}
568
+ >
569
+ <span className="relative">
570
+ <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
571
+ <path
572
+ strokeLinecap="round"
573
+ strokeLinejoin="round"
574
+ d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"
575
+ />
576
+ </svg>
577
+ {diffPending && viewMode !== 'raw' && (
578
+ <span className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-primary animate-pulse" />
579
+ )}
580
+ </span>
581
+ </IconButton>
582
+
583
+ {/* Hand off (primary action with multi-file dropdown) */}
584
+ {commentCount > 0 && onCopyAgentPrompt && activeFilePath && (
585
+ <HandOffButton
586
+ activeFilePath={activeFilePath}
587
+ commentCounts={commentCounts}
588
+ onCopyAgentPrompt={onCopyAgentPrompt}
589
+ />
590
+ )}
591
+ </div>
592
+ </div>
593
+ );
594
+ }
@@ -0,0 +1,70 @@
1
+ import { useRef, useEffect } from 'react';
2
+ import type { TocHeading } from './MarkdownViewer';
3
+
4
+ interface Props {
5
+ headings: TocHeading[];
6
+ activeHeadingId: string | null;
7
+ onHeadingClick: (id: string) => void;
8
+ }
9
+
10
+ const INDENT: Record<number, string> = {
11
+ 1: 'pl-3',
12
+ 2: 'pl-6',
13
+ 3: 'pl-9',
14
+ 4: 'pl-12',
15
+ 5: 'pl-12',
16
+ 6: 'pl-12',
17
+ };
18
+
19
+ export function TableOfContents({ headings, activeHeadingId, onHeadingClick }: Props) {
20
+ const activeRef = useRef<HTMLButtonElement>(null);
21
+
22
+ // Auto-scroll the active heading into view within the TOC panel
23
+ useEffect(() => {
24
+ activeRef.current?.scrollIntoView({ block: 'nearest' });
25
+ }, [activeHeadingId]);
26
+
27
+ if (headings.length === 0) {
28
+ return (
29
+ <div className="flex-1 flex flex-col items-center justify-center text-content-muted px-4">
30
+ <svg
31
+ className="w-8 h-8 mb-2 opacity-40"
32
+ fill="none"
33
+ viewBox="0 0 24 24"
34
+ stroke="currentColor"
35
+ strokeWidth={1.5}
36
+ >
37
+ <path
38
+ strokeLinecap="round"
39
+ strokeLinejoin="round"
40
+ d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
41
+ />
42
+ </svg>
43
+ <span className="text-[10px]">No headings</span>
44
+ </div>
45
+ );
46
+ }
47
+
48
+ return (
49
+ <div className="flex-1 overflow-y-auto py-1">
50
+ {headings.map((h) => {
51
+ const isActive = h.id === activeHeadingId;
52
+ return (
53
+ <button
54
+ key={h.id}
55
+ ref={isActive ? activeRef : undefined}
56
+ onClick={() => onHeadingClick(h.id)}
57
+ className={`w-full text-left py-1.5 pr-3 text-xs transition-colors ${INDENT[h.level] || 'pl-3'} ${
58
+ isActive
59
+ ? 'bg-primary-bg text-primary-text font-medium'
60
+ : 'text-content hover:bg-tint'
61
+ }`}
62
+ title={h.text}
63
+ >
64
+ <span className="block truncate">{h.text}</span>
65
+ </button>
66
+ );
67
+ })}
68
+ </div>
69
+ );
70
+ }