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
@@ -7,8 +7,13 @@
7
7
  * `/btw [question]` typed into the main composer is the side-question entry
8
8
  * point, not chat text: the panel owns the token (omp's `/btw` is TUI-only, so
9
9
  * nothing downstream would understand it). This module is the whole
10
- * interception — recognize the command, carry the composer's images across as
11
- * provider payloads, and hand the question to the panel over `omp:btw`.
10
+ * interception — recognize the command and hand it, with the composer's
11
+ * attachments, to the panel over `omp:btw`.
12
+ *
13
+ * The attachments cross as the composer's own `Attachment` objects, not as
14
+ * pre-built provider payloads: the side session accepts the same shapes the chat
15
+ * does (images as payloads, text files inlined into the prompt), and it makes
16
+ * that split itself — from the fields the attach step already filled in.
12
17
  *
13
18
  * It lives beside the rest of the client-side btw state (`hooks/chat/btw/`)
14
19
  * rather than in the send handler: the attachment read goes through the shared
@@ -16,7 +21,6 @@
16
21
  */
17
22
 
18
23
  import type { Attachment } from '@/shared/types';
19
- import { attachmentImage } from '@/shared/lib/chat/attachments';
20
24
 
21
25
  /** A bare `/btw` opens the form on the current history; anything after it is
22
26
  * the first question. Case-insensitive, because omp's command tokens are. */
@@ -30,13 +34,9 @@ export function dispatchBtwCommand(text: string, attachments: Attachment[]): boo
30
34
  const match = BTW_COMMAND_RE.exec(text);
31
35
  if (!match) return false;
32
36
 
33
- const images = attachments
34
- .map((attachment) => attachmentImage(attachment))
35
- .filter((image): image is NonNullable<typeof image> => image !== null);
36
-
37
37
  window.dispatchEvent(
38
38
  new CustomEvent('omp:btw', {
39
- detail: { question: match[1]?.trim() ?? '', ...(images.length ? { images } : {}) },
39
+ detail: { question: match[1]?.trim() ?? '', ...(attachments.length ? { attachments } : {}) },
40
40
  }),
41
41
  );
42
42
  return true;
@@ -16,17 +16,26 @@
16
16
  * arrived with the command is asked immediately, which is what the TUI does.
17
17
  */
18
18
 
19
- import { useCallback } from 'preact/hooks';
20
- import type { BtwState, BtwTopic } from '@/shared/types';
21
- import { useBtwSession, type BtwImage, type BtwLiveAnswer } from '@/client/hooks/chat/btw';
19
+ import { useCallback, useState } from 'preact/hooks';
20
+ import type { Attachment, BtwDialog, BtwState, BtwTopic, ChatMessageData } from '@/shared/types';
21
+ import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
22
+ import { useBtwSession, type BtwFirstQuestionPicks, type BtwLiveAnswer } from '@/client/hooks/chat/btw';
22
23
  import { useChamberEvent } from '@/client/hooks/ui/window-event';
23
24
  import { useSessionState } from '@/client/hooks/workspace/session-state';
24
25
  import { useSearchParams } from '@/client/lib/router/search-params';
25
26
  import { readStreamTransport } from '@/shared/lib/chat/omp/transport';
27
+ import { normalizeApprovalMode } from '@/shared/lib/omp/config/access-mode';
28
+ import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
29
+ import { readChamberSetting } from '@/shared/lib/settings/client';
26
30
 
27
31
  /** Question sent when a question arrived carrying only images. */
28
32
  const ATTACHMENT_ONLY_QUESTION = 'Describe the attached image.';
29
33
 
