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,147 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { afterAll, describe, expect, test } from 'bun:test';
7
+ import fs from 'fs';
8
+ import { join } from 'path';
9
+
10
+ import { scanSessionInfo } from '@/server/lib/omp/session/files';
11
+
12
+ /**
13
+ * `modified` is the session's last real activity, and two signals that look
14
+ * like it are not:
15
+ *
16
+ * - the file mtime, which a title-slot rename bumps without a turn having
17
+ * happened (measured: 5 days of skew on a real session);
18
+ * - a `session_exit` lifecycle marker, written per process dispose, which
19
+ * floats a session quiet for days to the top of the sidebar (measured: 255
20
+ * of 319 sessions ranked by one).
21
+ *
22
+ * The cost of regressing either is a silently wrong sidebar order, which no
23
+ * other test observes.
24
+ */
25
+
26
+ const tempDirs: string[] = [];
27
+
28
+ /** Write a session file whose lines are exactly the given records, in order. */
29
+ async function writeSession(lines: unknown[]): Promise<string> {
30
+ const dir = await fs.promises.mkdtemp('omp-session-files-');
31
+ tempDirs.push(dir);
32
+ const file = join(dir, 'session.jsonl');
33
+ await Bun.write(file, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`);
34
+ return file;
35
+ }
36
+
37
+ afterAll(() => {
38
+ for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true });
39
+ });
40
+
41
+ const at = (day: number, second = 0) =>
42
+ `2026-09-${String(day).padStart(2, '0')}T03:00:${String(second).padStart(2, '0')}.000Z`;
43
+
44
+ const slot = (title: string) => ({ type: 'title', v: 1, title, updatedAt: at(1), pad: '' });
45
+ const header = { type: 'session', version: 3, id: 'session-id', cwd: '/tmp/proj', timestamp: at(1) };
46
+ const message = (day: number, text = 'hello') => ({
47
+ type: 'message',
48
+ id: `m-${day}`,
49
+ parentId: null,
50
+ timestamp: at(day),
51
+ message: { role: 'user', content: [{ type: 'text', text }] },
52
+ });
53
+ /** The marker omp appends every time it disposes the session process. */
54
+ const sessionExit = (day: number) => ({
55
+ type: 'custom',
56
+ customType: 'session_exit',
57
+ data: { reason: 'dispose', kind: 'normal', recordedAt: at(day) },
58
+ id: `x-${day}`,
59
+ parentId: null,
60
+ timestamp: at(day),
61
+ });
62
+ const toolStart = (day: number) => ({
63
+ type: 'custom',
64
+ customType: 'tool_execution_start',
65
+ data: { toolCallId: 'call', toolName: 'bash' },
66
+ id: `t-${day}`,
67
+ parentId: null,
68
+ timestamp: at(day),
69
+ });
70
+
71
+ describe('scanSessionInfo last activity', () => {
72
+ test('dates the session by its newest entry, not the file mtime', async () => {
73
+ // The mtime is "now" (just written); only the entry timestamp is the answer.
74
+ const file = await writeSession([slot('t'), header, message(10)]);
75
+ const info = await scanSessionInfo(file);
76
+ expect(info?.modified.toISOString()).toBe(at(10));
77
+ expect(info?.modified.getTime()).not.toBe(info?.fileMtime.getTime());
78
+ });
79
+
80
+ test('ignores a trailing session_exit marker', async () => {
81
+ const file = await writeSession([slot('t'), header, message(10), sessionExit(20)]);
82
+ const info = await scanSessionInfo(file);
83
+ expect(info?.modified.toISOString()).toBe(at(10));
84
+ });
85
+
86
+ test('ignores a stack of session_exit markers, however many accumulated', async () => {
87
+ const file = await writeSession([
88
+ slot('t'),
89
+ header,
90
+ message(11),
91
+ sessionExit(20),
92
+ sessionExit(21),
93
+ sessionExit(22),
94
+ ]);
95
+ const info = await scanSessionInfo(file);
96
+ expect(info?.modified.toISOString()).toBe(at(11));
97
+ });
98
+
99
+ test('still counts an in-flight tool call as activity', async () => {
100
+ // A run killed mid-tool-call has no later message, but it IS the newest
101
+ // session. Excluding every custom entry would misrank it.
102
+ const file = await writeSession([slot('t'), header, message(12), toolStart(13)]);
103
+ const info = await scanSessionInfo(file);
104
+ expect(info?.modified.toISOString()).toBe(at(13));
105
+ });
106
+
107
+ test('counts an unrecognised custom entry rather than assuming it is bookkeeping', async () => {
108
+ const file = await writeSession([
109
+ slot('t'),
110
+ header,
111
+ message(12),
112
+ { type: 'custom', customType: 'brand_new_marker', id: 'z', timestamp: at(19) },
113
+ ]);
114
+ const info = await scanSessionInfo(file);
115
+ expect(info?.modified.toISOString()).toBe(at(19));
116
+ });
117
+
118
+ test('uses a later message when the exit marker came first', async () => {
119
+ const file = await writeSession([slot('t'), header, sessionExit(5), message(6)]);
120
+ const info = await scanSessionInfo(file);
121
+ expect(info?.modified.toISOString()).toBe(at(6));
122
+ });
123
+
124
+ test('falls back to the creation time for a session with no activity at all', async () => {
125
+ const file = await writeSession([slot('t'), header, sessionExit(30)]);
126
+ const info = await scanSessionInfo(file);
127
+ expect(info?.modified.toISOString()).toBe(at(1));
128
+ });
129
+
130
+ test('reads the timestamp past an entry larger than the tail window', async () => {
131
+ // A tool result carrying a big file read is the newest entry and is
132
+ // usually the largest one; the window must widen to reach its line.
133
+ const file = await writeSession([
134
+ slot('t'),
135
+ header,
136
+ message(2, 'head'),
137
+ message(4, 'x'.repeat(200_000)),
138
+ ]);
139
+ const info = await scanSessionInfo(file);
140
+ expect(info?.modified.toISOString()).toBe(at(4));
141
+ });
142
+
143
+ test('returns undefined for a file with no session header', async () => {
144
+ const file = await writeSession([slot('t'), message(3)]);
145
+ expect(await scanSessionInfo(file)).toBeUndefined();
146
+ });
147
+ });
@@ -15,12 +15,15 @@
15
15
  * File layout: optional fixed-width 256-byte title-slot line, then the
16
16
  * {"type":"session"} header line, then message entries. Legacy pi v1/v2
17
17
  * shapes parse leniently (raw-text scans); nothing here mutates on-disk data.
18
- * The lenient JSONL + header parsing lives in ./session-jsonl.ts.
18
+ * The lenient JSONL + header parsing lives in
19
+ * `@/shared/lib/omp/session/jsonl`; the bounded window readers (head prefix
20
+ * plus escalating tail) live in ./file-scan.ts.
19
21
  */
20
22
 
21
23
  import fs from 'fs';
22
24
  import * as path from 'path';
23
25
  import { getSessionsDir } from '@/server/lib/omp/core/paths';
26
+ import { readLastEntryTimestamp, readTextPrefix, SESSION_LIST_PREFIX_BYTES } from '@/server/lib/omp/session/file-scan';
24
27
  import { countMessageMarkers, extractFirstDisplayMessageFromPrefix, extractTextFromContent, parseJsonlLenient, parseSessionListHeader } from '@/shared/lib/omp/session/jsonl';
25
28
  import { isRecord } from '@/shared/lib/util/guards';
26
29
 
@@ -37,37 +40,30 @@ export interface OmpSessionInfo {
37
40
  title?: string;
38
41
  parentSessionPath?: string;
39
42
  created: Date;
43
+ /** Last activity: the newest JSONL entry's own `timestamp`, falling back to
44
+ * the file mtime for a file the tail window cannot date. */
40
45
  modified: Date;
46
+ /** Filesystem mtime. Scan-cache version only — never user-visible, and never
47
+ * an ordering key (see readLastEntryTimestamp for why). */
48
+ fileMtime: Date;
41
49
  messageCount: number;
42
50
  size: number;
43
51
  firstMessage: string;
44
52
  }
45
53
 
46
- // ============================================================================
47
- // Per-file scan (4 KiB prefix window)
48
- // ============================================================================
49
-
50
- const SESSION_LIST_PREFIX_BYTES = 4096;
51
-
52
- async function readTextPrefix(filePath: string, prefixBytes: number): Promise<[string, number, Date]> {
53
- const file = Bun.file(filePath);
54
- const stat = await file.stat();
55
- const prefixLength = Math.min(prefixBytes, stat.size);
56
- const prefix = prefixLength > 0 ? await file.slice(0, prefixLength).text() : '';
57
- return [prefix, stat.size, new Date(stat.mtimeMs)];
58
- }
59
-
60
54
  /**
61
- * Scan a single session file into an OmpSessionInfo using only a 4 KiB prefix
62
- * window. Faithful port of omp's scanSessionFile — messageCount is a
63
- * prefix-derived lower bound. Missing/unreadable/header-less files yield
64
- * undefined instead of throwing.
55
+ * Scan a single session file into an OmpSessionInfo from a 4 KiB prefix window
56
+ * (header, title, first message) plus a bounded tail window (last activity).
57
+ * Faithful port of omp's scanSessionFile except for `modified`, which is the
58
+ * last entry's timestamp rather than the file mtime. Missing/unreadable/
59
+ * header-less files yield undefined instead of throwing.
65
60
  */
66
61
  export async function scanSessionInfo(filePath: string): Promise<OmpSessionInfo | undefined> {
67
62
  try {
68
- const [content, size, mtime] = await readTextPrefix(filePath, SESSION_LIST_PREFIX_BYTES);
69
- const entries = parseJsonlLenient<Record<string, unknown>>(content);
70
- const header = parseSessionListHeader(content, entries);
63
+ const { text: prefix, size, mtime } = await readTextPrefix(filePath, SESSION_LIST_PREFIX_BYTES);
64
+ const lastEntryAt = await readLastEntryTimestamp(filePath, size, prefix);
65
+ const entries = parseJsonlLenient<Record<string, unknown>>(prefix);
66
+ const header = parseSessionListHeader(prefix, entries);
71
67
  if (!header) return undefined;
72
68
 
73
69
  let parsedMessageCount = 0;
@@ -90,7 +86,7 @@ export async function scanSessionInfo(filePath: string): Promise<OmpSessionInfo
90
86
  }
91
87
  }
92
88
 
93
- firstMessage ||= extractFirstDisplayMessageFromPrefix(content) ?? '';
89
+ firstMessage ||= extractFirstDisplayMessageFromPrefix(prefix) ?? '';
94
90
  return {
95
91
  path: filePath,
96
92
  id: header.id,
@@ -98,8 +94,9 @@ export async function scanSessionInfo(filePath: string): Promise<OmpSessionInfo
98
94
  title: header.title ?? shortSummary,
99
95
  parentSessionPath: header.parentSession,
100
96
  created: new Date(header.timestamp ?? ''),
101
- modified: mtime,
102
- messageCount: Math.max(parsedMessageCount, countMessageMarkers(content)),
97
+ modified: lastEntryAt ?? mtime,
98
+ fileMtime: mtime,
99
+ messageCount: Math.max(parsedMessageCount, countMessageMarkers(prefix)),
103
100
  size,
104
101
  firstMessage: firstMessage || '(no messages)',
105
102
  };
@@ -120,9 +117,24 @@ interface SessionFileListCacheEntry {
120
117
  files: string[];
121
118
  }
122
119
 
120
+ /**
121
+ * Shape version of a cached OmpSessionInfo. The caches hang off `globalThis`
122
+ * so they survive a module reload, which means an entry written by an older
123
+ * build of this file is still readable after an edit — and a field added or
124
+ * renamed since then reads as undefined at the point of use rather than
125
+ * missing a scan. Bump this whenever OmpSessionInfo changes; the cache is
126
+ * discarded instead of misread.
127
+ */
128
+ const SESSION_SCAN_CACHE_VERSION = 2;
129
+
130
+ interface SessionScanCache {
131
+ version: number;
132
+ entries: Map<string, OmpSessionInfo>;
133
+ }
134
+
123
135
  declare global {
124
136
  var __ompChamberSessionFileListCache: Map<string, SessionFileListCacheEntry> | undefined;
125
- var __ompChamberSessionScanCache: Map<string, OmpSessionInfo> | undefined;
137
+ var __ompChamberSessionScanCache: SessionScanCache | undefined;
126
138
  }
127
139
 
128
140
  const MAX_SESSION_SCAN_CACHE_ENTRIES = 2048;
@@ -197,16 +209,18 @@ export async function listSessionFiles(sessionsRoot: string = getSessionsDir()):
197
209
  }
198
210
 
199
211
  function getSessionScanCache(): Map<string, OmpSessionInfo> {
200
- if (!globalThis.__ompChamberSessionScanCache) {
201
- globalThis.__ompChamberSessionScanCache = new Map();
202
- }
203
- return globalThis.__ompChamberSessionScanCache;
212
+ const slot = globalThis.__ompChamberSessionScanCache;
213
+ if (slot && slot.version === SESSION_SCAN_CACHE_VERSION) return slot.entries;
214
+ const fresh: SessionScanCache = { version: SESSION_SCAN_CACHE_VERSION, entries: new Map() };
215
+ globalThis.__ompChamberSessionScanCache = fresh;
216
+ return fresh.entries;
204
217
  }
205
218
 
206
219
  /** scanSessionInfo memoized on (path, size, mtimeMs) — an unchanged file costs
207
220
  * a single stat. Cache hits share one object; callers must treat results as
208
- * immutable. */
209
- async function scanSessionInfoCached(filePath: string): Promise<OmpSessionInfo | undefined> {
221
+ * immutable. Exported so the id locator shares this cache with the sidebar
222
+ * list instead of re-reading every prefix and tail on each resolve. */
223
+ export async function scanSessionInfoCached(filePath: string): Promise<OmpSessionInfo | undefined> {
210
224
  let stat: { size: number; mtimeMs: number };
211
225
  try {
212
226
  stat = await Bun.file(filePath).stat();
@@ -217,8 +231,11 @@ async function scanSessionInfoCached(filePath: string): Promise<OmpSessionInfo |
217
231
  const cached = cache.get(filePath);
218
232
  if (cached) {
219
233
  const info = cached;
220
- // Re-scan when size or mtime changed.
221
- if (info.size === stat.size && info.modified.getTime() === stat.mtimeMs) {
234
+ // Re-scan when size or mtime changed. The version is the FILE mtime, not
235
+ // `modified` — the latter is now an entry timestamp, and a title-slot
236
+ // rewrite that bumps the file without appending an entry must still be
237
+ // picked up (it can change the title the row displays).
238
+ if (info.size === stat.size && info.fileMtime.getTime() === stat.mtimeMs) {
222
239
  cache.delete(filePath);
223
240
  cache.set(filePath, info);
224
241
  return info;
@@ -259,7 +276,7 @@ export async function listAllSessionInfos(sessionsRoot: string = getSessionsDir(
259
276
  * that mtime keys cannot detect). */
260
277
  export function clearSessionFileCaches(): void {
261
278
  globalThis.__ompChamberSessionFileListCache?.clear();
262
- globalThis.__ompChamberSessionScanCache?.clear();
279
+ globalThis.__ompChamberSessionScanCache?.entries.clear();
263
280
  }
264
281
 
265
282
  /** Exported for tests/spot-checks: read a raw JSONL session header directly. */
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { json } from '@/server/lib/remix-compat';
13
13
  import { getSessionsDir } from '@/server/lib/omp/core/paths';
14
- import { listSessionFiles, readRawHeaderLine, scanSessionInfo } from '@/server/lib/omp/session/files';
14
+ import { listSessionFiles, readRawHeaderLine, scanSessionInfoCached } from '@/server/lib/omp/session/files';
15
15
 
16
16
  /** Find the absolute path of the .jsonl whose header id matches. */
17
17
  export async function findSessionFileById(
@@ -20,7 +20,7 @@ export async function findSessionFileById(
20
20
  ): Promise<string | undefined> {
21
21
  const files = await listSessionFiles(sessionsRoot);
22
22
  for (const file of files) {
23
- const info = await scanSessionInfo(file);
23
+ const info = await scanSessionInfoCached(file);
24
24
  if (info?.id === sessionId) return file;
25
25
  }
26
26
  return undefined;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Merging the chamber's stored turns onto JSONL-loaded ones.
8
+ *
9
+ * The two sources see different halves of an attachment: the JSONL recovers a
10
+ * text file's name from the delivered prompt but has no preview or content,
11
+ * while the chamber row has those display fields but nothing about what omp
12
+ * received. Taking one list over the other dropped whichever entries only
13
+ * existed on the losing side — a dropped `.md` disappeared from a reloaded
14
+ * session as soon as the same turn carried an image.
15
+ */
16
+
17
+ import { describe, expect, test } from 'bun:test';
18
+ import { mergeOmpAttachments, type StoredMessage } from '@/server/lib/omp/session/merge-stored';
19
+
20
+ function userTurn(overrides: Partial<StoredMessage> = {}): StoredMessage {
21
+ return { id: 'u1', role: 'user', content: 'hello', ...overrides };
22
+ }
23
+
24
+ function storedJson(messages: StoredMessage[]): string {
25
+ return JSON.stringify(messages);
26
+ }
27
+
28
+ describe('mergeOmpAttachments attachments', () => {
29
+ test('keeps both an image from the JSONL and a text file from the store', () => {
30
+ const jsonl = [userTurn({
31
+ attachments: [{ name: 'shot.png', type: 'image/png', preview: 'data:image/png;base64,AAAA' }],
32
+ })];
33
+ const stored = [userTurn({
34
+ attachments: [
35
+ { name: 'notes.md', type: 'text/plain', content: '# hi' },
36
+ { name: 'shot.png', type: 'image/png', preview: 'blob:http://x/1' },
37
+ ],
38
+ })];
39
+
40
+ const [merged] = mergeOmpAttachments(jsonl, storedJson(stored));
41
+ expect(merged.attachments?.map((a) => a.name)).toEqual(['shot.png', 'notes.md']);
42
+ });
43
+
44
+ test('a text file recovered from the JSONL keeps its name when the store has none', () => {
45
+ const jsonl = [userTurn({
46
+ attachments: [{ name: 'script.py', type: 'text/plain', content: 'x=1' }],
47
+ })];
48
+ const [merged] = mergeOmpAttachments(jsonl, storedJson([userTurn()]));
49
+ expect(merged.attachments).toEqual([{ name: 'script.py', type: 'text/plain', content: 'x=1' }]);
50
+ });
51
+
52
+ test('a matching name fills in the fields the JSONL could not carry', () => {
53
+ const jsonl = [userTurn({
54
+ attachments: [{ name: 'shot.png', type: 'image/png', preview: 'data:image/png;base64,AAAA' }],
55
+ })];
56
+ const stored = [userTurn({
57
+ attachments: [{ name: 'shot.png', type: 'image/png', size: 42, content: 'stored' }],
58
+ })];
59
+
60
+ const [merged] = mergeOmpAttachments(jsonl, storedJson(stored));
61
+ expect(merged.attachments).toEqual([
62
+ { name: 'shot.png', type: 'image/png', size: 42, content: 'stored', preview: 'data:image/png;base64,AAAA' },
63
+ ]);
64
+ });
65
+
66
+ test('an attachment only the store knows about is still shown', () => {
67
+ const jsonl = [userTurn({
68
+ attachments: [{ name: 'shot.png', type: 'image/png', preview: 'data:image/png;base64,AAAA' }],
69
+ })];
70
+ const stored = [userTurn({
71
+ attachments: [
72
+ { name: 'shot.png', type: 'image/png' },
73
+ { name: 'dropped.txt', type: 'text/plain', content: 'kept' },
74
+ ],
75
+ })];
76
+
77
+ const [merged] = mergeOmpAttachments(jsonl, storedJson(stored));
78
+ expect(merged.attachments?.map((a) => a.name)).toEqual(['shot.png', 'dropped.txt']);
79
+ });
80
+
81
+ test('a turn with no attachments on either side stays attachment-free', () => {
82
+ const [merged] = mergeOmpAttachments([userTurn()], storedJson([userTurn()]));
83
+ expect(merged.attachments).toBeUndefined();
84
+ });
85
+
86
+ test('a stored turn that never reached the JSONL is still merged in', () => {
87
+ const [merged] = mergeOmpAttachments([userTurn()], storedJson([userTurn({
88
+ attachments: [{ name: 'notes.md', type: 'text/plain', content: '# hi' }],
89
+ })]));
90
+ expect(merged.attachments).toEqual([{ name: 'notes.md', type: 'text/plain', content: '# hi' }]);
91
+ });
92
+ });
93
+
94
+ describe('mergeOmpAttachments text restoration', () => {
95
+ test('a raw composer turn keeps the text the user typed', () => {
96
+ // The JSONL records only a synthesized `/skill:<name>`; the DB copy has the
97
+ // invocation with its arguments, which is what the user actually typed.
98
+ const jsonl = [userTurn({ content: '/skill:capacity' })];
99
+ const stored = [userTurn({ content: '/skill:capacity summarize this repo' })];
100
+ const [merged] = mergeOmpAttachments(jsonl, storedJson(stored));
101
+ expect(merged.content).toBe('/skill:capacity summarize this repo');
102
+ });
103
+
104
+ test('an unrelated stored turn does not overwrite the JSONL text', () => {
105
+ // Turns that cannot be recognized as the same request are left alone; the
106
+ // JSONL keeps what the model was actually sent.
107
+ const jsonl = [userTurn({ content: 'rewritten prompt' })];
108
+ const stored = [userTurn({ content: 'a completely different request' })];
109
+ const [merged] = mergeOmpAttachments(jsonl, storedJson(stored));
110
+ expect(merged.content).toBe('rewritten prompt');
111
+ });
112
+
113
+ test('malformed stored JSON leaves the JSONL messages untouched', () => {
114
+ const jsonl = [userTurn()];
115
+ expect(mergeOmpAttachments(jsonl, 'not json')).toBe(jsonl);
116
+ expect(mergeOmpAttachments(jsonl, undefined)).toBe(jsonl);
117
+ });
118
+ });
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Merge the chamber's own copy of a conversation onto the messages loaded from
8
+ * an omp JSONL file.
9
+ *
10
+ * The JSONL is authoritative for what the model received, but it is not a
11
+ * complete record of the chamber's UI: omp rewrites raw composer input
12
+ * (`@agent` → a task-tool delegation prompt, `/skill:a b` → `/skill:a`) and
13
+ * writes no user entry at all for `/command` turns. The chamber DB keeps the
14
+ * turn as the user typed it, which is what this restores.
15
+ *
16
+ * Attachments are UNIONED rather than picked from one side. The two sources see
17
+ * different halves of the same attachment:
18
+ *
19
+ * - The JSONL recovers a text file's NAME from the delivered prompt (the
20
+ * composer inlines the file as `Attached file: <name>` plus a fenced block)
21
+ * and carries an image's bytes, but has no preview and no stored content.
22
+ * - The chamber row has the display fields — a text file's name and content, an
23
+ * image's name and preview — but nothing about what omp actually received.
24
+ *
25
+ * Choosing one list over the other is what made a dropped `.md`/`.py`/`.json`
26
+ * vanish from a reloaded session: the JSONL had an image, so its list won, and
27
+ * the text file only ever existed in the chamber row.
28
+ */
29
+
30
+ export interface StoredAttachment {
31
+ name?: string;
32
+ preview?: string;
33
+ type?: string;
34
+ size?: number;
35
+ content?: string;
36
+ }
37
+
38
+ export interface StoredMessage {
39
+ id?: string;
40
+ role: string;
41
+ content: string;
42
+ date?: string;
43
+ timestamp?: string;
44
+ attachments?: StoredAttachment[];
45
+ }
46
+
47
+ function isRawComposerInput(content: string): boolean {
48
+ return /^\s*\//.test(content) || /(^|\s)@[A-Za-z0-9_-]+/.test(content);
49
+ }
50
+
51
+ /** Whether a JSONL-derived user turn (a) and a stored one (b) are the same
52
+ * request despite omp rewriting the delivered prompt. */
53
+ function userTurnsRelate(a: string, b: string): boolean {
54
+ if (a === b || a.startsWith(b) || b.startsWith(a)) return true;
55
+ // `@agent` is delivered to omp as a task-tool delegation prompt.
56
+ if (a.startsWith('Use the task tool to delegate this request') && /(^|\s)@[A-Za-z0-9_-]+/.test(b)) {
57
+ return true;
58
+ }
59
+ // `/skill:<name> <args>`: the JSONL only records a synthesized `/skill:<name>`.
60
+ const token = a.split(' ')[0];
61
+ return a.startsWith('/skill:') && Boolean(token) && b.startsWith(token);
62
+ }
63
+
64
+ /**
65
+ * Union two attachment lists by name.
66
+ *
67
+ * The JSONL entry comes first, so the order the model received wins; a stored
68
+ * entry with the same name fills in the fields the JSONL could not carry.
69
+ */
70
+ function mergeAttachmentLists(
71
+ fromJsonl: StoredAttachment[] | undefined,
72
+ fromStore: StoredAttachment[] | undefined,
73
+ ): StoredAttachment[] {
74
+ const jsonl = Array.isArray(fromJsonl) ? fromJsonl : [];
75
+ const stored = Array.isArray(fromStore) ? fromStore : [];
76
+ if (jsonl.length === 0) return stored;
77
+ if (stored.length === 0) return jsonl;
78
+ const remaining = [...stored];
79
+ const merged = jsonl.map((attachment) => {
80
+ const index = remaining.findIndex((candidate) => candidate.name === attachment.name);
81
+ if (index === -1) return attachment;
82
+ const [match] = remaining.splice(index, 1);
83
+ return {
84
+ ...match,
85
+ ...attachment,
86
+ content: attachment.content ?? match.content,
87
+ preview: attachment.preview || match.preview,
88
+ };
89
+ });
90
+ // Anything the JSONL could not see (an attachment omp dropped before the
91
+ // prompt was persisted) is still shown rather than silently discarded.
92
+ return [...merged, ...remaining];
93
+ }
94
+
95
+ /** Merge user turns and attachments from the chamber DB copy onto the
96
+ * JSONL-loaded messages. */
97
+ export function mergeOmpAttachments(
98
+ messages: StoredMessage[],
99
+ storedMessagesJson: string | undefined,
100
+ ): StoredMessage[] {
101
+ if (!storedMessagesJson) return messages;
102
+ let stored: StoredMessage[] = [];
103
+ try {
104
+ stored = JSON.parse(storedMessagesJson);
105
+ } catch {
106
+ return messages;
107
+ }
108
+ if (!Array.isArray(stored)) return messages;
109
+ const storedUsers = stored.filter((m) => m?.role === 'user' && typeof m.content === 'string');
110
+
111
+ const jsonlIndex = new Map<string, number>();
112
+ messages.forEach((m, index) => {
113
+ if (m.id) jsonlIndex.set(m.id, index);
114
+ });
115
+
116
+ const used = new Set<number>();
117
+ const merged = messages.map((m) => {
118
+ if (m.role !== 'user') return m;
119
+ const storedIndex = storedUsers.findIndex((s, i) => !used.has(i) && userTurnsRelate(m.content, s.content));
120
+ if (storedIndex === -1) return m;
121
+ used.add(storedIndex);
122
+ const storedMsg = storedUsers[storedIndex];
123
+ const content = storedMsg.content !== m.content && isRawComposerInput(storedMsg.content)
124
+ ? storedMsg.content
125
+ : m.content;
126
+ const attachments = mergeAttachmentLists(m.attachments, storedMsg.attachments);
127
+ const next = { ...m, content };
128
+ return attachments.length ? { ...next, attachments } : next;
129
+ });
130
+
131
+ // Unmatched raw turns are anchored before the next stored message the JSONL
132
+ // still carries; splice highest-anchor-first so earlier indices stay valid.
133
+ const insertions = new Map<number, StoredMessage[]>();
134
+ storedUsers.forEach((storedMsg, userIndex) => {
135
+ if (used.has(userIndex) || !isRawComposerInput(storedMsg.content)) return;
136
+ if (storedMsg.id && jsonlIndex.has(storedMsg.id)) return;
137
+ let anchor = merged.length;
138
+ for (let i = stored.indexOf(storedMsg) + 1; i < stored.length; i += 1) {
139
+ const id = stored[i]?.id;
140
+ const found = typeof id === 'string' ? jsonlIndex.get(id) : undefined;
141
+ if (found !== undefined) {
142
+ anchor = found;
143
+ break;
144
+ }
145
+ }
146
+ const group = insertions.get(anchor);
147
+ if (group) group.push(storedMsg);
148
+ else insertions.set(anchor, [storedMsg]);
149
+ });
150
+ for (const anchor of [...insertions.keys()].sort((a, b) => b - a)) {
151
+ const group = insertions.get(anchor);
152
+ if (group) merged.splice(anchor, 0, ...group);
153
+ }
154
+
155
+ const byContent = new Map<string, StoredAttachment[]>();
156
+ for (const m of stored) {
157
+ if (m?.role === 'user' && Array.isArray(m.attachments) && m.attachments.length > 0) {
158
+ byContent.set(m.content, m.attachments);
159
+ }
160
+ }
161
+ return merged.map((m) => {
162
+ if (m.role !== 'user' || m.attachments?.length) return m;
163
+ // The JSONL content may carry inlined text-file blocks appended to the
164
+ // original prompt, so match by prefix instead of exact equality.
165
+ const atts = byContent.get(m.content)
166
+ ?? [...byContent.entries()].find(([storedContent]) =>
167
+ m.content.startsWith(storedContent) || storedContent.startsWith(m.content)
168
+ )?.[1];
169
+ return atts ? { ...m, attachments: atts } : m;
170
+ });
171
+ }
@@ -25,6 +25,7 @@ import { normalizeThinkingLevel } from '@/shared/lib/models/thinking-levels';
25
25
  import type { ChatMessageData } from '@/shared/types/chat';
26
26
  import { collectToolOutputs, noticeFromCustomMessage, toChatMessage, type SequenceState } from '@/shared/lib/omp/session/messages-map';
27
27
  import type { OmpMessageEntry } from '@/shared/lib/omp/session/messages-parse';
28
+ import { readBlobRef } from '@/server/lib/omp/session/blobs.server';
28
29
 
29
30
  const MAX_SESSION_LOAD_BYTES = 512 * 1024 * 1024;
30
31
 
@@ -121,9 +122,42 @@ export async function loadSessionMessages(filePath: string): Promise<ChatMessage
121
122
  }
122
123
  state.messages.push(mapped);
123
124
  }
125
+ await resolveImageBlobs(state.messages);
124
126
  return state.messages;
125
127
  }
126
128
 
129
+ /**
130
+ * Replace `blob:sha256:` image references with the bytes they point at.
131
+ *
132
+ * omp externalizes a large attached image out of the session JSONL: the entry
133
+ * keeps the ref, the bytes go to the blob store. Only omp's own loader resolves
134
+ * them, so a reloaded chamber session used to render
135
+ * `data:image/png;base64,blob:sha256:…` — a data URL whose payload is the ref
136
+ * string, which the browser cannot decode. The ref itself is dropped once
137
+ * resolved; an unresolvable one leaves the attachment with no preview rather
138
+ * than a broken image.
139
+ */
140
+ async function resolveImageBlobs(messages: ChatMessageData[]): Promise<void> {
141
+ const pending: Promise<void>[] = [];
142
+ for (const message of messages) {
143
+ for (const attachment of message.attachments ?? []) {
144
+ // `blobRef` rides the attachment only until the bytes are read back; it is
145
+ // not part of the public attachment shape, so narrow before touching it.
146
+ if (!('blobRef' in attachment) || typeof attachment.blobRef !== 'string') continue;
147
+ const ref = attachment.blobRef;
148
+ delete attachment.blobRef;
149
+ pending.push(
150
+ readBlobRef(ref).then((bytes) => {
151
+ if (bytes) {
152
+ attachment.preview = `data:${attachment.type ?? 'image/png'};base64,${bytes.toString('base64')}`;
153
+ }
154
+ }),
155
+ );
156
+ }
157
+ }
158
+ await Promise.all(pending);
159
+ }
160
+
127
161
  /** Derive the display title from the JSONL header/title slot (cheap read). */
128
162
  export async function loadSessionTitle(filePath: string): Promise<string | undefined> {
129
163
  try {