ompchamber 1.2.1 → 2.0.1

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 (237) hide show
  1. package/README.md +143 -147
  2. package/dist/client/index.html +4 -2
  3. package/dist/client/static/css/index.5f630421b7.css +2 -0
  4. package/dist/client/static/js/77.0d81c74cf0.js +139 -0
  5. package/dist/client/static/js/async/1k.973e8e21fc.js +2 -0
  6. package/dist/client/static/js/async/{3y.3186ecdd63.js → 3y.3530b824ad.js} +6 -6
  7. package/dist/client/static/js/async/{q.b453193538.js → 4m.928a1dde85.js} +10 -10
  8. package/dist/client/static/js/async/66.5dfa29f957.js +93 -0
  9. package/dist/client/static/js/async/7g.a7814350c3.js +1 -0
  10. package/dist/client/static/js/async/ar.9303e5f2e8.js +2 -0
  11. package/dist/client/static/js/async/{cx.9c8268d542.js → cx.3cb0242a3a.js} +1 -1
  12. package/dist/client/static/js/async/e.93214d622b.js +79 -0
  13. package/dist/client/static/js/async/g.cb81980498.js +2 -0
  14. package/dist/client/static/js/async/h1.393f066d09.js +1 -0
  15. package/dist/client/static/js/async/jl.874d352278.js +1 -0
  16. package/dist/client/static/js/async/{k.d16cd09ae7.js → k.3210286401.js} +1 -1
  17. package/dist/client/static/js/async/{m.2e1ea3db9e.js → m.eb2e6752e2.js} +3 -3
  18. package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +11 -0
  19. package/dist/client/static/js/async/mt.0352e976d8.js +1 -0
  20. package/dist/client/static/js/async/{oc.eb22093f05.js → oc.b73042e8bd.js} +1 -1
  21. package/dist/client/static/js/async/{og.53f068804c.js → og.8eb577893a.js} +1 -1
  22. package/dist/client/static/js/async/px.fc6877924b.js +1 -0
  23. package/dist/client/static/js/async/q.22588845a9.js +21 -0
  24. package/dist/client/static/js/async/{q5.88b50ffa95.js → q5.97dc1481b6.js} +1 -1
  25. package/dist/client/static/js/async/s.0f395cdda5.js +94 -0
  26. package/dist/client/static/js/async/{tr.2a0d2b7600.js → tr.e878658140.js} +1 -1
  27. package/dist/client/static/js/async/vt.42416b707d.js +1 -0
  28. package/dist/client/static/js/async/x1.da8687159b.js +90 -0
  29. package/dist/client/static/js/async/xe.da8dcc2015.js +2 -0
  30. package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +11 -0
  31. package/dist/client/static/js/async/{xn.6360379f71.js → xn.6510c0abe2.js} +2 -2
  32. package/dist/client/static/js/async/{yx.af2936c368.js → xn1.83a7784fe1.js} +3 -3
  33. package/dist/client/static/js/async/xn1.83a7784fe1.js.LICENSE.txt +11 -0
  34. package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
  35. package/dist/client/static/js/async/yv.d99d829136.js +1 -0
  36. package/dist/client/static/js/index.1fd9e1c543.js +38 -0
  37. package/package.json +3 -3
  38. package/src/client/components/common/ComposerTextarea.tsx +9 -1
  39. package/src/client/components/common/MarkdownRenderer.tsx +50 -12
  40. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +9 -3
  41. package/src/client/components/settings/Modal.tsx +11 -2
  42. package/src/client/components/settings/categories/AppearanceSettings.tsx +7 -30
  43. package/src/client/components/settings/categories/ProviderSettings.tsx +2 -0
  44. package/src/client/components/settings/categories/appearance-settings/ThemeCard.tsx +61 -0
  45. package/src/client/components/settings/categories/appearance-settings/ThemeGrid.tsx +80 -0
  46. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +1 -1
  47. package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +43 -6
  48. package/src/client/components/settings/categories/provider-settings/ModelConfigModal.tsx +54 -54
  49. package/src/client/components/workspace/SearchPanel.tsx +16 -3
  50. package/src/client/components/workspace/btw-panel/AskCard.tsx +11 -6
  51. package/src/client/components/workspace/btw-panel/Messages.tsx +105 -51
  52. package/src/client/components/workspace/btw-panel/index.tsx +124 -45
  53. package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +30 -14
  54. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +10 -1
  55. package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +8 -2
  56. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +27 -16
  57. package/src/client/components/workspace/chat-timeline/MessageList.tsx +11 -0
  58. package/src/client/components/workspace/chat-timeline/RunFooter.tsx +8 -0
  59. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +12 -1
  60. package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +42 -15
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +13 -1
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index.tsx +13 -4
  64. package/src/client/components/workspace/diff-panel/SplitView.tsx +4 -1
  65. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +70 -65
  66. package/src/client/components/workspace/diff-panel/index.tsx +8 -1
  67. package/src/client/components/workspace/editor/index.tsx +18 -2
  68. package/src/client/components/workspace/file-explorer/GitRepoDropdown.tsx +15 -41
  69. package/src/client/components/workspace/file-explorer/index.tsx +86 -16
  70. package/src/client/components/workspace/git-panel/index.tsx +60 -78
  71. package/src/client/components/workspace/terminal-panel/Header.tsx +16 -1
  72. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +5 -5
  73. package/src/client/components/workspace/terminal-panel/index.tsx +6 -2
  74. package/src/client/components/workspace/terminal-panel/mount.ts +2 -2
  75. package/src/client/data/theme/terminal.ts +50 -52
  76. package/src/client/hooks/chat/btw/index.ts +139 -18
  77. package/src/client/hooks/chat/btw/intercept.ts +8 -8
  78. package/src/client/hooks/chat/btw/mode.ts +111 -16
  79. package/src/client/hooks/chat/composer/index.ts +10 -4
  80. package/src/client/hooks/chat/timeline/access-mode.ts +41 -0
  81. package/src/client/hooks/chat/timeline/ask-frames.test.ts +87 -0
  82. package/src/client/hooks/chat/timeline/ask-frames.ts +20 -1
  83. package/src/client/hooks/chat/timeline/auto-scroll.ts +13 -4
  84. package/src/client/hooks/chat/timeline/index.ts +8 -20
  85. package/src/client/hooks/chat/timeline/queue.ts +66 -9
  86. package/src/client/hooks/chat/timeline/scroll.test.ts +260 -0
  87. package/src/client/hooks/chat/timeline/scroll.ts +93 -20
  88. package/src/client/hooks/chat/timeline/session-load.ts +7 -1
  89. package/src/client/hooks/chat/timeline/session-pagination.test.ts +167 -0
  90. package/src/client/hooks/chat/timeline/session-pagination.ts +37 -11
  91. package/src/client/hooks/editor/save-file.ts +62 -0
  92. package/src/client/hooks/editor/use-file-editor.test.ts +61 -5
  93. package/src/client/hooks/editor/use-file-editor.ts +38 -29
  94. package/src/client/hooks/editor/use-file-transfer.ts +5 -2
  95. package/src/client/hooks/settings/providers.ts +34 -3
  96. package/src/client/hooks/ui/open-file.ts +7 -1
  97. package/src/client/hooks/ui/theme.ts +82 -57
  98. package/src/client/hooks/workspace/file-tabs.ts +5 -1
  99. package/src/client/hooks/workspace/repo-scope/index.ts +119 -0
  100. package/src/client/hooks/workspace/repo-scope/store.test.ts +161 -0
  101. package/src/client/hooks/workspace/repo-scope/store.ts +201 -0
  102. package/src/client/hooks/workspace/search-stream.ts +24 -5
  103. package/src/client/main.tsx +6 -0
  104. package/src/client/tailwind.css +19 -56
  105. package/src/server/lib/btw/child.server.ts +41 -0
  106. package/src/server/lib/btw/lifecycle.server.ts +254 -0
  107. package/src/server/lib/btw/prompt.ts +13 -5
  108. package/src/server/lib/btw/registry.server.ts +13 -4
  109. package/src/server/lib/btw/runtime.server.ts +149 -156
  110. package/src/server/lib/btw/schema.server.ts +27 -0
  111. package/src/server/lib/btw/service.server.ts +106 -5
  112. package/src/server/lib/btw/store.server.ts +57 -6
  113. package/src/server/lib/db/schema.ts +4 -0
  114. package/src/server/lib/db/settings-store.test.ts +65 -0
  115. package/src/server/lib/db/settings-store.ts +25 -0
  116. package/src/server/lib/fs/atomic-write.ts +42 -4
  117. package/src/server/lib/fs/config-lock.ts +74 -0
  118. package/src/server/lib/fs/git-ignore.ts +55 -5
  119. package/src/server/lib/fs/ripgrep.ts +119 -0
  120. package/src/server/lib/lifecycle/identity.ts +10 -64
  121. package/src/server/lib/lifecycle/proc/darwin.ts +238 -0
  122. package/src/server/lib/lifecycle/proc/index.ts +38 -0
  123. package/src/server/lib/lifecycle/proc/linux.ts +80 -0
  124. package/src/server/lib/lifecycle/proc/ps.ts +86 -0
  125. package/src/server/lib/lifecycle/proc/types.ts +33 -0
  126. package/src/server/lib/lifecycle/proc/win32.ts +53 -0
  127. package/src/server/lib/models/provider-registry.server.ts +2 -2
  128. package/src/server/lib/models/registry.server.ts +1 -1
  129. package/src/server/lib/omp/config/behavior.ts +17 -17
  130. package/src/server/lib/omp/config/disabled-providers.ts +84 -35
  131. package/src/server/lib/omp/config/document.ts +128 -0
  132. package/src/server/lib/omp/config/extensions.ts +11 -10
  133. package/src/server/lib/omp/config/mcp.ts +4 -57
  134. package/src/server/lib/omp/config/model-overrides.ts +123 -0
  135. package/src/server/lib/omp/config/models-config.ts +92 -0
  136. package/src/server/lib/omp/config/models-validation.ts +159 -0
  137. package/src/server/lib/omp/config/path-scoped.ts +74 -0
  138. package/src/server/lib/omp/config/providers.ts +241 -211
  139. package/src/server/lib/omp/config/roles.ts +6 -14
  140. package/src/server/lib/omp/core/auth-credentials.ts +1 -1
  141. package/src/server/lib/omp/rpc/frame-fold.test.ts +124 -0
  142. package/src/server/lib/omp/rpc/frame-fold.ts +43 -24
  143. package/src/server/lib/omp/rpc/lifecycle.test.ts +41 -0
  144. package/src/server/lib/omp/rpc/manager.ts +21 -21
  145. package/src/server/lib/omp/rpc/pending-ui-dialogs.ts +11 -7
  146. package/src/server/lib/omp/rpc/session-commands.ts +17 -29
  147. package/src/server/lib/omp/rpc/session-registry.ts +5 -66
  148. package/src/server/lib/omp/rpc/web-state.ts +0 -6
  149. package/src/server/lib/omp/session/auto-title.server.test.ts +109 -11
  150. package/src/server/lib/omp/session/auto-title.server.ts +95 -20
  151. package/src/server/lib/omp/session/sidebar-data.server.ts +11 -6
  152. package/src/server/lib/queue/delivery-dispatch.server.test.ts +162 -0
  153. package/src/server/lib/queue/delivery-harness.ts +88 -0
  154. package/src/server/lib/queue/delivery.server.test.ts +78 -149
  155. package/src/server/lib/queue/delivery.server.ts +105 -13
  156. package/src/server/lib/queue/store.server.ts +30 -5
  157. package/src/server/lib/terminal/foreground.test.ts +164 -0
  158. package/src/server/lib/terminal/foreground.ts +34 -6
  159. package/src/server/lib/terminal/history.ts +8 -7
  160. package/src/server/plugins/compress.ts +33 -5
  161. package/src/server/plugins/ssr.ts +14 -7
  162. package/src/server/routes/btw/topics.ts +89 -2
  163. package/src/server/routes/fs/action.test.ts +64 -0
  164. package/src/server/routes/fs/action.ts +10 -1
  165. package/src/server/routes/fs/git.ts +5 -6
  166. package/src/server/routes/fs/search.ts +17 -26
  167. package/src/server/routes/sessions/queue.ts +17 -4
  168. package/src/server/routes/settings/index.ts +2 -0
  169. package/src/server/routes/settings/model-override.ts +69 -0
  170. package/src/server/routes/settings/provider-models.ts +35 -12
  171. package/src/server/routes/settings/providers.ts +31 -1
  172. package/src/server/routes/settings/root.ts +8 -3
  173. package/src/shared/lib/chat/btw/client.ts +34 -0
  174. package/src/shared/lib/chat/timeline/tool-phrases.ts +7 -0
  175. package/src/shared/lib/code/line-endings.test.ts +38 -0
  176. package/src/shared/lib/code/line-endings.ts +56 -0
  177. package/src/shared/lib/code/shiki-themes.ts +9 -5
  178. package/src/shared/lib/fs/file-tree.ts +17 -12
  179. package/src/shared/lib/markdown/autolink.test.ts +32 -0
  180. package/src/shared/lib/markdown/autolink.ts +50 -0
  181. package/src/shared/lib/markdown/document-urls.test.ts +68 -0
  182. package/src/shared/lib/markdown/document-urls.ts +118 -0
  183. package/src/shared/lib/markdown/marked.ts +27 -15
  184. package/src/shared/lib/markdown/mermaid.ts +2 -2
  185. package/src/shared/lib/models/catalog.ts +1 -1
  186. package/src/shared/lib/omp/session/schema-migrations.ts +30 -0
  187. package/src/shared/lib/omp/session/stream-state.server.test.ts +54 -0
  188. package/src/shared/lib/omp/session/stream-state.server.ts +70 -21
  189. package/src/shared/lib/settings/client.ts +10 -4
  190. package/src/shared/lib/settings/diff.test.ts +31 -0
  191. package/src/shared/lib/settings/diff.ts +37 -0
  192. package/src/shared/lib/theme/catalog.test.ts +168 -0
  193. package/src/shared/lib/theme/catalog.ts +98 -0
  194. package/src/shared/lib/theme/color.ts +88 -0
  195. package/src/shared/lib/theme/css.ts +70 -0
  196. package/src/shared/lib/theme/palettes/chamber.ts +61 -0
  197. package/src/shared/lib/theme/palettes/dark.ts +313 -0
  198. package/src/shared/lib/theme/palettes/light.ts +313 -0
  199. package/src/shared/types/btw.ts +83 -4
  200. package/src/shared/types/index.ts +1 -0
  201. package/src/shared/types/settings/provider.ts +8 -1
  202. package/src/shared/types/settings/state.ts +10 -4
  203. package/src/shared/types/theme.ts +48 -0
  204. package/dist/client/static/css/index.b938f4a693.css +0 -2
  205. package/dist/client/static/js/77.8022a800b5.js +0 -139
  206. package/dist/client/static/js/async/1k.a411fe5b43.js +0 -2
  207. package/dist/client/static/js/async/66.86544ad741.js +0 -93
  208. package/dist/client/static/js/async/7g.db82aca7ba.js +0 -1
  209. package/dist/client/static/js/async/ar.ad5c8822a8.js +0 -2
  210. package/dist/client/static/js/async/e.6443e739a2.js +0 -79
  211. package/dist/client/static/js/async/g.ee41bfed7f.js +0 -2
  212. package/dist/client/static/js/async/h1.d7091cef78.js +0 -1
  213. package/dist/client/static/js/async/jl.4911fa3383.js +0 -1
  214. package/dist/client/static/js/async/m.2e1ea3db9e.js.LICENSE.txt +0 -6
  215. package/dist/client/static/js/async/mt.853be2b5aa.js +0 -1
  216. package/dist/client/static/js/async/ot.84c71b308f.js +0 -2
  217. package/dist/client/static/js/async/px.783b6f91f2.js +0 -1
  218. package/dist/client/static/js/async/q8.7f1b48e9cd.js +0 -21
  219. package/dist/client/static/js/async/q8.7f1b48e9cd.js.LICENSE.txt +0 -6
  220. package/dist/client/static/js/async/s.aad2ecebc6.js +0 -94
  221. package/dist/client/static/js/async/vt.20406a5604.js +0 -1
  222. package/dist/client/static/js/async/x1.fe8af17016.js +0 -90
  223. package/dist/client/static/js/async/xn.6360379f71.js.LICENSE.txt +0 -6
  224. package/dist/client/static/js/async/yg.5a94e1fc8a.js +0 -149
  225. package/dist/client/static/js/async/yv.2fa22f009a.js +0 -1
  226. package/dist/client/static/js/index.5fe7a2cb64.js +0 -34
  227. package/src/client/components/workspace/btw-panel/Composer.tsx +0 -183
  228. package/src/client/components/workspace/btw-panel/attachments.ts +0 -80
  229. /package/dist/client/static/js/{77.8022a800b5.js.LICENSE.txt → 77.0d81c74cf0.js.LICENSE.txt} +0 -0
  230. /package/dist/client/static/js/async/{g.ee41bfed7f.js.LICENSE.txt → 1k.973e8e21fc.js.LICENSE.txt} +0 -0
  231. /package/dist/client/static/js/async/{3y.3186ecdd63.js.LICENSE.txt → 3y.3530b824ad.js.LICENSE.txt} +0 -0
  232. /package/dist/client/static/js/async/{ot.84c71b308f.js.LICENSE.txt → 4m.928a1dde85.js.LICENSE.txt} +0 -0
  233. /package/dist/client/static/js/async/{q.b453193538.js.LICENSE.txt → ar.9303e5f2e8.js.LICENSE.txt} +0 -0
  234. /package/dist/client/static/js/async/{yx.af2936c368.js.LICENSE.txt → g.cb81980498.js.LICENSE.txt} +0 -0
  235. /package/dist/client/static/js/async/{1k.a411fe5b43.js.LICENSE.txt → q.22588845a9.js.LICENSE.txt} +0 -0
  236. /package/dist/client/static/js/async/{ar.ad5c8822a8.js.LICENSE.txt → xn.6510c0abe2.js.LICENSE.txt} +0 -0
  237. /package/dist/client/static/js/{index.5fe7a2cb64.js.LICENSE.txt → index.1fd9e1c543.js.LICENSE.txt} +0 -0
