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
@@ -63,6 +63,14 @@ export function mergeProviderModels(
63
63
  return { merged: [...existing, ...added], added, addedCount: added.length };
64
64
  }
65
65
 
66
+ /**
67
+ * Register newly added provider models on the chamber's catalog.
68
+ *
69
+ * `isFavorite` is deliberately NOT sent: the picker derives favorites from the
70
+ * stored preference keys, so a catalog flag would be a second, silently
71
+ * ignored source of truth for the FAVORITES rail. A new provider's models are
72
+ * starred by the user, not by this sync.
73
+ */
66
74
  export async function syncProviderModelsToCatalog(
67
75
  providerName: string,
68
76
  models: ProviderModel[],
@@ -81,7 +89,6 @@ export async function syncProviderModelsToCatalog(
81
89
  provider: providerName,
82
90
  contextWindow: model.contextWindow?.split(' ')[0] || '128K',
83
91
  thinkingLevel: 'Default',
84
- isFavorite: true,
85
92
  capabilities: ['Tool calling', 'Reasoning'],
86
93
  inputFormats: ['text'],
87
94
  outputFormats: ['text'],
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Recovery of inlined text attachments from a delivered prompt.
8
+ *
9
+ * The composer does not send a text file as data — it appends the file to the
10
+ * prompt as `Attached file: <name>` plus a fenced block, and that composed
11
+ * prompt is the only record omp keeps. `stripInlinedTextAttachments` removes
12
+ * those blocks so the timeline shows the user's own words, which also discards
13
+ * the names: a reloaded session then showed an image chip and nothing for the
14
+ * `.md`/`.py`/`.json` that rode beside it.
15
+ *
16
+ * These tests pin the round trip, including the adversarial cases the format
17
+ * allows: content that itself contains a run of backticks (the fence grows to
18
+ * outrun it) and content that literally reads `Attached file:`.
19
+ */
20
+
21
+ import { describe, expect, test } from 'bun:test';
22
+ import {
23
+ extractInlinedTextAttachments,
24
+ stripInlinedTextAttachments,
25
+ } from '@/shared/lib/omp/session/parse-message-blocks';
26
+
27
+ describe('extractInlinedTextAttachments', () => {
28
+ test('recovers a file the composer appended to a prompt', () => {
29
+ const prompt = 'explain this\n\nAttached file: script.py\n```python\nx=1\n```';
30
+ expect(extractInlinedTextAttachments(prompt)).toEqual([{ name: 'script.py', content: 'x=1' }]);
31
+ // The visible text is the user's own words; the block is not shown twice.
32
+ expect(stripInlinedTextAttachments(prompt)).toBe('explain this');
33
+ });
34
+
35
+ test('recovers an attach-only prompt, whose visible text is empty', () => {
36
+ const prompt = 'Attached file: attachment-test.md\n```markdown\n# Title\n\nbody\n\n```';
37
+ // Trailing newline before the closing fence belongs to the content: the
38
+ // composer writes `<content>\n<fence>`.
39
+ expect(extractInlinedTextAttachments(prompt)).toEqual([
40
+ { name: 'attachment-test.md', content: '# Title\n\nbody\n' },
41
+ ]);
42
+ expect(stripInlinedTextAttachments(prompt)).toBe('');
43
+ });
44
+
45
+ test('recovers every file when several were attached', () => {
46
+ const prompt = 'q\n\nAttached file: a.md\n```markdown\nA\n```\n\nAttached file: b.py\n```python\nB\n```';
47
+ expect(extractInlinedTextAttachments(prompt)).toEqual([
48
+ { name: 'a.md', content: 'A' },
49
+ { name: 'b.py', content: 'B' },
50
+ ]);
51
+ });
52
+
53
+ test('leaves a prompt with no attachments alone', () => {
54
+ expect(extractInlinedTextAttachments('just a normal message')).toEqual([]);
55
+ expect(stripInlinedTextAttachments('just a normal message')).toBe('just a normal message');
56
+ });
57
+
58
+ test('does not mistake prose that merely mentions the header', () => {
59
+ // No fence follows, so this is not a block — it is the user talking.
60
+ expect(extractInlinedTextAttachments('I saw an Attached file: note in the docs')).toEqual([]);
61
+ });
62
+
63
+ test('keeps content that contains a backtick run', () => {
64
+ // `fenceForContent` grows the fence past the longest run in the content, so
65
+ // the inner ``` must survive as content and the outer ```` is the close.
66
+ const prompt = 'q\n\nAttached file: nested.md\n````markdown\nbefore\n```\ncode\n```\nafter\n````';
67
+ expect(extractInlinedTextAttachments(prompt)).toEqual([
68
+ { name: 'nested.md', content: 'before\n```\ncode\n```\nafter' },
69
+ ]);
70
+ });
71
+
72
+ test('keeps content that literally contains the header', () => {
73
+ const prompt = 'q\n\nAttached file: tricky.md\n````markdown\nAttached file: fake.txt\n```\ninner\n```\n````';
74
+ expect(extractInlinedTextAttachments(prompt)).toEqual([
75
+ { name: 'tricky.md', content: 'Attached file: fake.txt\n```\ninner\n```' },
76
+ ]);
77
+ });
78
+
79
+ test('an unterminated block yields nothing rather than a partial read', () => {
80
+ expect(extractInlinedTextAttachments('q\n\nAttached file: open.md\n```markdown\nno close')).toEqual([]);
81
+ });
82
+ });
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import type { ChatMessageData, ToolCallData } from '@/shared/types';
16
- import { extractText, extractUserImageAttachments, parseMessageBlocks, stripInlinedTextAttachments, toToolCallData } from '@/shared/lib/omp/session/parse-message-blocks';
16
+ import { extractInlinedTextAttachments, extractText, extractUserImageAttachments, parseMessageBlocks, stripInlinedTextAttachments, toToolCallData } from '@/shared/lib/omp/session/parse-message-blocks';
17
17
  import { reminderPartIndex } from '@/shared/lib/chat/notice-row';
18
18
  import { deriveTurnError } from '@/shared/lib/omp/session/turn-error';
19
19
  import { formatClock } from '@/shared/lib/format/time';
@@ -95,8 +95,22 @@ export function toChatMessage(raw: Record<string, unknown>, streaming = true): C
95
95
  }
96
96
 
97
97
  if (role === 'user') {
98
- const text = stripInlinedTextAttachments(parsed.textParts.join('\n'));
99
- const attachments = extractUserImageAttachments(raw.content);
98
+ const rawText = parsed.textParts.join('\n');
99
+ const text = stripInlinedTextAttachments(rawText);
100
+ // Parity with the JSONL path: the composer's inlined `Attached file:` blocks
101
+ // are the only record of a text attachment, so they are read back into
102
+ // chips here too. Without this a text file vanished from the timeline as
103
+ // soon as the turn was re-rendered from the wire.
104
+ const attachments = [
105
+ ...extractUserImageAttachments(raw.content),
106
+ ...extractInlinedTextAttachments(rawText).map((file, index) => ({
107
+ id: `text-${index + 1}`,
108
+ name: file.name,
109
+ type: 'text/plain',
110
+ preview: '',
111
+ content: file.content,
112
+ })),
113
+ ];
100
114
  return {
101
115
  id,
102
116
  role,
@@ -105,7 +119,13 @@ export function toChatMessage(raw: Record<string, unknown>, streaming = true): C
105
119
  startedAt,
106
120
  content: text,
107
121
  attribution,
108
- attachments: attachments.map((a) => ({ id: a.id, name: a.name, preview: a.preview, type: a.type })),
122
+ attachments: attachments.map((a) => ({
123
+ id: a.id,
124
+ name: a.name,
125
+ preview: a.preview,
126
+ type: a.type,
127
+ ...('content' in a ? { content: a.content } : {}),
128
+ })),
109
129
  };
110
130
  }
111
131
 
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { ChatMessageData } from '@/shared/types/chat';
7
- import { extractText, extractUserImageAttachments, parseAssistantContent, resultOutput, roleFor, stripInlinedTextAttachments, type OmpMessageEntry } from '@/shared/lib/omp/session/messages-parse';
7
+ import { extractInlinedTextAttachments, extractText, extractUserImageAttachments, parseAssistantContent, resultOutput, roleFor, stripInlinedTextAttachments, type OmpMessageEntry } from '@/shared/lib/omp/session/messages-parse';
8
8
  import { isRecord } from '@/shared/lib/util/guards';
9
9
  import { reminderPartIndex } from '@/shared/lib/chat/notice-row';
10
10
  import { deriveTurnError } from '@/shared/lib/omp/session/turn-error';
@@ -32,8 +32,21 @@ export function toChatMessage(entry: OmpMessageEntry): ChatMessageData | null {
32
32
  };
33
33
 
34
34
  if (role === 'user') {
35
- const text = stripInlinedTextAttachments(extractText(content));
36
- const attachments = extractUserImageAttachments(content);
35
+ const rawText = extractText(content);
36
+ const text = stripInlinedTextAttachments(rawText);
37
+ // omp keeps no attachment metadata for a text file: the composer inlined it
38
+ // into the prompt and that composed prompt is the only record. Recover the
39
+ // names from those blocks so a reloaded session still shows their chips —
40
+ // images survive on their own (they ride as a content block), text files
41
+ // did not, and disappeared from the timeline entirely.
42
+ const textAttachments = extractInlinedTextAttachments(rawText);
43
+ const attachments = [...extractUserImageAttachments(content), ...textAttachments.map((file, index) => ({
44
+ id: `text-${index + 1}`,
45
+ name: file.name,
46
+ type: 'text/plain',
47
+ preview: '',
48
+ content: file.content,
49
+ }))];
37
50
  if (!text.trim() && attachments.length === 0) return null;
38
51
  return { ...base, content: text, attachments };
39
52
  }
@@ -13,7 +13,7 @@
13
13
 
14
14
  import type { ToolCallData } from '@/shared/types/chat';
15
15
  import type { OmpMessageEntry } from '@/shared/types/omp/session';
16
- import { extractText as coreExtractText, extractUserImageAttachments, parseMessageBlocks, stripInlinedTextAttachments, toToolCallData } from '@/shared/lib/omp/session/parse-message-blocks';
16
+ import { extractInlinedTextAttachments, extractText as coreExtractText, extractUserImageAttachments, parseMessageBlocks, stripInlinedTextAttachments, toToolCallData } from '@/shared/lib/omp/session/parse-message-blocks';
17
17
  import { isRecord } from '@/shared/lib/util/guards';
18
18
 
19
19
  export { stripInlinedTextAttachments };
@@ -66,4 +66,4 @@ export function parseAssistantContent(content: unknown): ParsedAssistantTurn {
66
66
  };
67
67
  }
