@kabmo-labs/kabmo 0.8.0 → 0.9.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 (254) hide show
  1. package/README.md +7 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +314 -314
  4. package/dist/client/static/js/{index-qwpvd9fv.js → index-bpptfkz2.js} +922 -892
  5. package/dist/client/static/js/index-k315p1e8.css +1 -0
  6. package/package.json +9 -1
  7. package/src/client/components/common/code-surface/documents.test.ts +120 -0
  8. package/src/client/components/common/code-surface/documents.ts +201 -0
  9. package/src/client/components/common/code-surface/extensions.test.ts +110 -0
  10. package/src/client/components/common/code-surface/extensions.ts +162 -0
  11. package/src/client/components/common/code-surface/handle.ts +102 -0
  12. package/src/client/components/common/code-surface/index.tsx +253 -314
  13. package/src/client/components/common/code-surface/keymap.test.ts +133 -0
  14. package/src/client/components/common/code-surface/keymap.ts +267 -0
  15. package/src/client/components/common/code-surface/marks.ts +74 -0
  16. package/src/client/components/common/code-surface/shiki.ts +234 -0
  17. package/src/client/components/common/code-surface/sync.test.ts +185 -0
  18. package/src/client/components/common/code-surface/sync.ts +58 -0
  19. package/src/client/components/common/code-surface/theme.test.ts +202 -0
  20. package/src/client/components/common/code-surface/theme.ts +111 -0
  21. package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
  22. package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
  23. package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
  24. package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
  25. package/src/client/components/common/shell-update-button/index.tsx +125 -0
  26. package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
  27. package/src/client/components/common/toolbar-row/index.tsx +189 -0
  28. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
  29. package/src/client/components/layout/desktop-layout/index.tsx +47 -67
  30. package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
  31. package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
  32. package/src/client/components/mobile/LayoutWrapper.tsx +1 -0
  33. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
  34. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
  35. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
  36. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
  37. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
  38. package/src/client/components/settings/Modal.tsx +8 -0
  39. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  40. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
  41. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
  42. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +8 -2
  43. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +16 -8
  44. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
  45. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
  46. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
  47. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
  48. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
  49. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +8 -11
  56. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
  57. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
  58. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
  59. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
  60. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
  65. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
  66. package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
  67. package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
  68. package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
  69. package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
  70. package/src/client/components/workspace/diff-panel/index.tsx +76 -145
  71. package/src/client/components/workspace/diff-panel/merge.ts +315 -0
  72. package/src/client/components/workspace/editor/Body.tsx +4 -7
  73. package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
  74. package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
  75. package/src/client/components/workspace/editor/FilePane.tsx +4 -7
  76. package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
  77. package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
  78. package/src/client/components/workspace/editor/index.tsx +7 -9
  79. package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
  80. package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
  81. package/src/client/components/workspace/search-panel/Body.tsx +1 -1
  82. package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
  83. package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
  84. package/src/client/hooks/chat/omp/reconnect.ts +187 -0
  85. package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
  86. package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
  87. package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
  88. package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
  89. package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
  90. package/src/client/hooks/chat/omp/session-list.tsx +69 -9
  91. package/src/client/hooks/chat/timeline/generating.ts +19 -2
  92. package/src/client/hooks/chat/timeline/index.ts +19 -24
  93. package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
  94. package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
  95. package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
  96. package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
  97. package/src/client/hooks/chat/timeline/session-load.ts +69 -37
  98. package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
  99. package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
  100. package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
  101. package/src/client/hooks/code/deferred-highlight.ts +129 -0
  102. package/src/client/hooks/editor/find-state.ts +1 -1
  103. package/src/client/hooks/editor/theme.test.ts +206 -0
  104. package/src/client/hooks/editor/theme.ts +82 -0
  105. package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
  106. package/src/client/hooks/editor/use-editor-drop.ts +67 -14
  107. package/src/client/hooks/editor/use-editor-find.ts +7 -7
  108. package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
  109. package/src/client/hooks/editor/use-outline-view.ts +94 -7
  110. package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
  111. package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
  112. package/src/client/hooks/editor/use-search-reveal.ts +1 -1
  113. package/src/client/hooks/ui/settings-modal.ts +56 -0
  114. package/src/client/hooks/ui/updates/shell-download.test.ts +2 -0
  115. package/src/client/hooks/ui/updates/shell-download.ts +55 -14
  116. package/src/client/hooks/ui/updates/shell-offer.test.ts +26 -0
  117. package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
  118. package/src/client/hooks/ui/updates/shell-update.ts +119 -0
  119. package/src/client/hooks/workspace/diff-file.ts +29 -10
  120. package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
  121. package/src/client/hooks/workspace/editor-view.ts +61 -0
  122. package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
  123. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  124. package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
  125. package/src/client/hooks/workspace/worktree/index.ts +20 -5
  126. package/src/client/lib/desktop/bridge.ts +45 -0
  127. package/src/client/lib/search-reveal.ts +1 -1
  128. package/src/client/main.tsx +5 -1
  129. package/src/client/tailwind.css +37 -8
  130. package/src/server/lib/auth/guard.test.ts +28 -0
  131. package/src/server/lib/auth/guard.ts +10 -0
  132. package/src/server/lib/fs/worktree/create.ts +2 -3
  133. package/src/server/lib/fs/worktree/list.ts +13 -0
  134. package/src/server/lib/fs/worktree/remove.ts +3 -3
  135. package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
  136. package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
  137. package/src/server/lib/lifecycle/port-guard.ts +39 -7
  138. package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
  139. package/src/server/lib/lifecycle/probe.test.ts +77 -164
  140. package/src/server/lib/lifecycle/probe.ts +121 -13
  141. package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
  142. package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
  143. package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
  144. package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
  145. package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
  146. package/src/server/lib/omp/session/telemetry.test.ts +33 -0
  147. package/src/server/lib/realtime/signals.server.ts +10 -0
  148. package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
  149. package/src/server/lib/realtime/topics.server.ts +16 -0
  150. package/src/server/lib/updates/shell-assets.ts +94 -3
  151. package/src/server/lib/updates/shell-cache.ts +105 -0
  152. package/src/server/lib/updates/shell-pages.test.ts +33 -2
  153. package/src/server/lib/updates/shell-pages.ts +17 -5
  154. package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
  155. package/src/server/lib/updates/shell-release.test.ts +10 -27
  156. package/src/server/lib/updates/shell-release.ts +107 -111
  157. package/src/server/routes/fs/git-merge.test.ts +11 -1
  158. package/src/server/routes/fs/read.test.ts +24 -7
  159. package/src/server/routes/fs/scope.test.ts +15 -10
  160. package/src/server/routes/fs/scope.ts +3 -14
  161. package/src/server/routes/updates/android.ts +6 -1
  162. package/src/server/routes/updates/desktop.ts +29 -7
  163. package/src/server/routes/updates/ios.ts +5 -1
  164. package/src/server/routes/worktrees/list.ts +11 -1
  165. package/src/server/routes/worktrees/routes.test.ts +17 -0
  166. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
  167. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
  168. package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
  169. package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
  170. package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
  171. package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
  172. package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
  173. package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
  174. package/src/shared/lib/code/editor/find-scan.ts +177 -0
  175. package/src/shared/lib/code/editor/keymap.ts +3 -3
  176. package/src/shared/lib/code/editor/lines.test.ts +7 -55
  177. package/src/shared/lib/code/editor/lines.ts +8 -48
  178. package/src/shared/lib/code/editor/marks.test.ts +4 -5
  179. package/src/shared/lib/code/editor/marks.ts +13 -25
  180. package/src/shared/lib/code/editor/theme-loader.ts +97 -0
  181. package/src/shared/lib/code/editor/themes.generated.ts +156 -0
  182. package/src/shared/lib/code/editor/themes.test.ts +165 -0
  183. package/src/shared/lib/code/editor/themes.ts +165 -0
  184. package/src/shared/lib/code/highlight-memo.test.ts +103 -0
  185. package/src/shared/lib/code/highlight-memo.ts +149 -0
  186. package/src/shared/lib/code/highlighter-warmup.ts +112 -0
  187. package/src/shared/lib/code/highlighter.ts +3 -1
  188. package/src/shared/lib/code/idle.ts +37 -0
  189. package/src/shared/lib/code/outline/rows.test.ts +96 -0
  190. package/src/shared/lib/code/outline/rows.ts +94 -0
  191. package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
  192. package/src/shared/lib/code/syntax-highlight.ts +2 -3
  193. package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
  194. package/src/shared/lib/fs/file-kind.ts +47 -13
  195. package/src/shared/lib/fs/file-url.test.ts +52 -0
  196. package/src/shared/lib/fs/file-url.ts +37 -0
  197. package/src/shared/lib/fs/patch-sides.test.ts +114 -0
  198. package/src/shared/lib/fs/patch-sides.ts +139 -0
  199. package/src/shared/lib/realtime/protocol.ts +16 -0
  200. package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
  201. package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
  202. package/src/shared/lib/updates/shell-arch.ts +54 -0
  203. package/src/shared/lib/updates/shell-ua.ts +4 -4
  204. package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
  205. package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
  206. package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
  207. package/src/shared/lib/workspace/panel-widths.ts +25 -5
  208. package/src/shared/lib/workspace/session-state/cache.ts +159 -0
  209. package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
  210. package/src/shared/lib/workspace/session-state/merge.ts +100 -0
  211. package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
  212. package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
  213. package/src/shared/lib/workspace/session-state/store.ts +92 -130
  214. package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
  215. package/src/shared/types/realtime.ts +29 -0
  216. package/src/shared/types/settings/state.ts +10 -0
  217. package/src/test-support/loopback.ts +183 -0
  218. package/dist/client/static/js/index-gtbpewr7.css +0 -1
  219. package/src/client/components/common/code-editor/handler.test.ts +0 -274
  220. package/src/client/components/common/code-editor/handler.ts +0 -175
  221. package/src/client/components/common/code-editor/index.tsx +0 -314
  222. package/src/client/components/common/code-surface/measure.ts +0 -148
  223. package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +0 -227
  224. package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +0 -260
  225. package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
  226. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
  227. package/src/client/hooks/editor/scroll-container.test.ts +0 -102
  228. package/src/client/hooks/editor/scroll-container.ts +0 -26
  229. package/src/client/hooks/editor/use-code-window.ts +0 -100
  230. package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
  231. package/src/client/hooks/editor/use-editor-history.ts +0 -134
  232. package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
  233. package/src/client/hooks/editor/use-occurrences.ts +0 -98
  234. package/src/shared/lib/code/editor/commands.test.ts +0 -155
  235. package/src/shared/lib/code/editor/commands.ts +0 -260
  236. package/src/shared/lib/code/editor/edits.test.ts +0 -100
  237. package/src/shared/lib/code/editor/edits.ts +0 -122
  238. package/src/shared/lib/code/editor/history.test.ts +0 -118
  239. package/src/shared/lib/code/editor/history.ts +0 -101
  240. package/src/shared/lib/code/editor/layers.test.ts +0 -77
  241. package/src/shared/lib/code/editor/layers.ts +0 -67
  242. package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
  243. package/src/shared/lib/code/editor/line-edits.ts +0 -159
  244. package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
  245. package/src/shared/lib/code/editor/occurrences.ts +0 -111
  246. package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
  247. package/src/shared/lib/code/editor/reset-css.ts +0 -32
  248. package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
  249. package/src/shared/lib/code/editor/text-commands.ts +0 -154
  250. package/src/shared/lib/code/lazy-window.test.ts +0 -161
  251. package/src/shared/lib/code/lazy-window.ts +0 -156
  252. package/src/shared/lib/code/windowed-highlight.ts +0 -114
  253. package/src/shared/lib/fs/diff-parser.test.ts +0 -116
  254. package/src/shared/lib/fs/diff-parser.ts +0 -213
