ompchamber 1.1.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 (192) 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.a5ccbc19dc.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/file-icon/index.tsx +4 -1
  36. package/src/client/components/common/image-viewer/index.tsx +224 -0
  37. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
  38. package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
  39. package/src/client/components/settings/Modal.tsx +1 -0
  40. package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
  41. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
  42. package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
  43. package/src/client/components/workspace/btw-panel/Composer.tsx +181 -0
  44. package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
  45. package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
  46. package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
  47. package/src/client/components/workspace/btw-panel/index.tsx +83 -0
  48. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +37 -21
  49. package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
  50. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
  51. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
  52. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
  53. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +44 -40
  54. package/src/client/components/workspace/chat-timeline/index.tsx +11 -9
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
  56. package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
  57. package/src/client/components/workspace/editor/index.tsx +45 -36
  58. package/src/client/hooks/chat/btw/index.ts +180 -0
  59. package/src/client/hooks/chat/btw/intercept.ts +43 -0
  60. package/src/client/hooks/chat/btw/mode.ts +179 -0
  61. package/src/client/hooks/chat/composer/attachments.ts +198 -0
  62. package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
  63. package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
  64. package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
  65. package/src/client/hooks/chat/composer/drop-references.ts +106 -0
  66. package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
  67. package/src/client/hooks/chat/composer/file-drop.ts +226 -0
  68. package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
  69. package/src/client/hooks/chat/composer/file-reads.ts +238 -0
  70. package/src/client/hooks/chat/composer/index.ts +7 -1
  71. package/src/client/hooks/chat/composer/pipeline.ts +181 -0
  72. package/src/client/hooks/chat/omp/index.ts +3 -3
  73. package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
  74. package/src/client/hooks/chat/timeline/actions.ts +16 -1
  75. package/src/client/hooks/chat/timeline/index.ts +16 -8
  76. package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
  77. package/src/client/hooks/chat/timeline/send.ts +29 -65
  78. package/src/client/hooks/chat/timeline/session-load.ts +2 -1
  79. package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
  80. package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
  81. package/src/client/hooks/editor/file-actions.ts +49 -0
  82. package/src/client/hooks/editor/use-file-editor.ts +38 -27
  83. package/src/client/hooks/editor/use-file-transfer.ts +78 -0
  84. package/src/client/tailwind.css +22 -0
  85. package/src/server/db.server.ts +21 -287
  86. package/src/server/lib/btw/frames.test.ts +68 -0
  87. package/src/server/lib/btw/frames.ts +56 -0
  88. package/src/server/lib/btw/prompt.ts +28 -0
  89. package/src/server/lib/btw/registry.server.ts +141 -0
  90. package/src/server/lib/btw/runtime.server.ts +324 -0
  91. package/src/server/lib/btw/schema.server.ts +44 -0
  92. package/src/server/lib/btw/service.server.ts +171 -0
  93. package/src/server/lib/btw/session-copy.server.ts +213 -0
  94. package/src/server/lib/btw/session-copy.test.ts +166 -0
  95. package/src/server/lib/btw/store.server.ts +240 -0
  96. package/src/server/lib/db/schema.ts +110 -0
  97. package/src/server/lib/db/seed.ts +160 -0
  98. package/src/server/lib/db/workspace-sync.ts +70 -0
  99. package/src/server/lib/fs/root.ts +37 -0
  100. package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
  101. package/src/server/lib/omp/rpc/manager.ts +27 -123
  102. package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
  103. package/src/server/lib/omp/session/auto-title.server.ts +152 -0
  104. package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
  105. package/src/server/lib/omp/session/blobs.server.ts +76 -0
  106. package/src/server/lib/omp/session/file-scan.ts +174 -0
  107. package/src/server/lib/omp/session/files.test.ts +147 -0
  108. package/src/server/lib/omp/session/files.ts +52 -35
  109. package/src/server/lib/omp/session/locator.ts +2 -2
  110. package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
  111. package/src/server/lib/omp/session/merge-stored.ts +171 -0
  112. package/src/server/lib/omp/session/messages.ts +34 -0
  113. package/src/server/lib/omp/session/reader.ts +2 -2
  114. package/src/server/lib/omp/session/subagent-presence.ts +9 -6
  115. package/src/server/lib/queue/delivery.server.test.ts +57 -0
  116. package/src/server/lib/queue/delivery.server.ts +25 -3
  117. package/src/server/plugins/ssr.ts +29 -2
  118. package/src/server/routes/btw/events.ts +70 -0
  119. package/src/server/routes/btw/index.ts +8 -0
  120. package/src/server/routes/btw/topics.ts +87 -0
  121. package/src/server/routes/btw/ws.ts +136 -0
  122. package/src/server/routes/chat/index.ts +2 -0
  123. package/src/server/routes/chat/session.ts +1 -115
  124. package/src/server/routes/chat/turns.ts +87 -0
  125. package/src/server/routes/fs/index.ts +4 -1
  126. package/src/server/routes/fs/raw.ts +85 -0
  127. package/src/server/routes/fs/read.ts +58 -3
  128. package/src/server/routes/index.ts +7 -1
  129. package/src/shared/lib/chat/attachments.test.ts +208 -0
  130. package/src/shared/lib/chat/attachments.ts +200 -15
  131. package/src/shared/lib/chat/btw/client.ts +94 -0
  132. package/src/shared/lib/chat/composer/client.test.ts +48 -0
  133. package/src/shared/lib/chat/composer/client.ts +6 -1
  134. package/src/shared/lib/chat/composer/composer.test.ts +39 -1
  135. package/src/shared/lib/chat/composer/trigger.ts +45 -1
  136. package/src/shared/lib/chat/omp/agent-events.ts +33 -80
  137. package/src/shared/lib/chat/omp/socket.ts +11 -6
  138. package/src/shared/lib/chat/omp/sse.ts +11 -6
  139. package/src/shared/lib/chat/omp/tool-results.ts +102 -0
  140. package/src/shared/lib/chat/omp/transport.ts +19 -3
  141. package/src/shared/lib/chat/text-file.ts +148 -0
  142. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
  143. package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
  144. package/src/shared/lib/chat/timeline/turns.ts +7 -0
  145. package/src/shared/lib/fs/file-kind.ts +50 -0
  146. package/src/shared/lib/fs/paths.ts +19 -1
  147. package/src/shared/lib/markdown/math.test.ts +20 -3
  148. package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
  149. package/src/shared/lib/omp/session/mapper.ts +24 -4
  150. package/src/shared/lib/omp/session/messages-map.ts +16 -3
  151. package/src/shared/lib/omp/session/messages-parse.ts +2 -2
  152. package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
  153. package/src/shared/types/btw.ts +65 -0
  154. package/src/shared/types/chat.ts +39 -1
  155. package/src/shared/types/index.ts +1 -0
  156. package/src/shared/types/omp/agent.ts +14 -1
  157. package/src/shared/types/omp/session.ts +4 -1
  158. package/src/shared/types/settings/state.ts +5 -0
  159. package/dist/client/static/css/index.b859cc1cc5.css +0 -2
  160. package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
  161. package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
  162. package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
  163. package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
  164. package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
  165. package/dist/client/static/js/async/e.93214d622b.js +0 -79
  166. package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
  167. package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
  168. package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
  169. package/dist/client/static/js/async/jl.874d352278.js +0 -1
  170. package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
  171. package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
  172. package/dist/client/static/js/async/q.23af2a358f.js +0 -21
  173. package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
  174. package/dist/client/static/js/async/s.222ee974f6.js +0 -2
  175. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  176. package/dist/client/static/js/async/vt.42416b707d.js +0 -1
  177. package/dist/client/static/js/async/x1.da8687159b.js +0 -90
  178. package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
  179. package/dist/client/static/js/index.6e065017eb.js +0 -33
  180. package/dist/client/static/js/jp.50817896f3.js +0 -139
  181. /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
  182. /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
  183. /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
  184. /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
  185. /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
  186. /package/dist/client/static/js/async/{5b.a5ccbc19dc.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
  187. /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
  188. /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
  189. /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
  190. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
  191. /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
  192. /package/dist/client/static/js/{index.6e065017eb.js.LICENSE.txt → index.57b39ffa7f.js.LICENSE.txt} +0 -0
@@ -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,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,65 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * BTW (side question) domain types.
8
+ *
9
+ * A BTW topic is an independent question plus its explicit follow-ups, run
10
+ * against the parent session's context but never appended to its transcript —
11
+ * the same contract omp's own `/btw` implements in the TUI. The server is the
12
+ * source of truth for a session's topics; the panel renders the state it is
13
+ * sent and streams answer text through `btw_delta` frames.
14
+ */
15
+
16
+ /** Lifecycle of one question/answer pair inside a topic. */
17
+ export type BtwTurnStatus = 'running' | 'complete' | 'cancelled' | 'failed' | 'interrupted';
18
+
19
+ /** Model a topic's side conversation runs on — the parent session's model,
20
+ * captured when the topic was created (omp's `/btw` uses the active model). */
21
+ export interface BtwModel {
22
+ provider: string;
23
+ id: string;
24
+ /** Display name reported by the running side session (`DeepSeek-V4.1-Flash`). */
25
+ name?: string;
26
+ }
27
+
28
+ /** One question and its answer inside a topic. */
29
+ export interface BtwTurn {
30
+ index: number;
31
+ question: string;
32
+ answer: string;
33
+ status: BtwTurnStatus;
34
+ createdAt: number;
35
+ updatedAt: number;
36
+ }
37
+
38
+ /** A side conversation scoped to one chamber session. */
39
+ export interface BtwTopic {
40
+ id: string;
41
+ sessionId: string;
42
+ /** The first question, used as the topic's label in history. */
43
+ title: string;
44
+ createdAt: number;
45
+ updatedAt: number;
46
+ model?: BtwModel;
47
+ /** Set once the topic was promoted into the chat as a session of its own. */
48
+ promotedSessionId?: string;
49
+ turns: BtwTurn[];
50
+ }
51
+
52
+ /** A session's whole BTW view. */
53
+ export interface BtwState {
54
+ topics: BtwTopic[];
55
+ /** Topic with a question in flight; at most one per session, because a
56
+ * session runs one side conversation at a time. */
57
+ runningTopicId: string | null;
58
+ }
59
+
60
+ /** Server → client frames on `/api/btw/:sessionId/*` (WS and SSE alike). */
61
+ export type BtwFrame =
62
+ | { type: 'connected'; sessionId: string }
63
+ | { type: 'btw_state'; state: BtwState }
64
+ | { type: 'btw_delta'; topicId: string; turnIndex: number; text: string }
65
+ | { type: 'btw_error'; topicId: string | null; message: string };