ompchamber 1.2.0 → 2.0.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 (223) hide show
  1. package/README.md +131 -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.74ff36419f.js +2 -0
  14. package/dist/client/static/js/async/{m.2e1ea3db9e.js.LICENSE.txt → g.74ff36419f.js.LICENSE.txt} +5 -0
  15. package/dist/client/static/js/async/h1.393f066d09.js +1 -0
  16. package/dist/client/static/js/async/jl.874d352278.js +1 -0
  17. package/dist/client/static/js/async/{k.d16cd09ae7.js → k.3210286401.js} +1 -1
  18. package/dist/client/static/js/async/{m.2e1ea3db9e.js → m.eb2e6752e2.js} +3 -3
  19. package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +11 -0
  20. package/dist/client/static/js/async/mt.0352e976d8.js +1 -0
  21. package/dist/client/static/js/async/{oc.eb22093f05.js → oc.b73042e8bd.js} +1 -1
  22. package/dist/client/static/js/async/{og.53f068804c.js → og.8eb577893a.js} +1 -1
  23. package/dist/client/static/js/async/px.fc6877924b.js +1 -0
  24. package/dist/client/static/js/async/q.33d6779f98.js +21 -0
  25. package/dist/client/static/js/async/{q5.88b50ffa95.js → q5.97dc1481b6.js} +1 -1
  26. package/dist/client/static/js/async/s.0f395cdda5.js +94 -0
  27. package/dist/client/static/js/async/{tr.2a0d2b7600.js → tr.e878658140.js} +1 -1
  28. package/dist/client/static/js/async/vt.42416b707d.js +1 -0
  29. package/dist/client/static/js/async/x1.da8687159b.js +90 -0
  30. package/dist/client/static/js/async/xe.da8dcc2015.js +2 -0
  31. package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +11 -0
  32. package/dist/client/static/js/async/{xn.6360379f71.js → xn.6510c0abe2.js} +2 -2
  33. package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
  34. package/dist/client/static/js/async/yv.d99d829136.js +1 -0
  35. package/dist/client/static/js/index.c05535dbde.js +38 -0
  36. package/package.json +3 -3
  37. package/src/client/components/common/ComposerTextarea.tsx +9 -1
  38. package/src/client/components/common/MarkdownRenderer.tsx +50 -12
  39. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +9 -3
  40. package/src/client/components/settings/Modal.tsx +11 -2
  41. package/src/client/components/settings/categories/AppearanceSettings.tsx +7 -30
  42. package/src/client/components/settings/categories/ProviderSettings.tsx +2 -0
  43. package/src/client/components/settings/categories/appearance-settings/ThemeCard.tsx +61 -0
  44. package/src/client/components/settings/categories/appearance-settings/ThemeGrid.tsx +80 -0
  45. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +1 -1
  46. package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +43 -6
  47. package/src/client/components/settings/categories/provider-settings/ModelConfigModal.tsx +54 -54
  48. package/src/client/components/workspace/SearchPanel.tsx +16 -3
  49. package/src/client/components/workspace/btw-panel/AskCard.tsx +11 -6
  50. package/src/client/components/workspace/btw-panel/Messages.tsx +105 -51
  51. package/src/client/components/workspace/btw-panel/index.tsx +129 -45
  52. package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +30 -14
  53. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +10 -1
  54. package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +8 -2
  55. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +27 -16
  56. package/src/client/components/workspace/chat-timeline/MessageList.tsx +11 -0
  57. package/src/client/components/workspace/chat-timeline/RunFooter.tsx +8 -0
  58. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +12 -1
  59. package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
  60. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +42 -15
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +13 -1
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index.tsx +13 -4
  63. package/src/client/components/workspace/editor/index.tsx +11 -2
  64. package/src/client/components/workspace/file-explorer/GitRepoDropdown.tsx +15 -41
  65. package/src/client/components/workspace/file-explorer/index.tsx +86 -16
  66. package/src/client/components/workspace/git-panel/index.tsx +60 -78
  67. package/src/client/components/workspace/terminal-panel/Header.tsx +16 -1
  68. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +5 -5
  69. package/src/client/components/workspace/terminal-panel/index.tsx +6 -2
  70. package/src/client/components/workspace/terminal-panel/mount.ts +2 -2
  71. package/src/client/data/theme/terminal.ts +50 -52
  72. package/src/client/hooks/chat/btw/index.ts +139 -18
  73. package/src/client/hooks/chat/btw/intercept.ts +8 -8
  74. package/src/client/hooks/chat/btw/mode.ts +111 -16
  75. package/src/client/hooks/chat/composer/index.ts +10 -4
  76. package/src/client/hooks/chat/timeline/access-mode.ts +41 -0
  77. package/src/client/hooks/chat/timeline/ask-frames.test.ts +87 -0
  78. package/src/client/hooks/chat/timeline/ask-frames.ts +20 -1
  79. package/src/client/hooks/chat/timeline/auto-scroll.ts +13 -4
  80. package/src/client/hooks/chat/timeline/index.ts +8 -20
  81. package/src/client/hooks/chat/timeline/queue.ts +66 -9
  82. package/src/client/hooks/chat/timeline/scroll.test.ts +260 -0
  83. package/src/client/hooks/chat/timeline/scroll.ts +93 -20
  84. package/src/client/hooks/chat/timeline/session-load.ts +7 -1
  85. package/src/client/hooks/chat/timeline/session-pagination.test.ts +167 -0
  86. package/src/client/hooks/chat/timeline/session-pagination.ts +37 -11
  87. package/src/client/hooks/settings/providers.ts +34 -3
  88. package/src/client/hooks/ui/open-file.ts +7 -1
  89. package/src/client/hooks/ui/theme.ts +82 -57
  90. package/src/client/hooks/workspace/file-tabs.ts +5 -1
  91. package/src/client/hooks/workspace/repo-scope/index.ts +119 -0
  92. package/src/client/hooks/workspace/repo-scope/store.test.ts +161 -0
  93. package/src/client/hooks/workspace/repo-scope/store.ts +201 -0
  94. package/src/client/hooks/workspace/search-stream.ts +24 -5
  95. package/src/client/main.tsx +6 -0
  96. package/src/client/tailwind.css +19 -56
  97. package/src/server/lib/btw/child.server.ts +41 -0
  98. package/src/server/lib/btw/lifecycle.server.ts +254 -0
  99. package/src/server/lib/btw/prompt.ts +13 -5
  100. package/src/server/lib/btw/registry.server.ts +13 -4
  101. package/src/server/lib/btw/runtime.server.ts +149 -156
  102. package/src/server/lib/btw/schema.server.ts +27 -0
  103. package/src/server/lib/btw/service.server.ts +106 -5
  104. package/src/server/lib/btw/store.server.ts +57 -6
  105. package/src/server/lib/db/schema.ts +4 -0
  106. package/src/server/lib/db/settings-store.test.ts +65 -0
  107. package/src/server/lib/db/settings-store.ts +25 -0
  108. package/src/server/lib/fs/atomic-write.ts +42 -4
  109. package/src/server/lib/fs/config-lock.ts +74 -0
  110. package/src/server/lib/fs/git-ignore.ts +55 -5
  111. package/src/server/lib/fs/ripgrep.ts +119 -0
  112. package/src/server/lib/lifecycle/identity.ts +10 -64
  113. package/src/server/lib/lifecycle/proc/darwin.ts +238 -0
  114. package/src/server/lib/lifecycle/proc/index.ts +38 -0
  115. package/src/server/lib/lifecycle/proc/linux.ts +80 -0
  116. package/src/server/lib/lifecycle/proc/ps.ts +86 -0
  117. package/src/server/lib/lifecycle/proc/types.ts +33 -0
  118. package/src/server/lib/lifecycle/proc/win32.ts +53 -0
  119. package/src/server/lib/models/provider-registry.server.ts +2 -2
  120. package/src/server/lib/models/registry.server.ts +1 -1
  121. package/src/server/lib/omp/config/behavior.ts +17 -17
  122. package/src/server/lib/omp/config/disabled-providers.ts +84 -35
  123. package/src/server/lib/omp/config/document.ts +128 -0
  124. package/src/server/lib/omp/config/extensions.ts +11 -10
  125. package/src/server/lib/omp/config/mcp.ts +4 -57
  126. package/src/server/lib/omp/config/model-overrides.ts +123 -0
  127. package/src/server/lib/omp/config/models-config.ts +92 -0
  128. package/src/server/lib/omp/config/models-validation.ts +159 -0
  129. package/src/server/lib/omp/config/path-scoped.ts +74 -0
  130. package/src/server/lib/omp/config/providers.ts +241 -211
  131. package/src/server/lib/omp/config/roles.ts +6 -14
  132. package/src/server/lib/omp/core/auth-credentials.ts +1 -1
  133. package/src/server/lib/omp/rpc/frame-fold.test.ts +124 -0
  134. package/src/server/lib/omp/rpc/frame-fold.ts +43 -24
  135. package/src/server/lib/omp/rpc/lifecycle.test.ts +41 -0
  136. package/src/server/lib/omp/rpc/manager.ts +21 -21
  137. package/src/server/lib/omp/rpc/pending-ui-dialogs.ts +11 -7
  138. package/src/server/lib/omp/rpc/session-commands.ts +17 -29
  139. package/src/server/lib/omp/rpc/session-registry.ts +5 -66
  140. package/src/server/lib/omp/rpc/web-state.ts +0 -6
  141. package/src/server/lib/omp/session/auto-title.server.test.ts +109 -11
  142. package/src/server/lib/omp/session/auto-title.server.ts +95 -20
  143. package/src/server/lib/omp/session/sidebar-data.server.ts +11 -6
  144. package/src/server/lib/queue/delivery-dispatch.server.test.ts +162 -0
  145. package/src/server/lib/queue/delivery-harness.ts +88 -0
  146. package/src/server/lib/queue/delivery.server.test.ts +78 -149
  147. package/src/server/lib/queue/delivery.server.ts +105 -13
  148. package/src/server/lib/queue/store.server.ts +30 -5
  149. package/src/server/lib/terminal/foreground.test.ts +164 -0
  150. package/src/server/lib/terminal/foreground.ts +34 -6
  151. package/src/server/lib/terminal/history.ts +8 -7
  152. package/src/server/plugins/compress.ts +33 -5
  153. package/src/server/plugins/ssr.ts +14 -7
  154. package/src/server/routes/btw/topics.ts +89 -2
  155. package/src/server/routes/fs/git.ts +5 -6
  156. package/src/server/routes/fs/search.ts +17 -26
  157. package/src/server/routes/sessions/queue.ts +17 -4
  158. package/src/server/routes/settings/index.ts +2 -0
  159. package/src/server/routes/settings/model-override.ts +69 -0
  160. package/src/server/routes/settings/provider-models.ts +35 -12
  161. package/src/server/routes/settings/providers.ts +31 -1
  162. package/src/server/routes/settings/root.ts +8 -3
  163. package/src/shared/lib/chat/btw/client.ts +34 -0
  164. package/src/shared/lib/chat/timeline/tool-phrases.ts +7 -0
  165. package/src/shared/lib/code/shiki-themes.ts +9 -5
  166. package/src/shared/lib/fs/file-tree.ts +17 -12
  167. package/src/shared/lib/markdown/autolink.test.ts +32 -0
  168. package/src/shared/lib/markdown/autolink.ts +50 -0
  169. package/src/shared/lib/markdown/document-urls.test.ts +68 -0
  170. package/src/shared/lib/markdown/document-urls.ts +118 -0
  171. package/src/shared/lib/markdown/marked.ts +27 -15
  172. package/src/shared/lib/markdown/mermaid.ts +2 -2
  173. package/src/shared/lib/models/catalog.ts +1 -1
  174. package/src/shared/lib/omp/session/schema-migrations.ts +30 -0
  175. package/src/shared/lib/omp/session/stream-state.server.test.ts +54 -0
  176. package/src/shared/lib/omp/session/stream-state.server.ts +70 -21
  177. package/src/shared/lib/settings/client.ts +10 -4
  178. package/src/shared/lib/settings/diff.test.ts +31 -0
  179. package/src/shared/lib/settings/diff.ts +37 -0
  180. package/src/shared/lib/theme/catalog.test.ts +168 -0
  181. package/src/shared/lib/theme/catalog.ts +98 -0
  182. package/src/shared/lib/theme/color.ts +88 -0
  183. package/src/shared/lib/theme/css.ts +70 -0
  184. package/src/shared/lib/theme/palettes/chamber.ts +61 -0
  185. package/src/shared/lib/theme/palettes/dark.ts +313 -0
  186. package/src/shared/lib/theme/palettes/light.ts +313 -0
  187. package/src/shared/types/btw.ts +83 -4
  188. package/src/shared/types/index.ts +1 -0
  189. package/src/shared/types/settings/provider.ts +8 -1
  190. package/src/shared/types/settings/state.ts +10 -4
  191. package/src/shared/types/theme.ts +48 -0
  192. package/dist/client/static/css/index.b938f4a693.css +0 -2
  193. package/dist/client/static/js/77.8022a800b5.js +0 -139
  194. package/dist/client/static/js/async/1k.a411fe5b43.js +0 -2
  195. package/dist/client/static/js/async/66.86544ad741.js +0 -93
  196. package/dist/client/static/js/async/7g.db82aca7ba.js +0 -1
  197. package/dist/client/static/js/async/ar.ad5c8822a8.js +0 -2
  198. package/dist/client/static/js/async/e.6443e739a2.js +0 -79
  199. package/dist/client/static/js/async/g.ee41bfed7f.js +0 -2
  200. package/dist/client/static/js/async/h1.d7091cef78.js +0 -1
  201. package/dist/client/static/js/async/jl.4911fa3383.js +0 -1
  202. package/dist/client/static/js/async/mt.853be2b5aa.js +0 -1
  203. package/dist/client/static/js/async/ot.84c71b308f.js +0 -2
  204. package/dist/client/static/js/async/px.783b6f91f2.js +0 -1
  205. package/dist/client/static/js/async/q8.7f1b48e9cd.js +0 -21
  206. package/dist/client/static/js/async/q8.7f1b48e9cd.js.LICENSE.txt +0 -6
  207. package/dist/client/static/js/async/s.aad2ecebc6.js +0 -94
  208. package/dist/client/static/js/async/vt.20406a5604.js +0 -1
  209. package/dist/client/static/js/async/x1.fe8af17016.js +0 -90
  210. package/dist/client/static/js/async/xn.6360379f71.js.LICENSE.txt +0 -6
  211. package/dist/client/static/js/async/yg.5a94e1fc8a.js +0 -149
  212. package/dist/client/static/js/async/yv.2fa22f009a.js +0 -1
  213. package/dist/client/static/js/index.57b39ffa7f.js +0 -34
  214. package/src/client/components/workspace/btw-panel/Composer.tsx +0 -181
  215. package/src/client/components/workspace/btw-panel/attachments.ts +0 -80
  216. /package/dist/client/static/js/{77.8022a800b5.js.LICENSE.txt → 77.0d81c74cf0.js.LICENSE.txt} +0 -0
  217. /package/dist/client/static/js/async/{g.ee41bfed7f.js.LICENSE.txt → 1k.973e8e21fc.js.LICENSE.txt} +0 -0
  218. /package/dist/client/static/js/async/{3y.3186ecdd63.js.LICENSE.txt → 3y.3530b824ad.js.LICENSE.txt} +0 -0
  219. /package/dist/client/static/js/async/{ot.84c71b308f.js.LICENSE.txt → 4m.928a1dde85.js.LICENSE.txt} +0 -0
  220. /package/dist/client/static/js/async/{q.b453193538.js.LICENSE.txt → ar.9303e5f2e8.js.LICENSE.txt} +0 -0
  221. /package/dist/client/static/js/async/{1k.a411fe5b43.js.LICENSE.txt → q.33d6779f98.js.LICENSE.txt} +0 -0
  222. /package/dist/client/static/js/async/{ar.ad5c8822a8.js.LICENSE.txt → xn.6510c0abe2.js.LICENSE.txt} +0 -0
  223. /package/dist/client/static/js/{index.57b39ffa7f.js.LICENSE.txt → index.c05535dbde.js.LICENSE.txt} +0 -0
