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
@@ -15,18 +15,50 @@
15
15
  */
16
16
 
17
17
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
18
- import type { AgentImage, BtwState, StreamTransport } from '@/shared/types';
19
- import { BtwRequestError, abortBtwQuestion, askBtwQuestion, connectBtwStream, deleteBtwTopic, fetchBtwState, promoteBtwTopic } from '@/shared/lib/chat/btw/client';
18
+ import type { AgentImage, Attachment, BtwState, ChatMessageData, StreamTransport } from '@/shared/types';
19
+ import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
20
+ import { attachmentImage, readTextAttachments } from '@/shared/lib/chat/attachments';
21
+ import {
22
+ BtwRequestError,
23
+ abortBtwQuestion,
24
+ askBtwQuestion,
25
+ connectBtwStream,
26
+ deleteBtwTopic,
27
+ fetchBtwState,
28
+ promoteBtwTopic,
29
+ respondBtwDialog,
30
+ setBtwAccessMode,
31
+ setBtwModel,
32
+ setBtwThinkingLevel,
33
+ } from '@/shared/lib/chat/btw/client';
20
34
  import type { StreamConnection } from '@/shared/lib/chat/omp/transport';
21
35
 
22
36
  /** Images the side session accepts, already read as base64. */
23
37
  export type BtwImage = AgentImage;
24
38
 
25
- /** The turn streaming right now; `text` accumulates its deltas. */
39
+ /**
40
+ * The turn streaming right now. `messages` is the turn's conversation in the
41
+ * chat's own shape, upserted by `message.id` exactly as the chat stream does —
42
+ * so the panel renders a live side answer through the same components the chat
43
+ * uses, tool calls included.
44
+ */
26
45
  export interface BtwLiveAnswer {
27
46
  topicId: string;
28
47
  turnIndex: number;
29
- text: string;
48
+ messages: ChatMessageData[];
49
+ /** Activity phrase for the panel's indicator, from the side child's frames. */
50
+ activity: string;
51
+ }
52
+
53
+ /**
54
+ * Composer picks for a topic's FIRST question. A topic that does not exist yet
55
+ * has no row to write them to, so they ride the ask and reach the spawn; later
56
+ * questions address an existing topic through the dedicated setters.
57
+ */
58
+ export interface BtwFirstQuestionPicks {
59
+ model?: { provider: string; id: string };
60
+ thinkingLevel?: string;
61
+ approvalMode?: ApprovalMode;
30
62
  }
31
63
 