@@ -161,6 +161,19 @@ function storedOffsets(): ScrollPositions | undefined {
161
161
  return getSessionValue<ScrollPositions>(SESSION, SCROLL_POSITIONS_KEY);
162
162
  }
163
163
 
164
+ /**
165
+ * A READER's scroll: the input that causes it, then the movement.
166
+ *
167
+ * The hook distinguishes the two, and it has to: a `scroll` with no input behind
168
+ * it is the browser re-anchoring the document as its real height lands, which
169
+ * must NOT be read as the reader moving. `wheel` is the simplest input a code
170
+ * surface sees.
171
+ */
172
+ function readerScroll(probe: ProbeHandle, top: number): void {
173
+ probe.el.dispatchEvent(new Event('wheel', { bubbles: true }));
174
+ probe.el.scrollTop = top;
175
+ }
176
+
164
177
  describe('useScrollRestore', () => {
165
178
  test('restores the stored offset for the tab and surface', async () => {
166
179
  const probe = await mount('7', 'code', { '7': { code: 900 } });
@@ -181,7 +194,47 @@ describe('useScrollRestore', () => {
181
194
 
182
195
  test("a reader's own scroll wins over a pending restore", async () => {
183
196
  const probe = await mount('7', 'code', { '7': { code: 900 } });
184
- probe.el.scrollTop = 120;
197
+ readerScroll(probe, 120);
198
+ await act(async () => {
199
+ StubResizeObserver.latest()?.fire();
200
+ });
201
+ expect(probe.el.scrollTop).toBe(120);
202
+ });
203
+
204
+ test('the document re-anchoring is not the reader, and the restore re-applies', async () => {
205
+ // The bug the e2e spec found: the restore writes 900, the document's real
206
+ // height lands a frame later, and CodeMirror re-anchors to keep the visible
207
+ // content stable — `scrollTop` moves to 420 with nobody assigning. Reading
208
+ // that as the reader's own scroll dropped the pending restore, so the
209
+ // anchored value became where the reader stayed (measured: opening a tab
210
+ // moved them 29 lines) and the wrong offset was persisted.
211
+ const probe = await mount('7', 'code', { '7': { code: 900 } }, 600);
212
+ expect(probe.el.scrollTop).toBe(100);
213
+
214
+ // The browser's own correction: no input event behind it.
215
+ probe.el.scrollTop = 420;
216
+ probe.setHeight(5000);
217
+ await act(async () => {
218
+ StubResizeObserver.latest()?.fire();
219
+ });
220
+
221
+ // The restore is still in charge, so the reader lands where the session
222
+ // recorded — not where the anchoring put them.
223
+ expect(probe.el.scrollTop).toBe(900);
224
+ });
225
+
226
+ test('a reader’s scroll is still theirs after an anchoring correction', async () => {
227
+ // The discriminator must not swallow the NEXT real scroll: the input that
228
+ // explains a movement is consumed by it, so a reader who scrolls after the
229
+ // document settles keeps their own position.
230
+ const probe = await mount('7', 'code', { '7': { code: 900 } });
231
+ probe.el.scrollTop = 420; // anchoring, no input
232
+ await act(async () => {
233
+ StubResizeObserver.latest()?.fire();
234
+ });
235
+ expect(probe.el.scrollTop).toBe(900);
236
+
237
+ readerScroll(probe, 120);
185
238
  await act(async () => {
186
239
  StubResizeObserver.latest()?.fire();
187
240
  });
@@ -192,7 +245,7 @@ describe('useScrollRestore', () => {
192
245
  const probe = await mount('7', 'code', {});
193
246
  jest.useFakeTimers();
194
247
  try {
195
- probe.el.scrollTop = 350;
248
+ readerScroll(probe, 350);
196
249
  jest.advanceTimersByTime(200);
197
250
  await act(async () => {});
198
251
  } finally {
@@ -32,7 +32,7 @@
32
32
  * overwrite the position this hook exists to keep.
33
33
  */
34
34
 
35
- import { useCallback, useContext, useLayoutEffect, useRef } from 'preact/hooks';
35
+ import { useCallback, useContext, useEffect, useLayoutEffect, useRef } from 'preact/hooks';
36
36
  import { SessionStateContext } from '@/client/hooks/workspace/session-state/context';
37
37
  import { getSessionValue, setSessionKey } from '@/shared/lib/workspace/session-state/store';
38
38
  import {
@@ -52,6 +52,29 @@ const SAVE_DEBOUNCE_MS = 150;
52
52
  /** Scroll deltas below this are rounding, not a move. */
53
53
  const NOISE_PX = 1;
54
54
 
55
+ /**
56
+ * The cancel hook of every restore currently armed, by scroller.
57
+ *
58
+ * A pending restore REPLAYS while the document settles, and it re-applies
59
+ * through a `ResizeObserver` — which can fire BEFORE the scroll event a
60
+ * programmatic reveal causes. Measured: an outline jump scrolled a 2 520-line
61
+ * file to 34 695 px and the observer wrote the stored 0 back 375 ms later, so
62
+ * the caret moved and the view stayed at the top.
63
+ *
64
+ * An explicit reveal therefore has to CANCEL the restore rather than race it,
65
+ * and the surface that scrolls is the one that knows. This registry is that
66
+ * hand-off: keyed by the element, so a caller needs no reference to the hook
67
+ * instance that armed it (the surface owns the scroller; the panel owns the
68
+ * hook).
69
+ */
70
+ const armedRestores = new Map<HTMLElement, () => void>();
71
+
72
+ /** Stop a pending restore of `element` — a reveal wins over a remembered offset. */
73
+ export function cancelScrollRestore(element: HTMLElement | null): void {
74
+ if (!element) return;
75
+ armedRestores.get(element)?.();
76
+ }
77
+
55
78
  /** What a caller attaches to its scroll container. */
56
79
  export interface ScrollRestoreBinding {
57
80
  ref: (element: HTMLElement | null) => void;
@@ -67,6 +90,16 @@ export interface UseScrollRestoreOptions {
67
90
  ready?: boolean;
68
91
  /** The session-state slot to read/write; the editor column's by default. */
69
92
  slot?: string;
93
+ /**
94
+ * The element to restore, for a caller that does not own a `ref` on it.
95
+ *
96
+ * The code surface's scroller belongs to CodeMirror (`view.scrollDOM`) and
97
+ * exists only after the view mounts, so the surface reports it through
98
+ * `onScroller` and the caller passes it here. `undefined` means "this caller
99
+ * uses `ref`"; `null` means "the element is not there yet", which re-arms the
100
+ * restore on the next render that has one.
101
+ */
102
+ element?: HTMLElement | null;
70
103
  }
71
104
 
72
105
  export function useScrollRestore({
@@ -74,6 +107,7 @@ export function useScrollRestore({
74
107
  surface,
75
108
  ready = true,
76
109
  slot = SCROLL_POSITIONS_KEY,
110
+ element: providedElement,
77
111
  }: UseScrollRestoreOptions): ScrollRestoreBinding {
78
112
  // Read OPTIONALLY: the shared diff views also draw a chat tool card, and a
79
113
  // caller may mount one without the provider. A null context makes the whole
@@ -100,6 +134,37 @@ export function useScrollRestore({
100
134
  * whose height is still growing then stayed where the clamp left it.
101
135
  */
102
136
  const writingRef = useRef(false);
137
+ /**
138
+ * What the element's `scrollTop` was at the last observation, so a scroll
139
+ * event that moves it WITHOUT anyone assigning can be recognised as the
140
+ * browser's own anchoring rather than the reader's.
141
+ *
142
+ * This is the bug the e2e spec found on CI and could not explain: returning to
143
+ * a long file landed the reader on line 99 instead of line 70, and the wrong
144
+ * offset was then persisted. Measured in the browser: the restore wrote 1255,
145
+ * the document's real height arrived a frame later (5796 → 7232), and
146
+ * CodeMirror re-anchored — `scrollTop` became 1777 with no assignment from
147
+ * this hook. That produced a `scroll` event, the handler below compared it
148
+ * against `restoredTopRef` (1255), read a 522px delta, and treated it as the
149
+ * reader's own scroll: the pending restore was dropped, and the anchored
150
+ * value became what got stored. The reader moved 29 lines for opening a tab.
151
+ *
152
+ * An anchoring correction is not a reader. It is re-applied like any other
153
+ * height change, because the target offset is the one the session recorded.
154
+ */
155
+ const observedTopRef = useRef(0);
156
+ /**
157
+ * Whether the reader has DONE something since the last scroll event — the
158
+ * only thing that separates their scroll from the browser's own.
159
+ *
160
+ * Every way a reader scrolls a code surface starts with an input event: a
161
+ * wheel, a key (arrows, PageDown, Home/End), a pointer press on the scrollbar
162
+ * or a drag-select, a touch. CodeMirror re-anchoring starts with none, so a
163
+ * `scroll` with no input behind it is the document settling. The flag is
164
+ * cleared by the scroll it explains, so the next unattributed move is read as
165
+ * anchoring again.
166
+ */
167
+ const readerIntentRef = useRef(false);
103
168
  /** The last offset the READER produced, persisted on the way out. */
104
169
  const lastTopRef = useRef<number | null>(null);
105
170
  const saveTimerRef = useRef<ReturnType<typeof setTimeout>>();
@@ -113,13 +178,17 @@ export function useScrollRestore({
113
178
  pendingRef.current = null;
114
179
  return;
115
180
  }
116
- if (Math.abs(element.scrollTop - pending.top) < NOISE_PX) return;
181
+ if (Math.abs(element.scrollTop - pending.top) < NOISE_PX) {
182
+ observedTopRef.current = element.scrollTop;
183
+ return;
184
+ }
117
185
  writingRef.current = true;
118
186
  element.scrollTop = pending.top;
119
187
  writingRef.current = false;
120
188
  // Read back: the document may be shorter than the target, and the clamped
121
189
  // value is what an asynchronous scroll event will report.
122
190
  restoredTopRef.current = element.scrollTop;
191
+ observedTopRef.current = element.scrollTop;
123
192
  }, []);
124
193
 
125
194
  const attach = useCallback(
@@ -152,9 +221,23 @@ export function useScrollRestore({
152
221
  // because the event may arrive synchronously (during the assignment) or
153
222
  // on a later frame (against the value the write landed on).
154
223
  if (writingRef.current) return;
224
+ // Moved with nobody assigning AND no reader input behind it: CodeMirror
225
+ // re-anchoring after the document's real height landed. NOT the reader —
226
+ // re-applying the recorded offset is what keeps them where they were. See
227
+ // `observedTopRef` for the measurement.
228
+ const moved = Math.abs(top - observedTopRef.current) >= NOISE_PX;
229
+ observedTopRef.current = top;
230
+ if (moved && !readerIntentRef.current) {
231
+ applyPending();
232
+ return;
233
+ }
155
234
  if (Math.abs(top - restoredTopRef.current) < NOISE_PX * 2) return;
156
235
  pendingRef.current = null;
157
236
  }
237
+ observedTopRef.current = top;
238
+ // The input this scroll belongs to has now been accounted for; the next
239
+ // unattributed move is anchoring again.
240
+ readerIntentRef.current = false;
158
241
  lastTopRef.current = top;
159
242
  clearTimeout(saveTimerRef.current);
160
243
  saveTimerRef.current = setTimeout(() => {
@@ -162,14 +245,47 @@ export function useScrollRestore({
162
245
  const value = lastTopRef.current;
163
246
  if (value !== null) persist(value);
164
247
  }, SAVE_DEBOUNCE_MS);
165
- }, [persist, tabKey]);
248
+ }, [applyPending, persist, tabKey]);
249
+
250
+ // A provided element is not a React-rendered node, so its scroll events have
251
+ // to be wired here: the caller has no `onScroll` prop to hang them on. The
252
+ // ref path keeps the caller's own `onScroll`.
253
+ useEffect(() => {
254
+ if (!providedElement) return;
255
+ const listener = () => onScroll();
256
+ providedElement.addEventListener('scroll', listener, { passive: true });
257
+ return () => providedElement.removeEventListener('scroll', listener);
258
+ }, [providedElement, onScroll]);
259
+
260
+ // What counts as the reader having scrolled, for `readerIntentRef`. Wired on
261
+ // the element AND the window, because a keydown goes to the focused editor
262
+ // while a wheel over the gutter goes to the window's capture phase.
263
+ useEffect(() => {
264
+ const element = providedElement ?? elementRef.current;
265
+ if (!element) return;
266
+ const note = () => {
267
+ readerIntentRef.current = true;
268
+ };
269
+ const types = ['wheel', 'keydown', 'pointerdown', 'touchstart'] as const;
270
+ for (const type of types) element.addEventListener(type, note, { passive: true });
271
+ window.addEventListener('keydown', note, { passive: true });
272
+ return () => {
273
+ for (const type of types) element.removeEventListener(type, note);
274
+ window.removeEventListener('keydown', note);
275
+ };
276
+ }, [providedElement]);
166
277
 
167
278
  // A layout effect so the restore lands in the same frame the tab paints — a
168
279
  // passive effect would show the document's head for one frame first.
169
280
  useLayoutEffect(() => {
170
281
  pendingRef.current = null;
171
282
  lastTopRef.current = null;
283
+ readerIntentRef.current = false;
172
284
  let observer: ResizeObserver | null = null;
285
+ // A provided element WINS over the ref: the caller handed us the element it
286
+ // wants restored, and a stale ref from the other surface of the same tab
287
+ // must not be restored instead.
288
+ if (providedElement !== undefined) elementRef.current = providedElement;
173
289
  const element = elementRef.current;
174
290
 
175
291
  if (ready && storeReady && tabKey && element) {
@@ -179,6 +295,9 @@ export function useScrollRestore({
179
295
  // of it. "No stored offset" means the top, and the top is where they go.
180
296
  const top = readScrollTop(getSessionValue<ScrollPositions>(sessionId, slot), tabKey, surface);
181
297
  pendingRef.current = { top, until: performance.now() + RESTORE_WINDOW_MS };
298
+ armedRestores.set(element, () => {
299
+ pendingRef.current = null;
300
+ });
182
301
  applyPending();
183
302
  // The document's own height is what moves after this commit (measurement,
184
303
  // a width animation, spacers landing), so its growth is what re-applies.
@@ -190,6 +309,7 @@ export function useScrollRestore({
190
309
  }
191
310
 
192
311
  return () => {
312
+ if (element) armedRestores.delete(element);
193
313
  observer?.disconnect();
194
314
  clearTimeout(saveTimerRef.current);
195
315
  saveTimerRef.current = undefined;
@@ -203,7 +323,7 @@ export function useScrollRestore({
203
323
  };
204
324
  // `applyPending`/`persist` are stable, and re-running this effect restarts
205
325
  // the restore — which is exactly what a tab or surface change needs.
206
- }, [ready, storeReady, tabKey, surface, sessionId, slot, applyPending, persist]);
326
+ }, [ready, storeReady, tabKey, surface, sessionId, slot, applyPending, persist, providedElement]);
207
327
 
208
328
  return { ref: attach, onScroll };
209
329
  }
@@ -29,7 +29,7 @@
29
29
  import { useEffect, useMemo, useState } from 'preact/hooks';
30
30
  import { useSyncExternalStore } from 'preact/compat';
31
31
 
32
- import { findMatches, lineStartOffset, matchIndexAtOrAfter } from '@/shared/lib/code/editor/find';
32
+ import { findMatches, lineStartOffset, matchIndexAtOrAfter } from '@/shared/lib/code/editor/find-scan';
33
33
  import {
34
34
  getSearchReveal,
35
35
  setSearchReveal,
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The desktop layout's settings modal: whether it is open, which category it
8
+ * opens on, and whether a provider dialog follows.
9
+ *
10
+ * A hook because two different triggers drive it — the sidebar footer's button
11
+ * and the `omp:open-settings` window event (dispatched by the model dropdown,
12
+ * the phone's footer and any plugin) — and the event carries the category it
13
+ * wants. Stated once, the two paths cannot open the same modal on different
14
+ * screens.
15
+ */
16
+
17
+ import { useState } from 'preact/hooks';
18
+ import { useKabmoEvent } from '@/client/hooks/ui/window-event';
19
+ import type { SettingsCategoryId } from '@/shared/types';
20
+
21
+ export interface SettingsModalState {
22
+ isOpen: boolean;
23
+ category: SettingsCategoryId;
24
+ /** The "add provider" dialog opens on top of the modal's Providers screen. */
25
+ autoOpenAddProvider: boolean;
26
+ open: (category?: SettingsCategoryId, autoOpenAdd?: boolean) => void;
27
+ close: () => void;
28
+ }
29
+
30
+ export function useSettingsModal(): SettingsModalState {
31
+ const [isOpen, setOpen] = useState(false);
32
+ const [category, setCategory] = useState<SettingsCategoryId>('appearance');
33
+ const [autoOpenAddProvider, setAutoOpenAddProvider] = useState(false);
34
+
35
+ const open = (next?: SettingsCategoryId, autoOpenAdd = false) => {
36
+ setCategory(next ?? 'appearance');
37
+ setAutoOpenAddProvider(autoOpenAdd);
38
+ setOpen(true);
39
+ };
40
+
41
+ useKabmoEvent('omp:open-settings', (event) => {
42
+ const detail = (event as CustomEvent<{ category?: SettingsCategoryId; autoOpenAdd?: boolean }>).detail;
43
+ open(detail?.category, Boolean(detail?.autoOpenAdd));
44
+ });
45
+
46
+ return {
47
+ isOpen,
48
+ category,
49
+ autoOpenAddProvider,
50
+ open,
51
+ close: () => {
52
+ setOpen(false);
53
+ setAutoOpenAddProvider(false);
54
+ },
55
+ };
56
+ }
@@ -21,6 +21,7 @@ const RELEASE = {
21
21
  url: 'https://github.com/kabmo-labs/kabmo/releases/download/v1.2.3/Kabmo-1.2.3-android.apk',
22
22
  latest: '1.2.3',
23
23
  releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v1.2.3',
24
+ direct: true,
24
25
  };
25
26
 
26
27
  const UPDATE: ShellDownload = { ...RELEASE, kind: 'update', platform: 'android' };
@@ -209,6 +210,7 @@ describe('desktopShellFromIdentity', () => {
209
210
  expect(desktopShellFromIdentity({ platform: 'macos', shellVersion: '0.2.0' })).toEqual({
210
211
  platform: 'macos',
211
212
  version: '0.2.0',
213
+ arch: null,
212
214
  });
213
215
  expect(desktopShellFromIdentity({ platform: 'windows', shellVersion: '0.2.0' })?.platform).toBe('windows');
214
216
  expect(desktopShellFromIdentity({ platform: 'linux', shellVersion: '0.2.0' })?.platform).toBe('linux');
@@ -29,6 +29,7 @@
29
29
 
30
30
  import { useEffect, useState } from 'preact/hooks';
31
31
  import { isNewer } from '@/shared/lib/updates/semver';
32
+ import { parseShellArch, type ShellArch } from '@/shared/lib/updates/shell-arch';
32
33
  import {
33
34
  isDesktopShellKind,
34
35
  type DesktopShellKind,
@@ -43,11 +44,13 @@ export type ShellOfferKind = 'update' | 'install';
43
44
 
44
45
  export interface ShellDownload {
45
46
  /**
46
- * What the control links to.
47
+ * What the control points at.
47
48
  *
48
49
  * The artifact itself for the mobile pair (an APK the shell installs, an IPA
49
- * the release page explains), and the RELEASE PAGE for a desktop shell: every
50
- * desktop build is per-architecture, so the console cannot name one file.
50
+ * the release page explains), and for a desktop shell EITHER its own
51
+ * per-architecture artifact (when it reported `arch`) or the RELEASE PAGE (when
52
+ * it did not). `direct` says which, and that is what decides between a button
53
+ * and a link.
51
54
  */
52
55
  url: string;
53
56
  /** The version that artifact ships. */
@@ -58,12 +61,22 @@ export interface ShellDownload {
58
61
  kind: ShellOfferKind;
59
62
  /** Which shell this offer is for. */
60
63
  platform: ShellPlatform;
64
+ /**
65
+ * True when `url` is the artifact itself, so the SHELL can install it.
66
+ *
67
+ * False means `url` is the release page: the console cannot name one file
68
+ * (a desktop shell that did not report its architecture), so the offer is a
69
+ * link a human follows. That distinction is what decides between an `<a>` and
70
+ * a button that drives `update_apply`.
71
+ */
72
+ direct: boolean;
61
73
  }
62
74
 
63
75
  interface ReleasePayload {
64
76
  url?: unknown;
65
77
  latest?: unknown;
66
78
  releaseUrl?: unknown;
79
+ direct?: unknown;
67
80
  }
68
81
 
69
82
  /** The platform's own name, as the label says it. */
@@ -91,6 +104,10 @@ export function parseShellRelease(payload: unknown): Omit<ShellDownload, 'kind'
91
104
  url: body.url,
92
105
  latest: body.latest,
93
106
  releaseUrl: typeof body.releaseUrl === 'string' ? body.releaseUrl : null,
107
+ // Absent means "not installable": a shell that cannot install the bytes must
108
+ // fall back to the link rather than to a button that would download into
109
+ // nowhere.
110
+ direct: body.direct === true,
94
111
  };
95
112
  }
96
113
 
@@ -147,8 +164,14 @@ export function shellDownloadLabel(download: ShellDownload): string {
147
164
  * The desktop trio shares ONE route with an explicit `kind`, so no absence can
148
165
  * silently mean a platform — the reason the mobile pair has two.
149
166
  */
150
- export function shellReleasePath(platform: ShellPlatform): string {
151
- if (isDesktopShellKind(platform)) return `/api/updates/desktop?kind=${platform}`;
167
+ export function shellReleasePath(platform: ShellPlatform, arch: ShellArch | null = null): string {
168
+ if (isDesktopShellKind(platform)) {
169
+ // The arch is what makes the answer a FILE rather than a page. A shell that
170
+ // did not report one still asks, and still gets the page that lists every
171
+ // architecture — the offer is then a link, which is the honest fallback.
172
+ const base = `/api/updates/desktop?kind=${platform}`;
173
+ return arch ? `${base}&arch=${arch}` : base;
174
+ }
152
175
  return `/api/updates/${platform}`;
153
176
  }
154
177
 
@@ -182,10 +205,17 @@ export function shellFromBootstrap(bootstrap: {
182
205
  export function desktopShellFromIdentity(identity: {
183
206
  platform?: string;
184
207
  shellVersion?: string;
185
- } | null | undefined): { platform: DesktopShellKind; version: string } | null {
208
+ arch?: string;
209
+ } | null | undefined): { platform: DesktopShellKind; version: string; arch: ShellArch | null } | null {
186
210
  if (!identity?.platform || !identity.shellVersion) return null;
187
211
  if (!isDesktopShellKind(identity.platform)) return null;
188
- return { platform: identity.platform, version: identity.shellVersion };
212
+ return {
213
+ platform: identity.platform,
214
+ version: identity.shellVersion,
215
+ // A shell too old to report one keeps working: the offer degrades to the
216
+ // release page instead of disappearing.
217
+ arch: parseShellArch(identity.arch),
218
+ };
189
219
  }
190
220
 
191
221
  /**
@@ -200,6 +230,7 @@ export function useShellDownload(
200
230
  version: string | null | undefined,
201
231
  onAndroid: boolean | null | undefined,
202
232
  fetchImpl: typeof fetch = fetch,
233
+ arch: ShellArch | null = null,
203
234
  ): ShellDownload | null {
204
235
  /**
205
236
  * The answer, TAGGED with the platform it was read for.
@@ -210,10 +241,14 @@ export function useShellDownload(
210
241
  * — offering an Android APK to a Mac for one render.
211
242
  */
212
243
  const [answer, setAnswer] = useState<{
213
- platform: ShellPlatform;
244
+ key: string;
214
245
  release: Omit<ShellDownload, 'kind' | 'platform'> | null;
215
246
  } | null>(null);
216
247
  const wanted = Boolean(version) || (platform === 'android' && Boolean(onAndroid));
248
+ // The question this hook is asking, arch included: two windows on one console
249
+ // differ only by architecture, and an answer read for the other one is not an
250
+ // answer.
251
+ const key = `${platform}:${arch ?? ''}`;
217
252
 
218
253
  useEffect(() => {
219
254
  if (!wanted) return;
@@ -221,10 +256,10 @@ export function useShellDownload(
221
256
  let cancelled = false;
222
257
  void (async () => {
223
258
  try {
224
- const response = await fetchImpl(shellReleasePath(platform));
259
+ const response = await fetchImpl(shellReleasePath(platform, arch));
225
260
  if (!response.ok) return;
226
261
  const payload: unknown = await response.json();
227
- if (!cancelled) setAnswer({ platform, release: parseShellRelease(payload) });
262
+ if (!cancelled) setAnswer({ key, release: parseShellRelease(payload) });
228
263
  } catch {
229
264
  // A probe that cannot answer is a button that must not appear.
230
265
  }
@@ -233,10 +268,10 @@ export function useShellDownload(
233
268
  return () => {
234
269
  cancelled = true;
235
270
  };
236
- }, [wanted, platform, fetchImpl]);
271
+ }, [wanted, key, platform, arch, fetchImpl]);
237
272
 
238
- // A stale answer belongs to the platform it was read for, not this one.
239
- const release = answer?.platform === platform ? answer.release : null;
273
+ // A stale answer belongs to the question it was read for, not this one.
274
+ const release = answer?.key === key ? answer.release : null;
240
275
  return shouldOfferShellDownload(platform, version, onAndroid, release);
241
276
  }
242
277
 
@@ -285,7 +320,13 @@ export function useShellOffer(
285
320
  const platform = fromBridge?.platform ?? mobile?.platform ?? 'android';
286
321
  const version = fromBridge?.version ?? mobile?.version ?? null;
287
322
 
288
- return useShellDownload(platform, version, fromBridge ? null : bootstrap?.isAndroid, fetchImpl);
323
+ return useShellDownload(
324
+ platform,
325
+ version,
326
+ fromBridge ? null : bootstrap?.isAndroid,
327
+ fetchImpl,
328
+ fromBridge?.arch ?? null,
329
+ );
289
330
  }
290
331
 
291
332
  /**
@@ -35,6 +35,7 @@ const RELEASE = {
35
35
  url: 'https://github.com/kabmo-labs/kabmo/releases/download/v0.7.0/Kabmo-0.6.0-android.apk',
36
36
  latest: '0.6.0',
37
37
  releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0',
38
+ direct: true,
38
39
  };
39
40
 
40
41
  function Harness({ fetchImpl }: { fetchImpl: typeof fetch }) {
@@ -169,6 +170,31 @@ describe('the desktop shells', () => {
169
170
  expect(await mount(stubFetch([]))).toBe('update:linux:0.6.0');
170
171
  });
171
172
 
173
+ /**
174
+ * A shell that reports its architecture asks for THAT machine's artifact.
175
+ *
176
+ * Without it the console can only answer the release page — every desktop
177
+ * artifact is per-architecture — so the offer is a link a human follows instead
178
+ * of an update the shell installs.
179
+ */
180
+ test('reports the arch, so the answer is a file rather than a page', async () => {
181
+ shell({ desktop: { platform: 'macos', shellVersion: '0.1.0', arch: 'arm64' } });
182
+ const asked: string[] = [];
183
+
184
+ await mount(stubFetch(asked));
185
+ expect(asked).toEqual(['/api/updates/desktop?kind=macos&arch=arm64']);
186
+ });
187
+
188
+ test('a shell with no arch still asks, and gets the page', async () => {
189
+ // An older shell must keep working: the offer degrades to the release page
190
+ // instead of disappearing.
191
+ shell({ desktop: { platform: 'macos', shellVersion: '0.1.0' } });
192
+ const asked: string[] = [];
193
+
194
+ await mount(stubFetch(asked));
195
+ expect(asked).toEqual(['/api/updates/desktop?kind=macos']);
196
+ });
197
+
172
198
  test('a refused probe draws no button', async () => {
173
199
  shell({ desktop: { platform: 'macos', shellVersion: '0.1.0' } });
174
200
  const refusing = (async () => ({ ok: false, json: async () => ({}) }) as Response) as unknown as typeof fetch;