@@ -23,6 +23,24 @@ import { useSessionState } from '@/client/hooks/workspace/session-state';
23
23
 
24
24
  const STEERING_STATE_KEY = 'chat.steeringQueue';
25
25
 
26
+ /**
27
+ * How often a mounted session with a non-empty queue re-offers a delivery slot.
28
+ *
29
+ * This is the safety net for a server whose own timers have stopped firing: a
30
+ * long-lived `bun --hot` process can stop running `setTimeout` callbacks
31
+ * entirely while still answering HTTP normally (observed live — a 4-hour dev
32
+ * server whose SSE heartbeat never fired and whose queued follow-ups never
33
+ * drained, while every request returned in 0.01s). Auto-delivery is timer-driven
34
+ * server-side, so it dies with those timers; a client poll runs in a different
35
+ * process and is immune.
36
+ *
37
+ * Only armed while something is actually queued, and each tick is one POST that
38
+ * doubles as the panel's reconcile, so an idle session costs nothing. 3s is a
39
+ * deliberate compromise: slow enough to be invisible on the wire, fast enough
40
+ * that a stranded item lands before the user wonders about it.
41
+ */
42
+ const QUEUE_POLL_INTERVAL_MS = 3_000;
43
+
26
44
  function queueUrl(sessionId: string): string {
27
45
  return `/api/sessions/${encodeURIComponent(sessionId)}/queue`;
28
46
  }