32
64
  export interface BtwSessionHandle {
@@ -37,8 +69,12 @@ export interface BtwSessionHandle {
37
69
  busy: boolean;
38
70
  /** Resolves with the canonical state the command produced, or null when it
39
71
  * was refused (the reason is then in `error`). */
40
- ask: (question: string, images?: BtwImage[], topicId?: string) => Promise<BtwState | null>;
72
+ ask: (question: string, attachments?: Attachment[], topicId?: string, picks?: BtwFirstQuestionPicks) => Promise<BtwState | null>;
41
73
  abort: (topicId: string) => Promise<void>;
74
+ setModel: (topicId: string, provider: string, modelId: string) => Promise<void>;
75
+ setThinkingLevel: (topicId: string, level: string) => Promise<void>;
76
+ setAccessMode: (topicId: string, mode: ApprovalMode) => Promise<void>;
77
+ respondToDialog: (topicId: string, id: string, response: { value: string } | { confirmed: boolean } | { cancelled: true }) => Promise<void>;
42
78
  /** Returns the new session id to open, or null when it was refused. */
43
79
  promote: (topicId: string) => Promise<string | null>;
44
80
  remove: (topicId: string) => Promise<void>;
@@ -74,20 +110,39 @@ export function useBtwSession(sessionId: string | null, options: BtwSessionOptio
74
110
  switch (frame.type) {
75
111
  case 'btw_state':
76
112
  setState(frame.state);
77
- // A settled turn's text is in the snapshot now; holding the live
78
- // buffer any longer would keep painting the partial answer.
113
+ // The snapshot carries the running turn's conversation, so a client
114
+ // attaching mid-turn (a reload, a second tab) shows the answer the
115
+ // child already produced instead of an empty bubble. A snapshot with
116
+ // no live turn ends the local buffer — the persisted rows own it now.
117
+ setLive(frame.state.live
118
+ ? {
119
+ topicId: frame.state.live.topicId,
120
+ turnIndex: frame.state.live.turnIndex,
121
+ messages: frame.state.live.messages,
122
+ activity: frame.state.live.activity,
123
+ }
124
+ : null);
125
+ break;
126
+ case 'btw_message':
127
+ // Same upsert the chat stream performs: omp emits one frame per
128
+ // segment carrying that segment's full accumulated content, so a
129
+ // same-id frame replaces in place and a new id appends.
79
130
  setLive((current) => {
80
- if (!current) return null;
81
- const topic = frame.state.topics.find((candidate) => candidate.id === current.topicId);
82
- const turn = topic?.turns.find((candidate) => candidate.index === current.turnIndex);
83
- return turn && turn.status === 'running' ? current : null;
131
+ const base =
132
+ current && current.topicId === frame.topicId && current.turnIndex === frame.turnIndex
133
+ ? current
134
+ : { topicId: frame.topicId, turnIndex: frame.turnIndex, messages: [], activity: '' };
135
+ const existing = base.messages.findIndex((message) => message.id === frame.message.id);
136
+ const messages =
137
+ existing === -1
138
+ ? [...base.messages, frame.message]
139
+ : [...base.messages.slice(0, existing), frame.message, ...base.messages.slice(existing + 1)];
140
+ return { ...base, messages };
84
141
  });
85
142
  break;
86
- case 'btw_delta':
143
+ case 'btw_activity':
87
144
  setLive((current) =>
88
- current && current.topicId === frame.topicId && current.turnIndex === frame.turnIndex
89
- ? { ...current, text: current.text + frame.text }
90
- : { topicId: frame.topicId, turnIndex: frame.turnIndex, text: frame.text },
145
+ current && current.topicId === frame.topicId ? { ...current, activity: frame.verb } : current,
91
146
  );
92
147
  break;
93
148
  case 'btw_error':
@@ -127,9 +182,29 @@ export function useBtwSession(sessionId: string | null, options: BtwSessionOptio
127
182
  );
128
183
 
129
184
  const ask = useCallback(
130
- async (question: string, images?: BtwImage[], topicId?: string): Promise<BtwState | null> => {
185
+ async (
186
+ question: string,
187
+ attachments: Attachment[] = [],
188
+ topicId?: string,
189
+ picks?: BtwFirstQuestionPicks,
190
+ ): Promise<BtwState | null> => {
131
191
  setError(null);
132
- const next = await run((id) => askBtwQuestion(id, { question, images, topicId }));
192
+ // Same split the chat send path makes: images travel as provider payloads,
193
+ // text files are inlined into the prompt. Both are read here, from the
194
+ // attachments the composer already filled in at attach time.
195
+ const images = attachments
196
+ .map((attachment) => attachmentImage(attachment))
197
+ .filter((image): image is NonNullable<typeof image> => image !== null);
198
+ const textFiles = (await readTextAttachments(attachments)).filter((file) => !file.missing);
199
+ const next = await run((id) => askBtwQuestion(id, {
200
+ question,
201
+ topicId,
202
+ ...(images.length ? { images } : {}),
203
+ ...(textFiles.length ? { textFiles } : {}),
204
+ // Only a first question carries picks; a follow-up addresses a topic
205
+ // that already owns them.
206
+ ...(topicId ? {} : picks ?? {}),
207
+ }));
133
208
  setLive(null);
134
209
  if (next && !next.runningTopicId) {
135
210
  setError('The side question was accepted but did not start running.');
@@ -147,6 +222,38 @@ export function useBtwSession(sessionId: string | null, options: BtwSessionOptio
147
222
  [run],
148
223
  );
149
224
 
225
+ const setModel = useCallback(
226
+ async (topicId: string, provider: string, modelId: string) => {
227
+ setError(null);
228
+ await run((id) => setBtwModel(id, topicId, provider, modelId));
229
+ },
230
+ [run],
231
+ );
232
+
233
+ const setThinkingLevel = useCallback(
234
+ async (topicId: string, level: string) => {
235
+ setError(null);
236
+ await run((id) => setBtwThinkingLevel(id, topicId, level));
237
+ },
238
+ [run],
239
+ );
240
+
241
+ const setAccessMode = useCallback(
242
+ async (topicId: string, mode: ApprovalMode) => {
243
+ setError(null);
244
+ await run((id) => setBtwAccessMode(id, topicId, mode));
245
+ },
246
+ [run],
247
+ );
248
+
249
+ const respondToDialog = useCallback(
250
+ async (topicId: string, id: string, response: { value: string } | { confirmed: boolean } | { cancelled: true }) => {
251
+ setError(null);
252
+ await run((requestId) => respondBtwDialog(requestId, topicId, id, response));
253
+ },
254
+ [run],
255
+ );
256
+
150
257
  const remove = useCallback(
151
258
  async (topicId: string) => {
152
259
  setError(null);
@@ -176,5 +283,19 @@ export function useBtwSession(sessionId: string | null, options: BtwSessionOptio
176
283
 
177
284
  const clearError = useCallback(() => setError(null), []);
178
285
 
179
- return { state, live, error, busy, ask, abort, promote, remove, clearError };
286
+ return {
287
+ state,
288
+ live,
289
+ error,
290
+ busy,
291
+ ask,
292
+ abort,
293
+ setModel,
294
+ setThinkingLevel,
295
+ setAccessMode,
296
+ respondToDialog,
297
+ promote,
298
+ remove,
299
+ clearError,
300
+ };
180
301
  }
@@ -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
+ });