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
@@ -18,6 +18,17 @@
18
18
  * Any frame left over is modal material — including an ask-shaped frame whose
19
19
  * tool call the timeline has not seen, which must stay answerable rather than
20
20
  * silently vanish.
21
+ *
22
+ * That fallback must wait for the session's own history, though. Ownership is
23
+ * decided against the ask tool calls in `messages`, and on session open the
24
+ * pending dialogs are replayed from the agent-state probe — which answers out
25
+ * of the wrapper's local flags — long before the JSONL fetch lands. Every
26
+ * replayed frame therefore looks unowned for that window, and rendering one as
27
+ * a modal painted the question on screen and yanked it away the instant the
28
+ * timeline arrived and its card claimed it: the flash seen when switching to a
29
+ * session parked on an ask. `historyLoaded` holds the modal — never the card
30
+ * claim — until there is a timeline to decide against; a frame no card claims
31
+ * after that still gets its modal.
21
32
  */
22
33
 
23
34
  import { createContext, useContext } from 'preact/compat';
@@ -51,6 +62,9 @@ export function useAskFrames(): AskFramesHandle {
51
62
  export function splitAskFrames(
52
63
  messages: ChatMessageData[],
53
64
  pending: ExtensionUiDialogRequest[],
65
+ /** Whether `messages` is the session's committed history yet (see the module
66
+ * note). False holds the modal back; it never blocks a card from claiming. */
67
+ historyLoaded: boolean,
54
68
  ): AskFramesSplit {
55
69
  const framesByTool = new Map<string, ExtensionUiDialogRequest[][]>();
56
70
  if (pending.length === 0) return { framesByTool, modalRequest: null };
@@ -98,5 +112,10 @@ export function splitAskFrames(
98
112
  framesByTool.set(toolId, groupAskFrames(openAsks.get(toolId) ?? [], frames));
99
113
  }
100
114
 
101
- return { framesByTool, modalRequest: pending.find((request) => !claimed.has(request)) ?? null };
115
+ // The frame stays queued either way — only the modal waits for the history,
116
+ // so nothing is dropped and nothing flashes.
117
+ return {
118
+ framesByTool,
119
+ modalRequest: historyLoaded ? pending.find((request) => !claimed.has(request)) ?? null : null,
120
+ };
102
121
  }
@@ -10,6 +10,14 @@
10
10
  * because every other trigger lives in the streaming callbacks. This jumps to
11
11
  * the tail exactly once per session, on the commit where that session's
12
12
  * messages appear.
13
+ *
14
+ * The jump is the UNCONDITIONAL one (`jumpToBottom`), not the follow-gated
15
+ * `scrollToBottom`. Follow mode is a property of this mounted view, not of the
16
+ * session being opened: a user who scrolled up before switching left it
17
+ * disengaged, and the open then painted the top of the new session with no
18
+ * scroll event to re-engage it — the session stayed at the head until the
19
+ * button was clicked (reproduced: scroll up in session A, open session B,
20
+ * `scrollTop` pinned at 0 with the jump button showing).
13
21
  */
14
22
 
15
23
  import type { RefObject } from 'preact/compat';
@@ -24,14 +32,15 @@ export interface UseTimelineAutoScrollDeps {
24
32
  messages: ChatMessageData[];
25
33
  /** Scroll container owned by useChatTimelineScroll. */
26
34
  scrollRef: RefObject<HTMLDivElement>;
27
- scrollToBottom: (behavior?: ScrollBehavior) => void;
35
+ /** Unconditional tail jump (bypasses follow mode, re-engages it). */
36
+ jumpToBottom: (behavior?: ScrollBehavior) => void;
28
37
  /** Pending "new-…" sessions render the workspace picker, which owns its own
29
38
  * timeline and scroll handling. */
30
39
  enabled: boolean;
31
40
  }
32
41
 
33
42
  export function useTimelineAutoScroll(deps: UseTimelineAutoScrollDeps): void {
34
- const { sessionId, messages, scrollRef, scrollToBottom, enabled } = deps;
43
+ const { sessionId, messages, scrollRef, jumpToBottom, enabled } = deps;
35
44
  // Session the container was last rendering. On the commit where this flips
36
45
  // the DOM still holds the previous session's rows, so scrolling then would
37
46
  // aim at content that is about to be replaced.
@@ -53,6 +62,6 @@ export function useTimelineAutoScroll(deps: UseTimelineAutoScrollDeps): void {
53
62
  // scrollHeight forces layout, so the jump lands before the browser paints.
54
63
  // 'instant' is required because the container carries Tailwind's
55
64
  // `scroll-smooth`, which 'auto' would resolve to.
56
- scrollToBottom('instant');
57
- }, [sessionId, messages, enabled, scrollRef, scrollToBottom]);
65
+ jumpToBottom('instant');
66
+ }, [sessionId, messages, enabled, scrollRef, jumpToBottom]);
58
67
  }