@@ -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
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The prepend anchor vs. a bottom jump. The anchor exists to hold a READING
8
+ * position when older history is paged in above the viewport, and it writes
9
+ * `scrollTop` directly (which CSS `scroll-behavior: smooth` then animates). A
10
+ * window that landed after the user asked for the tail therefore wrote
11
+ * `scrollTop` straight over the jump: reproduced live in the chamber against a
12
+ * session with older pages — the viewport stopped ~26k px short of the tail
13
+ * (the anchor's own target), and only the next click reached the bottom, once
14
+ * no window was left to page in.
15
+ *
16
+ * Runs against a real DOM (happy-dom) because the hook is a Preact hook;
17
+ * geometry and `fetch` are faked (happy-dom does no layout and no network).
18
+ */
19
+
20
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
21
+ import { Window } from 'happy-dom';
22
+ import type { Dispatch, SetStateAction } from 'preact/compat';
23
+ import type { h as PreactH, render as PreactRender } from 'preact';
24
+ import type { act as PreactAct } from 'preact/test-utils';
25
+ import type { useSessionPagination as UsePagination, SessionPagination } from '@/client/hooks/chat/timeline/session-pagination';
26
+ import type { ChatMessageData } from '@/shared/types';
27
+
28
+ let useSessionPagination: typeof UsePagination;
29
+ let h: typeof PreactH;
30
+ let render: typeof PreactRender;
31
+ let act: typeof PreactAct;
32
+
33
+ let win: Window;
34
+ let container: HTMLDivElement;
35
+ let scrollEl: HTMLDivElement;
36
+ /** Prepend this many pixels of older rows when the stubbed window resolves. */
37
+ let prependHeightPx = 1000;
38
+ let fetchCount = 0;
39
+
40
+ const geometry = { top: 0, height: 5000, viewport: 500 };
41
+
42
+ function installScroller(el: HTMLDivElement) {
43
+ Object.defineProperty(el, 'scrollHeight', { get: () => geometry.height, configurable: true });
44
+ Object.defineProperty(el, 'clientHeight', { get: () => geometry.viewport, configurable: true });
45
+ Object.defineProperty(el, 'scrollTop', {
46
+ get: () => geometry.top,
47
+ set: (value: number) => {
48
+ geometry.top = Math.max(0, Math.min(value, geometry.height - geometry.viewport));
49
+ },
50
+ configurable: true,
51
+ });
52
+ Object.defineProperty(el, 'scrollTo', { value: () => {}, configurable: true });
53
+ }
54
+
55
+ /** The two refs the scroll hook owns and the pagination hook reads. */
56
+ const jumpActiveRef = { current: false };
57
+ const jumpCountRef = { current: 0 };
58
+ const sessionIdRef = { current: 's1' };
59
+ const scrollRef: { current: HTMLDivElement | null } = { current: null };
60
+ /** The hook prepends through this; the commit under test comes from its own
61
+ * prepend tick, so recording the update is all this needs to do. */
62
+ const setLocalMessages: Dispatch<SetStateAction<ChatMessageData[]>> = () => {};
63
+
64
+ let pagination: SessionPagination;
65
+
66
+ function Probe() {
67
+ pagination = useSessionPagination({
68
+ sessionId: 's1',
69
+ sessionIdRef,
70
+ setLocalMessages,
71
+ scrollRef,
72
+ jumpActiveRef,
73
+ jumpCountRef,
74
+ });
75
+ return null;
76
+ }
77
+
78
+ /** A window that prepends `prependHeightPx` of older rows when it lands. */
79
+ function stubWindowFetch() {
80
+ (globalThis as Record<string, unknown>).fetch = async () => {
81
+ fetchCount += 1;
82
+ // The prepend commit is where the anchor reads the new scrollHeight.
83
+ geometry.height += prependHeightPx;
84
+ return {
85
+ ok: true,
86
+ json: async () => ({
87
+ session: { messages: [{ id: 'old-1', role: 'user', content: 'old' }] },
88
+ hasMore: false,
89
+ oldestIndex: 0,
90
+ }),
91
+ };
92
+ };
93
+ }
94
+
95
+ async function settle() {
96
+ for (let i = 0; i < 10; i++) await Promise.resolve();
97
+ await act(async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); });
98
+ }
99
+
100
+ beforeAll(async () => {
101
+ win = new Window({ url: 'http://localhost' });
102
+ for (const key of ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event'] as const) {
103
+ (globalThis as Record<string, unknown>)[key] = (win as unknown as Record<string, unknown>)[key];
104
+ }
105
+ // Static imports cannot work here: Preact binds its environment at module
106
+ // evaluation time, so the modules below must load after those DOM globals.
107
+ ({ useSessionPagination } = await import('@/client/hooks/chat/timeline/session-pagination'));
108
+ ({ h, render } = await import('preact'));
109
+ ({ act } = await import('preact/test-utils'));
110
+ });
111
+
112
+ afterAll(() => {
113
+ delete (globalThis as Record<string, unknown>).fetch;
114
+ for (const key of ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event']) {
115
+ delete (globalThis as Record<string, unknown>)[key];
116
+ }
117
+ });
118
+
119
+ beforeEach(async () => {
120
+ geometry.top = 0;
121
+ geometry.height = 5000;
122
+ fetchCount = 0;
123
+ jumpActiveRef.current = false;
124
+ jumpCountRef.current = 0;
125
+ stubWindowFetch();
126
+ container = document.createElement('div');
127
+ document.body.appendChild(container);
128
+ scrollEl = document.createElement('div');
129
+ installScroller(scrollEl);
130
+ scrollRef.current = scrollEl;
131
+ await act(async () => { render(h(Probe, {}), container); });
132
+ await act(async () => { pagination.applyWindow(true, 100); });
133
+ });
134
+
135
+ describe('prepend anchor vs bottom jump', () => {
136
+ test('holds the reading position when the user has not asked for the tail', async () => {
137
+ await act(async () => { pagination.loadOlder(); });
138
+ await settle();
139
+
140
+ expect(fetchCount).toBe(1);
141
+ expect(geometry.height).toBe(6000);
142
+ // prevTop (0) + the prepended height: the rows being read stay put.
143
+ expect(scrollEl.scrollTop).toBe(1000);
144
+ });
145
+
146
+ test('is dropped when the user asked for the tail after the request went out', async () => {
147
+ const requested = pagination.loadOlder();
148
+ // The user clicks scroll-to-bottom while the window is still in flight.
149
+ jumpCountRef.current += 1;
150
+ await requested;
151
+ await settle();
152
+
153
+ expect(fetchCount).toBe(1);
154
+ expect(geometry.height).toBe(6000);
155
+ // The jump owns the position; the anchor wrote nothing over it.
156
+ expect(scrollEl.scrollTop).toBe(0);
157
+ });
158
+
159
+ test('a jump in flight blocks the paging trigger', async () => {
160
+ jumpActiveRef.current = true;
161
+ await act(async () => { pagination.loadOlder(); });
162
+ await settle();
163
+
164
+ expect(fetchCount).toBe(0);
165
+ expect(geometry.height).toBe(5000);
166
+ });
167
+ });
@@ -31,6 +31,15 @@ export interface UseSessionPaginationDeps {
31
31
  /** Scroll container of the timeline: lets loadOlder preserve the viewport
32
32
  * position when older rows are prepended above it. */
33
33
  scrollRef?: RefObject<HTMLDivElement>;
34
+ /** Scroll hook's bottom-jump guard: history paging stands down while a jump
35
+ * to the tail is settling, so the jump does not race a window it never
36
+ * asked for (the jump starts at the top, where the trigger lives). */
37
+ jumpActiveRef?: RefObject<boolean>;
38
+ /** Scroll hook's bottom-jump counter: a page request stamps its viewport
39
+ * anchor with the count it saw and drops the anchor when it has moved on.
40
+ * The anchor exists to hold a READING position, and applying it over a jump
41
+ * the user asked for parked the viewport back where they used to be. */
42
+ jumpCountRef?: RefObject<number>;
34
43
  }