34
+ /** The composer's persisted access pick, for a topic that has none yet. */
35
+ function readAccessMode(appSettings: Record<string, unknown>): ApprovalMode {
36
+ return normalizeApprovalMode(readChamberSetting<unknown>('omp_access_mode', appSettings));
37
+ }
38
+
30
39
  export interface BtwMode {
31
40
  open: boolean;
32
41
  topics: BtwTopic[];
@@ -35,9 +44,16 @@ export interface BtwMode {
35
44
  runningTopicId: string | null;
36
45
  /** A command is still in flight — the panel holds its actions meanwhile. */
37
46
  busy: boolean;
38
- /** Partial answer of the running turn, when it belongs to the active topic. */
39
- liveAnswer: string;
47
+ /** The running turn's conversation, chat-shaped; empty when none. */
48
+ liveMessages: ChatMessageData[];
49
+ /** What that turn is doing, for the panel's indicator (side stream's own). */
50
+ liveVerb?: string;
40
51
  error: string | null;
52
+ /** Ask/approval dialogs the side child is blocked on, oldest first. */
53
+ dialogs: BtwDialog[];
54
+ /** Access mode the active topic's child runs under. */
55
+ accessMode: ApprovalMode;
56
+ thinkingLevel: string;
41
57
  askDraft: string;
42
58
  followUpDraft: string;
43
59
  setAskDraft: (value: string) => void;
@@ -51,8 +67,13 @@ export interface BtwMode {
51
67
  /** Submit the ask card: a question of its own. */
52
68
  submitAsk: () => void;
53
69
  /** Submit the side session's composer: a follow-up to the active topic. */
54
- submitFollowUp: (question: string, images?: BtwImage[]) => void;
70
+ submitFollowUp: (question: string, attachments?: Attachment[]) => void;
55
71
  abort: () => void;
72
+ /** Re-target the side child's model (applies to the running turn). */
73
+ setModel: (provider: string, modelId: string) => void;
74
+ setThinkingLevel: (level: string) => void;
75
+ setAccessMode: (mode: ApprovalMode) => void;
76
+ respondToDialog: (id: string, response: { value: string } | { confirmed: boolean } | { cancelled: true }) => void;
56
77
  /** Returns the session id to open, or null when promotion was refused. */
57
78
  promote: () => Promise<string | null>;
58
79
  removeTopic: (topicId: string) => void;
@@ -61,7 +82,18 @@ export interface BtwMode {
61
82
  }
62
83
 
63
84
  export function useBtwMode(sessionId: string | null, appSettings: Record<string, unknown>): BtwMode {
64
- const [open, setOpen] = useSessionState<boolean>('chat.btwOpen', false);
85
+ /**
86
+ * Whether the panel is on screen. Deliberately NOT restored from session
87
+ * state: a panel that reopens itself on every load leaves the user with no
88
+ * chat composer and a field they did not ask for, and a `/btw` typed into
89
+ * that field becomes a real question to the model. The panel is a mode the
90
+ * user enters, so every load starts in the chat.
91
+ *
92
+ * The drafts and the selected topic DO persist (`chat.btwAsk` and friends):
93
+ * leaving and re-entering the panel loses nothing, which is what those slots
94
+ * are for.
95
+ */
96
+ const [open, setOpen] = useState(false);
65
97
  const [askDraft, setAskDraft] = useSessionState<string>('chat.btwAsk', '');
66
98
  const [followUpDraft, setFollowUpDraft] = useSessionState<string>('chat.btwFollowUp', '');
67
99
  const [selectedTopicId, setSelectedTopicId] = useSessionState<string | null>('chat.btwTopicId', null);
@@ -79,6 +111,26 @@ export function useBtwMode(sessionId: string | null, appSettings: Record<string,
79
111
  const runningTopicId = session.state?.runningTopicId ?? null;
80
112
  const live: BtwLiveAnswer | null = session.live && session.live.topicId === activeTopic?.id ? session.live : null;
81
113
 
114
+ /**
115
+ * What the running turn is doing, for the panel's indicator.
116
+ *
117
+ * The verb is the SERVER's, derived from the side child's own frames
118
+ * (`describeAssistantPhase` / `describeToolActivity` over `btw_activity`) —
119
+ * never the chat's, whose run is a different process. Until the child reports
120
+ * one the turn is still working, which is all the panel can honestly say.
121
+ */
122
+ const liveVerb = live?.activity || (runningTopicId ? PHASE_VERBS.sideQuestion : undefined);
123
+
124
+ /**
125
+ * Composer picks made before a topic exists. They are per-session UI state
126
+ * rather than props, because the form must show what the FIRST question will
127
+ * actually run with — the topic that would otherwise own these values has not
128
+ * been created yet.
129
+ */
130
+ const [pendingModel, setPendingModel] = useSessionState<BtwFirstQuestionPicks['model'] | null>('chat.btwModel', null);
131
+ const [pendingThinking, setPendingThinking] = useSessionState<string>('chat.btwThinking', 'auto');
132
+ const [pendingAccess, setPendingAccess] = useSessionState<ApprovalMode | null>('chat.btwAccess', null);
133
+
82
134
  const selectNewest = useCallback(
83
135
  (next: BtwState | null) => {
84
136
  if (!next) return;
@@ -90,30 +142,45 @@ export function useBtwMode(sessionId: string | null, appSettings: Record<string,
90
142
  // `/btw [question]` hands the question over; a bare `/btw` only opens the
91
143
  // form, which is the history view.
92
144
  useChamberEvent('omp:btw', (event) => {
93
- const detail = (event as CustomEvent<{ question?: string; images?: BtwImage[] }>).detail;
145
+ const detail = (event as CustomEvent<{ question?: string; attachments?: Attachment[] }>).detail;
94
146
  const question = detail?.question?.trim();
95
147
  setOpen(true);
96
- if (question || detail?.images?.length) {
148
+ if (question || detail?.attachments?.length) {
97
149
  setAskDraft('');
98
- void session.ask(question || ATTACHMENT_ONLY_QUESTION, detail?.images).then(selectNewest);
150
+ void session.ask(question || ATTACHMENT_ONLY_QUESTION, detail?.attachments).then(selectNewest);
99
151
  }
100
152
  });
101
153
 
154
+ /**
155
+ * What the next FIRST question will run with: the composer's picks until a
156
+ * topic exists, then that topic's own settings. Every setter below writes to
157
+ * the topic when there is one and to this pending slot when there is not —
158
+ * a pick that only reached the UI would silently not apply.
159
+ */
160
+ const firstQuestionPicks = useCallback(
161
+ (): BtwFirstQuestionPicks => ({
162
+ ...(pendingModel ? { model: pendingModel } : {}),
163
+ ...(pendingThinking && pendingThinking !== 'auto' ? { thinkingLevel: pendingThinking } : {}),
164
+ ...(pendingAccess ? { approvalMode: pendingAccess } : {}),
165
+ }),
166
+ [pendingModel, pendingThinking, pendingAccess],
167
+ );
168
+
102
169
  const submitAsk = useCallback(() => {
103
170
  const question = askDraft.trim();
104
171
  if (!question) return;
105
172
  setAskDraft('');
106
- void session.ask(question).then(selectNewest);
107
- }, [askDraft, session, setAskDraft, selectNewest]);
173
+ void session.ask(question, undefined, undefined, firstQuestionPicks()).then(selectNewest);
174
+ }, [askDraft, session, setAskDraft, selectNewest, firstQuestionPicks]);
108
175
 
109
176
  const submitFollowUp = useCallback(
110
- (question: string, images?: BtwImage[]) => {
177
+ (question: string, attachments?: Attachment[]) => {
111
178
  const text = question.trim();
112
- if (!text && !images?.length) return;
179
+ if (!text && !attachments?.length) return;
113
180
  setFollowUpDraft('');
114
181
  // With no topic in view yet, the side session's first question is the
115
182
  // topic — there is nothing to follow up on.
116
- void session.ask(text || ATTACHMENT_ONLY_QUESTION, images, activeTopic?.id).then((next) => {
183
+ void session.ask(text || ATTACHMENT_ONLY_QUESTION, attachments, activeTopic?.id).then((next) => {
117
184
  if (!activeTopic) selectNewest(next);
118
185
  });
119
186
  },
@@ -150,8 +217,15 @@ export function useBtwMode(sessionId: string | null, appSettings: Record<string,
150
217
  activeTopic,
151
218
  runningTopicId,
152
219
  busy: session.busy,
153
- liveAnswer: live ? live.text : '',
220
+ liveMessages: live ? live.messages : [],
221
+ liveVerb,
154
222
  error: session.error,
223
+ dialogs: session.state?.dialogs ?? [],
224
+ // Before a topic exists the composer's own picks are what the next question
225
+ // will run with, so they are what the controls must show — and after it,
226
+ // the topic's own settings (which the pick was written to).
227
+ accessMode: activeTopic?.approvalMode ?? pendingAccess ?? readAccessMode(appSettings),
228
+ thinkingLevel: activeTopic?.thinkingLevel ?? pendingThinking,
155
229
  askDraft,
156
230
  followUpDraft,
157
231
  setAskDraft,
@@ -168,6 +242,27 @@ export function useBtwMode(sessionId: string | null, appSettings: Record<string,
168
242
  abort: () => {
169
243
  if (runningTopicId) void session.abort(runningTopicId);
170
244
  },
245
+ setModel: (provider: string, modelId: string) => {
246
+ if (!activeTopic) {
247
+ setPendingModel({ provider, id: modelId });
248
+ return;
249
+ }
250
+ void session.setModel(activeTopic.id, provider, modelId);
251
+ },
252
+ setThinkingLevel: (level: string) => {
253
+ setPendingThinking(level);
254
+ if (!activeTopic) return;
255
+ void session.setThinkingLevel(activeTopic.id, level);
256
+ },
257
+ setAccessMode: (mode: ApprovalMode) => {
258
+ setPendingAccess(mode);
259
+ if (!activeTopic) return;
260
+ void session.setAccessMode(activeTopic.id, mode);
261
+ },
262
+ respondToDialog: (id: string, response: { value: string } | { confirmed: boolean } | { cancelled: true }) => {
263
+ if (!activeTopic) return;
264
+ void session.respondToDialog(activeTopic.id, id, response);
265
+ },
171
266
  promote,
172
267
  removeTopic,
173
268
  selectTopic: (topicId: string) => {
@@ -13,6 +13,12 @@ export interface UseComposerTriggerOptions {
13
13
  disabled?: boolean;
14
14
  /** Workspace root to scope `@` file mentions to (null → app root). */
15
15
  rootPath?: string | null;
16
+ /**
17
+ * Whether the popup may open at all. A composer with no completable surface
18
+ * (the side-question form) turns it off so a stray `@` or `/` cannot open an
19
+ * empty list over its text.
20
+ */
21
+ enabled?: boolean;
16
22
  }
17
23
 
18
24
  export interface ComposerTriggerController {
@@ -55,7 +61,7 @@ function shouldOpen(trigger: ComposerTrigger | null, matches: ComposerMatchItem[
55
61
 
56
62
  /** Orchestrates the composer `@`-mention / `/`-command autocomplete flow. */
57
63
  export function useComposerTrigger(options: UseComposerTriggerOptions): ComposerTriggerController {
58
- const { value, setValue, textareaRef, disabled = false, rootPath } = options;
64
+ const { value, setValue, textareaRef, disabled = false, rootPath, enabled = true } = options;
59
65
 
60
66
  const [trigger, setTrigger] = useState<ComposerTrigger | null>(null);
61
67
  const [activeIndex, setActiveIndexState] = useState(0);
@@ -77,7 +83,7 @@ export function useComposerTrigger(options: UseComposerTriggerOptions): Composer
77
83
 
78
84
  const optionId = useCallback((index: number) => `${listboxId}-option-${index}`, [listboxId]);
79
85
 
80
- const open = shouldOpen(trigger, matches);
86
+ const open = enabled && shouldOpen(trigger, matches);
81
87
 
82
88
  const close = useCallback(() => {
83
89
  setTrigger(null);
@@ -109,14 +115,14 @@ export function useComposerTrigger(options: UseComposerTriggerOptions): Composer
109
115
  }
110
116
 
111
117
  setValue(text);
112
- if (disabled) {
118
+ if (disabled || !enabled) {
113
119
  close();
114
120
  return;
115
121
  }
116
122
  setTrigger(detectComposerTrigger(text, caret));
117
123
  setActiveIndexState(0);
118
124
  },
119
- [setValue, close, disabled],
125
+ [setValue, close, disabled, enabled],
120
126
  );
121
127
 
122
128
  const selectItem = useCallback(
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Access-control (approval) mode for the composer: a global, persisted user
8
+ * preference, unlike the per-session model/thinking picks. It hydrates from
9
+ * appSettings at first paint and is mirrored into a ref so the send path reads
10
+ * the latest value without re-creating `executeSend`. Extracted from
11
+ * useChatTimeline so that hook stays under the repo's per-file size ceiling.
12
+ */
13
+
14
+ import { useCallback, useRef, useState } from 'preact/hooks';
15
+ import { ACCESS_MODE_SETTING_KEY, normalizeApprovalMode } from '@/shared/lib/omp/config/access-mode';
16
+ import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
17
+ import { writeSetting } from '@/shared/lib/settings/client';
18
+
19
+ export interface ChatTimelineAccessMode {
20
+ accessMode: ApprovalMode;
21
+ /** Live value for callbacks created once (send, queue delivery). */
22
+ accessModeRef: { current: ApprovalMode };
23
+ setAccessMode: (mode: ApprovalMode) => void;
24
+ }
25
+
26
+ export function useChatTimelineAccessMode(appSettings: Record<string, any>): ChatTimelineAccessMode {
27
+ const [accessMode, setAccessModeState] = useState<ApprovalMode>(() => normalizeApprovalMode(appSettings.omp_access_mode));
28
+ const accessModeRef = useRef<ApprovalMode>(accessMode);
29
+ accessModeRef.current = accessMode;
30
+
31
+ const setAccessMode = useCallback((mode: ApprovalMode) => {
32
+ setAccessModeState(mode);
33
+ accessModeRef.current = mode;
34
+ // Persist the last selection; the server reads this key as the spawn-time
35
+ // default. Fire-and-forget: the in-memory value is already authoritative
36
+ // for this session's requests, which carry it explicitly.
37
+ writeSetting(ACCESS_MODE_SETTING_KEY, mode);
38
+ }, []);
39
+
40
+ return { accessMode, accessModeRef, setAccessMode };
41
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The ask/approval routing split, and the one thing it must never do: paint a
8
+ * dialog as a modal and then take it back.
9
+ *
10
+ * On session open the pending dialogs are replayed from the agent-state probe
11
+ * (which answers out of the wrapper's local flags) long before the session
12
+ * JSONL lands, so every replayed frame looks unowned while `messages` is still
13
+ * empty — while the ask card that owns it is one fetch away. Deciding "no card
14
+ * owns this" during that window flashed the ask modal on every switch to a
15
+ * session parked on a question. These pin the hold, and that it never turns
16
+ * into a silently dropped question.
17
+ */
18
+
19
+ import { describe, expect, test } from 'bun:test';
20
+
21
+ import type { ChatMessageData } from '@/shared/types';
22
+ import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
23
+ import { splitAskFrames } from '@/client/hooks/chat/timeline/ask-frames';
24
+
25
+ /** An assistant turn whose `ask` tool call asked `questions`, as the JSONL and
26
+ * the live stream both deliver it. */
27
+ function askHistory(questions: string[]): ChatMessageData[] {
28
+ return [
29
+ {
30
+ id: 'msg-ask',
31
+ role: 'ai',
32
+ content: '',
33
+ toolCalls: [
34
+ {
35
+ id: 'call_ask',
36
+ type: 'ask',
37
+ name: 'ask',
38
+ title: 'ask',
39
+ input: { questions: questions.map((question) => ({ question, options: [{ label: 'A' }, { label: 'B' }] })) },
40
+ },
41
+ ],
42
+ },
43
+ ];
44
+ }
45
+
46
+ function selectFrame(id: string, title: string): ExtensionUiDialogRequest {
47
+ return { type: 'extension_ui_request', id, method: 'select', title, options: ['A (Recommended)', 'B', 'Other (type your own)'] };
48
+ }
49
+
50
+ function confirmFrame(id: string, title: string): ExtensionUiDialogRequest {
51
+ return { type: 'extension_ui_request', id, method: 'confirm', title };
52
+ }
53
+
54
+ describe('splitAskFrames', () => {
55
+ test('a question dialog is claimed by the ask card, not handed to the modal', () => {
56
+ const frame = selectFrame('f1', 'Which language?');
57
+ const split = splitAskFrames(askHistory(['Which language?']), [frame], true);
58
+
59
+ expect(split.framesByTool.get('call_ask')?.[0]?.map((held) => held.id)).toEqual(['f1']);
60
+ expect(split.modalRequest).toBeNull();
61
+ });
62
+
63
+ test('the modal is held while the session history is still loading', () => {
64
+ const question = selectFrame('f1', 'Which language?');
65
+ const gate = confirmFrame('g1', 'Run bash?');
66
+
67
+ // The regression: the frame has been replayed, its tool call has not.
68
+ expect(splitAskFrames([], [question], false)).toEqual({ framesByTool: new Map(), modalRequest: null });
69
+ // The hold is uniform — a gate cannot be told from an ask before history
70
+ // either, and it is re-decided the moment the history settles.
71
+ expect(splitAskFrames([], [gate], false).modalRequest).toBeNull();
72
+ });
73
+
74
+ test('once the history has settled an unclaimed dialog stays answerable', () => {
75
+ const frame = selectFrame('f1', 'Which language?');
76
+ expect(splitAskFrames([], [frame], true).modalRequest).toBe(frame);
77
+ });
78
+
79
+ test('an approval gate keeps the modal beside a claimed question', () => {
80
+ const question = selectFrame('f1', 'Which language?');
81
+ const gate = confirmFrame('g1', 'Run bash?');
82
+ const split = splitAskFrames(askHistory(['Which language?']), [question, gate], true);
83
+
84
+ expect(split.framesByTool.get('call_ask')?.[0]?.map((held) => held.id)).toEqual(['f1']);
85
+ expect(split.modalRequest).toBe(gate);
86
+ });
87
+ });
@@ -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;