@@ -15,10 +15,8 @@ import { useBrowserPageContextInsert } from '@/client/hooks/chat/timeline/browse
15
15
  import { createOmpAgentCallbacks } from '@/shared/lib/chat/timeline/omp-callbacks';
16
16
  import { cancelStreamingCoalescer } from '@/shared/lib/chat/timeline/stream-coalescer';
17
17
  import { readStreamTransport } from '@/shared/lib/chat/omp/transport';
18
- import { ACCESS_MODE_SETTING_KEY, normalizeApprovalMode } from '@/shared/lib/omp/config/access-mode';
19
- import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
18
+ import { useChatTimelineAccessMode } from '@/client/hooks/chat/timeline/access-mode';
20
19
  import { useSessionState } from '@/client/hooks/workspace/session-state';
21
- import { writeSetting } from '@/shared/lib/settings/client';
22
20
 
23
21
  interface UseChatTimelineOptions {
24
22
  folders?: any[];
@@ -51,7 +49,7 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
51
49
  // without re-creating it (useSessionLoad is defined below the scroll hook).
52
50
  const loadOlderRef = useRef<() => void>(() => {});
53
51
 
54
- const { scrollRef, contentRef, showScrollBottom, isScrolling, handleScroll, scrollToBottom, jumpToBottom } = useChatTimelineScroll({
52
+ const { scrollRef, contentRef, showScrollBottom, isScrolling, handleScroll, scrollToBottom, jumpToBottom, jumpActiveRef, jumpCountRef } = useChatTimelineScroll({
55
53
  onScrollTop: () => loadOlderRef.current(),
56
54
  });
57
55
 
@@ -65,11 +63,12 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
65
63
  const isPendingSession = Boolean(sessionId?.startsWith('new-'));
66
64
  // Committed history lands asynchronously (session-load.ts), so the container
67
65
  // paints at the top first; jump to the tail once per session when it does.
66
+ // Unconditional: follow mode is this view's, not the opened session's.
68
67
  useTimelineAutoScroll({
69
68
  sessionId,
70
69
  messages: localMessages,
71
70
  scrollRef,
72
- scrollToBottom,
71
+ jumpToBottom,
73
72
  enabled: !isPendingSession,
74
73
  });
75
74
 
@@ -135,6 +134,8 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
135
134
  cancelStreamingCoalescer,
136
135
  metaRefreshedRef,
137
136
  scrollRef,
137
+ jumpActiveRef,
138
+ jumpCountRef,
138
139
  });
139
140
  loadOlderRef.current = loadOlder;
140
141
 
@@ -165,21 +166,8 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
165
166
  }, [sessionId]);
166
167
 
167
168
  // Access-control mode is a global, persisted user preference (unlike the
168
- // per-session model/thinking picks): it hydrates from appSettings at first
169
- // paint and is mirrored into a ref so the send path reads the latest value
170
- // without re-creating executeSend.
171
- const [accessMode, setAccessMode] = useState<ApprovalMode>(() => normalizeApprovalMode(appSettings.omp_access_mode));
172
- const accessModeRef = useRef<ApprovalMode>(accessMode);
173
- accessModeRef.current = accessMode;
174
-
175
- const handleAccessModeChange = useCallback((mode: ApprovalMode) => {
176
- setAccessMode(mode);
177
- accessModeRef.current = mode;
178
- // Persist the last selection; the server reads this key as the spawn-time
179
- // default. Fire-and-forget: the in-memory value is already authoritative
180
- // for this session's requests, which carry it explicitly.
181
- writeSetting(ACCESS_MODE_SETTING_KEY, mode);
182
- }, []);
169
+ // per-session model/thinking picks).
170
+ const { accessMode, accessModeRef, setAccessMode: handleAccessModeChange } = useChatTimelineAccessMode(appSettings);
183
171
 
184
172
  const persistMessages = useCallback((messagesToSave: any[]) => {
185
173
  if (!sessionId) return;
@@ -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
+ });