35
44
 
36
45
  export interface SessionPagination {
@@ -67,7 +76,7 @@ interface WindowPayload {
67
76
  }
68
77
 
69
78
  export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPagination {
70
- const { sessionId, sessionIdRef, setLocalMessages, scrollRef } = deps;
79
+ const { sessionId, sessionIdRef, setLocalMessages, scrollRef, jumpActiveRef: bottomJumpActiveRef, jumpCountRef } = deps;
71
80
 
72
81
  const [hasMore, setHasMore] = useState(false);
73
82
  const [oldestIndex, setOldestIndex] = useState(0);
@@ -83,14 +92,14 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
83
92
  // the scroll handler would read "the user reached the top" and page an extra
84
93
  // window nobody asked for — and that window's own scroll anchor would fight
85
94
  // the jump's.
86
- const jumpActiveRef = useRef(false);
95
+ const railJumpActiveRef = useRef(false);
87
96
  // Live cursor: a jump walks it across several requests, and state does not
88
97
  // update between iterations of that loop.
89
98
  const oldestIndexRef = useRef(0);
90
99
  oldestIndexRef.current = oldestIndex;
91
100
  // Set while prepending older rows; the layout effect below re-anchors the
92
101
  // viewport to the content the user was reading instead of jumping.
93
- const pendingScrollAnchorRef = useRef<{ prevHeight: number; prevTop: number } | null>(null);
102
+ const pendingScrollAnchorRef = useRef<{ prevHeight: number; prevTop: number; jumpCount: number } | null>(null);
94
103
 
95
104
  const resetPages = useCallback(() => {
96
105
  setHasMore(false);
@@ -101,6 +110,10 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
101
110
  loadingOlderRef.current = false;
102
111
  setLoadingOlder(false);
103
112
  setLoadOlderError(false);
113
+ // A recorded anchor belongs to the previous session's geometry, and its
114
+ // request was dropped on the switch — applying it would write THAT window's
115
+ // offset into this session's scrollTop.
116
+ pendingScrollAnchorRef.current = null;
104
117
  }, []);
105
118
 
106
119
  const applyWindow = useCallback((hasMoreFromLoad: unknown, oldestIndexFromLoad: unknown) => {
@@ -119,11 +132,15 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
119
132
  return { messages: data.session?.messages ?? [], hasMore: data.hasMore, oldestIndex: data.oldestIndex };
120
133
  }, [sessionId]);
121
134
 
122
- /** Record the viewport geometry an older-window prepend is about to shift. */
135
+ /** Record the viewport geometry an older-window prepend is about to shift,
136
+ * stamped with the bottom-jump count so the anchor can be dropped if the
137
+ * user asked for the tail after this request went out. */
123
138
  const anchorViewport = useCallback(() => {
124
139
  const el = scrollRef?.current;
125
- if (el) pendingScrollAnchorRef.current = { prevHeight: el.scrollHeight, prevTop: el.scrollTop };
126
- }, [scrollRef]);
140
+ if (el) {
141
+ pendingScrollAnchorRef.current = { prevHeight: el.scrollHeight, prevTop: el.scrollTop, jumpCount: jumpCountRef?.current ?? 0 };
142
+ }
143
+ }, [scrollRef, jumpCountRef]);
127
144
 