68
68
 
69
- export { extractUserImageAttachments };
69
+ export { extractUserImageAttachments, extractInlinedTextAttachments };
@@ -182,14 +182,37 @@ export function toToolCallData(
182
182
  };
183
183
  }
184
184
 
185
+ /**
186
+ * A user-attachment entry extracted from an omp content block. `blobRef` is set
187
+ * when omp externalized the bytes instead of inlining them (see
188
+ * `@/server/lib/omp/session/blobs.server`); the server resolves it before the
189
+ * timeline sees the attachment, so `preview` here is only a placeholder.
190
+ */
191
+ export interface ExtractedImageAttachment {
192
+ id: string;
193
+ name: string;
194
+ preview: string;
195
+ type: string;
196
+ /** `blob:sha256:<hash>` when the bytes were externalized, else undefined. */
197
+ blobRef?: string;
198
+ }
199
+
200
+ /** True for the `blob:sha256:<hash>` references omp writes in place of image data. */
201
+ export function isBlobImageRef(data: string): boolean {
202
+ return data.startsWith('blob:sha256:');
203
+ }
204
+
185
205
  /** Extract image blocks from an omp user content array ({type:'image',
186
- * data: base64, mimeType}) into ChatMessageData attachment entries so the
187
- * timeline keeps showing them after a reload from the session JSONL. */
206
+ * data: base64 | blob:sha256:<hash>, mimeType}) into ChatMessageData attachment
207
+ * entries so the timeline keeps showing them after a reload from the session
208
+ * JSONL. A blob ref carries no preview: building one from the ref string
209
+ * produced an undecodable `data:` URL, which is what the server-side resolver
210
+ * now replaces with the real bytes. */
188
211
  export function extractUserImageAttachments(
189
212
  content: unknown,
190
- ): { id: string; name: string; preview: string; type: string }[] {
213
+ ): ExtractedImageAttachment[] {
191
214
  if (!Array.isArray(content)) return [];
192
- const attachments: { id: string; name: string; preview: string; type: string }[] = [];
215
+ const attachments: ExtractedImageAttachment[] = [];
193
216
  let index = 0;
194
217
  for (const block of content) {
195
218
  if (!isRecord(block) || block.type !== 'image') continue;
@@ -198,11 +221,13 @@ export function extractUserImageAttachments(
198
221
  if (!data) continue;
199
222
  index += 1;
200
223
  const ext = (mimeType.split('/')[1] || 'png').replace(/[^a-z0-9]/gi, '') || 'png';
224
+ const externalized = isBlobImageRef(data);
201
225
  attachments.push({
202
226
  id: `att-${index}`,
203
227
  name: `attachment-${index}.${ext}`,
204
- preview: `data:${mimeType};base64,${data}`,
228
+ preview: externalized ? '' : `data:${mimeType};base64,${data}`,
205
229
  type: mimeType,
230
+ ...(externalized ? { blobRef: data } : {}),
206
231
  });
207
232
  }
208
233
  return attachments;
@@ -221,3 +246,58 @@ export function stripInlinedTextAttachments(text: string): string {
221
246
  if (!/^[^\n]+\n```[a-z]*\n/.test(rest)) return text;
222
247
  return before.trim();
223
248
  }
249
+
250
+ /** One text file recovered from an inlined `Attached file:` block. */
251
+ export interface InlinedTextAttachment {
252
+ name: string;
253
+ content: string;
254
+ }
255
+
256
+ /**
257
+ * Recover the text files the composer inlined into a delivered prompt.
258
+ *
259
+ * omp stores no attachment metadata for a text file: the composer appends each
260
+ * one to the prompt as `Attached file: <name>` plus a fenced block, and that
261
+ * composed prompt IS the session record. `stripInlinedTextAttachments` removes
262
+ * those blocks for display, which also throws the names away — so a reloaded
263
+ * session showed no chip for a dropped `.md`/`.py`/`.json` while its image
264
+ * siblings survived (images ride as their own content block).
265
+ *
266
+ * This walks the same format back out, so the timeline can render the chips and
267
+ * re-open the files. The fence length is whatever `fenceForContent` chose (at
268
+ * least 3 backticks, longer when the content itself contains a run), and a
269
+ * block's content may contain anything — including the literal string
270
+ * "Attached file:" — so the closing fence is matched by exact length rather
271
+ * than by looking for the next header.
272
+ */
273
+ export function extractInlinedTextAttachments(text: string): InlinedTextAttachment[] {
274
+ const attachments: InlinedTextAttachment[] = [];
275
+ const header = /(?:^|\n{2})Attached file: ([^\n]+)\n(`{3,})[a-zA-Z0-9_-]*\n/g;
276
+ let match: RegExpExecArray | null;
277
+ while ((match = header.exec(text)) !== null) {
278
+ const name = match[1].trim();
279
+ const fence = match[2];
280
+ const contentStart = match.index + match[0].length;
281
+ // The closing fence sits on its own line and repeats the opening run
282
+ // exactly; a longer run is content, not the close.
283
+ const closer = `\n${fence}`;
284
+ let cursor = contentStart;
285
+ let end = -1;
286
+ while (cursor <= text.length) {
287
+ const at = text.indexOf(closer, cursor);
288
+ if (at === -1) break;
289
+ const after = text.slice(at + closer.length);
290
+ // Accept the close only at a block boundary: end of string, or a blank
291
+ // line followed by the next header / nothing.
292
+ if (after === '' || after.startsWith('\n') || /^\n{2}Attached file: /.test(after)) {
293
+ end = at;
294
+ break;
295
+ }
296
+ cursor = at + closer.length;
297
+ }
298
+ if (end === -1) continue;
299
+ attachments.push({ name, content: text.slice(contentStart, end) });
300
+ header.lastIndex = end + closer.length;
301
+ }
302
+ return attachments;
303
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Covers the boundaries of the subagent label derivation: the Target-section
8
+ * scan (heading level, colon variant, section end) and the two identity shapes.
9
+ *
10
+ * These are the cases where the label is derived from prompt text the caller
11
+ * controls, so a wrong boundary silently renders template boilerplate or a
12
+ * section name where the assignment's headline belongs.
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+
17
+ import { assignmentTarget, subagentHeaderTitle, subagentRowLabel } from '@/shared/lib/omp/subagent/label';
18
+ import type { SubagentInfo } from '@/shared/types/omp/subagent';
19
+
20
+ /** Minimal roster entry — only the fields the label helpers read. */
21
+ function makeSubagent(overrides: Partial<SubagentInfo> = {}): SubagentInfo {
22
+ return { id: 'Scout', agent: 'task', status: 'completed', index: 0, ...overrides };
23
+ }
24
+
25
+ describe('assignmentTarget', () => {
26
+ test('reads the headline under the task template Target section', () => {
27
+ const task = 'Complete assignment thoroughly:\n\n# Target\nCount .tsx files under src/.\n\n# Change\nNone.\n\n# Acceptance\nReport the integer.';
28
+ expect(assignmentTarget(task)).toBe('Count .tsx files under src/.');
29
+ });
30
+
31
+ test('stops at the next heading so Change prose never becomes the headline', () => {
32
+ const task = '# Target\nMap the panel data model.\n\n# Change\nDo the thing';
33
+ expect(assignmentTarget(task)).toBe('Map the panel data model.');
34
+ });
35
+
36
+ test('accepts any heading level and a trailing colon', () => {
37
+ expect(assignmentTarget('## Target:\nHeadline here')).toBe('Headline here');
38
+ expect(assignmentTarget('### Target\nHeadline here')).toBe('Headline here');
39
+ });
40
+
41
+ test('skips an empty Target section instead of promoting the next section', () => {
42
+ expect(assignmentTarget('# Target\n\n# Change\nDo the thing')).toBe('Do the thing');
43
+ });
44
+
45
+ test('falls back to the first prose line when no Target section exists', () => {
46
+ expect(assignmentTarget('Fix the sidebar expand bug')).toBe('Fix the sidebar expand bug');
47
+ expect(assignmentTarget('# Scope\nNot a target section')).toBe('Not a target section');
48
+ });
49
+
50
+ test('never returns the template lead-in or a bare heading', () => {
51
+ expect(assignmentTarget('Complete assignment thoroughly:')).toBeUndefined();
52
+ expect(assignmentTarget('# Target')).toBeUndefined();
53
+ });
54
+
55
+ test('returns undefined for absent or empty text', () => {
56
+ expect(assignmentTarget(undefined)).toBeUndefined();
57
+ expect(assignmentTarget(' \n\n ')).toBeUndefined();
58
+ });
59
+ });
60
+
61
+ describe('subagentHeaderTitle', () => {
62
+ test('appends the model role recorded on a history entry', () => {
63
+ expect(subagentHeaderTitle(makeSubagent({ modelRole: 'smol' }))).toBe('Scout (task:smol)');
64
+ });
65
+
66
+ test('falls back to the role carried by a live progress frame', () => {
67
+ expect(subagentHeaderTitle(makeSubagent({ progress: { modelRole: 'task' } }))).toBe('Scout (task:task)');
68
+ });
69
+
70
+ test('omits the role when the run recorded none', () => {
71
+ expect(subagentHeaderTitle(makeSubagent())).toBe('Scout (task)');
72
+ });
73
+ });
74
+
75
+ describe('subagentRowLabel', () => {
76
+ test('appends the assignment headline to the identity', () => {
77
+ const subagent = makeSubagent({ task: 'Complete assignment thoroughly:\n\n# Target\nHeadline here.\n\n# Change\nx' });
78
+ expect(subagentRowLabel(subagent)).toBe('Scout (task): Headline here.');
79
+ });
80
+
81
+ test('reads the assignment field when the task template is absent', () => {
82
+ expect(subagentRowLabel(makeSubagent({ assignment: '# Target\nFrom the assignment field.' })))
83
+ .toBe('Scout (task): From the assignment field.');
84
+ });
85
+
86
+ test('falls back to the description, then to the bare identity', () => {
87
+ expect(subagentRowLabel(makeSubagent({ description: 'fallback description' }))).toBe('Scout (task): fallback description');
88
+ expect(subagentRowLabel(makeSubagent())).toBe('Scout (task)');
89
+ });
90
+
91
+ test('keeps the identity when the assignment has no usable headline', () => {
92
+ expect(subagentRowLabel(makeSubagent({ task: '# Target' }))).toBe('Scout (task)');
93
+ });
94
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * One-line labels for a subagent roster entry.
8
+ *
9
+ * A delegated assignment is a task prompt, not a sentence. The wire carries it
10
+ * twice: `assignment` is the document the caller wrote, and `task` is that same
11
+ * document wrapped by the task tool's template ("Complete assignment
12
+ * thoroughly:" followed by `# Target` / `# Change` / `# Acceptance` sections).
13
+ * Rendering either verbatim in a 268px sidebar row would spend the whole row on
14
+ * the template's lead-in, so a row shows the Target section's headline — the
15
+ * one line that says what the agent is for.
16
+ *
17
+ * The two surfaces differ only in what they append to the identity:
18
+ * roster row → `id (agent): target`
19
+ * banner → `id (agent:modelRole)`
20
+ */
21
+
22
+ import type { SubagentInfo } from '@/shared/types/omp/subagent';
23
+
24
+ /** Any markdown heading — the boundary between prompt sections. */
25
+ const HEADING_RE = /^#{1,6}\s+\S/;
26
+ /** The section the task prompt template mandates as the assignment's summary. */
27
+ const TARGET_HEADING_RE = /^#{1,6}\s+target\s*:?\s*$/i;
28
+ /** The task template's own lead-in; it says nothing about the assignment. */
29
+ const LEAD_IN_RE = /^complete (the )?assignment\b/i;
30
+
31
+ /** First line of `lines` that has content, trimmed. */
32
+ function firstLine(lines: string[]): string | undefined {
33
+ for (const line of lines) {
34
+ const trimmed = line.trim();
35
+ if (trimmed) return trimmed;
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ /**
41
+ * The headline of a delegated assignment: the first line of its `# Target`
42
+ * section, falling back to the prompt's own first prose line when there is no
43
+ * such section (or it is empty).
44
+ */
45
+ export function assignmentTarget(text: string | undefined): string | undefined {
46
+ if (!text) return undefined;
47
+ const lines = text.split('\n');
48
+ const headingAt = lines.findIndex((line) => TARGET_HEADING_RE.test(line.trim()));
49
+ if (headingAt !== -1) {
50
+ // Bounded by the next heading: an empty Target section must not promote the
51
+ // Change/Acceptance prose that follows it into the headline.
52
+ const body = lines.slice(headingAt + 1);
53
+ const nextHeading = body.findIndex((line) => HEADING_RE.test(line.trim()));
54
+ const headline = firstLine(nextHeading === -1 ? body : body.slice(0, nextHeading));
55
+ if (headline) return headline;
56
+ }
57
+ // No Target section, or an empty one: the prompt's first prose line. Headings
58
+ // are skipped because a section name is not a summary of the assignment.
59
+ return firstLine(lines.filter((line) => {
60
+ const trimmed = line.trim();
61
+ return !HEADING_RE.test(trimmed) && !LEAD_IN_RE.test(trimmed);
62
+ }));
63
+ }
64
+
65
+ /** `id (agent:modelRole)`, or `id (agent)` when no role was recorded. Live
66
+ * snapshots carry the role on `progress`; history entries carry it top-level,
67
+ * because the on-disk fold records it from the parent's task toolResult rather
68
+ * than from a progress frame. */
69
+ export function subagentHeaderTitle(subagent: SubagentInfo): string {
70
+ const role = subagent.modelRole ?? subagent.progress?.modelRole;
71
+ return role
72
+ ? `${subagent.id} (${subagent.agent}:${role})`
73
+ : `${subagent.id} (${subagent.agent})`;
74
+ }
75
+
76
+ /** `id (agent): target` — the roster row's one-line label. The description is
77
+ * the fallback for entries whose assignment text never reached the roster. */
78
+ export function subagentRowLabel(subagent: SubagentInfo): string {
79
+ const target = assignmentTarget(subagent.task)
80
+ ?? assignmentTarget(subagent.assignment)
81
+ ?? subagent.description;
82
+ const identity = `${subagent.id} (${subagent.agent})`;
83
+ return target ? `${identity}: ${target}` : identity;
84
+ }