@@ -74,25 +92,64 @@ export function useChatTimelineQueue(sessionId: string | null): ChatTimelineQueu
74
92
  })
75
93
  .catch(() => {});
76
94
  }, [sessionId]);
95
+ // Offer the server a delivery slot for this session. The server-side claim
96
+ // makes this harmless when a run is active or another tab already took the
97
+ // head — this used to be the client auto-delivery effect, which fired into
98
+ // whatever session was open at the time.
99
+ //
100
+ // The response carries the canonical queue, so this doubles as a reconcile.
101
+ const nudgeDelivery = useCallback(() => {
102
+ if (!sessionId) return;
103
+ const epoch = hydrateEpochRef.current;
104
+ fetch(`${queueUrl(sessionId)}/deliver`, { method: 'POST' })
105
+ .then((res) => readQueueResponse(res))
106
+ .then((queue) => {
107
+ // Only reconcile when this is still the session on screen: the poll can
108
+ // outlive a switch, and writing another session's queue here is the
109
+ // exact cross-session bug the server-owned store was built to stop.
110
+ if (cancelledRef.current || hydrateEpochRef.current !== epoch) return;
111
+ if (queue) setMessageQueueState(queue);
112
+ })
113
+ .catch(() => {});
114
+ }, [sessionId]);
115
+
77
116
  useEffect(() => {
78
117
  cancelledRef.current = false;
79
118
  refresh();
80
- const onFocus = () => refresh();
119
+ // Mount and every focus regain offer a delivery slot. Focus is the trigger
120
+ // the recovery path depends on, so it must re-nudge even when the queue
121
+ // length is unchanged: keying this on `messageQueue.length > 0` (a boolean
122
+ // that stays true while the item is stranded) meant the effect never re-ran
123
+ // and a stuck queue stayed stuck.
124
+ nudgeDelivery();
125
+ const onFocus = () => {
126
+ refresh();
127
+ nudgeDelivery();
128
+ };
81
129
  window.addEventListener('focus', onFocus);
82
130
  return () => {
83
131
  cancelledRef.current = true;
84
132
  window.removeEventListener('focus', onFocus);
85
133
  };
86
- }, [refresh]);
134
+ }, [refresh, nudgeDelivery]);
135
+
136
+ // Items appearing while the session is already mounted (a send while
137
+ // streaming, or another tab's write): offer a slot without waiting for focus.
138
+ useEffect(() => {
139
+ if (messageQueue.length > 0) nudgeDelivery();
140
+ }, [messageQueue.length, nudgeDelivery]);
87
141
 
