ompchamber 1.0.0 → 1.2.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 (233) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.b938f4a693.css +2 -0
  3. package/dist/client/static/js/77.8022a800b5.js +139 -0
  4. package/dist/client/static/js/async/{1k.5974df3b87.js → 1k.a411fe5b43.js} +2 -2
  5. package/dist/client/static/js/async/{3y.71f2ccf3b6.js → 3y.3186ecdd63.js} +5 -5
  6. package/dist/client/static/js/async/5b.d88304bc13.js +1 -0
  7. package/dist/client/static/js/async/66.86544ad741.js +93 -0
  8. package/dist/client/static/js/async/7g.db82aca7ba.js +1 -0
  9. package/dist/client/static/js/async/7l.fadbbc0583.js +1 -0
  10. package/dist/client/static/js/async/{dc.856fda6560.js → ar.ad5c8822a8.js} +2 -2
  11. package/dist/client/static/js/async/cx.9c8268d542.js +226 -0
  12. package/dist/client/static/js/async/e.6443e739a2.js +79 -0
  13. package/dist/client/static/js/async/f7.46971e9d6d.js +2 -0
  14. package/dist/client/static/js/async/g.ee41bfed7f.js +2 -0
  15. package/dist/client/static/js/async/h1.d7091cef78.js +1 -0
  16. package/dist/client/static/js/async/hs.e7c59d8090.js +41 -0
  17. package/dist/client/static/js/async/jl.4911fa3383.js +1 -0
  18. package/dist/client/static/js/async/{k.7466330d1a.js → k.d16cd09ae7.js} +2 -2
  19. package/dist/client/static/js/async/{m.c038c71b9f.js → m.2e1ea3db9e.js} +2 -2
  20. package/dist/client/static/js/async/m3.d2b05f9604.js +2 -0
  21. package/dist/client/static/js/async/mt.853be2b5aa.js +1 -0
  22. package/dist/client/static/js/async/{oc.9c060c9779.js → oc.eb22093f05.js} +1 -1
  23. package/dist/client/static/js/async/{og.634fba99ac.js → og.53f068804c.js} +1 -1
  24. package/dist/client/static/js/async/{5b.0d6ae50fe8.js → q.b453193538.js} +10 -10
  25. package/dist/client/static/js/async/q5.88b50ffa95.js +286 -0
  26. package/dist/client/static/js/async/q8.7f1b48e9cd.js +21 -0
  27. package/dist/client/static/js/async/{sy.d16be843a7.js → s.aad2ecebc6.js} +1 -1
  28. package/dist/client/static/js/async/{tr.e878658140.js → tr.2a0d2b7600.js} +1 -1
  29. package/dist/client/static/js/async/vt.20406a5604.js +1 -0
  30. package/dist/client/static/js/async/x1.fe8af17016.js +90 -0
  31. package/dist/client/static/js/async/yg.5a94e1fc8a.js +149 -0
  32. package/dist/client/static/js/async/yx.af2936c368.js +10 -0
  33. package/dist/client/static/js/index.57b39ffa7f.js +34 -0
  34. package/package.json +1 -1
  35. package/src/client/components/common/ComposerPicker.tsx +16 -4
  36. package/src/client/components/common/ComposerTextarea.tsx +1 -0
  37. package/src/client/components/common/file-icon/index.tsx +4 -1
  38. package/src/client/components/common/image-viewer/index.tsx +224 -0
  39. package/src/client/components/layout/RightActivityBar.tsx +1 -1
  40. package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
  41. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
  42. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
  43. package/src/client/components/layout/desktop-layout/index.tsx +25 -96
  44. package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
  45. package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
  46. package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
  47. package/src/client/components/layout/session-sidebar/SessionItem.tsx +26 -12
  48. package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
  49. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
  50. package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
  51. package/src/client/components/settings/Modal.tsx +1 -0
  52. package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
  53. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
  54. package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
  55. package/src/client/components/workspace/btw-panel/Composer.tsx +181 -0
  56. package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
  57. package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
  58. package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
  59. package/src/client/components/workspace/btw-panel/index.tsx +83 -0
  60. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +39 -20
  61. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
  62. package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
  63. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
  64. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
  65. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
  66. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
  67. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +67 -43
  68. package/src/client/components/workspace/chat-timeline/index.tsx +14 -9
  69. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
  70. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
  71. package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
  72. package/src/client/components/workspace/editor/index.tsx +45 -36
  73. package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
  74. package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
  75. package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
  76. package/src/client/hooks/chat/btw/index.ts +180 -0
  77. package/src/client/hooks/chat/btw/intercept.ts +43 -0
  78. package/src/client/hooks/chat/btw/mode.ts +179 -0
  79. package/src/client/hooks/chat/composer/attachments.ts +198 -0
  80. package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
  81. package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
  82. package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
  83. package/src/client/hooks/chat/composer/drop-references.ts +106 -0
  84. package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
  85. package/src/client/hooks/chat/composer/file-drop.ts +226 -0
  86. package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
  87. package/src/client/hooks/chat/composer/file-reads.ts +238 -0
  88. package/src/client/hooks/chat/composer/index.ts +35 -9
  89. package/src/client/hooks/chat/composer/pipeline.ts +181 -0
  90. package/src/client/hooks/chat/omp/index.ts +3 -3
  91. package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
  92. package/src/client/hooks/chat/timeline/actions.ts +40 -4
  93. package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
  94. package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
  95. package/src/client/hooks/chat/timeline/index.ts +29 -9
  96. package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
  97. package/src/client/hooks/chat/timeline/send.ts +45 -70
  98. package/src/client/hooks/chat/timeline/session-load.ts +2 -1
  99. package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
  100. package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
  101. package/src/client/hooks/editor/file-actions.ts +49 -0
  102. package/src/client/hooks/editor/use-file-editor.ts +38 -27
  103. package/src/client/hooks/editor/use-file-transfer.ts +78 -0
  104. package/src/client/hooks/workspace/available-width.ts +33 -0
  105. package/src/client/hooks/workspace/panel-widths.ts +34 -37
  106. package/src/client/tailwind.css +22 -0
  107. package/src/server/db.server.ts +21 -287
  108. package/src/server/lib/btw/frames.test.ts +68 -0
  109. package/src/server/lib/btw/frames.ts +56 -0
  110. package/src/server/lib/btw/prompt.ts +28 -0
  111. package/src/server/lib/btw/registry.server.ts +141 -0
  112. package/src/server/lib/btw/runtime.server.ts +324 -0
  113. package/src/server/lib/btw/schema.server.ts +44 -0
  114. package/src/server/lib/btw/service.server.ts +171 -0
  115. package/src/server/lib/btw/session-copy.server.ts +213 -0
  116. package/src/server/lib/btw/session-copy.test.ts +166 -0
  117. package/src/server/lib/btw/store.server.ts +240 -0
  118. package/src/server/lib/db/schema.ts +110 -0
  119. package/src/server/lib/db/seed.ts +160 -0
  120. package/src/server/lib/db/workspace-sync.ts +70 -0
  121. package/src/server/lib/fs/root.ts +37 -0
  122. package/src/server/lib/models/preferences.server.ts +110 -0
  123. package/src/server/lib/models/registry.server.ts +206 -0
  124. package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
  125. package/src/server/lib/omp/rpc/manager.ts +27 -123
  126. package/src/server/lib/omp/rpc/utility.ts +7 -1
  127. package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
  128. package/src/server/lib/omp/session/auto-title.server.ts +152 -0
  129. package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
  130. package/src/server/lib/omp/session/blobs.server.ts +76 -0
  131. package/src/server/lib/omp/session/file-scan.ts +174 -0
  132. package/src/server/lib/omp/session/files.test.ts +147 -0
  133. package/src/server/lib/omp/session/files.ts +52 -35
  134. package/src/server/lib/omp/session/locator.ts +2 -2
  135. package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
  136. package/src/server/lib/omp/session/merge-stored.ts +171 -0
  137. package/src/server/lib/omp/session/messages.ts +34 -0
  138. package/src/server/lib/omp/session/reader.ts +2 -2
  139. package/src/server/lib/omp/session/subagent-presence.ts +9 -6
  140. package/src/server/lib/queue/delivery.server.test.ts +260 -0
  141. package/src/server/lib/queue/delivery.server.ts +63 -11
  142. package/src/server/plugins/ssr.ts +29 -2
  143. package/src/server/routes/btw/events.ts +70 -0
  144. package/src/server/routes/btw/index.ts +8 -0
  145. package/src/server/routes/btw/topics.ts +87 -0
  146. package/src/server/routes/btw/ws.ts +136 -0
  147. package/src/server/routes/chat/index.ts +2 -0
  148. package/src/server/routes/chat/session.ts +1 -115
  149. package/src/server/routes/chat/turns.ts +87 -0
  150. package/src/server/routes/fs/index.ts +4 -1
  151. package/src/server/routes/fs/raw.ts +85 -0
  152. package/src/server/routes/fs/read.ts +58 -3
  153. package/src/server/routes/index.ts +7 -1
  154. package/src/server/routes/models/root.ts +88 -215
  155. package/src/server/routes/settings/commands.ts +59 -18
  156. package/src/shared/lib/chat/attachments.test.ts +208 -0
  157. package/src/shared/lib/chat/attachments.ts +200 -15
  158. package/src/shared/lib/chat/btw/client.ts +94 -0
  159. package/src/shared/lib/chat/composer/client.test.ts +48 -0
  160. package/src/shared/lib/chat/composer/client.ts +23 -9
  161. package/src/shared/lib/chat/composer/commands.ts +290 -0
  162. package/src/shared/lib/chat/composer/composer.test.ts +195 -0
  163. package/src/shared/lib/chat/composer/filter.ts +21 -7
  164. package/src/shared/lib/chat/composer/trigger.ts +154 -21
  165. package/src/shared/lib/chat/omp/agent-events.ts +33 -80
  166. package/src/shared/lib/chat/omp/socket.ts +11 -6
  167. package/src/shared/lib/chat/omp/sse.ts +11 -6
  168. package/src/shared/lib/chat/omp/tool-results.ts +102 -0
  169. package/src/shared/lib/chat/omp/transport.ts +19 -3
  170. package/src/shared/lib/chat/text-file.ts +148 -0
  171. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
  172. package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
  173. package/src/shared/lib/chat/timeline/turns.ts +7 -0
  174. package/src/shared/lib/fs/file-kind.ts +50 -0
  175. package/src/shared/lib/fs/paths.ts +19 -1
  176. package/src/shared/lib/markdown/math.test.ts +20 -3
  177. package/src/shared/lib/models/preferences.test.ts +131 -0
  178. package/src/shared/lib/models/preferences.ts +88 -0
  179. package/src/shared/lib/models/provider-models.ts +8 -1
  180. package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
  181. package/src/shared/lib/omp/session/mapper.ts +24 -4
  182. package/src/shared/lib/omp/session/messages-map.ts +16 -3
  183. package/src/shared/lib/omp/session/messages-parse.ts +2 -2
  184. package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
  185. package/src/shared/lib/omp/subagent/label.test.ts +94 -0
  186. package/src/shared/lib/omp/subagent/label.ts +84 -0
  187. package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
  188. package/src/shared/lib/workspace/panel-widths.ts +161 -30
  189. package/src/shared/lib/workspace/right-panels.ts +50 -11
  190. package/src/shared/lib/workspace/session-state/store.ts +3 -0
  191. package/src/shared/types/btw.ts +65 -0
  192. package/src/shared/types/chat.ts +77 -66
  193. package/src/shared/types/index.ts +2 -0
  194. package/src/shared/types/models.ts +101 -0
  195. package/src/shared/types/omp/agent.ts +14 -1
  196. package/src/shared/types/omp/session.ts +4 -1
  197. package/src/shared/types/omp/subagent.ts +4 -0
  198. package/src/shared/types/settings/command.ts +13 -0
  199. package/src/shared/types/settings/state.ts +5 -0
  200. package/dist/client/static/css/index.e880517fcd.css +0 -2
  201. package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
  202. package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
  203. package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
  204. package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
  205. package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
  206. package/dist/client/static/js/async/e.93214d622b.js +0 -79
  207. package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
  208. package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
  209. package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
  210. package/dist/client/static/js/async/jl.874d352278.js +0 -1
  211. package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
  212. package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
  213. package/dist/client/static/js/async/q.23af2a358f.js +0 -21
  214. package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
  215. package/dist/client/static/js/async/s.222ee974f6.js +0 -2
  216. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  217. package/dist/client/static/js/async/vt.42416b707d.js +0 -1
  218. package/dist/client/static/js/async/x1.da8687159b.js +0 -90
  219. package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
  220. package/dist/client/static/js/index.0de15c5e17.js +0 -33
  221. package/dist/client/static/js/jp.50817896f3.js +0 -139
  222. /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
  223. /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
  224. /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
  225. /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
  226. /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
  227. /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
  228. /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
  229. /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
  230. /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
  231. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
  232. /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
  233. /package/dist/client/static/js/{index.0de15c5e17.js.LICENSE.txt → index.57b39ffa7f.js.LICENSE.txt} +0 -0
