ompchamber 1.1.0 → 1.2.1

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.5fe7a2cb64.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 +183 -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 +88 -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.5fe7a2cb64.js.LICENSE.txt} +0 -0
@@ -0,0 +1,211 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The priming contract, which is a TIMING contract.
8
+ *
9
+ * A dropped `File` may only be read from a read begun while the drop handler is
10
+ * still running; a read started afterwards fails with `NotReadableError` and
11
+ * keeps failing. These tests pin the two halves of that: `primeFileReads`
12
+ * issues its read synchronously, and the read pipeline consumes the primed
13
+ * result instead of starting a second, unauthorized one.
14
+ */
15
+
16
+ import { afterEach, describe, expect, test } from 'bun:test';
17
+ import { primeFileReads, readFileTextWithRetry } from '@/client/hooks/chat/composer/file-reads';
18
+
19
+ /** A `FileReader` that answers from a lookup, counting the reads it was asked for. */
20
+ function installFakeReader(
21
+ contents: Map<string, string>,
22
+ options: { failDataUrl?: boolean; unreadable?: Set<File> } = {},
23
+ ) {
24
+ const calls: string[] = [];
25
+ /** A file the browser refuses to read, whatever its metadata says. */
26
+ const dead = (file: File) => options.unreadable?.has(file) ?? false;
27
+ class FakeReader {
28
+ result: string | ArrayBuffer | null = null;
29
+ error: { name: string; message: string } | null = null;
30
+ onload: (() => void) | null = null;
31
+ onerror: (() => void) | null = null;
32
+ onabort: (() => void) | null = null;
33
+
34
+ readAsArrayBuffer() {
35
+ calls.push('arrayBuffer');
36
+ queueMicrotask(() => {
37
+ if (options.failDataUrl) {
38
+ this.error = { name: 'NotReadableError', message: 'unreadable' };
39
+ this.onerror?.();
40
+ return;
41
+ }
42
+ this.result = new Uint8Array([1, 2, 3]).buffer;
43
+ this.onload?.();
44
+ });
45
+ }
46
+
47
+ readAsDataURL(file: File) {
48
+ calls.push(`dataURL:${file.name}`);
49
+ const body = dead(file) ? undefined : contents.get(file.name);
50
+ queueMicrotask(() => {
51
+ if (options.failDataUrl || body === undefined) {
52
+ this.error = {
53
+ name: 'NotReadableError',
54
+ message: 'permission problems that have occurred after a reference to a file was acquired',
55
+ };
56
+ this.onerror?.();
57
+ return;
58
+ }
59
+ // `data:<mime>;base64,<payload>` — the real reader's shape.
60
+ const base64 = btoa(String.fromCharCode(...new TextEncoder().encode(body)));
61
+ this.result = `data:text/plain;base64,${base64}`;
62
+ this.onload?.();
63
+ });
64
+ }
65
+
66
+ readAsText(file: File) {
67
+ calls.push(`text:${file.name}`);
68
+ const body = dead(file) ? undefined : contents.get(file.name);
69
+ queueMicrotask(() => {
70
+ if (body === undefined) {
71
+ this.error = { name: 'NotReadableError', message: 'unreadable' };
72
+ this.onerror?.();
73
+ return;
74
+ }
75
+ this.result = body;
76
+ this.onload?.();
77
+ });
78
+ }
79
+ }
80
+ (globalThis as unknown as { FileReader: unknown }).FileReader = FakeReader;
81
+ return calls;
82
+ }
83
+
84
+ const originalFileReader = (globalThis as unknown as { FileReader: unknown }).FileReader;
85
+
86
+ afterEach(() => {
87
+ (globalThis as unknown as { FileReader: unknown }).FileReader = originalFileReader;
88
+ });
89
+
90
+ describe('primeFileReads', () => {
91
+ test('starts the read synchronously, in the caller\'s own tick', () => {
92
+ const calls = installFakeReader(new Map([['a.md', 'body']]));
93
+ const file = new File(['body'], 'a.md', { type: 'text/markdown' });
94
+
95
+ primeFileReads([file]);
96
+
97
+ // The point of the module: the calls are already made when this returns. A
98
+ // read deferred to a microtask would be outside the drop handler's tick and
99
+ // therefore unauthorized.
100
+ expect(calls.filter((c) => c.startsWith('dataURL:'))).toEqual(['dataURL:a.md']);
101
+ expect(calls.filter((c) => c === 'arrayBuffer').length).toBe(1);
102
+ });
103
+
104
+ test('reads each file once however many batches carry it', () => {
105
+ const calls = installFakeReader(new Map([['a.md', 'body']]));
106
+ const file = new File(['body'], 'a.md', { type: 'text/markdown' });
107
+
108
+ primeFileReads([file, file]);
109
+
110
+ expect(calls.filter((c) => c.startsWith('dataURL:'))).toEqual(['dataURL:a.md']);
111
+ });
112
+
113
+ test('stays inside the attach cap, so an oversized drop is not read whole', () => {
114
+ const calls = installFakeReader(new Map());
115
+ const files = Array.from({ length: 80 }, (_, i) => new File(['x'], `f${i}.txt`));
116
+
117
+ primeFileReads(files);
118
+
119
+ expect(calls.filter((c) => c.startsWith('dataURL:')).length).toBeLessThanOrEqual(50);
120
+ });
121
+ });
122
+
123
+ describe('a drop that exposes one file as two objects', () => {
124
+ // A drop can hand over the same file twice — once in `files`, once in the item
125
+ // list — and only one of the two objects actually reads. Which one is not
126
+ // knowable up front: the same drop read fine in a browser tab and failed in an
127
+ // installed app window, with the unreadable object still reporting the right
128
+ // name and size. So the read path tries the counterpart instead of trusting
129
+ // the choice.
130
+ test('falls back to the counterpart object when the primary will not read', async () => {
131
+ const primary = new File(['x'.repeat(40)], 'notes.md', { type: 'text/markdown' });
132
+ const counterpart = new File(['rescued body'], 'notes.md', { type: 'text/markdown' });
133
+ // The primary refuses to read while declaring bytes; the counterpart reads.
134
+ const calls = installFakeReader(
135
+ new Map([['notes.md', 'rescued body']]),
136
+ { unreadable: new Set([primary]) },
137
+ );
138
+ const primed = primeFileReads([primary], new Map([[primary, counterpart]]));
139
+
140
+ const content = await readFileTextWithRetry(primary, primed);
141
+
142
+ expect(content).toBe('rescued body');
143
+ expect(calls.filter((c) => c.startsWith('dataURL:')).length).toBe(2);
144
+
145
+ // Not vacuous: with no counterpart primed the same file stays unreadable,
146
+ // which is exactly the failure this pairing exists to prevent.
147
+ const alone = primeFileReads([primary]);
148
+ expect(await readFileTextWithRetry(primary, alone)).toBeNull();
149
+ });
150
+
151
+ test('the counterpart is primed in the same tick, not read on demand', () => {
152
+ const primary = new File(['x'], 'a.md', { type: 'text/markdown' });
153
+ const counterpart = new File(['y'], 'a.md', { type: 'text/markdown' });
154
+ const calls = installFakeReader(new Map());
155
+
156
+ primeFileReads([primary], new Map([[primary, counterpart]]));
157
+
158
+ // Both reads must already be in flight when the call returns: one started
159
+ // later would be outside the drop's permission window.
160
+ expect(calls.filter((c) => c.startsWith('dataURL:')).length).toBe(2);
161
+ });
162
+ });
163
+
164
+ describe('readFileTextWithRetry with a primed read', () => {
165
+ test('returns the primed body without starting another read', async () => {
166
+ const calls = installFakeReader(new Map([['a.md', 'primed body']]));
167
+ const file = new File(['primed body'], 'a.md', { type: 'text/markdown' });
168
+ const primed = primeFileReads([file]);
169
+
170
+ const content = await readFileTextWithRetry(file, primed);
171
+
172
+ expect(content).toBe('primed body');
173
+ // Exactly one read: a second would be the unauthorized one, and its failure
174
+ // would be indistinguishable from an unreadable file.
175
+ expect(calls.filter((c) => c.startsWith('dataURL:'))).toEqual(['dataURL:a.md']);
176
+ });
177
+
178
+ test('falls back to its own reads when the file was never primed', async () => {
179
+ const calls = installFakeReader(new Map([['b.md', 'late body']]));
180
+ const file = new File(['late body'], 'b.md', { type: 'text/markdown' });
181
+
182
+ const content = await readFileTextWithRetry(file);
183
+
184
+ expect(content).toBe('late body');
185
+ expect(calls.length).toBeGreaterThan(0);
186
+ });
187
+
188
+ test('retries a primed read that came back empty, for a promised file', async () => {
189
+ // The primed read lands before the bytes do; the retry loop is what covers
190
+ // that, so it must not be short-circuited by the primed attempt.
191
+ const calls = installFakeReader(new Map());
192
+ const file = new File(['x'.repeat(100)], 'promised.md', { type: 'text/markdown' });
193
+ const primed = primeFileReads([file]);
194
+
195
+ const content = await readFileTextWithRetry(file, primed);
196
+
197
+ // Null, not '': the file DECLARES bytes, so an empty body is a failure the
198
+ // caller reports rather than a genuinely empty file it accepts.
199
+ expect(content).toBeNull();
200
+ expect(calls.filter((c) => c === 'text:promised.md').length).toBeGreaterThan(1);
201
+ });
202
+
203
+ test('accepts an empty body for a file that declares no bytes', async () => {
204
+ // A genuinely empty file is empty forever; retrying it cannot change that,
205
+ // so it settles as empty rather than as a failure.
206
+ installFakeReader(new Map());
207
+ const file = new File([], 'empty.md', { type: 'text/markdown' });
208
+
209
+ expect(await readFileTextWithRetry(file, primeFileReads([file]))).toBe('');
210
+ });
211
+ });
@@ -0,0 +1,238 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Reading a dropped `File`'s bytes — and the one browser rule that makes the
8
+ * TIMING of that read part of the contract.
9
+ *
10
+ * A file that arrives through a drop is not an ordinary `Blob`. The browser
11
+ * holds a permission reference to the real file, and that reference is released
12
+ * when the `drop` event's handler returns. A read started after that point
13
+ * fails with `NotReadableError: ... permission problems that have occurred
14
+ * after a reference to a file was acquired`, and it fails PERMANENTLY — every
15
+ * retry after it fails the same way, because the reference is gone rather than
16
+ * busy. This is why a dropped file showed a chip with a correct name and size
17
+ * while its contents never arrived, and why three retries did not help.
18
+ *
19
+ * `FileReader.readAs*` checks the permission when it is CALLED, not when it
20
+ * completes. So the read must be STARTED synchronously inside the drop handler;
21
+ * awaiting its result later is fine. `primeFileReads` is that start, and every
22
+ * later consumer takes the already-running read rather than beginning its own.
23
+ */
24
+
25
+
26
+ /** How many times a read that came back empty is retried before giving up. */
27
+ export const READ_ATTEMPTS = 3;
28
+
29
+ /** Base delay between read attempts; a promised file needs a beat to land. */
30
+ export const READ_RETRY_MS = 60;
31
+
32
+ /**
33
+ * How many files a single drop primes up front. Matches `MAX_DROPPED_FILES`:
34
+ * priming beyond what can be attached would hold bytes the budget will reject.
35
+ */
36
+ const MAX_PRIMED_FILES = 50;
37
+
38
+ export function delay(ms: number): Promise<void> {
39
+ const { promise, resolve } = Promise.withResolvers<void>();
40
+ setTimeout(resolve, ms);
41
+ return promise;
42
+ }
43
+
44
+ /**
45
+ * Start a data-URL read and return the still-running promise.
46
+ *
47
+ * MUST be called synchronously from the drop handler — see the module note. The
48
+ * result is the whole `data:<mime>;base64,<payload>` string, because the two
49
+ * consumers want different halves of it: an image takes the payload, a text file
50
+ * decodes it back to text.
51
+ */
52
+ function startDataUrlRead(file: File): Promise<string | null> {
53
+ const { promise, resolve } = Promise.withResolvers<string | null>();
54
+ const reader = new FileReader();
55
+ reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : null);
56
+ reader.onerror = () => {
57
+ resolve(null);
58
+ };
59
+ reader.onabort = () => resolve(null);
60
+ reader.readAsDataURL(file);
61
+ return promise;
62
+ }
63
+
64
+ /**
65
+ * A read already in flight, started while the drop's permission was live.
66
+ *
67
+ * `dataUrl` and `prefix` are the PRIMARY file's reads. `spare` holds the reads
68
+ * of the same drop's other `File` object, tried when the primary yields nothing:
69
+ * a drop can expose one file twice and only one of the two objects reads, with
70
+ * neither the metadata nor the context saying which. See `drop-entries.ts`.
71
+ */
72
+ export interface PrimedRead {
73
+ /** The whole file as a data URL — the authorized read of a dropped file. */
74
+ dataUrl: Promise<string | null>;
75
+ /**
76
+ * The leading bytes, for the text sniff and the empty-stub check.
77
+ *
78
+ * Those two ask a `File` for bytes through `slice().arrayBuffer()`, which is
79
+ * subject to the same permission as `FileReader` — so they have to be served
80
+ * from a read started in the drop's tick as well, not started on demand.
81
+ */
82
+ prefix: Promise<Uint8Array | null>;
83
+ /** The counterpart object's reads, when the payload exposed the file twice. */
84
+ spare?: PrimedRead;
85
+ }
86
+
87
+ export type PrimedReads = Map<File, PrimedRead>;
88
+
89
+ /** Bytes sampled to decide whether an unclassified file is text. */
90
+ const PREFIX_BYTES = 4096;
91
+
92
+ function startPrefixRead(file: File): Promise<Uint8Array | null> {
93
+ const { promise, resolve } = Promise.withResolvers<Uint8Array | null>();
94
+ const reader = new FileReader();
95
+ reader.onload = () => resolve(reader.result instanceof ArrayBuffer ? new Uint8Array(reader.result) : null);
96
+ reader.onerror = () => resolve(null);
97
+ reader.onabort = () => resolve(null);
98
+ reader.readAsArrayBuffer(file.slice(0, PREFIX_BYTES));
99
+ return promise;
100
+ }
101
+
102
+ /**
103
+ * Begin reading every file NOW, in the drop handler's own tick.
104
+ *
105
+ * Returns a lookup the later async pipeline consults instead of reading the
106
+ * `File` itself. Files that were not primed (a directory walk discovers its
107
+ * children after the tick) simply have no entry and fall back to a fresh read.
108
+ */
109
+ export function primeFileReads(files: readonly File[], fallbacks?: Map<File, File>): PrimedReads {
110
+ const primed: PrimedReads = new Map();
111
+ // Bounded by the same cap the attach path applies: a read is buffered as a
112
+ // data URL, so priming a thousand-file drop would hold every byte twice
113
+ // before the budget had a chance to reject any of them.
114
+ for (const file of files.slice(0, MAX_PRIMED_FILES)) {
115
+ if (primed.has(file)) continue;
116
+ const counterpart = fallbacks?.get(file);
117
+ primed.set(file, {
118
+ dataUrl: startDataUrlRead(file),
119
+ prefix: startPrefixRead(file),
120
+ // Primed in the same tick for the same reason: a fallback whose own read
121
+ // starts after the drop window closes is not authorized either.
122
+ ...(counterpart
123
+ ? { spare: { dataUrl: startDataUrlRead(counterpart), prefix: startPrefixRead(counterpart) } }
124
+ : {}),
125
+ });
126
+ }
127
+ return primed;
128
+ }
129
+
130
+ /**
131
+ * The primed leading bytes, or `undefined` when the file was never primed.
132
+ *
133
+ * An empty array means the read was attempted and yielded nothing — which is
134
+ * also what a genuine empty file gives, and what the sniff should see.
135
+ */
136
+ export async function primedPrefix(file: File, primed?: PrimedReads): Promise<Uint8Array | undefined> {
137
+ const started = primed?.get(file);
138
+ if (!started) return undefined;
139
+ const bytes = await started.prefix;
140
+ if (bytes && bytes.byteLength > 0) return bytes;
141
+ if (started.spare) {
142
+ const spare = await started.spare.prefix;
143
+ if (spare && spare.byteLength > 0) return spare;
144
+ }
145
+ return bytes ?? new Uint8Array(0);
146
+ }
147
+
148
+ /** The base64 payload of a data URL, or null when there is none. */
149
+ export function base64Of(dataUrl: string | null): string | null {
150
+ if (dataUrl === null) return null;
151
+ const comma = dataUrl.indexOf(',');
152
+ return comma === -1 ? null : dataUrl.slice(comma + 1);
153
+ }
154
+
155
+ /**
156
+ * Decode a data URL's payload back to text, or null when it cannot be decoded.
157
+ *
158
+ * Null covers both "no payload" and "payload is not text": a dropped binary
159
+ * arrives through the same data-URL read, and its bytes are not a UTF-8 string.
160
+ */
161
+ export function textOf(dataUrl: string | null): string | null {
162
+ const base64 = base64Of(dataUrl);
163
+ if (base64 === null) return null;
164
+ try {
165
+ const binary = atob(base64);
166
+ const bytes = new Uint8Array(binary.length);
167
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
168
+ return new TextDecoder('utf-8').decode(bytes);
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ /** Read a File as base64, or null when the read fails. */
175
+ export async function readAsDataUrl(file: File, primed?: PrimedReads): Promise<string | null> {
176
+ const started = primed?.get(file);
177
+ if (started) {
178
+ const payload = base64Of(await started.dataUrl);
179
+ if (payload !== null) return payload;
180
+ // The primary object did not read; the drop's other object for the same file
181
+ // may. One of the two is unreadable in a given context and it is not the one
182
+ // the metadata suggests.
183
+ if (started.spare) return base64Of(await started.spare.dataUrl);
184
+ return null;
185
+ }
186
+ return base64Of(await startDataUrlRead(file));
187
+ }
188
+
189
+ /** Read a File as text through `readAsText`, or null when the read fails. */
190
+ function readAsTextDirect(file: File): Promise<string | null> {
191
+ const { promise, resolve } = Promise.withResolvers<string | null>();
192
+ const reader = new FileReader();
193
+ reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : null);
194
+ reader.onerror = () => {
195
+ resolve(null);
196
+ };
197
+ reader.onabort = () => resolve(null);
198
+ reader.readAsText(file);
199
+ return promise;
200
+ }
201
+
202
+ /**
203
+ * Read a File's text, preferring a read that was already started.
204
+ *
205
+ * The primed read is the one that matters: it is the only one the browser is
206
+ * guaranteed to authorize for a dropped file (see the module note), so it is
207
+ * consulted first and a non-empty body from it ends the call. The loop behind it
208
+ * exists for a PROMISED file — another app's drag, whose bytes land a moment
209
+ * after the entry exists — and for a file that was never primed at all.
210
+ *
211
+ * Returns null when no attempt produced a body for a file that declares bytes.
212
+ */
213
+ export async function readFileTextWithRetry(file: File, primed?: PrimedReads): Promise<string | null> {
214
+ const started = primed?.get(file);
215
+ if (started) {
216
+ const content = textOf(await started.dataUrl);
217
+ if (content !== null && content.length > 0) return content;
218
+ if (started.spare) {
219
+ const spare = textOf(await started.spare.dataUrl);
220
+ if (spare !== null && spare.length > 0) return spare;
221
+ }
222
+ }
223
+
224
+ let last: string | null = null;
225
+ for (let attempt = 0; attempt < READ_ATTEMPTS; attempt += 1) {
226
+ last = await readAsTextDirect(file);
227
+ if (last === null || last.length === 0) {
228
+ const viaDataUrl = textOf(await startDataUrlRead(file));
229
+ if (viaDataUrl !== null && viaDataUrl.length > 0) return viaDataUrl;
230
+ }
231
+ if (last !== null && last.length > 0) return last;
232
+ // An empty body is the real answer once the file has had a beat to
233
+ // materialize AND declares no bytes of its own.
234
+ if (file.size === 0 && attempt > 0) return last ?? '';
235
+ await delay(READ_RETRY_MS);
236
+ }
237
+ return last;
238
+ }
@@ -2,7 +2,7 @@ import { useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from '
2
2
  import type { ChangeEvent, RefObject } from 'preact/compat';
3
3
  import type { TargetedKeyboardEvent } from 'preact';
4
4
  import type { ComposerMatchItem, ComposerPickItem, ComposerTrigger } from '@/shared/types';
5
- import { detectComposerTrigger, insertToken, tokenForItem } from '@/shared/lib/chat/composer/trigger';
5
+ import { detectComposerTrigger, insertToken, sendInsteadOfAccept, tokenForItem } from '@/shared/lib/chat/composer/trigger';
6
6
  import { filterComposerItems } from '@/shared/lib/chat/composer/filter';
7
7
  import { useComposerItems } from '@/client/hooks/chat/composer/items';
8
8
 
@@ -152,6 +152,12 @@ export function useComposerTrigger(options: UseComposerTriggerOptions): Composer
152
152
  }
153
153
 
154
154
  if ((e.key === 'Enter' || e.key === 'Tab') && matches.length > 0) {
155
+ // A complete command with nothing left to complete: Enter is a send, so
156
+ // drop the popup rather than consuming the keystroke.
157
+ if (e.key === 'Enter' && sendInsteadOfAccept(trigger, matches[clampedIndex])) {
158
+ close();
159
+ return false;
160
+ }
155
161
  e.preventDefault();
156
162
  e.stopPropagation();
157
163
  selectItem(matches[clampedIndex]);
@@ -0,0 +1,181 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The composer's attach-and-send pipeline: everything between "a file arrived"
8
+ * and "a prompt is dispatched", plus the one line of feedback each outcome
9
+ * produces.
10
+ *
11
+ * Extracted from ChatInput so the component stays a layout shell and the
12
+ * composer keeps room under the repo's per-file size ceiling.
13
+ *
14
+ * The rules encoded here, each of which was a bug once:
15
+ *
16
+ * - A drop registers its files in the attachment hook BEFORE the component
17
+ * re-renders, so a send clicked immediately afterwards must read the hook's
18
+ * list, not the component's `attachments` prop — that prop is a frame behind
19
+ * and an empty read clears the composer and dispatches nothing.
20
+ * - A send waits for in-flight reads and then uses the list those reads
21
+ * returned: an attachment whose bytes landed during the wait is sent
22
+ * complete, never as the pre-read entry.
23
+ * - A send that would carry neither text nor any readable attachment is
24
+ * REPORTED, never dispatched. An empty prompt reaching the model is worse
25
+ * than not sending, and it is what a failed read used to produce.
26
+ */
27
+
28
+ import { useCallback, useRef, useState } from 'preact/hooks';
29
+ import type { SetStateAction } from 'preact/compat';
30
+ import type { Attachment } from '@/shared/types';
31
+ import { attachmentName, describeAttachmentBudget } from '@/shared/lib/chat/attachments';
32
+ import { useComposerAttachments } from '@/client/hooks/chat/composer/attachments';
33
+ import { useFileDrop, DROP_LIMIT_NOTICE, type FileDropProps } from '@/client/hooks/chat/composer/file-drop';
34
+ import type { PrimedReads } from '@/client/hooks/chat/composer/file-reads';
35
+ import {
36
+ describeReferenceFailure,
37
+ describeUnreadable,
38
+ readDroppedReference,
39
+ referenceName,
40
+ type ReferenceFailure,
41
+ } from '@/client/hooks/chat/composer/drop-references';
42
+
43
+ export interface UseComposerPipelineOptions {
44
+ attachments: Attachment[];
45
+ setAttachments: (updater: SetStateAction<Attachment[]>) => void;
46
+ /** Current composer text, read at send time. */
47
+ value: string;
48
+ disabled: boolean;
49
+ /** Workspace root, used to resolve a dropped path reference. */
50
+ rootPath?: string | null;
51
+ onSend: (attachments: Attachment[], options?: { steering?: boolean }) => void;
52
+ }
53
+
54
+ export interface ComposerPipeline {
55
+ /** Inline notice shown above the input; null when there is nothing to say. */
56
+ notice: string | null;
57
+ dismissNotice: () => void;
58
+ /** True while a file drag hovers the composer. */
59
+ isDragging: boolean;
60
+ /** Drag handlers for the composer card. */
61
+ dropProps: FileDropProps;
62
+ /** Attach a batch (picker, paste, or a drop the caller already parsed). */
63
+ acceptFiles: (files: File[], incomplete?: boolean, references?: string[], primed?: PrimedReads) => void;
64
+ /** Remove one attachment. */
65
+ removeAttachment: (id: string) => void;
66
+ /** Send the composer's contents. */
67
+ submit: (options?: { steering?: boolean }) => void;
68
+ }
69
+
70
+ export function useComposerPipeline({
71
+ attachments,
72
+ setAttachments,
73
+ value,
74
+ disabled,
75
+ rootPath,
76
+ onSend,
77
+ }: UseComposerPipelineOptions): ComposerPipeline {
78
+ const { addFiles, removeAttachment, readyForSend, peek, clear } = useComposerAttachments({ attachments, setAttachments });
79
+ // Guards a double click: the composer is cleared only after the reads land, so
80
+ // without this a second click would dispatch the same attachment twice.
81
+ const sendingRef = useRef(false);
82
+ const [notice, setNotice] = useState<string | null>(null);
83
+
84
+ // A download dragged out of a web page carries no File — only a URL. The
85
+ // server reads it, because the browser cannot fetch an arbitrary path and the
86
+ // drop gives no bytes to work with.
87
+ const attachReferences = useCallback(async (references: string[]) => {
88
+ const resolved: File[] = [];
89
+ const failed: string[] = [];
90
+ const reasons = new Map<string, ReferenceFailure>();
91
+ await Promise.all(references.map(async (reference) => {
92
+ const name = referenceName(reference);
93
+ const result = await readDroppedReference(reference, rootPath ?? null);
94
+ if (result.ok) {
95
+ resolved.push(result.file);
96
+ return;
97
+ }
98
+ failed.push(name);
99
+ reasons.set(name, result.reason);
100
+ }));
101
+ if (resolved.length > 0) {
102
+ const { unreadable } = await addFiles(resolved);
103
+ setNotice(unreadable.length > 0 ? describeUnreadable(unreadable) : null);
104
+ }
105
+ if (failed.length > 0) {
106
+ // Named per cause: "outside the folders the chamber may read" is something
107
+ // the user can act on, while a bare "could not be read" is not.
108
+ const reason = reasons.get(failed[0]) ?? 'unavailable';
109
+ setNotice(describeReferenceFailure(failed, reason));
110
+ }
111
+ }, [addFiles, rootPath]);
112
+
113
+ const acceptFiles = useCallback((
114
+ files: File[],
115
+ incomplete = false,
116
+ references: string[] = [],
117
+ primed?: PrimedReads,
118
+ ) => {
119
+ void (async () => {
120
+ const { added, skipped, unreadable } = await addFiles(files, primed);
121
+ if (skipped.length > 0) {
122
+ setNotice(describeAttachmentBudget(skipped));
123
+ } else if (incomplete) {
124
+ setNotice(DROP_LIMIT_NOTICE);
125
+ } else if (unreadable.length > 0) {
126
+ // Reported, never silent: a chip whose bytes never arrived would send a
127
+ // prompt that names a file the model cannot see.
128
+ setNotice(describeUnreadable(unreadable));
129
+ } else if (added > 0) {
130
+ setNotice(null);
131
+ }
132
+ if (references.length > 0) await attachReferences(references);
133
+ })();
134
+ }, [addFiles, attachReferences]);
135
+
136
+ // OS drag-and-drop. Bound to the composer CARD, not the textarea: the whole
137
+ // composer is the drop target, so a drop anywhere inside it (including on the
138
+ // textarea) bubbles here instead of letting the browser navigate away.
139
+ const { isDragging, dropProps } = useFileDrop({ disabled, onFiles: acceptFiles });
140
+
141
+ const submit = useCallback((options?: { steering?: boolean }) => {
142
+ // The hook's list, not the prop: a drop registers its files before this
143
+ // component re-renders, so a send clicked right after a drop would see an
144
+ // empty prop, clear the composer and dispatch a prompt carrying nothing.
145
+ const pending = peek();
146
+ if (!value.trim() && pending.length === 0) {
147
+ return;
148
+ }
149
+ if (sendingRef.current) {
150
+ return;
151
+ }
152
+ sendingRef.current = true;
153
+ // Bytes are read at attach time; waiting here returns the list as it stands
154
+ // once those reads land, so an attachment whose read was still in flight is
155
+ // sent complete rather than empty.
156
+ void readyForSend().then((outgoing) => {
157
+ clear();
158
+ // "Readable" means there is something TO SEND. An attachment whose read
159
+ // produced an empty string has a `content` field but no content, and
160
+ // treating that as readable is how a prompt with an empty body went out.
161
+ const readable = outgoing.some((a) => (a.content?.length ?? 0) > 0 || a.dataBase64 !== undefined);
162
+ if (!value.trim() && outgoing.length > 0 && !readable) {
163
+ setNotice(describeUnreadable(outgoing.map((a) => attachmentName(a))));
164
+ return;
165
+ }
166
+ onSend(outgoing, options);
167
+ }).finally(() => {
168
+ sendingRef.current = false;
169
+ });
170
+ }, [clear, onSend, peek, readyForSend, value]);
171
+
172
+ return {
173
+ notice,
174
+ dismissNotice: useCallback(() => setNotice(null), []),
175
+ isDragging,
176
+ dropProps,
177
+ acceptFiles,
178
+ removeAttachment,
179
+ submit,
180
+ };
181
+ }
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
- import type { ChatMessageData, OmpAgentCallbacks, OmpAgentHandle, OmpAgentState, StreamTransport } from '@/shared/types';
2
+ import type { AgentImage, ChatMessageData, OmpAgentCallbacks, OmpAgentHandle, OmpAgentState, StreamTransport } from '@/shared/types';
3
3
  import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
4
4
  import { type ToolResultRecord, useOmpAgentStream } from '@/client/hooks/chat/omp/stream';
5
5
  import { useOmpPromptSender } from '@/client/hooks/chat/omp/prompt-send';
@@ -120,7 +120,7 @@ export function useOmpAgent(sessionId: string | null, callbacks: OmpAgentCallbac
120
120
  * arrives via the interruptPending guard. */
121
121
  const sendInterruptAndReply = useCallback(async (
122
122
  message: string,
123
- images?: { data: string; mimeType: string }[],
123
+ images?: AgentImage[],
124
124
  ): Promise<boolean> => {
125
125
  const sid = sessionIdRef.current;
126
126
  if (!sid) return false;
@@ -153,7 +153,7 @@ export function useOmpAgent(sessionId: string | null, callbacks: OmpAgentCallbac
153
153
  /** Enqueue a follow-up message the agent processes after the current turn. */
154
154
  const sendFollowUp = useCallback(async (
155
155
  message: string,
156
- images?: { data: string; mimeType: string }[],
156
+ images?: AgentImage[],
157
157
  ): Promise<boolean> => {
158
158
  const sid = sessionIdRef.current;
159
159
  if (!sid) return false;