88
- // Mounted session with queued items: offer the server a delivery slot. The
89
- // server-side claim makes this harmless when a run is active or another tab
90
- // already took the head — this used to be the client auto-delivery effect,
91
- // which fired into whatever session was open at the time.
142
+ // Safety net: while anything is queued, keep offering a slot on an interval.
143
+ // Server-side auto-delivery is timer-driven and a long-lived `--hot` server
144
+ // can lose that capability silently; this poll lives in the browser and is
145
+ // unaffected. The effect is torn down the moment the queue empties, so a
146
+ // session with nothing queued never polls.
147
+ const hasQueued = messageQueue.length > 0;
92
148
  useEffect(() => {
93
- if (!sessionId || messageQueue.length === 0) return;
94
- fetch(`${queueUrl(sessionId)}/deliver`, { method: 'POST' }).catch(() => {});
95
- }, [sessionId, messageQueue.length > 0]);
149
+ if (!sessionId || !hasQueued) return;
150
+ const timer = setInterval(nudgeDelivery, QUEUE_POLL_INTERVAL_MS);
151
+ return () => clearInterval(timer);
152
+ }, [sessionId, hasQueued, nudgeDelivery]);
96
153
 
97
154
  const enqueueMessage = useCallback((item: Omit<QueuedMessage, 'id'>) => {
98
155
  if (!sessionId) return;
@@ -0,0 +1,260 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Chat timeline scroll contract. Both behaviours here were the bug:
8
+ *
9
+ * 1. The jump that follows a session's committed history used the follow-gated
10
+ * `scrollToBottom`, and follow mode belongs to the mounted view, not to the
11
+ * session being opened — a user who scrolled up before switching left it
12
+ * disengaged, so the newly opened session painted at `scrollTop = 0` with no
13
+ * scroll event to re-engage it and stayed there. Verified live in the
14
+ * chamber: scroll up in session A, open session B, viewport pinned at the
15
+ * head with the jump button showing until it was clicked.
16
+ * 2. Nothing re-pinned the tail when content grew while the viewport was
17
+ * pinned (a prepended window, a late Shiki/mermaid/image layout), so a jump
18
+ * target measured a frame earlier — or at open time — was stranded.
19
+ *
20
+ * Runs against a real DOM (happy-dom) because these are Preact hooks; geometry
21
+ * is faked on the container, since happy-dom does no layout.
22
+ */
23
+
24
+ import { afterAll, beforeAll, describe, expect, jest as fakeTimers, test } from 'bun:test';
25
+ import { Window } from 'happy-dom';
26
+ import type { h as PreactH, render as PreactRender } from 'preact';
27
+ import type { act as PreactAct } from 'preact/test-utils';
28
+ import type { useChatTimelineScroll as UseScroll, ChatTimelineScrollResult } from '@/client/hooks/chat/timeline/scroll';
29
+ import type { useTimelineAutoScroll as UseAuto } from '@/client/hooks/chat/timeline/auto-scroll';
30
+ import type { ChatMessageData } from '@/shared/types';
31
+
32
+ let useChatTimelineScroll: typeof UseScroll;
33
+ let useTimelineAutoScroll: typeof UseAuto;
34
+ let h: typeof PreactH;
35
+ let render: typeof PreactRender;
36
+ let act: typeof PreactAct;
37
+
38
+ let win: Window;
39
+ const scrolled: Array<{ top: number; behavior?: ScrollBehavior }> = [];
40
+ const geometry = { top: 0, height: 5000, viewport: 500 };
41
+
42
+ /** Observe what the hook asks for; 'instant' also lands (a smooth scroll would
43
+ * animate in a browser, so the fake keeps it as a recorded intent only). */
44
+ function applyTop(el: HTMLDivElement, value: number) {
45
+ geometry.top = Math.max(0, Math.min(value, geometry.height - geometry.viewport));
46
+ // The global `Event` the hook's listener expects: happy-dom's, installed
47
+ // into globalThis in beforeAll.
48
+ el.dispatchEvent(new Event('scroll'));
49
+ }
50
+
51
+ function installScroller(el: HTMLDivElement) {
52
+ Object.defineProperty(el, 'scrollHeight', { get: () => geometry.height, configurable: true });
53
+ Object.defineProperty(el, 'clientHeight', { get: () => geometry.viewport, configurable: true });
54
+ Object.defineProperty(el, 'scrollTop', {
55
+ get: () => geometry.top,
56
+ set: (value: number) => applyTop(el, value),
57
+ configurable: true,
58
+ });
59
+ Object.defineProperty(el, 'scrollTo', {
60
+ value: (options: { top: number; behavior?: ScrollBehavior }) => {
61
+ scrolled.push(options);
62
+ if (options.behavior !== 'smooth') applyTop(el, options.top);
63
+ },
64
+ configurable: true,
65
+ });
66
+ }
67
+
68
+ /** ResizeObserver the tests drive by hand — the hook rebuilds one per content
69
+ * node, so the last instance of the current mount is the live one. */
70
+ class StubResizeObserver {
71
+ static instances: StubResizeObserver[] = [];
72
+ static latest(): StubResizeObserver { return StubResizeObserver.instances[StubResizeObserver.instances.length - 1]; }
73
+ constructor(private readonly callback: () => void) {
74
+ StubResizeObserver.instances.push(this);
75
+ }
76
+ observe() {}
77
+ unobserve() {}
78
+ disconnect() {}
79
+ fire() { this.callback(); }
80
+ }
81
+
82
+ interface ProbeProps {
83
+ sessionId: string;
84
+ messages: ChatMessageData[];
85
+ /** Holder owned by ONE test: a stale tree can only write its own hook result. */
86
+ holder: { current: ChatTimelineScrollResult | null };
87
+ }
88
+
89
+ function Probe(props: ProbeProps) {
90
+ const scroll = useChatTimelineScroll({});
91
+ useTimelineAutoScroll({
92
+ sessionId: props.sessionId,
93
+ messages: props.messages,
94
+ scrollRef: scroll.scrollRef,
95
+ jumpToBottom: scroll.jumpToBottom,
96
+ enabled: true,
97
+ });
98
+ props.holder.current = scroll;
99
+ return h('div', { ref: scroll.scrollRef, onScroll: scroll.handleScroll },
100
+ h('div', { ref: scroll.contentRef }));
101
+ }
102
+
103
+ interface ProbeHandle {
104
+ holder: { current: ChatTimelineScrollResult | null };
105
+ node: HTMLDivElement;
106
+ el: HTMLDivElement;
107
+ }
108
+
109
+ const message = (id: string) => ({ id, role: 'user', content: id } as unknown as ChatMessageData);
110
+
111
+ /** Fresh mount, own container: `follow`, the jump counter and the observers are
112
+ * per-hook state, so sharing them across tests would hide the behaviour. */
113
+ async function mountProbe(sessionId: string, messages: ChatMessageData[]): Promise<ProbeHandle> {
114
+ const holder: ProbeHandle['holder'] = { current: null };
115
+ const node = document.createElement('div');
116
+ document.body.appendChild(node);
117
+ await act(async () => { render(h(Probe, { sessionId, messages, holder }), node); });
118
+ const el = holder.current!.scrollRef.current!;
119
+ installScroller(el);
120
+ return { holder, node, el };
121
+ }
122
+
123
+ /** Re-render with new props: a session switch, then that session's history. */
124
+ async function updateProbe(probe: ProbeHandle, sessionId: string, messages: ChatMessageData[]) {
125
+ await act(async () => {
126
+ render(h(Probe, { sessionId, messages, holder: probe.holder }), probe.node);
127
+ });
128
+ }
129
+
130
+ async function unmountProbe(probe: ProbeHandle) {
131
+ await act(async () => { render(null, probe.node); });
132
+ probe.node.remove();
133
+ }
134
+
135
+ beforeAll(async () => {
136
+ win = new Window({ url: 'http://localhost' });
137
+ for (const key of ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event'] as const) {
138
+ (globalThis as Record<string, unknown>)[key] = (win as unknown as Record<string, unknown>)[key];
139
+ }
140
+ (globalThis as Record<string, unknown>).ResizeObserver = StubResizeObserver;
141
+ // Static imports cannot work here: Preact binds its environment at module
142
+ // evaluation time, so the modules below must load after those DOM globals.
143
+ ({ useChatTimelineScroll } = await import('@/client/hooks/chat/timeline/scroll'));
144
+ ({ useTimelineAutoScroll } = await import('@/client/hooks/chat/timeline/auto-scroll'));
145
+ ({ h, render } = await import('preact'));
146
+ ({ act } = await import('preact/test-utils'));
147
+ });
148
+
149
+ afterAll(() => {
150
+ for (const key of ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event', 'ResizeObserver']) {
151
+ delete (globalThis as Record<string, unknown>)[key];
152
+ }
153
+ });
154
+
155
+ describe('timeline scroll', () => {
156
+ test('opening a session jumps to the tail even after the user scrolled up', async () => {
157
+ scrolled.length = 0;
158
+ geometry.top = 0;
159
+ geometry.height = 5000;
160
+ const probe = await mountProbe('a', [message('a1')]);
161
+ const scroll = () => probe.holder.current!;
162
+
163
+ await updateProbe(probe, 'a', [message('a1'), message('a2')]);
164
+ expect(probe.el.scrollTop).toBe(geometry.height - geometry.viewport);
165
+
166
+ // The user reads back — follow disengages, and that state outlives the
167
+ // session it was set in.
168
+ probe.el.scrollTop = 100;
169
+ expect(scroll().followRef.current).toBe(false);
170
+
171
+ // Session switch: the container paints empty first (the skeleton), the
172
+ // committed history lands a commit later.
173
+ await updateProbe(probe, 'b', []);
174
+ scrolled.length = 0;
175
+ await updateProbe(probe, 'b', [message('b1')]);
176
+
177
+ expect(scroll().followRef.current).toBe(true);
178
+ expect(scrolled.length).toBe(1);
179
+ expect(scrolled[0].behavior).toBe('instant');
180
+ expect(scrolled[0].top).toBe(geometry.height);
181
+ expect(probe.el.scrollTop).toBe(geometry.height - geometry.viewport);
182
+ await unmountProbe(probe);
183
+ });
184
+
185
+ test('a stream chunk may not move an unpinned viewport, a jump may', async () => {
186
+ scrolled.length = 0;
187
+ geometry.top = 0;
188
+ geometry.height = 5000;
189
+ const probe = await mountProbe('a', [message('a1')]);
190
+ const scroll = () => probe.holder.current!;
191
+
192
+ await act(async () => { scroll().jumpToBottom('instant'); });
193
+ expect(scroll().followRef.current).toBe(true);
194
+ expect(probe.el.scrollTop).toBe(geometry.height - geometry.viewport);
195
+ expect(scroll().jumpCountRef.current).toBe(1);
196
+ expect(scroll().jumpActiveRef.current).toBe(true);
197
+
198
+ // The user reads back. A stream chunk must not yank the viewport down...
199
+ probe.el.scrollTop = 100;
200
+ expect(scroll().followRef.current).toBe(false);
201
+ scrolled.length = 0;
202
+ await act(async () => { scroll().scrollToBottom('smooth'); });
203
+ expect(scrolled.length).toBe(0);
204
+
205
+ // ...while the explicit jump bypasses the gate and re-engages it.
206
+ await act(async () => { scroll().jumpToBottom('instant'); });
207
+ expect(scrolled.length).toBe(1);
208
+ expect(scroll().followRef.current).toBe(true);
209
+ expect(probe.el.scrollTop).toBe(geometry.height - geometry.viewport);
210
+ expect(scroll().jumpCountRef.current).toBe(2);
211
+ await unmountProbe(probe);
212
+ });
213
+
214
+ test('the jump guard releases so history paging is not blocked for good', async () => {
215
+ scrolled.length = 0;
216
+ geometry.top = 0;
217
+ geometry.height = 5000;
218
+ const probe = await mountProbe('a', [message('a1')]);
219
+ const scroll = () => probe.holder.current!;
220
+
221
+ fakeTimers.useFakeTimers();
222
+ await act(async () => { scroll().jumpToBottom('instant'); });
223
+ expect(scroll().jumpActiveRef.current).toBe(true);
224
+
225
+ await act(async () => { fakeTimers.advanceTimersByTime(1000); });
226
+ expect(scroll().jumpActiveRef.current).toBe(false);
227
+ fakeTimers.useRealTimers();
228
+ await unmountProbe(probe);
229
+ });
230
+
231
+ test('content growing while pinned re-pins the tail; while reading it does not', async () => {
232
+ scrolled.length = 0;
233
+ geometry.top = 0;
234
+ geometry.height = 5000;
235
+ const probe = await mountProbe('a', [message('a1')]);
236
+ const scroll = () => probe.holder.current!;
237
+
238
+ await updateProbe(probe, 'a', [message('a1')]);
239
+ expect(probe.el.scrollTop).toBe(geometry.height - geometry.viewport);
240
+
241
+ // A prepended window / a late layout: the tail moved down by 800px.
242
+ geometry.height += 800;
243
+ const observer = StubResizeObserver.latest();
244
+ scrolled.length = 0;
245
+ await act(async () => { observer.fire(); });
246
+ expect(scrolled.length).toBe(1);
247
+ expect(scrolled[0].behavior).toBe('instant');
248
+ expect(probe.el.scrollTop).toBe(geometry.height - geometry.viewport);
249
+
250
+ // Reading back: the same growth must leave the viewport alone.
251
+ probe.el.scrollTop = 400;
252
+ scrolled.length = 0;
253
+ geometry.height += 800;
254
+ await act(async () => { StubResizeObserver.latest().fire(); });
255
+ expect(scrolled.length).toBe(0);
256
+ expect(probe.el.scrollTop).toBe(400);
257
+ expect(scroll().followRef.current).toBe(false);
258
+ await unmountProbe(probe);
259
+ });
260
+ });
@@ -19,6 +19,12 @@
19
19
  * the tail disengages it (the scroll fight is gone — they can read while the
20
20
  * AI generates); scrolling back near the tail, or clicking the scroll-bottom
21
21
  * button, re-engages it. Programmatic jumps (`scrollTo`) never flip the mode.
22
+ *
23
+ * `jumpToBottom` is the unconditional bypass (`follow` is re-engaged, geometry
24
+ * is re-baselined, and the paging trigger stands down for a moment) — the
25
+ * button's jump, the open-session jump, and the send jump all go through it.
26
+ * `follow` lives across session switches, so an open jump that honoured it was
27
+ * simply refused for a session opened after the user had scrolled up.
22
28
  */
23
29
 
24
30
  import type { RefObject } from 'preact/compat';
@@ -27,6 +33,13 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
27
33
  /** Distance from the tail, in pixels, that still counts as "at the bottom". */
28
34
  const FOLLOW_BOTTOM_THRESHOLD_PX = 100;
29
35
 
36
+ /** How long a bottom jump counts as settling. The scroll handler reads "the
37
+ * viewport is at the top, fetch older history" from geometry, and a jump that
38
+ * STARTS at the top (the button, on a session scrolled back to its head)
39
+ * passes through those first pixels — so the paging trigger stands down for
40
+ * the jump's duration instead of paging a window the jump must then outrun. */
41
+ const JUMP_SETTLE_MS = 1000;
42
+
30
43
  export interface ChatTimelineScrollOptions {
31
44
  /** Called when the viewport reaches the top (history paging trigger).
32
45
  * Ref-indirect; the hook never depends on the callback's identity. */
@@ -41,15 +54,29 @@ export interface ChatTimelineScrollResult {
41
54
  isScrolling: boolean;
42
55
  /** Streaming auto-scroll gates on this: pinned to the tail = true. */
43
56
  followRef: RefObject<boolean>;
57
+ /** Bottom jumps issued so far. A paging request stamps its viewport anchor
58
+ * with this and drops the anchor once it has moved on — otherwise a window
59
+ * that lands AFTER the user asked for the tail writes `scrollTop` straight
60
+ * over the jump (reproduced: the jump stopped ~26k px short of the tail and
61
+ * only the next click reached it, once no window was left to page in). */
62
+ jumpCountRef: RefObject<number>;
63
+ /** True while a bottom jump is settling ({@link JUMP_SETTLE_MS}); history
64
+ * paging stands down meanwhile. */
65
+ jumpActiveRef: RefObject<boolean>;
44
66
  handleScroll: () => void;
45
67
  scrollToBottom: (behavior?: ScrollBehavior) => void;
46
- /** Jump to the tail and re-engage follow mode (scroll-bottom button). */
68
+ /** Jump to the tail and re-engage follow mode (scroll-bottom button, the
69
+ * session-open jump, the post-send jump). */
47
70
  jumpToBottom: (behavior?: ScrollBehavior) => void;
48
71
  }
49
72
 
50
73
  /** Distance from the top that counts as "reached the history boundary". */
51
74
  const SCROLL_TOP_TRIGGER_PX = 40;
52
75
 
76
+ /** Pending timeout handle. Named here rather than re-derived per ref because
77
+ * the DOM and Bun/Node typings disagree on what `setTimeout` returns. */
78
+ type TimeoutHandle = ReturnType<typeof setTimeout>;
79
+
53
80
  export function useChatTimelineScroll(options: ChatTimelineScrollOptions = {}): ChatTimelineScrollResult {
54
81
  const onScrollTopRef = useRef<(() => void) | undefined>(options.onScrollTop);
55
82
  onScrollTopRef.current = options.onScrollTop ?? onScrollTopRef.current;
@@ -57,17 +84,33 @@ export function useChatTimelineScroll(options: ChatTimelineScrollOptions = {}):
57
84
  const [contentNode, setContentNode] = useState<HTMLDivElement | null>(null);
58
85
  const [showScrollBottom, setShowScrollBottom] = useState(false);
59
86
  const [isScrolling, setIsScrolling] = useState(false);
60
- const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
87
+ const scrollTimerRef = useRef<TimeoutHandle>();
61
88
  // True while the viewport is (or should stay) pinned to the tail. A ref, not
62
89
  // state: scroll chunks read it every frame and a flip must not re-render.
63
90
  const followRef = useRef(true);
64
91
  // Previous scrollTop, for upward-scroll detection in handleScroll.
65
92
  const lastScrollTopRef = useRef(0);
93
+ // Bottom-jump bookkeeping; see ChatTimelineScrollResult for each ref.
94
+ const jumpCountRef = useRef(0);
95
+ const jumpActiveRef = useRef(false);
96
+ const jumpTimerRef = useRef<TimeoutHandle>();
66
97
 
67
98
  const contentRef = useCallback((node: HTMLDivElement | null) => {
68
99
  setContentNode(node);
69
100
  }, []);
70
101
 
102
+ /** Move the viewport to the tail unconditionally, then re-baseline the
103
+ * upward-scroll detector. Both matter: `scrollTo` applies the new position
104
+ * for 'instant', and the previous session's (larger) scrollTop would
105
+ * otherwise read as the user scrolling UP on the very event this jump
106
+ * produces — disengaging follow and cancelling a jump in flight. */
107
+ const pinToBottom = useCallback((behavior: ScrollBehavior) => {
108
+ const el = scrollRef.current;
109
+ if (!el) return;
110
+ el.scrollTo({ top: el.scrollHeight, behavior });
111
+ lastScrollTopRef.current = el.scrollTop;
112
+ }, []);
113
+
71
114
  const syncFollowBottom = useCallback(() => {
72
115
  const el = scrollRef.current;
73
116
  if (!el) return;
@@ -83,7 +126,12 @@ export function useChatTimelineScroll(options: ChatTimelineScrollOptions = {}):
83
126
  // increasing scrollTop, so a geometry check alone would flip the flag
84
127
  // mid-animation as content outgrows the animation. Re-engage whenever
85
128
  // the viewport is back near the tail, whichever way it got there.
86
- if (el.scrollTop < lastScrollTopRef.current - 1) followRef.current = false;
129
+ if (el.scrollTop < lastScrollTopRef.current - 1) {
130
+ followRef.current = false;
131
+ // The user took the viewport back: a jump still settling must not keep
132
+ // re-pinning the tail under them.
133
+ jumpActiveRef.current = false;
134
+ }
87
135
  if (el.scrollHeight - el.scrollTop - el.clientHeight < FOLLOW_BOTTOM_THRESHOLD_PX) followRef.current = true;
88
136
  lastScrollTopRef.current = el.scrollTop;
89
137
  // History paging: the viewport hit the top — ask for the older window.
@@ -92,43 +140,68 @@ export function useChatTimelineScroll(options: ChatTimelineScrollOptions = {}):
92
140
  }
93
141
  syncFollowBottom();
94
142
  setIsScrolling(true);
95
- if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current);
143
+ clearTimeout(scrollTimerRef.current);
96
144
  scrollTimerRef.current = setTimeout(() => setIsScrolling(false), 600);
97
145
  }, [syncFollowBottom]);
98
146
 
99
147
  const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
100
- const el = scrollRef.current;
101
- if (!el) return;
102
148
  // Follow-gated: a stream chunk in read mode (user scrolled away) must not
103
149
  // yank the viewport down. jumpToBottom is the explicit bypass.
104
150
  if (!followRef.current) return;
105
- el.scrollTo({ top: el.scrollHeight, behavior });
106
- }, []);
151
+ pinToBottom(behavior);
152
+ }, [pinToBottom]);
107
153
 