128
145
  /** Drop the jump's scroll-handler guard once its target scroll has settled.
129
146
  * Until then the viewport is still near the top of history (where the paging
@@ -132,7 +149,7 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
132
149
  const releaseJumpGuard = useCallback(() => {
133
150
  const el = scrollRef?.current;
134
151
  if (!el) {
135
- jumpActiveRef.current = false;
152
+ railJumpActiveRef.current = false;
136
153
  return;
137
154
  }
138
155
  let lastTop = el.scrollTop;
@@ -145,7 +162,7 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
145
162
  }
146
163
  // A few still frames = the smooth scroll is done (or never started).
147
164
  if (settledFrames >= 5) {
148
- jumpActiveRef.current = false;
165
+ railJumpActiveRef.current = false;
149
166
  return;
150
167
  }
151
168
  requestAnimationFrame(watch);
@@ -162,7 +179,11 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
162
179
  * placeholder, and blocking it made the button feel dead mid-run. */
163
180
  const loadOlder = useCallback(() => {
164
181
  if (!sessionId || sessionId.startsWith('new-')) return;
165
- if (!hasMore || loadingOlderRef.current || jumpActiveRef.current) return;
182
+ if (!hasMore || loadingOlderRef.current || railJumpActiveRef.current) return;
183
+ // A jump to the tail starts at the top, where this trigger lives: paging a
184
+ // window then is work the jump never asked for, and the window's own
185
+ // anchor would race the jump's landing.
186
+ if (bottomJumpActiveRef?.current) return;
166
187
  loadingOlderRef.current = true;
167
188
  setLoadingOlder(true);
168
189
  setLoadOlderError(false);
@@ -204,7 +225,7 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
204
225
  loadingOlderRef.current = false;
205
226
  setLoadingOlder(false);
206
227
  });