@@ -0,0 +1,181 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The composer's attach-and-send pipeline: everything between "a file arrived"
8
+ * and "a prompt is dispatched", plus the one line of feedback each outcome
9
+ * produces.
10
+ *
11
+ * Extracted from ChatInput so the component stays a layout shell and the
12
+ * composer keeps room under the repo's per-file size ceiling.
13
+ *
14
+ * The rules encoded here, each of which was a bug once:
15
+ *
16
+ * - A drop registers its files in the attachment hook BEFORE the component
17
+ * re-renders, so a send clicked immediately afterwards must read the hook's
18
+ * list, not the component's `attachments` prop — that prop is a frame behind
19
+ * and an empty read clears the composer and dispatches nothing.
20
+ * - A send waits for in-flight reads and then uses the list those reads
21
+ * returned: an attachment whose bytes landed during the wait is sent
22
+ * complete, never as the pre-read entry.
23
+ * - A send that would carry neither text nor any readable attachment is
24
+ * REPORTED, never dispatched. An empty prompt reaching the model is worse
25
+ * than not sending, and it is what a failed read used to produce.
26
+ */
27
+
28
+ import { useCallback, useRef, useState } from 'preact/hooks';
29
+ import type { SetStateAction } from 'preact/compat';
30
+ import type { Attachment } from '@/shared/types';
31
+ import { attachmentName, describeAttachmentBudget } from '@/shared/lib/chat/attachments';
32
+ import { useComposerAttachments } from '@/client/hooks/chat/composer/attachments';
33
+ import { useFileDrop, DROP_LIMIT_NOTICE, type FileDropProps } from '@/client/hooks/chat/composer/file-drop';
34
+ import type { PrimedReads } from '@/client/hooks/chat/composer/file-reads';
35
+ import {
36
+ describeReferenceFailure,
37
+ describeUnreadable,
38
+ readDroppedReference,
39
+ referenceName,
40
+ type ReferenceFailure,
41
+ } from '@/client/hooks/chat/composer/drop-references';
42
+
43
+ export interface UseComposerPipelineOptions {
44
+ attachments: Attachment[];
45
+ setAttachments: (updater: SetStateAction<Attachment[]>) => void;
46
+ /** Current composer text, read at send time. */
47
+ value: string;
48
+ disabled: boolean;
49
+ /** Workspace root, used to resolve a dropped path reference. */
50
+ rootPath?: string | null;
51
+ onSend: (attachments: Attachment[], options?: { steering?: boolean }) => void;
52
+ }
53
+
54
+ export interface ComposerPipeline {
55
+ /** Inline notice shown above the input; null when there is nothing to say. */
56
+ notice: string | null;
57
+ dismissNotice: () => void;
58
+ /** True while a file drag hovers the composer. */
59
+ isDragging: boolean;
60
+ /** Drag handlers for the composer card. */
61
+ dropProps: FileDropProps;
62
+ /** Attach a batch (picker, paste, or a drop the caller already parsed). */
63
+ acceptFiles: (files: File[], incomplete?: boolean, references?: string[], primed?: PrimedReads) => void;
64
+ /** Remove one attachment. */
65
+ removeAttachment: (id: string) => void;
66
+ /** Send the composer's contents. */
67
+ submit: (options?: { steering?: boolean }) => void;
68
+ }
69
+
70
+ export function useComposerPipeline({
71
+ attachments,
72
+ setAttachments,
73
+ value,
74
+ disabled,
75
+ rootPath,
76
+ onSend,
77
+ }: UseComposerPipelineOptions): ComposerPipeline {
78
+ const { addFiles, removeAttachment, readyForSend, peek, clear } = useComposerAttachments({ attachments, setAttachments });
79
+ // Guards a double click: the composer is cleared only after the reads land, so
80
+ // without this a second click would dispatch the same attachment twice.
81
+ const sendingRef = useRef(false);
82
+ const [notice, setNotice] = useState<string | null>(null);
83
+
84
+ // A download dragged out of a web page carries no File — only a URL. The
85
+ // server reads it, because the browser cannot fetch an arbitrary path and the
86
+ // drop gives no bytes to work with.
87
+ const attachReferences = useCallback(async (references: string[]) => {
88
+ const resolved: File[] = [];
89
+ const failed: string[] = [];
90
+ const reasons = new Map<string, ReferenceFailure>();
91
+ await Promise.all(references.map(async (reference) => {
92
+ const name = referenceName(reference);
93
+ const result = await readDroppedReference(reference, rootPath ?? null);
94
+ if (result.ok) {
95
+ resolved.push(result.file);
96
+ return;
97
+ }
98
+ failed.push(name);
99
+ reasons.set(name, result.reason);
100
+ }));
101
+ if (resolved.length > 0) {
102
+ const { unreadable } = await addFiles(resolved);
103
+ setNotice(unreadable.length > 0 ? describeUnreadable(unreadable) : null);
104
+ }
105
+ if (failed.length > 0) {
106
+ // Named per cause: "outside the folders the chamber may read" is something
107
+ // the user can act on, while a bare "could not be read" is not.
108
+ const reason = reasons.get(failed[0]) ?? 'unavailable';
109
+ setNotice(describeReferenceFailure(failed, reason));
110
+ }
111
+ }, [addFiles, rootPath]);
112
+
113
+ const acceptFiles = useCallback((
114
+ files: File[],
115
+ incomplete = false,
116
+ references: string[] = [],
117
+ primed?: PrimedReads,
118
+ ) => {
119
+ void (async () => {
120
+ const { added, skipped, unreadable } = await addFiles(files, primed);
121
+ if (skipped.length > 0) {
122
+ setNotice(describeAttachmentBudget(skipped));
123
+ } else if (incomplete) {
124
+ setNotice(DROP_LIMIT_NOTICE);
125
+ } else if (unreadable.length > 0) {
126
+ // Reported, never silent: a chip whose bytes never arrived would send a
127
+ // prompt that names a file the model cannot see.
128
+ setNotice(describeUnreadable(unreadable));
129
+ } else if (added > 0) {
130
+ setNotice(null);
131
+ }
132
+ if (references.length > 0) await attachReferences(references);
133
+ })();
134
+ }, [addFiles, attachReferences]);
135
+
136
+ // OS drag-and-drop. Bound to the composer CARD, not the textarea: the whole
137
+ // composer is the drop target, so a drop anywhere inside it (including on the
138
+ // textarea) bubbles here instead of letting the browser navigate away.
139
+ const { isDragging, dropProps } = useFileDrop({ disabled, onFiles: acceptFiles });
140
+
141
+ const submit = useCallback((options?: { steering?: boolean }) => {
142
+ // The hook's list, not the prop: a drop registers its files before this
143
+ // component re-renders, so a send clicked right after a drop would see an
144
+ // empty prop, clear the composer and dispatch a prompt carrying nothing.
145
+ const pending = peek();
146
+ if (!value.trim() && pending.length === 0) {
147
+ return;
148
+ }
149
+ if (sendingRef.current) {
150
+ return;
151
+ }
152
+ sendingRef.current = true;
153
+ // Bytes are read at attach time; waiting here returns the list as it stands
154
+ // once those reads land, so an attachment whose read was still in flight is
155
+ // sent complete rather than empty.
156
+ void readyForSend().then((outgoing) => {
157
+ clear();
158
+ // "Readable" means there is something TO SEND. An attachment whose read
159
+ // produced an empty string has a `content` field but no content, and
160
+ // treating that as readable is how a prompt with an empty body went out.
161
+ const readable = outgoing.some((a) => (a.content?.length ?? 0) > 0 || a.dataBase64 !== undefined);
162
+ if (!value.trim() && outgoing.length > 0 && !readable) {
163
+ setNotice(describeUnreadable(outgoing.map((a) => attachmentName(a))));
164
+ return;
165
+ }
166
+ onSend(outgoing, options);
167
+ }).finally(() => {
168
+ sendingRef.current = false;
169
+ });
170
+ }, [clear, onSend, peek, readyForSend, value]);
171
+
172
+ return {
173
+ notice,
174
+ dismissNotice: useCallback(() => setNotice(null), []),
175
+ isDragging,
176
+ dropProps,
177
+ acceptFiles,
178
+ removeAttachment,
179
+ submit,
180
+ };
181
+ }
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
- import type { ChatMessageData, OmpAgentCallbacks, OmpAgentHandle, OmpAgentState, StreamTransport } from '@/shared/types';
2
+ import type { AgentImage, ChatMessageData, OmpAgentCallbacks, OmpAgentHandle, OmpAgentState, StreamTransport } from '@/shared/types';
3
3
  import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
