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,148 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Deciding whether a file's contents are text, and what language to fence them
8
+ * under, from the file itself rather than from its metadata.
9
+ *
10
+ * Split out of `attachments.ts` because it is the one part of classification
11
+ * that reads BYTES: everything else there works from `name`/`type`/`size`,
12
+ * which is what a replayed attachment has.
13
+ */
14
+
15
+ /**
16
+ * Extensions treated as inline-able text when the browser reports no useful
17
+ * MIME type. `File.type` is empty for many code files, so the extension is the
18
+ * only signal — a dropped `script.py` used to attach without its contents ever
19
+ * reaching the prompt.
20
+ */
21
+ export const TEXT_FILE_EXTENSIONS: Record<string, true> = {
22
+ txt: true,
23
+ text: true,
24
+ md: true,
25
+ markdown: true,
26
+ mdx: true,
27
+ json: true,
28
+ jsonc: true,
29
+ yaml: true,
30
+ yml: true,
31
+ toml: true,
32
+ ini: true,
33
+ env: true,
34
+ csv: true,
35
+ tsv: true,
36
+ log: true,
37
+ xml: true,
38
+ html: true,
39
+ htm: true,
40
+ css: true,
41
+ scss: true,
42
+ less: true,
43
+ js: true,
44
+ mjs: true,
45
+ cjs: true,
46
+ jsx: true,
47
+ ts: true,
48
+ mts: true,
49
+ cts: true,
50
+ tsx: true,
51
+ py: true,
52
+ rb: true,
53
+ go: true,
54
+ rs: true,
55
+ java: true,
56
+ kt: true,
57
+ kts: true,
58
+ swift: true,
59
+ c: true,
60
+ h: true,
61
+ cc: true,
62
+ cpp: true,
63
+ cxx: true,
64
+ hpp: true,
65
+ hh: true,
66
+ cs: true,
67
+ php: true,
68
+ pl: true,
69
+ lua: true,
70
+ r: true,
71
+ sql: true,
72
+ sh: true,
73
+ bash: true,
74
+ zsh: true,
75
+ fish: true,
76
+ ps1: true,
77
+ bat: true,
78
+ cmd: true,
79
+ dockerfile: true,
80
+ makefile: true,
81
+ gitignore: true,
82
+ editorconfig: true,
83
+ diff: true,
84
+ patch: true,
85
+ };
86
+
87
+ /** MIME types the browser reports for text-ish files it does know. */
88
+ export function isTextMimeType(type: string): boolean {
89
+ if (!type) return false;
90
+ if (type === 'text/plain' || type === 'text/markdown') return true;
91
+ return type.startsWith('text/')
92
+ || type === 'application/json'
93
+ || type === 'application/xml'
94
+ || type === 'application/x-yaml'
95
+ || type === 'application/yaml'
96
+ || type === 'application/javascript'
97
+ || type === 'application/x-sh'
98
+ || type === 'application/sql'
99
+ || type.endsWith('+json')
100
+ || type.endsWith('+xml');
101
+ }
102
+
103
+ /** Bytes sampled to decide whether an unclassified file is text. */
104
+ const SNIFF_BYTES = 4096;
105
+
106
+ /**
107
+ * Whether an unclassified file's own bytes read as text.
108
+ *
109
+ * Some hosts hand over a file whose name and MIME say nothing useful: a
110
+ * promised-file drag from another app arrives as `document` with an empty type,
111
+ * and an opaque download as `application/octet-stream`. Deciding from the name
112
+ * alone left those attached but never inlined — the chip rendered, the model saw
113
+ * nothing.
114
+ *
115
+ * Three signals, because control-character density alone misses a short binary:
116
+ * a NUL byte (no text encoding this app handles contains one), an invalid UTF-8
117
+ * sequence (a lone continuation or lead byte), and the control-character ratio
118
+ * a browser applies to a Blob. `TextDecoder` in fatal mode is the cheapest
119
+ * correct UTF-8 validator available.
120
+ */
121
+ export async function looksLikeTextFile(
122
+ file: Pick<File, 'slice'>,
123
+ /**
124
+ * Bytes already read while the drop's permission was live. A dropped file
125
+ * cannot be `slice()`d after that window closes, so the sniff is served from
126
+ * the primed read where one exists. See `file-reads.ts`.
127
+ */
128
+ primed?: Uint8Array,
129
+ ): Promise<boolean> {
130
+ try {
131
+ const bytes = primed ?? new Uint8Array(await file.slice(0, SNIFF_BYTES).arrayBuffer());
132
+ if (bytes.length === 0) return false;
133
+ if (bytes.includes(0)) return false;
134
+ try {
135
+ new TextDecoder('utf-8', { fatal: true }).decode(bytes);
136
+ } catch {
137
+ return false;
138
+ }
139
+ let control = 0;
140
+ for (const byte of bytes) {
141
+ if (byte < 9 || (byte > 13 && byte < 32)) control += 1;
142
+ }
143
+ return control / bytes.length <= 0.3;
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
@@ -16,36 +16,14 @@ import type { Dispatch, SetStateAction } from 'preact/compat';
16
16
  import type { ChatMessageData, ExtensionUiDialogRequest, IncomingExtensionUiRequest, OmpAgentCallbacks } from '@/shared/types';
17
17
  import { triggerChatCompletionSound } from '@/client/hooks/ui/notification-sound';
18
18
  import { isNoticeRow } from '@/shared/lib/chat/notice-row';
19
- import { createRafBatch } from '@/shared/lib/chat/timeline/stream-raf';
19
+ import {
20
+ bindStreamingCoalescer,
21
+ disposeStreamingCoalescer,
22
+ flushStreamingUpdates,
23
+ queueStreamingUpdate,
24
+ } from '@/shared/lib/chat/timeline/stream-coalescer';
20
25
  import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
21
26
 
22
- // omp emits one message_update per model chunk, each carrying that message's
23
- // FULL accumulated content, so a burst only needs the newest payload per
24
- // message id. This batch commits at most once per animation frame; the terminal
25
- // frame flushes synchronously first so the last chunk is never dropped.
26
- // `apply`/`afterFlush` delegate through module vars because the callbacks
27
- // factory re-runs on every render.
28
- let applyMessageUpdater: Dispatch<SetStateAction<ChatMessageData[]>> = () => {};
29
- let scrollAfterFlush: () => void = () => {};
30
-
31
- const messageBatch = createRafBatch<ChatMessageData[]>(
32
- updater => applyMessageUpdater(updater),
33
- () => scrollAfterFlush(),
34
- );
35
-
36
- /** Apply any coalesced update, then invalidate its scheduled frame. */
37
- export function disposeStreamingCoalescer(): void {
38
- messageBatch.flush();
39
- messageBatch.cancel();
40
- }
41
-
42
- /** Drop coalesced updates without applying them — a session switch discards
43
- * the previous session's queued `message_update` frames so they cannot leak
44
- * into the freshly cleared timeline of the next session. */
45
- export function cancelStreamingCoalescer(): void {
46
- messageBatch.cancel();
47
- }
48
-
49
27
  export interface OmpAgentCallbacksDeps {
50
28
  setGenerating: (v: boolean) => void;
51
29
  setGeneratingVerb: (v: string) => void;
@@ -91,8 +69,7 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
91
69
  withdrawExtensionDialog,
92
70
  } = deps;
93
71
 
94
- applyMessageUpdater = setLocalMessages;
95
- scrollAfterFlush = () => scrollToBottom('smooth');
72
+ bindStreamingCoalescer(setLocalMessages, () => scrollToBottom('smooth'));
96
73
 
97
74
  return {
98
75
  onAgentStart: () => {
@@ -152,7 +129,7 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
152
129
  if (msg.role !== 'user' && !isNoticeRow(msg)) optimisticUserIdRef.current = null;
153
130
  // Queue for the next frame, collapsing same-message bursts to the newest
154
131
  // full-content frame; scroll runs once per rendered frame.
155
- messageBatch.queue(prev => {
132
+ queueStreamingUpdate(prev => {
156
133
  const placeholderId = aiPlaceholderIdRef.current;
157
134
  // Notice rows (e.g. background job done, system alerts) are appended in
158
135
  // arrival order — the timeline renders state as-is, so omp's own write
@@ -237,7 +214,7 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
237
214
  }
238
215
  // Apply any coalesced update before the terminal frame so the last chunk
239
216
  // is never dropped and the end always runs after it.
240
- messageBatch.flush();
217
+ flushStreamingUpdates();
241
218
  if (msg.role !== 'user') optimisticUserIdRef.current = null;
242
219
  setLocalMessages(prev => {
243
220
  const placeholderId = aiPlaceholderIdRef.current;
@@ -345,6 +322,15 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
345
322
  return [...prev, row];
346
323
  });
347
324
  },
325
+ // omp renamed the session — the auto-title generation the chamber asks for
326
+ // after a settled run, a `/rename`, or an RPC set_session_name. The slot
327
+ // write is in place, so the title is already on disk: refresh the metadata
328
+ // the navbar/context panel read, and signal the sidebar (whose scan cache
329
+ // is keyed on file mtime, which a fixed-width in-place write cannot move).
330
+ onSessionTitleChanged: () => {
331
+ const sid = adoptedSessionIdRef.current ?? sessionIdRef.current;
332
+ if (sid) refreshSessionMeta(sid);
333
+ },
348
334
  onNotice: (_level, message) => {
349
335
  console.info('OMP notice:', message);
350
336
  },
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Frame coalescer for the live agent stream.
8
+ *
9
+ * omp emits one `message_update` per model chunk, each carrying that message's
10
+ * FULL accumulated content, so a burst only needs the newest payload per
11
+ * message id. This batches at most one commit per animation frame; the terminal
12
+ * frame flushes synchronously first so the last chunk is never dropped.
13
+ *
14
+ * The sink is a module-level singleton bound once per callbacks factory run
15
+ * (`bindStreamingCoalescer`), because that factory re-runs on every render
16
+ * while the batch's scheduled frame must survive across renders. Split out of
17
+ * omp-callbacks.ts so that file stays under the repo's per-file size ceiling.
18
+ */
19
+
20
+ import type { Dispatch, SetStateAction } from 'preact/compat';
21
+ import type { ChatMessageData } from '@/shared/types';
22
+ import { createRafBatch } from '@/shared/lib/chat/timeline/stream-raf';
23
+
24
+ let applyMessageUpdater: Dispatch<SetStateAction<ChatMessageData[]>> = () => {};
25
+ let scrollAfterFlush: () => void = () => {};
26
+
27
+ const messageBatch = createRafBatch<ChatMessageData[]>(
28
+ updater => applyMessageUpdater(updater),
29
+ () => scrollAfterFlush(),
30
+ );
31
+
32
+ /** Point the batch at the current render's state setter and scroll hook. */
33
+ export function bindStreamingCoalescer(
34
+ apply: Dispatch<SetStateAction<ChatMessageData[]>>,
35
+ afterFlush: () => void,
36
+ ): void {
37
+ applyMessageUpdater = apply;
38
+ scrollAfterFlush = afterFlush;
39
+ }
40
+
41
+ /** Queue a coalesced update for the next animation frame, keyed per message. */
42
+ export function queueStreamingUpdate(
43
+ updater: (prev: ChatMessageData[]) => ChatMessageData[],
44
+ key: string,
45
+ ): void {
46
+ messageBatch.queue(updater, key);
47
+ }
48
+
49
+ /** Apply any queued update synchronously (terminal frames call this first so
50
+ * the last chunk is never dropped). */
51
+ export function flushStreamingUpdates(): void {
52
+ messageBatch.flush();
53
+ }
54
+
55
+ /** Apply any coalesced update, then invalidate its scheduled frame. */
56
+ export function disposeStreamingCoalescer(): void {
57
+ messageBatch.flush();
58
+ messageBatch.cancel();
59
+ }
60
+
61
+ /** Drop coalesced updates without applying them — a session switch discards
62
+ * the previous session's queued `message_update` frames so they cannot leak
63
+ * into the freshly cleared timeline of the next session. */
64
+ export function cancelStreamingCoalescer(): void {
65
+ messageBatch.cancel();
66
+ }
@@ -8,8 +8,15 @@
8
8
  * rewind endpoint: identifying raw composer input and deciding whether a
9
9
  * JSONL-derived user turn and a stored DB-copy turn are the same request
10
10
  * (omp rewrites delivered prompts, so equality is not enough).
11
+ *
12
+ * The jump rail also reads from here: it lists user turns of the FULL session
13
+ * (`/api/chat/:id/turns`) and from the mounted window, and both build the same
14
+ * tooltip preview, so the truncation length is shared rather than duplicated.
11
15
  */
12
16
 
17
+ /** Longest prompt excerpt a rail tooltip renders. */
18
+ export const TURN_PREVIEW_CHARS = 220;
19
+
13
20
  /** Whether a stored content string is raw composer input (slash command or
14
21
  * @mention) rather than the prompt omp actually delivered. */
15
22
  export function isRawComposerInput(content: string): boolean {
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * File-kind classification shared by the server read routes and every client
8
+ * viewer (desktop editor, mobile editor, chat tool panels).
9
+ *
10
+ * A raster image is not text: decoding its bytes as UTF-8 yields replacement
11
+ * characters, so the editor used to paint a PNG as mojibake. The extension is
12
+ * the only signal available before reading the file, and it is the same signal
13
+ * the Files panel already uses to pick an icon — keeping it in one module means
14
+ * the reader, the icon, and the viewer can never disagree about which files are
15
+ * pictures.
16
+ *
17
+ * SVG is deliberately absent: it is editable XML text and stays on the
18
+ * syntax-highlighted code path.
19
+ */
20
+ export const IMAGE_MIME_BY_EXT: Record<string, string> = {
21
+ png: 'image/png',
22
+ apng: 'image/apng',
23
+ jpg: 'image/jpeg',
24
+ jpeg: 'image/jpeg',
25
+ jfif: 'image/jpeg',
26
+ gif: 'image/gif',
27
+ webp: 'image/webp',
28
+ avif: 'image/avif',
29
+ bmp: 'image/bmp',
30
+ ico: 'image/x-icon',
31
+ tif: 'image/tiff',
32
+ tiff: 'image/tiff',
33
+ };
34
+
35
+ /** Lower-cased extension of a path or name, query/fragment/line suffix removed. */
36
+ export function getFileExtension(filePath: string | undefined | null): string {
37
+ if (!filePath) return '';
38
+ const clean = filePath.split('?')[0].split('#')[0].split(/[/\\]/).pop() ?? '';
39
+ const dot = clean.lastIndexOf('.');
40
+ return dot > 0 ? clean.slice(dot + 1).toLowerCase() : '';
41
+ }
42
+
43
+ /**
44
+ * MIME type for a raster image path, or null when the extension is not one.
45
+ * Null is the "treat as text" answer, so every caller branches on this single
46
+ * check instead of re-deriving the extension list.
47
+ */
48
+ export function getImageMimeType(filePath: string | undefined | null): string | null {
49
+ return IMAGE_MIME_BY_EXT[getFileExtension(filePath)] ?? null;
50
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Path formatting for the Files panel's copy actions.
2
+ * Path helpers shared by the Files panel and every surface that opens a file.
3
3
  *
4
4
  * `/api/fs/dir` emits every entry as a path relative to the listing's base
5
5
  * directory — the workspace root, or the selected nested repo — and reports
@@ -7,7 +7,25 @@
7
7
  * two halves; a relative path on its own is not resolvable outside the panel
8
8
  * (the client-side `rootPath` may be `~`-relative, rejected by the server, or
9
9
  * absent entirely in mock mode).
10
+ *
11
+ * `buildFsRawUrl` is the byte-stream counterpart of that same scoping: it is
12
+ * what an image tab points its `<img>` at, so the file it names is the file
13
+ * the tab names, nested repo included.
14
+ */
15
+
16
+ /**
17
+ * URL of one file's raw bytes through `/api/fs/raw`, carrying the same scope
18
+ * (`root`, nested `repo`) as `/api/fs/read` so an image opened from a nested
19
+ * repo resolves to the file the tab names. `repo: '.'` is omitted: the server
20
+ * treats an absent repo as the scoped root.
10
21
  */
22
+ export function buildFsRawUrl(file: { path: string; root?: string; repo?: string }): string {
23
+ const params = new URLSearchParams({ path: file.path });
24
+ if (file.root) params.set('root', file.root);
25
+ if (file.repo && file.repo !== '.') params.set('repo', file.repo);
26
+ return `/api/fs/raw?${params.toString()}`;
27
+ }
28
+
11
29
  export function toAbsolutePath(baseDir: string | null | undefined, relPath: string): string {
12
30
  // `/`- and `./`-prefixed raws are stripped; a dot-file name (`.env`) is not a prefix.
13
31
  const rel = relPath.replace(/^(?:\.?[/\\])+/, '').replace(/[/\\]+$/, '');
@@ -20,16 +20,19 @@
20
20
  * they are no-ops everywhere else.
21
21
  */
22
22
 
23
- import { beforeAll, describe, expect, test } from 'bun:test';
23
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
24
24
  import { Window } from 'happy-dom';
25
25
 
26
26
  let hydrateMathBlocks: typeof import('@/shared/lib/markdown/katex').hydrateMathBlocks;
27
27
  let renderMarkdown: typeof import('@/shared/lib/markdown/marked').renderMarkdown;
28
28
  let sanitizeHtml: typeof import('@/shared/lib/markdown/sanitize').sanitizeHtml;
29
29
 
30
+ const displaced: Record<string, unknown> = {};
31
+ const installed: Record<string, unknown> = {};
32
+
30
33
  beforeAll(async () => {
31
34
  const win = new Window({ url: 'http://localhost' });
32
- Object.assign(globalThis, {
35
+ for (const [key, value] of Object.entries({
33
36
  window: win,
34
37
  document: win.document,
35
38
  MutationObserver: win.MutationObserver,
@@ -37,7 +40,11 @@ beforeAll(async () => {
37
40
  Element: win.Element,
38
41
  HTMLElement: win.HTMLElement,
39
42
  NodeFilter: win.NodeFilter,
40
- });
43
+ })) {
44
+ displaced[key] = (globalThis as Record<string, unknown>)[key];
45
+ installed[key] = value;
46
+ (globalThis as Record<string, unknown>)[key] = value;
47
+ }
41
48
  // Dynamic import is required here, not stylistic: `sanitize.ts` binds
42
49
  // DOMPurify at module scope, which captures the DOM globals present at
43
50
  // evaluation time. A static import would run before the happy-dom globals
@@ -47,6 +54,16 @@ beforeAll(async () => {
47
54
  ({ sanitizeHtml } = await import('@/shared/lib/markdown/sanitize'));
48
55
  });
49
56
 
57
+ // Every test file shares one process, so the globals go back the way they were:
58
+ // the fold in `agent-events.ts` branches on `typeof window`, and a leaked DOM
59
+ // window would take its browser path with a `CustomEvent` from another realm.
60
+ afterAll(() => {
61
+ for (const key of Object.keys(installed)) {
62
+ if (displaced[key] === undefined) delete (globalThis as Record<string, unknown>)[key];
63
+ else (globalThis as Record<string, unknown>)[key] = displaced[key];
64
+ }
65
+ });
66
+
50
67
  /** Render markdown and mount it exactly as `MarkdownRenderer` does. */
51
68
  function mount(content: string): HTMLElement {
52
69
  const host = document.createElement('div');
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The preference rules are shared verbatim by the server (which persists the
8
+ * arrays) and the picker (which applies them optimistically), so these lock the
9
+ * behaviour both sides read back: composite keys, most-recent-first order, and
10
+ * the five-entry cap.
11
+ */
12
+
13
+ import { describe, expect, test } from 'bun:test';
14
+
15
+ import {
16
+ EMPTY_MODEL_PREFERENCES,
17
+ RECENT_MODELS_LIMIT,
18
+ applyModelPreferences,
19
+ filterKnownKeys,
20
+ readModelPreferences,
21
+ recordRecentKey,
22
+ toggleFavoriteKey,
23
+ } from '@/shared/lib/models/preferences';
24
+ import type { AIModelOption } from '@/shared/types';
25
+
26
+ describe('toggleFavoriteKey', () => {
27
+ test('adds an unknown key at the end and removes a known one', () => {
28
+ expect(toggleFavoriteKey(['a:1'], 'b:2')).toEqual(['a:1', 'b:2']);
29
+ expect(toggleFavoriteKey(['a:1', 'b:2'], 'a:1')).toEqual(['b:2']);
30
+ });
31
+
32
+ test('keys on provider + id, so the same id under another provider is a separate favorite', () => {
33
+ // The registry serves `deepseek-v4-pro` from both `deepseek` and `kenari`.
34
+ // An id-only list would unstar both rows when either is clicked.
35
+ const favorites = toggleFavoriteKey([], 'deepseek:deepseek-v4-pro');
36
+ expect(toggleFavoriteKey(favorites, 'kenari:deepseek-v4-pro')).toEqual([
37
+ 'deepseek:deepseek-v4-pro',
38
+ 'kenari:deepseek-v4-pro',
39
+ ]);
40
+ });
41
+ });
42
+
43
+ describe('recordRecentKey', () => {
44
+ test('moves a re-picked model to the front instead of duplicating it', () => {
45
+ expect(recordRecentKey(['a:1', 'b:2', 'c:3'], 'b:2')).toEqual(['b:2', 'a:1', 'c:3']);
46
+ });
47
+
48
+ test('caps the list at five, evicting the oldest entry', () => {
49
+ const full = ['a:1', 'b:2', 'c:3', 'd:4', 'e:5'];
50
+ const next = recordRecentKey(full, 'f:6');
51
+
52
+ expect(next).toHaveLength(RECENT_MODELS_LIMIT);
53
+ expect(next[0]).toBe('f:6');
54
+ expect(next).not.toContain('e:5');
55
+ });
56
+
57
+ test('re-picking inside a full list keeps the other four', () => {
58
+ const full = ['a:1', 'b:2', 'c:3', 'd:4', 'e:5'];
59
+ expect(recordRecentKey(full, 'c:3')).toEqual(['c:3', 'a:1', 'b:2', 'd:4', 'e:5']);
60
+ });
61
+ });
62
+
63
+ describe('readModelPreferences', () => {
64
+ test('reads a well-formed pair', () => {
65
+ expect(readModelPreferences({ favorites: ['a:1'], recentKeys: ['b:2'] }))
66
+ .toEqual({ favorites: ['a:1'], recentKeys: ['b:2'] });
67
+ });
68
+
69
+ test('drops non-string entries and caps an over-long recent list', () => {
70
+ const raw = {
71
+ favorites: ['a:1', 7, null, ''],
72
+ recentKeys: ['a:1', 'b:2', 'c:3', 'd:4', 'e:5', 'f:6', 'g:7'],
73
+ };
74
+
75
+ expect(readModelPreferences(raw)).toEqual({
76
+ favorites: ['a:1'],
77
+ recentKeys: ['a:1', 'b:2', 'c:3', 'd:4', 'e:5'],
78
+ });
79
+ });
80
+
81
+ test('an absent or malformed row yields empty rails', () => {
82
+ expect(readModelPreferences(null)).toEqual(EMPTY_MODEL_PREFERENCES);
83
+ expect(readModelPreferences('nonsense')).toEqual(EMPTY_MODEL_PREFERENCES);
84
+ expect(readModelPreferences({ favorites: 'not-a-list' })).toEqual(EMPTY_MODEL_PREFERENCES);
85
+ });
86
+ });
87
+
88
+ describe('filterKnownKeys', () => {
89
+ test('drops keys the registry no longer serves', () => {
90
+ // A model removed from models.yml, or every model of a disconnected
91
+ // provider, must not hold a slot in the five-entry rail.
92
+ const known = new Set(['a:1', 'c:3']);
93
+ expect(filterKnownKeys(['a:1', 'gone:9', 'c:3'], known)).toEqual(['a:1', 'c:3']);
94
+ });
95
+
96
+ test('pruning before a write frees the slot instead of merely hiding it', () => {
97
+ // The rail reads at most five entries. A stale key left in the stored array
98
+ // occupies one of them across every reload, so the user sees four models
99
+ // and the fifth pick appears to do nothing.
100
+ const stored = ['gone:9', 'a:1', 'b:2', 'c:3', 'd:4'];
101
+ const pruned = recordRecentKey(filterKnownKeys(stored, new Set(['a:1', 'b:2', 'c:3', 'd:4', 'e:5'])), 'e:5');
102
+
103
+ expect(pruned).toEqual(['e:5', 'a:1', 'b:2', 'c:3', 'd:4']);
104
+ expect(pruned).toHaveLength(RECENT_MODELS_LIMIT);
105
+ });
106
+ });
107
+
108
+ describe('applyModelPreferences', () => {
109
+ const rows: AIModelOption[] = [
110
+ { id: '1', name: 'One', provider: 'a' },
111
+ { id: '2', name: 'Two', provider: 'a', isFavorite: true },
112
+ { id: '3', name: 'Three', provider: 'b' },
113
+ ];
114
+
115
+ test('lights only the rows named by the stored key list', () => {
116
+ const applied = applyModelPreferences(rows, { favorites: ['b:3'], recentKeys: [] });
117
+
118
+ // Truthiness is the contract the star reads: a registry row has no
119
+ // `isFavorite` field at all, so `false` and `undefined` both mean "unlit".
120
+ expect(applied.map((m) => Boolean(m.isFavorite))).toEqual([false, false, true]);
121
+ });
122
+
123
+ test('leaves already-correct rows identical so only the changed row re-renders', () => {
124
+ const applied = applyModelPreferences(rows, { favorites: [], recentKeys: [] });
125
+
126
+ expect(applied[0]).toBe(rows[0]);
127
+ expect(applied[2]).toBe(rows[2]);
128
+ expect(applied[1]).not.toBe(rows[1]);
129
+ expect(applied[1].isFavorite).toBe(false);
130
+ });
131
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Favorite + recent model preferences, keyed by the composite `provider:id`
8
+ * identity (`modelKey`). A model id alone is not unique — the omp registry
9
+ * serves the same id from several providers — so an id-keyed favorite list
10
+ * lights up every provider's row and records the wrong "recent".
11
+ *
12
+ * Pure list operations only: the server persists the arrays (one
13
+ * `app_settings` row) and the picker applies them optimistically, so both
14
+ * sides MUST agree on the ordering and the cap. Keeping the rules here is what
15
+ * makes them agree — a second copy in the route or the hook would drift.
16
+ */
17
+
18
+ import type { AIModelOption, ModelPreferences } from '@/shared/types';
19
+ import { modelKey } from '@/shared/lib/models/identity';
20
+
21
+ /** How many last-used models the RECENT rail holds. */
22
+ export const RECENT_MODELS_LIMIT = 5;
23
+
24
+ /** Used before the first write, and when a payload carries no preferences. */
25
+ export const EMPTY_MODEL_PREFERENCES: ModelPreferences = { favorites: [], recentKeys: [] };
26
+
27
+ /** Add or remove one key; the relative order of the survivors is untouched. */
28
+ export function toggleFavoriteKey(favorites: readonly string[], key: string): string[] {
29
+ return favorites.includes(key)
30
+ ? favorites.filter((entry) => entry !== key)
31
+ : [...favorites, key];
32
+ }
33
+
34
+ /**
35
+ * Move a key to the front of the recents, de-duplicated and capped. Re-picking
36
+ * a model must not leave a second copy behind — the rail would then show the
37
+ * same model twice and the cap would evict a genuinely older entry.
38
+ */
39
+ export function recordRecentKey(
40
+ recentKeys: readonly string[],
41
+ key: string,
42
+ limit: number = RECENT_MODELS_LIMIT,
43
+ ): string[] {
44
+ return [key, ...recentKeys.filter((entry) => entry !== key)].slice(0, Math.max(1, limit));
45
+ }
46
+
47
+ /**
48
+ * Drop keys the live registry no longer offers — a model removed from
49
+ * models.yml, or every model of a provider the user disconnected. Without this
50
+ * a stale key holds one of the five recent slots forever, so a rail that looks
51
+ * empty is hiding real entries behind keys nothing can render.
52
+ */
53
+ export function filterKnownKeys(keys: readonly string[], known: ReadonlySet<string>): string[] {
54
+ return keys.filter((key) => known.has(key));
55
+ }
56
+
57
+ /** Parse a persisted preferences row without trusting the JSON shape. */
58
+ export function readModelPreferences(value: unknown): ModelPreferences {
59
+ const source = typeof value === 'object' && value !== null ? value as Record<string, unknown> : {};
60
+ const strings = (input: unknown): string[] =>
61
+ Array.isArray(input) ? input.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) : [];
62
+ return {
63
+ favorites: strings(source.favorites),
64
+ recentKeys: strings(source.recentKeys).slice(0, RECENT_MODELS_LIMIT),
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Stamp the favorite flag onto a catalog list. The registry carries no
70
+ * favorites of its own — it is a read-only view of omp's config — so the picker
71
+ * learns which rows are starred from the preference store, never from the
72
+ * catalog's own `isFavorite` (that is MOCK seed data).
73
+ *
74
+ * The comparison is by truthiness, not identity: a registry row has no
75
+ * `isFavorite` field at all, so an `undefined !== false` test would rebuild
76
+ * every row on every load and re-render the whole panel. Rows whose star state
77
+ * already reads correctly are returned untouched.
78
+ */
79
+ export function applyModelPreferences(
80
+ models: AIModelOption[],
81
+ preferences: ModelPreferences,
82
+ ): AIModelOption[] {
83
+ const favorites = new Set(preferences.favorites);
84
+ return models.map((model) => {
85
+ const isFavorite = favorites.has(modelKey(model));
86
+ return Boolean(model.isFavorite) === isFavorite ? model : { ...model, isFavorite };
87
+ });
88
+ }