207
- }, [sessionId, sessionIdRef, hasMore, oldestIndex, fetchWindow, anchorViewport, setLocalMessages, applyWindow]);
228
+ }, [sessionId, sessionIdRef, hasMore, oldestIndex, fetchWindow, anchorViewport, setLocalMessages, applyWindow, bottomJumpActiveRef]);
208
229
 
209
230
  /**
210
231
  * Walk the cursor down until the row with `id` is mounted, prepending every
@@ -226,7 +247,7 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
226
247
  if (loadingOlderRef.current) return false;
227
248
  const requestedSessionId = sessionId;
228
249
  loadingOlderRef.current = true;
229
- jumpActiveRef.current = true;
250
+ railJumpActiveRef.current = true;
230
251
  setLoadingOlder(true);
231
252
  setLoadOlderError(false);
232
253
  // No viewport anchor here, deliberately: the anchor exists to hold the
@@ -283,6 +304,11 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
283
304
  const el = scrollRef?.current;
284
305
  if (!el) return;
285
306
  pendingScrollAnchorRef.current = null;
307
+ // The user asked for the tail after this window was requested: holding the
308
+ // old reading position would write scrollTop straight over that jump (a
309
+ // window landing mid-jump parked the viewport ~26k px short of the tail).
310
+ // The jump's own landing, plus the scroll hook's re-pin, owns the position.
311
+ if (anchor.jumpCount !== (jumpCountRef?.current ?? 0)) return;
286
312
  const delta = el.scrollHeight - anchor.prevHeight;
287
313
  if (delta > 0) el.scrollTop = anchor.prevTop + delta;
288
314
  }, [prependTick, scrollRef]);
@@ -254,9 +254,40 @@ export function useProviderSettings({
254
254
  (models) => models.map((m) => (m.id === modelId ? { ...m, isVisible: !m.isVisible } : m)),
255
255
  );
256
256
 
257
- const handleSaveModelConfig = (modelId: string, updates: Partial<ProviderModel>) => updateSelectedModels(
258
- (models) => models.map((m) => (m.id === modelId ? { ...m, ...updates } : m)),
259
- );
257
+ /**
258
+ * Save the per-model knobs omp honours (`models.yml` modelOverrides). The
259
+ * overlay still stores the display value so the dialog reopens where the user
260
+ * left it, but omp only reads the override — writing just the overlay was the
261
+ * bug that made these controls inert.
262
+ */
263
+ const handleSaveModelConfig = async (modelId: string, updates: Partial<ProviderModel>) => {
264
+ if (!selectedProvider) return;
265
+ const provider = selectedProvider;
266
+ updateSelectedModels((models) => models.map((m) => (m.id === modelId ? { ...m, ...updates } : m)));
267
+
268
+ const body: Record<string, unknown> = { provider: provider.slug, modelId };
269
+ if ('maxTokens' in updates) body.maxTokens = updates.maxTokens ?? null;
270
+ if ('reasoningEffort' in updates) body.reasoningEffort = updates.reasoningEffort ?? null;
271
+ if (Object.keys(body).length === 2) return;
272
+
273
+ try {
274
+ const response = await fetch('/api/settings/model-override', {
275
+ method: 'PUT',
276
+ headers: { 'Content-Type': 'application/json' },
277
+ body: JSON.stringify(body),
278
+ });
279
+ const data = await response.json() as { success?: boolean; written?: boolean; reason?: string; error?: string };
280
+ if (!response.ok) {
281
+ pushToast(data.error || 'Failed to save the model configuration.', 'error');
282
+ return;
283
+ }
284
+ if (data.written) notifyModelsUpdated();
285
+ else if (data.reason) pushToast(data.reason, 'error');
286
+ } catch (error) {
287
+ console.error('Failed to save model override:', error);
288
+ pushToast('Failed to save the model configuration.', 'error');
289
+ }
290
+ };
260
291
 
261
292
  return {
262
293
  providers,
@@ -5,6 +5,10 @@ export interface OpenFilePayload {
5
5
  name?: string;
6
6
  id?: number;
7
7
  content?: string;
8
+ /** Absolute workspace root the path belongs to; the panel defaults it to the active project. */
9
+ root?: string;
10
+ /** Repo picked in the panel, relative to `root` — the path's own scope when set. */
11
+ repo?: string;
8
12
  }
9
13
 
10
14
  /**
@@ -26,7 +30,9 @@ export function openFileInEditor(payloadOrPath: string | OpenFilePayload) {
26
30
  path: cleanPath,
27
31
  name: fileName,
28
32
  id: payload.id,
29
- content: payload.content
33
+ content: payload.content,
34
+ root: payload.root,
35
+ repo: payload.repo,
30
36
  }
31
37
  })
32
38
  );