4
4
  import { type ToolResultRecord, useOmpAgentStream } from '@/client/hooks/chat/omp/stream';
5
5
  import { useOmpPromptSender } from '@/client/hooks/chat/omp/prompt-send';
@@ -120,7 +120,7 @@ export function useOmpAgent(sessionId: string | null, callbacks: OmpAgentCallbac
120
120
  * arrives via the interruptPending guard. */
121
121
  const sendInterruptAndReply = useCallback(async (
122
122
  message: string,
123
- images?: { data: string; mimeType: string }[],
123
+ images?: AgentImage[],
124
124
  ): Promise<boolean> => {
125
125
  const sid = sessionIdRef.current;
126
126
  if (!sid) return false;
@@ -153,7 +153,7 @@ export function useOmpAgent(sessionId: string | null, callbacks: OmpAgentCallbac
153
153
  /** Enqueue a follow-up message the agent processes after the current turn. */
154
154
  const sendFollowUp = useCallback(async (
155
155
  message: string,
156
- images?: { data: string; mimeType: string }[],
156
+ images?: AgentImage[],
157
157
  ): Promise<boolean> => {
158
158
  const sid = sessionIdRef.current;
159
159
  if (!sid) return false;
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { useCallback } from 'preact/hooks';
19
19
  import type { Dispatch, SetStateAction } from 'preact/compat';
20
- import type { OmpAgentState } from '@/shared/types';
20
+ import type { AgentImage, OmpAgentState } from '@/shared/types';
21
21
  import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
22
22
 
23
23
  export interface OmpPromptSenderDeps {
@@ -29,11 +29,11 @@ export interface OmpPromptSenderDeps {
29
29
  }
30
30
 
31
31
  export interface OmpPromptSender {
32
- sendPrompt: (message: string, images?: { data: string; mimeType: string }[], options?: { accessMode?: ApprovalMode }) => Promise<boolean>;
32
+ sendPrompt: (message: string, images?: AgentImage[], options?: { accessMode?: ApprovalMode }) => Promise<boolean>;
33
33
  sendNewPrompt: (
34
34
  message: string,
35
35
  cwd: string,
36
- images?: { data: string; mimeType: string }[],
36
+ images?: AgentImage[],
37
37
  composerOptions?: { model?: { provider: string; modelId: string } | null; thinkingLevel?: string | null; accessMode?: ApprovalMode },
38
38
  ) => Promise<{ sessionId: string; model: { provider: string; modelId: string } | null } | null>;
39
39
  }
@@ -44,7 +44,7 @@ export function useOmpPromptSender(deps: OmpPromptSenderDeps): OmpPromptSender {
44
44
  /** Send a prompt to the omp session via the RPC bridge. */
45
45
  const sendPrompt = useCallback(async (
46
46
  message: string,
47
- images?: { data: string; mimeType: string }[],
47
+ images?: AgentImage[],
48
48
  options?: { accessMode?: ApprovalMode },
49
49
  ) => {
50
50
  const sid = sessionIdRef.current;
@@ -105,7 +105,7 @@ export function useOmpPromptSender(deps: OmpPromptSenderDeps): OmpPromptSender {
105
105
  const sendNewPrompt = useCallback(async (
106
106
  message: string,
107
107
  cwd: string,
108
- images?: { data: string; mimeType: string }[],
108
+ images?: AgentImage[],
109
109
  composerOptions?: { model?: { provider: string; modelId: string } | null; thinkingLevel?: string | null; accessMode?: ApprovalMode },
110
110
  ): Promise<{ sessionId: string; model: { provider: string; modelId: string } | null } | null> => {
111
111
  setState((prev) => ({ ...prev, isGenerating: true, error: null }));
@@ -16,6 +16,9 @@ import type { Dispatch, SetStateAction } from 'preact/compat';
16
16
  import type { Attachment, ChatMessageData, OmpAgentHandle, QueuedMessageModel } from '@/shared/types';
17
17
  import type { QueuedMessage } from '@/client/components/workspace/chat-timeline/QueueList';
18
18
  import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
19
+ import { applyComposerPick, consumeComposerPick, stashComposerPick, type DeferredModelStore } from '@/client/hooks/chat/timeline/deferred-model';
20
+ import { dispatchBtwCommand } from '@/client/hooks/chat/btw/intercept';
21
+ import { prepareQueuedAttachments } from '@/shared/lib/chat/attachments';
19
22
 
20
23
  export interface ChatTimelineActionsDeps {
21
24
  inputValue: string;
@@ -48,6 +51,9 @@ export interface ChatTimelineActionsDeps {
48
51
  /** Live composer model/thinking mirror, snapshotted onto queued items so
49
52
  * auto-delivery replays the exact settings. */
50
53
  composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
54
+ /** Picks made while a turn streams: held back so they cannot re-target the
55
+ * in-flight answer, then pushed onto the session before the next prompt. */
56
+ deferredComposerPickRef: DeferredModelStore;
51
57
  /** Global access-control mode, snapshotted onto queued items. */
52
58
  accessModeRef: { current: ApprovalMode };
53
59
  setSearchParams: (fn: (prev: URLSearchParams) => URLSearchParams, opts?: { replace?: boolean }) => void;
@@ -89,6 +95,7 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
89
95
  pendingComposerModelRef,
90
96
  pendingThinkingLevelRef,
91
97
  composerModelRef,
98
+ deferredComposerPickRef,
92
99
  accessModeRef,
93
100
  setSearchParams,
94
101
  } = deps;
@@ -101,6 +108,14 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
101
108
  // resume delivering after this run ends.
102
109
  stopHoldRef.current = false;
103
110
 
111
+ // `/btw [question]` is the side-question entry point, not chat text: the
112
+ // panel owns it (omp's `/btw` is TUI-only, so nothing downstream would
113
+ // understand the token). A bare `/btw` only opens the panel's history.
114
+ if (dispatchBtwCommand(textToSend, attachments)) {
115
+ setInputValue('');
116
+ return;
117
+ }
118
+
104
119
  if (isGenerating) {
105
120
  if (options?.steering) {
106
121
  // Explicit steering while a run is active.
@@ -135,7 +150,12 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
135
150
  // panel is a view of it. Delivery happens when the run ends — the
136
151
  // wrapper's terminal `agent_end` claims the head and dispatches it.
137
152
  setInputValue('');
138
- enqueueMessage({ text: textToSend, attachments, model });
153
+ // The queue row is JSON: a `File` does not survive it. Capture the text
154
+ // contents and the display fields now, so delivery can inline the files
155
+ // and render the chips without a live handle.
156
+ void prepareQueuedAttachments(attachments).then((prepared) => {
157
+ enqueueMessage({ text: textToSend, attachments: prepared, model });
158
+ });
139
159
  return;
140
160
  }
141
161
 
@@ -285,16 +305,32 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
285
305
  pendingThinkingLevelRef.current = level;
286
306
  return;
287
307
  }
308
+ // A pick made mid-run is intent for the NEXT prompt: pushing it now would
309
+ // re-target the turn already streaming (omp applies set_thinking_level to
310
+ // the running turn's next LLM call).
311
+ if (isGenerating) {
312
+ stashComposerPick(deferredComposerPickRef, { thinkingLevel: level });
313
+ return;
314
+ }
315
+ consumeComposerPick(deferredComposerPickRef, { thinkingLevel: level });
288
316
  void ompAgent.setThinkingLevel(level);
289
- }, [isOmpSession, ompAgent, pendingThinkingLevelRef]);
317
+ }, [isGenerating, isOmpSession, ompAgent, pendingThinkingLevelRef, deferredComposerPickRef]);
290
318
 
291
319
  const handleModelChange = useCallback((provider: string, modelId: string) => {
292
320
  if (!isOmpSession) {
293
321
  pendingComposerModelRef.current = { provider, modelId };
294
322
  return;
295
323
  }
296
- void ompAgent.setModel(provider, modelId);
297
- }, [isOmpSession, ompAgent, pendingComposerModelRef]);
324
+ // Same rule as the thinking pick: a mid-run model change is held back so
325
+ // the answer being streamed finishes on the model it started with, and is
326
+ // applied right before the next prompt this composer sends.
327
+ if (isGenerating) {
328
+ stashComposerPick(deferredComposerPickRef, { provider, modelId });
329
+ return;
330
+ }
331
+ consumeComposerPick(deferredComposerPickRef, { provider, modelId });
332
+ void applyComposerPick(ompAgent, { provider, modelId });
333
+ }, [isGenerating, isOmpSession, ompAgent, pendingComposerModelRef, deferredComposerPickRef]);
298
334
 
299
335
  return {
300
336
  handleSend,
@@ -0,0 +1,219 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * A composer model/thinking pick made while a turn is streaming must not reach
8
+ * the live session: omp applies `set_model` to the RUNNING turn, so pushing it
9
+ * immediately re-targets the answer in flight (verified against omp's RPC
10
+ * handler and by driving a live session — one run answered its first turn on
11
+ * the old model and its next turns on the new one).
12
+ *
13
+ * The pick is held and pushed right before the next prompt instead. These run
14
+ * against a real DOM (happy-dom) because the hooks are Preact hooks: the
15
+ * modules below are imported inside `beforeAll`, after those globals exist,
16
+ * since Preact binds its environment at evaluation time.
17
+ */
18
+
19
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
20
+ import { Window } from 'happy-dom';
21
+ import type { h as PreactH, render as PreactRender } from 'preact';
22
+ import type { act as PreactAct } from 'preact/test-utils';
23
+ import type { useChatTimelineActions as UseChatTimelineActions, ChatTimelineActionsResult } from '@/client/hooks/chat/timeline/actions';
24
+ import type { ComposerModelPick, DeferredModelStore } from '@/client/hooks/chat/timeline/deferred-model';
25
+ import { flushDeferredPick } from '@/client/hooks/chat/timeline/deferred-model';
26
+ import type { OmpAgentHandle } from '@/shared/types';
27
+
28
+ let useChatTimelineActions: typeof UseChatTimelineActions;
29
+ let h: typeof PreactH;
30
+ let render: typeof PreactRender;
31
+ let act: typeof PreactAct;
32
+
33
+ let container: HTMLElement;
34
+
35
+ interface Harness {
36
+ calls: string[];
37
+ deferred: { current: ComposerModelPick | null };
38
+ actions: ChatTimelineActionsResult;
39
+ }
40
+
41
+ /** A recording agent handle plus the calls it received. */
42
+ function makeAgent() {
43
+ const calls: string[] = [];
44
+ const agent = {
45
+ setModel: async (provider: string, modelId: string) => { calls.push(`set_model:${provider}/${modelId}`); },
46
+ setThinkingLevel: async (level: string) => { calls.push(`set_thinking_level:${level}`); },
47
+ } as unknown as OmpAgentHandle;
48
+ return { calls, agent };
49
+ }
50
+
51
+ /** Mounts the real hook and records every model/thinking RPC it pushes. */
52
+ async function mountActions(isGenerating: boolean, stashed: ComposerModelPick | null): Promise<Harness> {
53
+ const deferred: { current: ComposerModelPick | null } = { current: stashed };
54
+ const { calls, agent } = makeAgent();
55
+
56
+ const captured: ChatTimelineActionsResult[] = [];
57
+ const Probe = () => {
58
+ captured.push(useChatTimelineActions({
59
+ inputValue: '',
60
+ setInputValue: () => {},
61
+ setInputAttachments: () => {},
62
+ isGenerating,
63
+ isOmpSession: true,
64
+ sessionId: '01a0ca36-f3be-7252-a95c-3d45d180c539',
65
+ appSettings: {},
66
+ messageQueue: [],
67
+ enqueueMessage: () => {},
68
+ removeMessage: () => {},
69
+ executeSend: async () => {},
70
+ steerOmpAgent: async () => {},
71
+ ompAgent: agent,
72
+ abortControllerRef: { current: null },
73
+ setGenerating: () => {},
74
+ stopHoldRef: { current: false },
75
+ persistMessages: () => {},
76
+ setLocalMessages: () => {},
77
+ pendingComposerModelRef: { current: null },
78
+ pendingThinkingLevelRef: { current: null },
79
+ composerModelRef: { current: null },
80
+ deferredComposerPickRef: deferred,
81
+ accessModeRef: { current: 'yolo' },
82
+ setSearchParams: () => {},
83
+ }));
84
+ return null;
85
+ };
86
+ await act(async () => {
87
+ render(h(Probe, {}), container);
88
+ });
89
+ // The handlers fire-and-forget their RPC, so let the microtask chain settle.
90
+ for (let i = 0; i < 10; i++) await Promise.resolve();
91
+ return { calls, deferred, actions: captured[captured.length - 1] };
92
+ }
93
+
94
+ /** The DOM surface this file installs for Preact to render into. */
95
+ function domGlobals(win: Window): Record<string, unknown> {
96
+ return {
97
+ window: win,
98
+ document: win.document,
99
+ navigator: win.navigator,
100
+ Node: win.Node,
101
+ Element: win.Element,
102
+ HTMLElement: win.HTMLElement,
103
+ };
104
+ }
105
+
106
+ const installed: Record<string, unknown> = {};
107
+ const displaced: Record<string, unknown> = {};
108
+
109
+ beforeAll(async () => {
110
+ const win = new Window({ url: 'http://localhost' });
111
+ for (const [key, value] of Object.entries(domGlobals(win))) {
112
+ displaced[key] = (globalThis as Record<string, unknown>)[key];
113
+ installed[key] = value;
114
+ (globalThis as Record<string, unknown>)[key] = value;
115
+ }
116
+ ({ useChatTimelineActions } = await import('@/client/hooks/chat/timeline/actions'));
117
+ ({ h, render } = await import('preact'));
118
+ ({ act } = await import('preact/test-utils'));
119
+ });
120
+
121
+ // Every test file shares one process, so the globals go back the way they were.
122
+ afterAll(() => {
123
+ for (const key of Object.keys(installed)) {
124
+ if (displaced[key] === undefined) delete (globalThis as Record<string, unknown>)[key];
125
+ else (globalThis as Record<string, unknown>)[key] = displaced[key];
126
+ }
127
+ });
128
+
129
+ beforeEach(() => {
130
+ container = document.createElement('div');
131
+ document.body.appendChild(container);
132
+ });
133
+
134
+ describe('composer model/thinking picks during a stream', () => {
135
+ test('a mid-stream model pick sends no RPC and is stashed', async () => {
136
+ const { calls, deferred, actions } = await mountActions(true, null);
137
+ actions.handleModelChange('kenari', 'deepseek-v4-pro');
138
+
139
+ expect(calls).toEqual([]);
140
+ expect(deferred.current).toEqual({ provider: 'kenari', modelId: 'deepseek-v4-pro' });
141
+ });
142
+
143
+ test('a mid-stream thinking pick sends no RPC and is stashed', async () => {
144
+ const { calls, deferred, actions } = await mountActions(true, null);
145
+ actions.handleThinkingLevelChange('high');
146
+
147
+ expect(calls).toEqual([]);
148
+ expect(deferred.current).toEqual({ thinkingLevel: 'high' });
149
+ });
150
+
151
+ test('model then thinking mid-stream both survive to the next prompt', async () => {
152
+ const { calls, deferred, actions } = await mountActions(true, null);
153
+ actions.handleModelChange('kenari', 'deepseek-v4-pro');
154
+ actions.handleThinkingLevelChange('max');
155
+
156
+ expect(calls).toEqual([]);
157
+ expect(deferred.current).toEqual({ provider: 'kenari', modelId: 'deepseek-v4-pro', thinkingLevel: 'max' });
158
+ });
159
+
160
+ test('an idle pick reaches the session immediately', async () => {
161
+ const { calls, deferred, actions } = await mountActions(false, null);
162
+ actions.handleModelChange('kenari', 'deepseek-v4-pro');
163
+ actions.handleThinkingLevelChange('high');
164
+ for (let i = 0; i < 10; i++) await Promise.resolve();
165
+
166
+ expect(calls).toEqual(['set_model:kenari/deepseek-v4-pro', 'set_thinking_level:high']);
167
+ expect(deferred.current).toBeNull();
168
+ });
169
+
170
+ test('an idle model pick leaves a stashed thinking level pending', async () => {
171
+ const { calls, deferred, actions } = await mountActions(false, { thinkingLevel: 'high' });
172
+ actions.handleModelChange('kenari', 'deepseek-v4-pro');
173
+ for (let i = 0; i < 10; i++) await Promise.resolve();
174
+
175
+ // The level was never applied here, so it must still wait for the next
176
+ // prompt rather than being dropped or pushed now.
177
+ expect(calls).toEqual(['set_model:kenari/deepseek-v4-pro']);
178
+ expect(deferred.current).toEqual({ thinkingLevel: 'high' });
179
+ });
180
+
181
+ test("'auto' thinking is never pushed onto the live session", async () => {
182
+ const { calls, deferred, actions } = await mountActions(false, null);
183
+ actions.handleThinkingLevelChange('auto');
184
+
185
+ expect(calls).toEqual([]);
186
+ expect(deferred.current).toBeNull();
187
+ });
188
+ });
189
+
190
+ describe('flushing a pick onto the session', () => {
191
+ test('pushes the stashed model and thinking level, then clears the slot', async () => {
192
+ const { calls, agent } = makeAgent();
193
+ const store: DeferredModelStore = { current: { provider: 'kenari', modelId: 'deepseek-v4-pro', thinkingLevel: 'max' } };
194
+
195
+ await flushDeferredPick(agent, store);
196
+
197
+ expect(calls).toEqual(['set_model:kenari/deepseek-v4-pro', 'set_thinking_level:max']);
198
+ expect(store.current).toBeNull();
199
+ });
200
+
201
+ test("a stashed 'auto' level is not pushed as a concrete level", async () => {
202
+ const { calls, agent } = makeAgent();
203
+ const store: DeferredModelStore = { current: { provider: 'kenari', modelId: 'deepseek-v4-pro', thinkingLevel: 'auto' } };
204
+
205
+ await flushDeferredPick(agent, store);
206
+
207
+ expect(calls).toEqual(['set_model:kenari/deepseek-v4-pro']);
208
+ expect(store.current).toBeNull();
209
+ });
210
+
211
+ test('flushing an empty slot sends nothing', async () => {
212
+ const { calls, agent } = makeAgent();
213
+ const store: DeferredModelStore = { current: null };
214
+
215
+ await flushDeferredPick(agent, store);
216
+
217
+ expect(calls).toEqual([]);
218
+ });
219
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Composer model/thinking picks that must NOT reach the live session yet.
8
+ *
9
+ * omp applies `set_model` / `set_thinking_level` to the RUNNING turn: the next
10
+ * LLM call inside the stream (the one after a tool result) goes out on the new
11
+ * model, so a pick made while an answer streams visibly re-targets that answer
12
+ * mid-flight. A composer pick is intent for the NEXT prompt, so while a turn is
13
+ * running it is stashed here and pushed to the session right before the next
14
+ * prompt the composer starts (plain send, steer, or queued delivery).
15
+ *
16
+ * The slot holds only what has NOT reached the session yet, so a pick applied
17
+ * directly (idle composer) clears exactly the fields it applied — a stashed
18
+ * thinking level survives a later model change instead of being resurrected
19
+ * over it.
20
+ *
21
+ * Verified against omp's RPC handler: `set_model` resolves the model and calls
22
+ * the session's `setModel` immediately — nothing defers it to a turn boundary.
23
+ */
24
+
25
+ import type { OmpAgentHandle } from '@/shared/types';
26
+
27
+ /** Fields a composer pick can carry; each handler owns its own. */
28
+ export interface ComposerModelPick {
29
+ provider?: string;
30
+ modelId?: string;
31
+ thinkingLevel?: string;
32
+ }
33
+
34
+ /** Mutable slot the timeline hook owns; the composer and send paths share it. */
35
+ export interface DeferredModelStore {
36
+ current: ComposerModelPick | null;
37
+ }
38
+
39
+ /**
40
+ * Slot that never holds a pick, for composers with no stream to defer against
41
+ * (the New Chat modal): every guard reads it as "nothing pending", so their
42
+ * adoption behaviour stays exactly as it was.
43
+ */
44
+ export const NO_PENDING_PICK: DeferredModelStore = { current: null };
45
+
46
+ /** A pick that names a model must name both halves of it. */
47
+ function hasModel(pick: ComposerModelPick): pick is ComposerModelPick & { provider: string; modelId: string } {
48
+ return Boolean(pick.provider && pick.modelId);
49
+ }
50
+
51
+ /**
52
+ * Record a pick made while a turn is streaming. Merged over the previous stash
53
+ * so a model pick and a later thinking pick both survive to the next prompt.
54
+ */
55
+ export function stashComposerPick(store: DeferredModelStore, pick: ComposerModelPick): void {
56
+ store.current = { ...store.current, ...pick };
57
+ }
58
+
59
+ /**
60
+ * Drop the fields a pick just applied directly, keeping any still-pending ones.
61
+ * Without this, a stashed pick would be flushed over the newer selection on the
62
+ * next send and silently revert the user's latest choice.
63
+ */
64
+ export function consumeComposerPick(store: DeferredModelStore, pick: ComposerModelPick): void {
65
+ const pending = store.current;
66
+ if (!pending) return;
67
+ const next: ComposerModelPick = { ...pending };
68
+ if (pick.provider || pick.modelId) {
69
+ delete next.provider;
70
+ delete next.modelId;
71
+ }
72
+ if (pick.thinkingLevel) delete next.thinkingLevel;
73
+ store.current = next.provider || next.modelId || next.thinkingLevel ? next : null;
74
+ }
75
+
76
+ /**
77
+ * Push a pick onto the live session. `thinkingLevel: 'auto'` means "leave omp's
78
+ * level alone" — the same rule the send path applies — so it is skipped rather
79
+ * than translated into a concrete level.
80
+ */
81
+ export async function applyComposerPick(agent: OmpAgentHandle, pick: ComposerModelPick): Promise<void> {
82
+ if (hasModel(pick)) await agent.setModel(pick.provider, pick.modelId);
83
+ if (pick.thinkingLevel && pick.thinkingLevel !== 'auto') await agent.setThinkingLevel(pick.thinkingLevel);
84
+ }
85
+
86
+ /** Push the stashed pick (if any) onto the session and clear the slot. */
87
+ export async function flushDeferredPick(agent: OmpAgentHandle, store: DeferredModelStore): Promise<void> {
88
+ const pick = store.current;
89
+ if (!pick) return;
90
+ store.current = null;
91
+ await applyComposerPick(agent, pick);
92
+ }