108
154
  const jumpToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
109
- const el = scrollRef.current;
110
- if (!el) return;
155
+ if (!scrollRef.current) return;
111
156
  followRef.current = true;
112
- el.scrollTo({ top: el.scrollHeight, behavior });
113
- }, []);
157
+ jumpActiveRef.current = true;
158
+ jumpCountRef.current += 1;
159
+ clearTimeout(jumpTimerRef.current);
160
+ jumpTimerRef.current = setTimeout(() => { jumpActiveRef.current = false; }, JUMP_SETTLE_MS);
161
+ pinToBottom(behavior);
162
+ }, [pinToBottom]);
114
163
 
115
- // Re-sync whenever an observed box changes size. `contentNode` is a dep so the
116
- // observer is rebuilt when the timeline mounts — a session picked from the
117
- // workspace picker swaps the whole subtree in, and the callback ref fires
118
- // before this effect, so the node is always available here.
164
+ // Geometry, not events: content that grows without the user scrolling
165
+ // (history prepending, an accordion expanding, an image decoding, a late
166
+ // Shiki/mermaid layout, the panel resizing) never fires `scroll`, so nothing
167
+ // else notices the tail moving away. While the viewport is pinned that growth
168
+ // is exactly when to re-pin it — a prepended window shifts the tail down by
169
+ // its own height, which strands a jump target measured a frame earlier (and
170
+ // a one-shot open jump by anything that lays out late). Unpinned (the user
171
+ // reading back) nothing is touched: the prepend anchor owns the position.
172
+ //
173
+ // `contentNode` is a dep so the observer is rebuilt when the timeline mounts
174
+ // — a session picked from the workspace picker swaps the whole subtree in,
175
+ // and the callback ref fires before this effect, so the node is always
176
+ // available here.
119
177
  useEffect(() => {
120
178
  syncFollowBottom();
121
179
  if (typeof ResizeObserver === 'undefined') return;
122
180
  const container = scrollRef.current;
123
- const observer = new ResizeObserver(syncFollowBottom);
181
+ const observer = new ResizeObserver(() => {
182
+ if (followRef.current) pinToBottom('instant');
183
+ syncFollowBottom();
184
+ });
124
185
  if (container) observer.observe(container);
125
186
  if (contentNode) observer.observe(contentNode);
126
187
  return () => observer.disconnect();
127
- }, [contentNode, syncFollowBottom]);
188
+ }, [contentNode, pinToBottom, syncFollowBottom]);
128
189
 
