@cueloop/client 0.1.0-alpha.8 → 0.1.0-alpha.81

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 (307) hide show
  1. package/native/build-pty.sh +51 -0
  2. package/native/build.sh +82 -0
  3. package/native/darwin-arm64/libcuelooppty.dylib +0 -0
  4. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  5. package/native/src/pty.zig +193 -0
  6. package/native/src/shim.zig +114 -0
  7. package/package.json +40 -32
  8. package/src/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +77 -51
  10. package/src/App.diff.test.tsx +176 -45
  11. package/src/App.export.test.tsx +58 -41
  12. package/src/App.focus.test.ts +89 -0
  13. package/src/App.identity.test.tsx +78 -0
  14. package/src/App.plan-render.test.tsx +209 -0
  15. package/src/App.plan-review.test.tsx +428 -0
  16. package/src/App.prototype-markdown.test.tsx +92 -0
  17. package/src/App.prototype.test.tsx +176 -0
  18. package/src/App.readonly.test.tsx +129 -67
  19. package/src/App.regression.test.tsx +94 -0
  20. package/src/App.rendering.test.tsx +61 -35
  21. package/src/App.share-dialog.test.tsx +172 -0
  22. package/src/App.submit-confirm.test.tsx +222 -0
  23. package/src/App.test.tsx +333 -131
  24. package/src/App.toast.test.tsx +98 -0
  25. package/src/App.tsx +1338 -608
  26. package/src/App.visual.test.tsx +57 -0
  27. package/src/App.walk.test.tsx +299 -0
  28. package/src/App.workbench.test.tsx +701 -0
  29. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  30. package/src/annotation-palette.ts +62 -0
  31. package/src/ansi-screen.test.ts +28 -0
  32. package/src/ansi-screen.ts +72 -0
  33. package/src/app-key-state.ts +61 -0
  34. package/src/app-screens.tsx +574 -0
  35. package/src/app-view-model.ts +162 -0
  36. package/src/attribution.test.ts +31 -0
  37. package/src/attribution.ts +42 -0
  38. package/src/clipboard.test.ts +23 -0
  39. package/src/clipboard.ts +33 -0
  40. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  41. package/src/components/AnnotatableFileView.test.tsx +102 -0
  42. package/src/components/AnnotatableFileView.tsx +134 -0
  43. package/src/components/AnnotationCards.stories.tsx +86 -0
  44. package/src/components/AnnotationCards.tsx +341 -0
  45. package/src/components/AnsiScreen.stories.tsx +12 -0
  46. package/src/components/AnsiScreen.tsx +28 -0
  47. package/src/components/AppHeader.stories.tsx +50 -0
  48. package/src/components/AppHeader.tsx +83 -0
  49. package/src/components/AppShell.brand.test.tsx +58 -0
  50. package/src/components/AppShell.stories.tsx +89 -0
  51. package/src/components/AppShell.tsx +329 -0
  52. package/src/components/AppShell.zoom.test.tsx +135 -0
  53. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  54. package/src/components/BareWorkbenchFileView.tsx +64 -0
  55. package/src/components/Breadcrumb.stories.tsx +19 -0
  56. package/src/components/Breadcrumb.tsx +45 -0
  57. package/src/components/ChangesColumn.stories.tsx +44 -0
  58. package/src/components/ChangesColumn.tsx +199 -0
  59. package/src/components/ChangesFileTree.test.tsx +61 -0
  60. package/src/components/CodeBlock.stories.tsx +42 -0
  61. package/src/components/CodeBlock.tsx +84 -0
  62. package/src/components/CompletionOverlay.stories.tsx +37 -0
  63. package/src/components/CompletionOverlay.tsx +77 -0
  64. package/src/components/ConfirmCard.stories.tsx +37 -0
  65. package/src/components/ConfirmCard.tsx +153 -0
  66. package/src/components/ConfirmDialog.stories.tsx +21 -0
  67. package/src/components/ConfirmDialog.tsx +72 -0
  68. package/src/components/DiffContentView.scroll.test.tsx +121 -0
  69. package/src/components/DiffContentView.split.test.tsx +140 -0
  70. package/src/components/DiffContentView.stories.tsx +80 -0
  71. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  72. package/src/components/DiffContentView.tsx +876 -0
  73. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  74. package/src/components/DiscussionMarkerRail.tsx +186 -0
  75. package/src/components/EditorGrid.stories.tsx +59 -0
  76. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  77. package/src/components/EditorGrid.tsx +426 -0
  78. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  79. package/src/components/FileContentsView.stories.tsx +16 -0
  80. package/src/components/FileContentsView.test.tsx +29 -0
  81. package/src/components/FileContentsView.tsx +98 -0
  82. package/src/components/GridTabContent.stories.tsx +59 -0
  83. package/src/components/GridTabContent.tsx +182 -0
  84. package/src/components/HandleChipsInput.stories.tsx +24 -0
  85. package/src/components/HandleChipsInput.test.tsx +35 -0
  86. package/src/components/HandleChipsInput.tsx +145 -0
  87. package/src/components/KeybindsDialog.stories.tsx +31 -0
  88. package/src/components/KeybindsDialog.tsx +103 -0
  89. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  90. package/src/components/MarkdownGridBlock.tsx +72 -0
  91. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  92. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  93. package/src/components/MarkdownThreadEditor.tsx +162 -0
  94. package/src/components/MenuBar.stories.tsx +28 -0
  95. package/src/components/MenuBar.tsx +44 -0
  96. package/src/components/NavModeHint.stories.tsx +40 -0
  97. package/src/components/NavModeHint.tsx +31 -0
  98. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  99. package/src/components/OverlayScrollbar.test.tsx +139 -0
  100. package/src/components/OverlayScrollbar.tsx +129 -0
  101. package/src/components/PaneGrid.stories.tsx +84 -0
  102. package/src/components/PaneGrid.tsx +128 -0
  103. package/src/components/PanelColumn.stories.tsx +25 -0
  104. package/src/components/PanelColumn.tsx +98 -0
  105. package/src/components/ProjectTreeView.stories.tsx +25 -0
  106. package/src/components/ProjectTreeView.test.tsx +59 -0
  107. package/src/components/ProjectTreeView.tsx +111 -0
  108. package/src/components/PromptDialog.stories.tsx +36 -0
  109. package/src/components/PromptDialog.test.tsx +61 -0
  110. package/src/components/PromptDialog.tsx +99 -0
  111. package/src/components/PrototypeContentView.stories.tsx +19 -0
  112. package/src/components/PrototypeContentView.tsx +446 -0
  113. package/src/components/RootOverlay.stories.tsx +45 -0
  114. package/src/components/RootOverlay.tsx +48 -0
  115. package/src/components/ScrollArea.stories.tsx +20 -0
  116. package/src/components/ScrollArea.tsx +44 -0
  117. package/src/components/SettingsDialog.stories.tsx +117 -0
  118. package/src/components/SettingsDialog.tsx +186 -0
  119. package/src/components/SettingsRows.stories.tsx +18 -0
  120. package/src/components/SettingsRows.tsx +112 -0
  121. package/src/components/ShareDialog.stories.tsx +121 -0
  122. package/src/components/ShareDialog.tsx +552 -0
  123. package/src/components/ShellHeader.stories.tsx +50 -0
  124. package/src/components/ShellHeader.tsx +137 -0
  125. package/src/components/SlashComposer.stories.tsx +37 -0
  126. package/src/components/SlashComposer.tsx +87 -0
  127. package/src/components/ThreadFooter.stories.tsx +30 -0
  128. package/src/components/ThreadFooter.test.tsx +47 -0
  129. package/src/components/ThreadFooter.tsx +76 -0
  130. package/src/components/ThreadTree.stories.tsx +46 -0
  131. package/src/components/ThreadTree.tsx +255 -0
  132. package/src/components/ThreadView.selection.test.tsx +899 -0
  133. package/src/components/ThreadView.slash.test.tsx +146 -0
  134. package/src/components/ThreadView.stories.tsx +26 -0
  135. package/src/components/ThreadView.tsx +472 -0
  136. package/src/components/Toast.stories.tsx +19 -0
  137. package/src/components/Toast.tsx +52 -0
  138. package/src/components/Tooltip.stories.tsx +26 -0
  139. package/src/components/Tooltip.tsx +72 -0
  140. package/src/components/TreePane.stories.tsx +106 -0
  141. package/src/components/TreePane.tsx +157 -0
  142. package/src/components/WalkWizard.stories.tsx +100 -0
  143. package/src/components/WalkWizard.tsx +150 -0
  144. package/src/components/WelcomePlayground.stories.tsx +12 -0
  145. package/src/components/WelcomePlayground.test.tsx +47 -0
  146. package/src/components/WelcomePlayground.tsx +142 -0
  147. package/src/components/__snapshots__/stories.test.tsx.snap +2238 -0
  148. package/src/components/diff-content-view-layout.test.ts +198 -0
  149. package/src/components/diff-content-view-layout.ts +84 -0
  150. package/src/components/editor-grid.test.ts +86 -0
  151. package/src/components/editor-grid.ts +138 -0
  152. package/src/components/file-tree.test.ts +60 -0
  153. package/src/components/file-tree.ts +112 -0
  154. package/src/components/menu-control.stories.tsx +35 -0
  155. package/src/components/menu-control.tsx +43 -0
  156. package/src/components/pages/Workspace.stories.tsx +181 -0
  157. package/src/components/primitives/Button.stories.tsx +32 -0
  158. package/src/components/primitives/Button.tsx +56 -0
  159. package/src/components/primitives/Card.stories.tsx +25 -0
  160. package/src/components/primitives/Card.tsx +63 -0
  161. package/src/components/primitives/Dialog.stories.tsx +24 -0
  162. package/src/components/primitives/Dialog.test.tsx +37 -0
  163. package/src/components/primitives/Dialog.tsx +71 -0
  164. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  165. package/src/components/primitives/DialogActions.tsx +39 -0
  166. package/src/components/primitives/IconButton.stories.tsx +36 -0
  167. package/src/components/primitives/IconButton.tsx +62 -0
  168. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  169. package/src/components/primitives/StatusBar.tsx +23 -0
  170. package/src/components/primitives/Switch.stories.tsx +17 -0
  171. package/src/components/primitives/Switch.tsx +32 -0
  172. package/src/components/primitives/Tabs.stories.tsx +29 -0
  173. package/src/components/primitives/Tabs.tsx +102 -0
  174. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  175. package/src/components/primitives/Toolbar.tsx +14 -0
  176. package/src/components/primitives/Tree.stories.tsx +124 -0
  177. package/src/components/primitives/Tree.tsx +100 -0
  178. package/src/components/primitives/frame.test.ts +12 -0
  179. package/src/components/primitives/frame.ts +17 -0
  180. package/src/components/primitives/icons.ts +61 -0
  181. package/src/components/primitives/tree-model.test.ts +99 -0
  182. package/src/components/primitives/tree-model.ts +137 -0
  183. package/src/components/quick-actions-editor.stories.tsx +58 -0
  184. package/src/components/quick-actions-editor.test.tsx +117 -0
  185. package/src/components/quick-actions-editor.tsx +226 -0
  186. package/src/components/share-dialog-store.ts +58 -0
  187. package/src/components/stories-app.tsx +193 -0
  188. package/src/components/stories.test.tsx +74 -0
  189. package/src/components/story-fixtures.ts +102 -0
  190. package/src/components/story-tree.test.ts +108 -0
  191. package/src/components/story.ts +159 -0
  192. package/src/components/syntax-highlight.ts +133 -0
  193. package/src/components/terminal-pane.test.tsx +40 -0
  194. package/src/components/terminal-pane.ts +199 -0
  195. package/src/components/theme-context.stories.tsx +46 -0
  196. package/src/components/theme-context.tsx +37 -0
  197. package/src/components/thread-tree.test.ts +134 -0
  198. package/src/components/thread-tree.ts +109 -0
  199. package/src/components/truncate-text.ts +7 -0
  200. package/src/components/truncate-title.test.ts +31 -0
  201. package/src/components/truncate-title.ts +15 -0
  202. package/src/config-identity.test.ts +78 -0
  203. package/src/config.test.ts +517 -33
  204. package/src/config.ts +467 -57
  205. package/src/diff-fold.test.ts +78 -0
  206. package/src/diff-fold.ts +126 -0
  207. package/src/diff-hunk-curate.test.ts +315 -0
  208. package/src/diff-hunk-curate.ts +153 -0
  209. package/src/diff-intraline.test.ts +238 -0
  210. package/src/diff-intraline.ts +236 -0
  211. package/src/diff-syntax.test.ts +112 -0
  212. package/src/diff-syntax.ts +179 -0
  213. package/src/discussions.ts +79 -0
  214. package/src/editor.test.ts +149 -0
  215. package/src/editor.ts +139 -9
  216. package/src/ghostty-terminal.test.ts +85 -0
  217. package/src/ghostty-terminal.ts +199 -0
  218. package/src/github-identity.test.ts +58 -0
  219. package/src/github-identity.ts +41 -0
  220. package/src/herdr.test.ts +9 -34
  221. package/src/herdr.ts +25 -21
  222. package/src/index.ts +13 -0
  223. package/src/integrations.test.ts +41 -0
  224. package/src/integrations.ts +32 -0
  225. package/src/intent-dispatch.test.ts +723 -0
  226. package/src/intent-dispatch.ts +692 -0
  227. package/src/key-bindings.test.ts +156 -0
  228. package/src/key-bindings.ts +459 -0
  229. package/src/keymap.test.ts +461 -113
  230. package/src/keymap.ts +321 -79
  231. package/src/kitty-image.test.ts +66 -0
  232. package/src/kitty-image.ts +138 -0
  233. package/src/launch-layout.test.ts +52 -0
  234. package/src/launch-layout.ts +42 -0
  235. package/src/mark-runs.ts +121 -0
  236. package/src/markdown-editor-syntax-style.ts +50 -0
  237. package/src/markdown-grid.test.ts +101 -0
  238. package/src/markdown-grid.ts +203 -0
  239. package/src/markdown-highlight.test.ts +61 -0
  240. package/src/markdown-highlight.ts +122 -0
  241. package/src/pasted-image.test.ts +67 -0
  242. package/src/pasted-image.ts +60 -0
  243. package/src/perf/otlp-trace.test.ts +116 -0
  244. package/src/perf/otlp-trace.ts +130 -0
  245. package/src/perf/perf-report.ts +17 -0
  246. package/src/perf/perf-timings.test.ts +18 -0
  247. package/src/perf/perf-timings.ts +38 -0
  248. package/src/prototype-browser.test.ts +64 -0
  249. package/src/prototype-browser.ts +329 -0
  250. package/src/prototype-pixels.tsx +35 -0
  251. package/src/pty.test.ts +81 -0
  252. package/src/pty.ts +245 -0
  253. package/src/ready-signal.test.ts +85 -0
  254. package/src/ready-signal.ts +52 -0
  255. package/src/run.ts +78 -13
  256. package/src/serve.test.ts +24 -12
  257. package/src/serve.ts +52 -10
  258. package/src/share.ts +237 -0
  259. package/src/skills.test.ts +74 -0
  260. package/src/skills.ts +75 -0
  261. package/src/slash-palette.test.ts +103 -0
  262. package/src/slash-palette.ts +129 -0
  263. package/src/split-diff.test.ts +110 -0
  264. package/src/split-diff.ts +149 -0
  265. package/src/test-support.test.ts +264 -0
  266. package/src/test-support.ts +369 -0
  267. package/src/theme-presets.test.ts +51 -0
  268. package/src/theme-presets.ts +214 -0
  269. package/src/theme.ts +84 -19
  270. package/src/thread-chords.test.ts +79 -0
  271. package/src/thread-chords.ts +163 -0
  272. package/src/thread-controller.curate.test.ts +450 -0
  273. package/src/thread-controller.derivation.test.ts +62 -0
  274. package/src/thread-controller.live-diff.test.ts +322 -0
  275. package/src/thread-controller.optimistic.test.ts +69 -0
  276. package/src/thread-controller.share.test.ts +561 -0
  277. package/src/thread-controller.switch-cache.test.ts +176 -0
  278. package/src/thread-controller.tree.test.ts +261 -0
  279. package/src/thread-controller.ts +1893 -0
  280. package/src/thread-selection.test.ts +210 -0
  281. package/src/thread-selection.ts +243 -0
  282. package/src/tree-view.test.ts +128 -0
  283. package/src/tree-view.ts +148 -0
  284. package/src/use-annotation-surface.tsx +915 -0
  285. package/src/use-changes-workbench.ts +190 -0
  286. package/src/use-frame-measure.ts +41 -0
  287. package/src/use-remember-layout.ts +31 -0
  288. package/src/use-settings-dialog.tsx +320 -0
  289. package/src/use-terminal-virtualizer.ts +233 -0
  290. package/src/use-thread-body-editing.tsx +58 -0
  291. package/src/version.ts +5 -0
  292. package/src/view-diff.test.ts +137 -9
  293. package/src/view-diff.ts +269 -23
  294. package/src/view-plan.regression.test.ts +60 -0
  295. package/src/view-plan.test.ts +456 -0
  296. package/src/view-plan.ts +719 -0
  297. package/src/visual/contrast.test.ts +35 -0
  298. package/src/visual/contrast.ts +33 -0
  299. package/src/walk.test.ts +121 -0
  300. package/src/walk.ts +74 -0
  301. package/src/workbench-snapshot.test.ts +81 -0
  302. package/src/workbench-snapshot.ts +29 -0
  303. package/src/session-controller.ts +0 -345
  304. package/src/syntax.test.ts +0 -41
  305. package/src/syntax.ts +0 -88
  306. package/src/view.test.ts +0 -157
  307. package/src/view.ts +0 -312
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Row virtualization for an OpenTUI scrollbox, driven by @tanstack/virtual-core.
3
+ * The core is DOM-free at heart: it only reaches the scroll container through the
4
+ * observe/scroll callbacks given here, so a terminal scrollbox stands in for the
5
+ * element - offsets and sizes are terminal rows, the "scroll event" is the
6
+ * renderer's frame event, and a mounted row's height is read off its renderable.
7
+ * Callers render `items` with a spacer of `items[0].start` rows above and
8
+ * `totalSize - last.end` below, and hand each row's box to `measureRef(index)`.
9
+ */
10
+
11
+ import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
12
+ import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core";
13
+ import { useRenderer } from "@opentui/react";
14
+ import { Virtualizer, type VirtualItem } from "@tanstack/virtual-core";
15
+
16
+ export interface TerminalVirtualizerOptions {
17
+ scrollbox: RefObject<ScrollBoxRenderable | null>;
18
+ count: number;
19
+ /** Rows an item occupies before it is measured (its wrapped visual lines). */
20
+ estimateSize: (index: number) => number;
21
+ /** Items this many rows beyond the viewport stay mounted, so a scroll step never shows a gap. */
22
+ overscan: number;
23
+ }
24
+
25
+ export interface TerminalVirtualizer {
26
+ items: VirtualItem[];
27
+ totalSize: number;
28
+ /** Ref callback for an item's row box, so its real height (cards included) replaces the estimate. */
29
+ measureRef: (index: number) => (renderable: BoxRenderable | null) => void | (() => void);
30
+ /** Scroll the item into view; "auto" only scrolls when it is off screen. */
31
+ scrollToIndex: (index: number, align?: "auto" | "start" | "center" | "end") => void;
32
+ }
33
+
34
+ /**
35
+ * The scroll container as the core reads it: the offset and the extents it clamps a scroll
36
+ * target with. These are the `Element` members of the same names, so the view passes as one.
37
+ */
38
+ interface ScrollContainerView {
39
+ readonly scrollTop: number;
40
+ readonly scrollHeight: number;
41
+ readonly clientHeight: number;
42
+ readonly scrollWidth: number;
43
+ readonly clientWidth: number;
44
+ }
45
+
46
+ /** A mounted row as the core measures it: its height under the `Element` member name. */
47
+ interface RowView {
48
+ readonly clientHeight: number;
49
+ }
50
+
51
+ type CoreVirtualizer = Virtualizer<Element, Element>;
52
+
53
+ function scrollContainerView(scrollbox: ScrollBoxRenderable): ScrollContainerView {
54
+ return {
55
+ get scrollTop() {
56
+ return scrollbox.scrollTop;
57
+ },
58
+ get scrollHeight() {
59
+ return scrollbox.scrollHeight;
60
+ },
61
+ get clientHeight() {
62
+ return scrollbox.viewport.height;
63
+ },
64
+ get scrollWidth() {
65
+ return scrollbox.scrollWidth;
66
+ },
67
+ get clientWidth() {
68
+ return scrollbox.viewport.width;
69
+ },
70
+ };
71
+ }
72
+
73
+ function rowView(renderable: BoxRenderable): RowView {
74
+ return {
75
+ get clientHeight() {
76
+ return renderable.height;
77
+ },
78
+ };
79
+ }
80
+
81
+ /**
82
+ * The core's element generics are DOM-typed, but every way it touches the container or a row
83
+ * (rect, offset, scroll, measurement, index lookup) is replaced here with a renderable-aware
84
+ * callback, so a view exposing just the members it reads stands in for the element.
85
+ */
86
+ function asElement(view: ScrollContainerView | RowView): Element {
87
+ // SAFETY: the core never calls DOM methods on this value; its DOM defaults are all overridden.
88
+ return view as Element;
89
+ }
90
+
91
+ export function useTerminalVirtualizer(options: TerminalVirtualizerOptions): TerminalVirtualizer {
92
+ const renderer = useRenderer();
93
+ const [, setRevision] = useState(0);
94
+ // one stable view per mounted row box, so the core's element cache keeps its identity
95
+ const viewOfRenderable = useRef(new WeakMap<BoxRenderable, Element>());
96
+ const indexOfView = useRef(new WeakMap<Element, number>());
97
+ const container = useRef<{ scrollbox: ScrollBoxRenderable; view: Element } | null>(null);
98
+
99
+ const scrollContainer = (): Element | null => {
100
+ const scrollbox = options.scrollbox.current;
101
+
102
+ if (!scrollbox) return null;
103
+ if (container.current?.scrollbox !== scrollbox) {
104
+ container.current = { scrollbox, view: asElement(scrollContainerView(scrollbox)) };
105
+ }
106
+
107
+ return container.current.view;
108
+ };
109
+ const viewFor = (renderable: BoxRenderable): Element => {
110
+ const known = viewOfRenderable.current.get(renderable);
111
+
112
+ if (known) return known;
113
+ const view = asElement(rowView(renderable));
114
+
115
+ viewOfRenderable.current.set(renderable, view);
116
+
117
+ return view;
118
+ };
119
+
120
+ // the offset reporter the core registered, so a scroll the core asks for reports at once
121
+ const reportOffset = useRef<(() => void) | null>(null);
122
+
123
+ // a frame-event poll stands in for scroll and resize events: the renderer fires it after every
124
+ // render, and the callbacks only fire the core when the value actually changed
125
+ const onFrame = (read: () => void): (() => void) => {
126
+ read();
127
+ renderer?.on("frame", read);
128
+
129
+ return () => renderer?.off("frame", read);
130
+ };
131
+
132
+ const coreOptions = () => ({
133
+ count: options.count,
134
+ overscan: options.overscan,
135
+ estimateSize: options.estimateSize,
136
+ getScrollElement: scrollContainer,
137
+ observeElementRect: (
138
+ _instance: CoreVirtualizer,
139
+ callback: (rect: { width: number; height: number }) => void,
140
+ ) => {
141
+ let last = { width: -1, height: -1 };
142
+
143
+ return onFrame(() => {
144
+ const scrollbox = options.scrollbox.current;
145
+
146
+ if (!scrollbox) return;
147
+ const next = { width: scrollbox.viewport.width, height: scrollbox.viewport.height };
148
+
149
+ if (next.width === last.width && next.height === last.height) return;
150
+ last = next;
151
+ callback(next);
152
+ });
153
+ },
154
+ observeElementOffset: (
155
+ _instance: CoreVirtualizer,
156
+ callback: (offset: number, isScrolling: boolean) => void,
157
+ ) => {
158
+ let last = -1;
159
+ const report = (): void => {
160
+ const scrollbox = options.scrollbox.current;
161
+
162
+ if (!scrollbox || scrollbox.scrollTop === last) return;
163
+ last = scrollbox.scrollTop;
164
+ callback(last, false);
165
+ };
166
+ const stop = onFrame(report);
167
+
168
+ reportOffset.current = report;
169
+
170
+ return () => {
171
+ reportOffset.current = null;
172
+ stop();
173
+ };
174
+ },
175
+ // report the new offset in the same tick: the item window then follows the scroll at once,
176
+ // instead of one frame later with the target rows still unmounted
177
+ scrollToFn: (offset: number) => {
178
+ options.scrollbox.current?.scrollTo({ x: 0, y: Math.max(0, Math.round(offset)) });
179
+ reportOffset.current?.();
180
+ },
181
+ // a box measures 0 until Yoga has laid it out; keep the estimate rather than collapse the row
182
+ measureElement: (element: Element) =>
183
+ element.clientHeight > 0
184
+ ? element.clientHeight
185
+ : options.estimateSize(indexOfView.current.get(element) ?? -1),
186
+ onChange: () => setRevision((revision) => revision + 1),
187
+ });
188
+
189
+ const [virtualizer] = useState<CoreVirtualizer>(() => {
190
+ const instance = new Virtualizer<Element, Element>(coreOptions());
191
+
192
+ // the core looks an item's index up through a DOM attribute; ours is remembered per row view
193
+ instance.indexFromElement = (element) => indexOfView.current.get(element) ?? -1;
194
+
195
+ return instance;
196
+ });
197
+
198
+ virtualizer.setOptions(coreOptions());
199
+ useLayoutEffect(() => {
200
+ virtualizer._willUpdate();
201
+ });
202
+ useEffect(() => virtualizer._didMount(), [virtualizer]);
203
+ // no ResizeObserver here: a mounted row's height settles after layout and grows when a card
204
+ // opens under it, so every frame re-reads the mounted rows (the core only notifies on change)
205
+ useEffect(
206
+ () =>
207
+ onFrame(() => {
208
+ for (const element of virtualizer.elementsCache.values()) {
209
+ virtualizer.measureElement(element);
210
+ }
211
+ }),
212
+ // eslint-disable-next-line react-hooks/exhaustive-deps
213
+ [virtualizer, renderer],
214
+ );
215
+
216
+ return {
217
+ items: virtualizer.getVirtualItems(),
218
+ totalSize: virtualizer.getTotalSize(),
219
+ measureRef: (index) => (renderable) => {
220
+ if (!renderable) return;
221
+ const view = viewFor(renderable);
222
+
223
+ indexOfView.current.set(view, index);
224
+ virtualizer.measureElement(view);
225
+
226
+ // the row unmounted: forget its view so the frame re-measure stops reading it
227
+ return () => {
228
+ if (virtualizer.elementsCache.get(index) === view) virtualizer.elementsCache.delete(index);
229
+ };
230
+ },
231
+ scrollToIndex: (index, align = "auto") => virtualizer.scrollToIndex(index, { align }),
232
+ };
233
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The thread pane's inline edit mode: owns whether the native markdown editor is
3
+ * open over the body, the editor element itself, and the open and exit handles.
4
+ * Opening is gated to an editable thread (the caller passes canEdit - owner,
5
+ * unresolved, not a diff); exiting saves the edited body as the working copy
6
+ * through the controller, so leaving the editor never loses work. The header
7
+ * edit/normal toggle exits through requestExit; switching threads closes the
8
+ * editor. Rendering the editor lives here too, so the pane pulls in one concept
9
+ * rather than wiring the component and its handle ref by hand.
10
+ */
11
+
12
+ import React, { useRef, useState } from "react";
13
+ import type { Theme } from "./theme";
14
+ import type { ReviewController } from "./thread-controller";
15
+ import { MarkdownThreadEditor, type MarkdownEditorHandle } from "./components/MarkdownThreadEditor";
16
+
17
+ export interface ThreadBodyEditing {
18
+ /** True while the inline markdown editor owns the thread pane and its keys. */
19
+ editing: boolean;
20
+ /** Open the editor when the active thread is editable; a no-op otherwise. */
21
+ openEditor: () => void;
22
+ /** Save the current text and close - the header edit/normal toggle's exit. */
23
+ requestExit: () => void;
24
+ /** The editor element for the thread pane, bound to the working copy and theme. */
25
+ renderEditor: (theme: Theme) => React.ReactNode;
26
+ }
27
+
28
+ export function useThreadBodyEditing(options: {
29
+ controller: ReviewController;
30
+ sessionId: string | undefined;
31
+ canEdit: boolean;
32
+ }): ThreadBodyEditing {
33
+ const { controller, sessionId, canEdit } = options;
34
+ // key editing by thread id so a thread switch closes the editor with no effect or ref
35
+ const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
36
+ const handleRef = useRef<MarkdownEditorHandle | null>(null);
37
+ const editing = editingSessionId !== null && editingSessionId === sessionId;
38
+ const exitEditor = (text: string): void => {
39
+ controller.saveEditedBody(text);
40
+ setEditingSessionId(null);
41
+ };
42
+
43
+ return {
44
+ editing,
45
+ openEditor: () => {
46
+ if (canEdit && sessionId !== undefined) setEditingSessionId(sessionId);
47
+ },
48
+ requestExit: () => handleRef.current?.requestExit(),
49
+ renderEditor: (theme) => (
50
+ <MarkdownThreadEditor
51
+ ref={handleRef}
52
+ initialText={controller.working()}
53
+ theme={theme}
54
+ onExitEditor={exitEditor}
55
+ />
56
+ ),
57
+ };
58
+ }
package/src/version.ts ADDED
@@ -0,0 +1,5 @@
1
+ /** The published client version, read from the package manifest. */
2
+
3
+ import manifest from "../package.json" with { type: "json" };
4
+
5
+ export const CLIENT_VERSION: string = manifest.version;
@@ -1,5 +1,13 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { diffRowAnchor, diffRows } from "./view-diff";
2
+ import { makeAnchor, type Annotation, type Thread } from "@cueloop/schema";
3
+ import {
4
+ changesMarks,
5
+ diffRowAnchor,
6
+ diffRowBlocks,
7
+ diffRows,
8
+ fileRowRange,
9
+ fileTargetMarks,
10
+ } from "./view-diff";
3
11
 
4
12
  const PATCH = `diff --git a/src/store.ts b/src/store.ts
5
13
  index 111..222 100644
@@ -21,23 +29,32 @@ index 111..222 100644
21
29
 
22
30
  describe("diffRows", () => {
23
31
  test("flattens files, hunks, and signed lines with line numbers", () => {
32
+ // Act
24
33
  const rows = diffRows(PATCH);
25
- expect(rows[0]).toMatchObject({ t: "file", file: "src/store.ts" });
26
- expect(rows[1]!.t).toBe("hunk");
27
- const del = rows.find((r) => r.t === "del")!;
34
+
35
+ // Assert
36
+ expect(rows[0]).toMatchObject({ kind: "file", file: "src/store.ts" });
37
+ expect(rows[1]!.kind).toBe("hunk");
38
+ const del = rows.find((row) => row.kind === "del")!;
39
+
28
40
  expect(del.text).toContain("private items = [];");
29
41
  expect(del.oldLine).toBe(3);
30
- const adds = rows.filter((r) => r.t === "add");
42
+ const adds = rows.filter((row) => row.kind === "add");
43
+
31
44
  expect(adds[0]!.text).toContain("new Map()");
32
45
  expect(adds[0]!.newLine).toBe(3);
33
46
  expect(adds[1]!.newLine).toBe(4);
34
47
  // two hunks
35
- expect(rows.filter((r) => r.t === "hunk").length).toBe(2);
48
+ expect(rows.filter((row) => row.kind === "hunk").length).toBe(2);
36
49
  });
37
50
 
38
51
  test("context lines carry both line numbers", () => {
52
+ // Act
39
53
  const rows = diffRows(PATCH);
40
- const ctx = rows.find((r) => r.t === "ctx")!;
54
+
55
+ // Assert
56
+ const ctx = rows.find((row) => row.kind === "ctx")!;
57
+
41
58
  expect(ctx.oldLine).toBe(1);
42
59
  expect(ctx.newLine).toBe(1);
43
60
  });
@@ -45,11 +62,122 @@ describe("diffRows", () => {
45
62
 
46
63
  describe("diffRowAnchor", () => {
47
64
  test("quote is the line, neighbors are the context selectors", () => {
65
+ // Arrange
48
66
  const rows = diffRows(PATCH);
49
- const idx = rows.findIndex((r) => r.text.includes("new Map()"));
50
- const anchor = diffRowAnchor(rows, idx);
67
+ const rowIndex = rows.findIndex((row) => row.text.includes("new Map()"));
68
+
69
+ // Act
70
+ const anchor = diffRowAnchor(rows, rowIndex);
71
+
72
+ // Assert
51
73
  expect(anchor.quote).toContain("new Map()");
52
74
  expect(anchor.prefix.length).toBeGreaterThan(0);
53
75
  expect(anchor.suffix.length).toBeGreaterThan(0);
54
76
  });
55
77
  });
78
+
79
+ describe("fileTargetMarks", () => {
80
+ // two files whose added line is byte-identical; a note on one must never mark the other
81
+ const TWO_FILE = `diff --git a/a.ts b/a.ts
82
+ --- a/a.ts
83
+ +++ b/a.ts
84
+ @@ -1,1 +1,2 @@
85
+ const keep = 0;
86
+ +const dup = 1;
87
+ diff --git a/b.ts b/b.ts
88
+ --- a/b.ts
89
+ +++ b/b.ts
90
+ @@ -1,1 +1,2 @@
91
+ const keep = 0;
92
+ +const dup = 1;
93
+ `;
94
+
95
+ test("a file-target note marks only its own file, never identical text in another", () => {
96
+ // Arrange - anchor the added line inside a.ts, file-relative like the controller does
97
+ const rows = diffRows(TWO_FILE);
98
+ const range = fileRowRange(rows, "a.ts")!;
99
+ const fileRows = rows.slice(range.start, range.end);
100
+ const addRelative = fileRows.findIndex((row) => row.kind === "add");
101
+ const quote = fileRows[addRelative]!.text.replace(/\n$/, "");
102
+ const anchor = makeAnchor(diffRowBlocks(fileRows), addRelative, 0, quote.length, addRelative);
103
+ const annotation: Annotation = {
104
+ id: "a1",
105
+ kind: "comment",
106
+ anchor,
107
+ target: { kind: "file", path: "a.ts", rev: "worktree" },
108
+ body: "note",
109
+ createdAt: "2026-01-01T00:00:00Z",
110
+ };
111
+
112
+ // Act
113
+ const marked = [...fileTargetMarks([annotation], rows).keys()];
114
+
115
+ // Assert - every marked row sits inside a.ts's range, and b.ts's identical line is untouched
116
+ expect(marked.length).toBeGreaterThan(0);
117
+ expect(marked.every((index) => index >= range.start && index < range.end)).toBe(true);
118
+ });
119
+ });
120
+
121
+ describe("changesMarks", () => {
122
+ const PATCH = `diff --git a/x.ts b/x.ts
123
+ --- a/x.ts
124
+ +++ b/x.ts
125
+ @@ -1,1 +1,2 @@
126
+ const keep = 0;
127
+ +const added = 1;
128
+ `;
129
+
130
+ function diffThread(meta: Thread["artifact"]["meta"], annotations: Annotation[]): Thread {
131
+ return {
132
+ schemaVersion: "1",
133
+ id: "s_changes",
134
+ workspace: { repoRoot: "/repo", branch: "main" },
135
+ artifact: { type: "diff", content: PATCH, meta },
136
+ revisions: [{ revision: 1, content: PATCH, submittedAt: "2026-01-01T00:00:00Z" }],
137
+ annotations,
138
+ message: null,
139
+ status: "pending",
140
+ createdAt: "2026-01-01T00:00:00Z",
141
+ };
142
+ }
143
+
144
+ function fileNote(rows: ReturnType<typeof diffRows>): Annotation {
145
+ const range = fileRowRange(rows, "x.ts")!;
146
+ const fileRows = rows.slice(range.start, range.end);
147
+ const addRelative = fileRows.findIndex((row) => row.kind === "add");
148
+ const quote = fileRows[addRelative]!.text.replace(/\n$/, "");
149
+ const anchor = makeAnchor(diffRowBlocks(fileRows), addRelative, 0, quote.length, addRelative);
150
+
151
+ return {
152
+ id: "f1",
153
+ kind: "comment",
154
+ anchor,
155
+ target: { kind: "file", path: "x.ts", rev: "worktree" },
156
+ body: "note",
157
+ createdAt: "2026-01-01T00:00:00Z",
158
+ };
159
+ }
160
+
161
+ test("a workbench thread paints its file-targeted working-tree notes", () => {
162
+ const rows = diffRows(PATCH);
163
+ const session = diffThread({ workbench: true }, [fileNote(rows)]);
164
+
165
+ expect([...changesMarks(session, rows).keys()].length).toBeGreaterThan(0);
166
+ });
167
+
168
+ test("a pinned diff review paints only artifact notes, not the working tree's file targets", () => {
169
+ const rows = diffRows(PATCH);
170
+ const session = diffThread({}, [fileNote(rows)]);
171
+
172
+ // the same file-targeted note is not an artifact note, so a frozen review leaves it unpainted
173
+ expect([...changesMarks(session, rows).keys()].length).toBe(0);
174
+ });
175
+
176
+ test("a shared snapshot still paints the workbench's file-targeted feedback", () => {
177
+ const rows = diffRows(PATCH);
178
+ // a served/shared snapshot keeps the workbench marker, so its notes stay file-targeted and visible
179
+ const session = diffThread({ workbench: true, snapshot: true }, [fileNote(rows)]);
180
+
181
+ expect([...changesMarks(session, rows).keys()].length).toBeGreaterThan(0);
182
+ });
183
+ });