129
190
  useEffect(() => () => {
130
- if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current);
191
+ clearTimeout(scrollTimerRef.current);
192
+ clearTimeout(jumpTimerRef.current);
131
193
  }, []);
132
194
 
133
- return { scrollRef, contentRef, showScrollBottom, isScrolling, followRef, handleScroll, scrollToBottom, jumpToBottom };
195
+ return {
196
+ scrollRef,
197
+ contentRef,
198
+ showScrollBottom,
199
+ isScrolling,
200
+ followRef,
201
+ jumpCountRef,
202
+ jumpActiveRef,
203
+ handleScroll,
204
+ scrollToBottom,
205
+ jumpToBottom,
206
+ };
134
207
  }
@@ -60,10 +60,14 @@ export interface UseSessionLoadDeps {
60
60
  /** Scroll container of the timeline: lets loadOlder preserve the viewport
61
61
  * position when older rows are prepended above it. */
62
62
  scrollRef?: RefObject<HTMLDivElement>;
63
+ /** Scroll hook's bottom-jump guard and counter, forwarded to the pagination
64
+ * hook: a jump to the tail outranks an in-flight older window. */
65
+ jumpActiveRef?: RefObject<boolean>;
66
+ jumpCountRef?: RefObject<number>;
63
67
  }
64
68
 
65
69
  export function useSessionLoad(deps: UseSessionLoadDeps) {
66
- const { sessionId, setLocalMessages, setGenerating, isGeneratingRef, aiPlaceholderIdRef, localMessagesRef, optimisticUserIdRef, cancelStreamingCoalescer, metaRefreshedRef, scrollRef } = deps;
70
+ const { sessionId, setLocalMessages, setGenerating, isGeneratingRef, aiPlaceholderIdRef, localMessagesRef, optimisticUserIdRef, cancelStreamingCoalescer, metaRefreshedRef, scrollRef, jumpActiveRef, jumpCountRef } = deps;
67
71
 
68
72
  const [sessionData, setSessionData] = useState<SessionDataShape | null>(null);
69
73
  const [sessionLoading, setSessionLoading] = useState(false);
@@ -99,6 +103,8 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
99
103
  sessionIdRef,
100
104
  setLocalMessages,
101
105
  scrollRef,
106
+ jumpActiveRef,
107
+ jumpCountRef,
102
108
  });
103
109
 
104
110
  /** Re-fetch the session's title/metadata after the omp JSONL has been