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,208 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Attachment metadata across the two representations the composer handles.
8
+ *
9
+ * A LIVE attachment carries `file: File`; a REPLAYED one — out of the queue row
10
+ * or back out of committed history — does not, because `JSON.stringify(new
11
+ * File(…))` is `{}`. Every accessor and every assembly path must therefore work
12
+ * from the persisted display fields, which is what used to break: a queued or
13
+ * retried send read `a.file.name` and threw, and the image payload filter
14
+ * matched on a `data:` preview the composer never produces.
15
+ */
16
+
17
+ import { describe, expect, test } from 'bun:test';
18
+ import type { Attachment } from '@/shared/types';
19
+ import {
20
+ applyTextAttachmentBudget,
21
+ attachmentImage,
22
+ attachmentName,
23
+ attachmentSize,
24
+ attachmentType,
25
+ composeMessageWithTextAttachments,
26
+ isImageAttachment,
27
+ isTextAttachment,
28
+ looksLikeTextFile,
29
+ prepareQueuedAttachments,
30
+ readTextAttachments,
31
+ } from '@/shared/lib/chat/attachments';
32
+
33
+ function liveFile(name: string, body: string, type = ''): File {
34
+ return new File([body], name, { type });
35
+ }
36
+
37
+ function liveAttachment(overrides: Partial<Attachment> & { id: string }): Attachment {
38
+ return { preview: '', ...overrides };
39
+ }
40
+
41
+ /** What the queue row and committed history actually hold. */
42
+ function replayed(attachments: Attachment[]): Attachment[] {
43
+ return JSON.parse(JSON.stringify(attachments)) as Attachment[];
44
+ }
45
+
46
+ describe('attachment metadata accessors', () => {
47
+ test('read a live file, and fall back to the persisted fields', () => {
48
+ const live = liveAttachment({ id: 'a', name: 'notes.md', type: 'text/markdown', size: 99, file: liveFile('notes.md', '# hi', 'text/markdown') });
49
+ expect(attachmentName(live)).toBe('notes.md');
50
+ expect(attachmentType(live)).toBe('text/markdown');
51
+ expect(attachmentSize(live)).toBe(4); // the live handle is authoritative
52
+
53
+ const replay = replayed([live])[0];
54
+ // `JSON.stringify` renders a File as an empty object — not as undefined —
55
+ // so the replay path must treat a truthiness check as unreliable and read
56
+ // the display fields first.
57
+ expect(replay.file as unknown).toEqual({});
58
+ expect(attachmentName(replay)).toBe('notes.md');
59
+ expect(attachmentType(replay)).toBe('text/markdown');
60
+ expect(attachmentSize(replay)).toBe(99);
61
+ });
62
+
63
+ test('never throw on an attachment with neither a file nor a name', () => {
64
+ const bare = { id: 'x', preview: '' } as Attachment;
65
+ expect(attachmentName(bare)).toBe('attachment');
66
+ expect(attachmentType(bare)).toBe('');
67
+ expect(attachmentSize(bare)).toBe(0);
68
+ expect(isTextAttachment(bare)).toBe(false);
69
+ expect(attachmentImage(bare)).toBeNull();
70
+ });
71
+ });
72
+
73
+ describe('text vs image classification', () => {
74
+ test('code files with no browser MIME type still inline', () => {
75
+ // `File.type` is empty for most source files, so the extension decides.
76
+ for (const name of ['script.py', 'app.tsx', 'query.sql', 'Dockerfile.json', 'data.yaml']) {
77
+ expect(isTextAttachment({ name, type: '', file: liveFile(name, 'x') })).toBe(true);
78
+ }
79
+ });
80
+
81
+ test('a browser-reported text MIME type is enough on its own', () => {
82
+ expect(isTextAttachment({ name: 'LICENSE', type: 'text/plain', file: liveFile('LICENSE', 'x', 'text/plain') })).toBe(true);
83
+ expect(isTextAttachment({ name: 'blob.bin', type: 'application/json', file: liveFile('blob.bin', '{}', 'application/json') })).toBe(true);
84
+ });
85
+
86
+ test('binaries and images never consume the inline budget', () => {
87
+ expect(isTextAttachment({ name: 'app.dmg', type: 'application/octet-stream', file: liveFile('app.dmg', 'x', 'application/octet-stream') })).toBe(false);
88
+ // An image whose name happens to look textual is still an image.
89
+ expect(isTextAttachment({ name: 'trap.md', type: 'image/png', file: liveFile('trap.md', 'x', 'image/png') })).toBe(false);
90
+ expect(isImageAttachment({ name: 'trap.md', type: 'image/png', file: liveFile('trap.md', 'x', 'image/png') })).toBe(true);
91
+ });
92
+ });
93
+
94
+ describe('image payloads', () => {
95
+ test('carry the omp ImageContent type and the persisted MIME type', () => {
96
+ const payload = attachmentImage({
97
+ type: 'image/png',
98
+ dataBase64: 'iVBORw0KGgo=',
99
+ });
100
+ expect(payload).toEqual({ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' });
101
+ });
102
+
103
+ test('a replay with a blob preview still yields its payload', () => {
104
+ // The old server-side filter matched `preview.startsWith('data:image/')`
105
+ // and so found nothing: the composer's preview is a blob URL.
106
+ const [replay] = replayed([
107
+ liveAttachment({ id: 'i', name: 's.png', type: 'image/png', preview: 'blob:http://x/1', dataBase64: 'AAAA', file: liveFile('s.png', 'x', 'image/png') }),
108
+ ]);
109
+ expect(attachmentImage(replay)?.data).toBe('AAAA');
110
+ });
111
+
112
+ test('an unread image yields nothing rather than a blank payload', () => {
113
+ expect(attachmentImage({ type: 'image/png', dataBase64: '' })).toBeNull();
114
+ expect(attachmentImage({ type: 'image/png' })).toBeNull();
115
+ });
116
+ });
117
+
118
+ describe('text contents', () => {
119
+ test('are read from a live file, and from `content` on a replay', async () => {
120
+ const live = [liveAttachment({ id: 't', name: 'notes.md', type: 'text/markdown', file: liveFile('notes.md', '# hi', 'text/markdown') })];
121
+ expect((await readTextAttachments(live)).map((f) => f.content)).toEqual(['# hi']);
122
+
123
+ const replay = replayed(await prepareQueuedAttachments(live));
124
+ expect(replay[0].file).toBeUndefined();
125
+ expect(replay[0].content).toBe('# hi');
126
+ expect((await readTextAttachments(replay)).map((f) => f.content)).toEqual(['# hi']);
127
+ });
128
+
129
+ test('a replay without a persisted copy is flagged, not silently dropped', async () => {
130
+ // It used to be filtered out, which sent a prompt naming a file whose
131
+ // contents never arrived — with nothing in the UI to say so.
132
+ const files = await readTextAttachments([
133
+ { id: 't', name: 'notes.md', type: 'text/markdown', preview: '' },
134
+ ]);
135
+ expect(files.map((f) => ({ name: f.name, missing: f.missing, content: f.content }))).toEqual([
136
+ { name: 'notes.md', missing: true, content: '' },
137
+ ]);
138
+ });
139
+
140
+ test('carry their attachment id so the caller can map them back', async () => {
141
+ const files = await readTextAttachments([
142
+ { id: 'keep', name: 'a.md', type: 'text/markdown', preview: '', content: 'a' },
143
+ { id: 'drop', name: 'b.bin', type: 'application/octet-stream', preview: '', content: 'b' },
144
+ ]);
145
+ expect(files.map((f) => f.id)).toEqual(['keep']);
146
+ });
147
+
148
+ test('are fenced with a language that matches the file', () => {
149
+ const py = [{ name: 'script.py', mimeType: 'text/x-python', content: 'x=1', size: 3 }];
150
+ expect(composeMessageWithTextAttachments('go', py)).toContain('```python');
151
+ });
152
+ });
153
+
154
+ describe('inline budget', () => {
155
+ test('counts replayed attachments, so repeated drops stop at the cap', () => {
156
+ const existing = Array.from({ length: 10 }, (_, i) => ({ name: `f${i}.md`, type: 'text/markdown', size: 10, content: 'x' }));
157
+ const { accepted, skipped } = applyTextAttachmentBudget([liveFile('extra.md', 'x', 'text/markdown')], existing);
158
+ expect(accepted).toEqual([]);
159
+ expect(skipped.map((f) => f.name)).toEqual(['extra.md']);
160
+ });
161
+
162
+ test('lets an image through when the text budget is spent', () => {
163
+ const existing = Array.from({ length: 10 }, (_, i) => ({ name: `f${i}.md`, type: 'text/markdown', size: 10, content: 'x' }));
164
+ const { accepted, skipped } = applyTextAttachmentBudget([liveFile('shot.png', 'x', 'image/png')], existing);
165
+ expect(accepted.map((f) => f.name)).toEqual(['shot.png']);
166
+ expect(skipped).toEqual([]);
167
+ });
168
+ });
169
+
170
+ describe('content sniffing for unclassified files', () => {
171
+ test('a file whose name and MIME say nothing is recognised as text', async () => {
172
+ // A promised-file drag from another app arrives like this: real bytes, a
173
+ // generic name, no usable type. Deciding from metadata alone left it
174
+ // attached but never inlined.
175
+ expect(await looksLikeTextFile(liveFile('document', 'IMPORTANT\ncode: ABC'))).toBe(true);
176
+ expect(await looksLikeTextFile(liveFile('file', '# Title\n\nBody'))).toBe(true);
177
+ });
178
+
179
+ test('binary content is refused', async () => {
180
+ const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
181
+ expect(await looksLikeTextFile(new File([png], 'download'))).toBe(false);
182
+ // A NUL byte is decisive: no text encoding this app handles contains one.
183
+ expect(await looksLikeTextFile(new File([new Uint8Array([65, 66, 0, 67])], 'blob'))).toBe(false);
184
+ });
185
+
186
+ test('an empty file is not treated as text', async () => {
187
+ expect(await looksLikeTextFile(liveFile('empty', ''))).toBe(false);
188
+ });
189
+
190
+ test('a sniffed attachment keeps its content through the read path', async () => {
191
+ // The sniffer's verdict is recorded on the attachment at attach time, so the
192
+ // send path must honour it even though the name still says nothing — and a
193
+ // bare `content` string must NOT be enough to smuggle a binary through.
194
+ const sniffed: Attachment = {
195
+ id: 'x',
196
+ name: 'document',
197
+ type: '',
198
+ size: 20,
199
+ preview: '',
200
+ content: 'IMPORTANT\ncode: ABC',
201
+ sniffedText: true,
202
+ };
203
+ const read = await readTextAttachments([sniffed]);
204
+ expect(read.map((f) => ({ name: f.name, content: f.content }))).toEqual([
205
+ { name: 'document', content: 'IMPORTANT\ncode: ABC' },
206
+ ]);
207
+ });
208
+ });
@@ -4,22 +4,32 @@
4
4
  */
5
5
 
6
6
  /**
7
- * Text-file attachment composition for the chat composer. Faithful port of
8
- * omp-web/lib/chat-attachments.ts: text files are inlined into the prompt as
9
- * fenced blocks so the model sees their full content.
7
+ * Attachment metadata and prompt composition for the chat composer.
8
+ *
9
+ * Two representations of the same attachment exist and the difference is the
10
+ * whole reason this module exists:
11
+ *
12
+ * - A LIVE attachment (just picked/pasted/dropped) carries `file: File`, so its
13
+ * bytes are readable.
14
+ * - A REPLAYED attachment comes from the `queued_messages` row or from committed
15
+ * history. `File` does not survive either trip (`JSON.stringify(new File(…))`
16
+ * is `{}`), so only `name`/`type`/`size`/`preview`/`dataBase64`/`content`
17
+ * remain.
18
+ *
19
+ * Every read of an attachment's metadata therefore goes through the accessors
20
+ * below, which read the display fields and fall back to the live `File` — never
21
+ * `a.file.name`, which throws on a replayed attachment and is what used to break
22
+ * queued delivery and retry.
10
23
  */
11
24
 
25
+ import type { AgentImage, Attachment } from '@/shared/types';
26
+ import { TEXT_FILE_EXTENSIONS, isTextMimeType } from '@/shared/lib/chat/text-file';
27
+
28
+ export { looksLikeTextFile } from '@/shared/lib/chat/text-file';
29
+
12
30
  export const MAX_ATTACHED_TEXT_BYTES = 256 * 1024;
13
31
  export const MAX_ATTACHED_TEXT_FILES = 10;
14
32
 
15
- const TEXT_FILE_EXTENSIONS: Record<string, true> = {
16
- txt: true,
17
- text: true,
18
- md: true,
19
- markdown: true,
20
- mdx: true,
21
- };
22
-
23
33
  export interface AttachedTextFileData {
24
34
  name: string;
25
35
  mimeType: string;
@@ -27,19 +37,125 @@ export interface AttachedTextFileData {
27
37
  size: number;
28
38
  }
29
39
 
40
+ /** An inlined text file plus the id of the attachment it came from. */
41
+ export interface ReadTextAttachment extends AttachedTextFileData {
42
+ id: string;
43
+ /** True when no bytes could be read — the file is named but not carried. */
44
+ missing?: boolean;
45
+ }
46
+
30
47
  function getFileExtension(name: string): string {
31
48
  return name.toLowerCase().replace(/\\/g, '/').split('/').pop()?.split('.').pop() ?? '';
32
49
  }
33
50
 
34
- export function isTextAttachmentFile(file: Pick<File, 'name' | 'type'>): boolean {
35
- return file.type === 'text/plain'
36
- || file.type === 'text/markdown'
37
- || TEXT_FILE_EXTENSIONS[getFileExtension(file.name)] === true;
51
+ /** Attachment display name, live file first, persisted name second. */
52
+ export function attachmentName(att: Pick<Attachment, 'name' | 'file'>): string {
53
+ return att.name ?? att.file?.name ?? 'attachment';
54
+ }
55
+
56
+ /** Attachment MIME type, live file first, persisted type second. */
57
+ export function attachmentType(att: Pick<Attachment, 'type' | 'file'>): string {
58
+ return att.type ?? att.file?.type ?? '';
59
+ }
60
+
61
+ /** Attachment byte size, live file first, persisted size second. */
62
+ export function attachmentSize(att: Pick<Attachment, 'size' | 'file'>): number {
63
+ return att.file?.size ?? att.size ?? 0;
64
+ }
65
+
66
+ /** Whether this attachment's contents can be inlined into the prompt. */
67
+ export function isTextAttachment(att: Pick<Attachment, 'name' | 'type' | 'file'>): boolean {
68
+ const type = attachmentType(att);
69
+ if (isTextMimeType(type)) return true;
70
+ // An image is never inlined, whatever its extension claims.
71
+ if (type.startsWith('image/')) return false;
72
+ return TEXT_FILE_EXTENSIONS[getFileExtension(attachmentName(att))] === true;
73
+ }
74
+
75
+ /** Whether this attachment is an image the model can receive. */
76
+ export function isImageAttachment(att: Pick<Attachment, 'name' | 'type' | 'file'>): boolean {
77
+ return attachmentType(att).startsWith('image/');
78
+ }
79
+
80
+ /** Base64 image payload for the omp RPC, or null when there is nothing to send. */
81
+ export function attachmentImage(
82
+ att: Pick<Attachment, 'type' | 'file' | 'dataBase64'>,
83
+ ): AgentImage | null {
84
+ if (!att.dataBase64 || !isImageAttachment(att)) return null;
85
+ return {
86
+ type: 'image',
87
+ data: att.dataBase64,
88
+ // A persisted attachment carries the authoritative type; the fallback keeps
89
+ // a provider from rejecting a payload whose type the browser left empty.
90
+ mimeType: attachmentType(att) || 'image/png',
91
+ };
92
+ }
93
+
94
+ export interface AttachmentBudget {
95
+ /** Files that fit the inline budget and may be attached. */
96
+ accepted: File[];
97
+ /** Text files refused because the budget was already spent. */
98
+ skipped: File[];
99
+ }
100
+
101
+ /** User-facing reason for a refusal, shared by every composer attach path. */
102
+ export function describeAttachmentBudget(skipped: File[]): string {
103
+ const limit = `${MAX_ATTACHED_TEXT_FILES} text files / ${Math.round(MAX_ATTACHED_TEXT_BYTES / 1024)} KB total`;
104
+ if (skipped.length === 1) return `"${skipped[0].name}" was not attached — inline limit is ${limit}.`;
105
+ return `${skipped.length} text files were not attached — inline limit is ${limit}.`;
106
+ }
107
+
108
+ /**
109
+ * Gate a batch of incoming files against the text-inline budget.
110
+ *
111
+ * Only text files are gated: their contents are inlined into the prompt, so a
112
+ * directory drop can otherwise push megabytes into a single request. Images and
113
+ * other binaries travel as payloads and are capped by the caller's own limit.
114
+ *
115
+ * The budget spans the whole composer, not the batch — `existing` counts too,
116
+ * so three successive drops of four text files stop at ten.
117
+ */
118
+ export function applyTextAttachmentBudget(
119
+ files: File[],
120
+ existing: { name?: string; type?: string; size?: number; file?: File }[],
121
+ ): AttachmentBudget {
122
+ const accepted: File[] = [];
123
+ const skipped: File[] = [];
124
+ let textFiles = existing.filter((a) => isTextAttachment(a)).length;
125
+ let textBytes = existing.reduce((total, a) => (isTextAttachment(a) ? attachmentSize(a) : total), 0);
126
+
127
+ for (const file of files) {
128
+ if (!isTextAttachment({ name: file.name, type: file.type, file })) {
129
+ accepted.push(file);
130
+ continue;
131
+ }
132
+ if (textFiles >= MAX_ATTACHED_TEXT_FILES || textBytes + file.size > MAX_ATTACHED_TEXT_BYTES) {
133
+ skipped.push(file);
134
+ continue;
135
+ }
136
+ textFiles += 1;
137
+ textBytes += file.size;
138
+ accepted.push(file);
139
+ }
140
+
141
+ return { accepted, skipped };
38
142
  }
39
143
 
40
144
  function languageForFile(name: string): string {
41
145
  const extension = getFileExtension(name);
42
146
  if (extension === 'md' || extension === 'markdown' || extension === 'mdx') return 'markdown';
147
+ if (extension === 'json' || extension === 'jsonc') return 'json';
148
+ if (extension === 'yaml' || extension === 'yml') return 'yaml';
149
+ if (extension === 'ts' || extension === 'tsx' || extension === 'mts' || extension === 'cts') return 'typescript';
150
+ if (extension === 'js' || extension === 'jsx' || extension === 'mjs' || extension === 'cjs') return 'javascript';
151
+ if (extension === 'py') return 'python';
152
+ if (extension === 'sh' || extension === 'bash' || extension === 'zsh') return 'bash';
153
+ if (extension === 'rs') return 'rust';
154
+ if (extension === 'go') return 'go';
155
+ if (extension === 'sql') return 'sql';
156
+ if (extension === 'html' || extension === 'htm') return 'html';
157
+ if (extension === 'css') return 'css';
158
+ if (extension === 'xml') return 'xml';
43
159
  return 'text';
44
160
  }
45
161
 
@@ -60,3 +176,72 @@ export function composeMessageWithTextAttachments(
60
176
  });
61
177
  return [message.trim(), ...blocks].filter(Boolean).join('\n\n');
62
178
  }
179
+
180
+ /**
181
+ * Snapshot attachments for storage in the queue row, where a `File` cannot
182
+ * survive. Reads text contents while the live handle still exists, so delivery
183
+ * can inline them, and keeps every display field the chip renderer needs.
184
+ */
185
+ export async function prepareQueuedAttachments(attachments: Attachment[]): Promise<Attachment[]> {
186
+ const textFiles = await readTextAttachments(attachments);
187
+ const contentById = new Map(textFiles.map((file) => [file.id, file.content]));
188
+ return attachments.map((att) => ({
189
+ id: att.id,
190
+ name: attachmentName(att),
191
+ type: attachmentType(att),
192
+ size: attachmentSize(att),
193
+ // The blob URL dies with the page; the data URL survives in the row.
194
+ preview: att.dataBase64 ? `data:${attachmentType(att)};base64,${att.dataBase64}` : att.preview,
195
+ ...(att.dataBase64 ? { dataBase64: att.dataBase64 } : {}),
196
+ ...(contentById.has(att.id) ? { content: contentById.get(att.id) } : {}),
197
+ ...(att.sniffedText ? { sniffedText: true } : {}),
198
+ }));
199
+ }
200
+
201
+ /**
202
+ * The inline-able text files of a batch, reading live `File` bytes where
203
+ * present and persisted `content` otherwise. Returns [] when nothing qualifies.
204
+ *
205
+ * Each entry carries the source attachment's `id`, so the caller can map a file
206
+ * back to the attachment it came from without re-deriving the filter.
207
+ *
208
+ * An attachment with NO readable bytes is returned with `missing: true` rather
209
+ * than dropped: silently omitting it sent a prompt that named a file the model
210
+ * never received, with nothing in the UI to say so. The caller decides whether
211
+ * to warn or to send anyway.
212
+ */
213
+ export async function readTextAttachments(
214
+ attachments: Attachment[],
215
+ ): Promise<ReadTextAttachment[]> {
216
+ // An attachment that already carries `content` was read at attach time — for
217
+ // an unclassified file that read only happened because the sniffer cleared it.
218
+ // Anything else must still classify from metadata, so a binary that happens to
219
+ // have a stray `content` string cannot smuggle itself into the prompt.
220
+ const textAttachments = attachments.filter((a) => isTextAttachment(a) || a.sniffedText === true);
221
+ const contents = await Promise.all(
222
+ textAttachments.map(async (att): Promise<{ content: string | null; missing: boolean }> => {
223
+ // The persisted copy wins: it was read at attach time through
224
+ // `FileReader`, which is the read that waits for another app's file to
225
+ // materialize. Re-reading the live handle here is what returned an empty
226
+ // body for a file whose bytes had already been captured.
227
+ if (typeof att.content === 'string') return { content: att.content, missing: false };
228
+ if (att.file) {
229
+ try {
230
+ return { content: await att.file.text(), missing: false };
231
+ } catch {
232
+ // Fall through to the persisted copy, if any.
233
+ }
234
+ }
235
+ if (typeof att.content === 'string') return { content: att.content, missing: false };
236
+ return { content: null, missing: true };
237
+ }),
238
+ );
239
+ return textAttachments.map((att, index) => ({
240
+ id: att.id,
241
+ name: attachmentName(att),
242
+ mimeType: attachmentType(att),
243
+ content: contents[index].content ?? '',
244
+ size: attachmentSize(att),
245
+ missing: contents[index].missing,
246
+ }));
247
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Client for a session's BTW API: the frame stream, plus the commands that
8
+ * create, continue, cancel and promote a side question.
9
+ *
10
+ * The stream reuses the generic JSON connectors the agent stream introduced
11
+ * (they are transport-only: URL in, decoded frames out), so a session's side
12
+ * questions follow the same transport setting — WebSocket by default, SSE
13
+ * where the upgrade is blocked — instead of forking a third stream flavour.
14
+ */
15
+
16
+ import type { AgentImage, BtwFrame, BtwState, StreamTransport } from '@/shared/types';
17
+ import { connectEvents } from '@/shared/lib/chat/omp/sse';
18
+ import { connectSocket } from '@/shared/lib/chat/omp/socket';
19
+ import { btwEventsUrl, btwSocketUrl } from '@/shared/lib/chat/omp/transport';
20
+ import type { StreamConnection, StreamHandlers } from '@/shared/lib/chat/omp/transport';
21
+
22
+ export type BtwStreamHandlers = StreamHandlers<BtwFrame>;
23
+
24
+ /** A rejected command, carrying the server's machine-readable reason. */
25
+ export class BtwRequestError extends Error {
26
+ readonly code?: string;
27
+
28
+ constructor(message: string, code?: string) {
29
+ super(message);
30
+ this.name = 'BtwRequestError';
31
+ this.code = code;
32
+ }
33
+ }
34
+
35
+ export function connectBtwStream(
36
+ sessionId: string,
37
+ transport: StreamTransport,
38
+ handlers: BtwStreamHandlers,
39
+ ): StreamConnection {
40
+ return transport === 'sse' ? connectEvents(btwEventsUrl(sessionId), handlers) : connectSocket(btwSocketUrl(sessionId), handlers);
41
+ }
42
+
43
+ interface BtwResponseBody {
44
+ success?: boolean;
45
+ data?: unknown;
46
+ error?: string;
47
+ code?: string;
48
+ }
49
+
50
+ async function requestBtw<T>(sessionId: string, method: 'GET' | 'POST', body?: Record<string, unknown>): Promise<T> {
51
+ const response = await fetch(`/api/btw/${encodeURIComponent(sessionId)}`, {
52
+ method,
53
+ ...(body
54
+ ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
55
+ : {}),
56
+ });
57
+ const payload = (await response.json().catch(() => null)) as BtwResponseBody | null;
58
+ if (!response.ok || !payload?.success) {
59
+ throw new BtwRequestError(payload?.error ?? `Side question request failed (${response.status})`, payload?.code);
60
+ }
61
+ return payload.data as T;
62
+ }
63
+
64
+ /** The session's side questions as the server sees them. */
65
+ export function fetchBtwState(sessionId: string): Promise<BtwState> {
66
+ return requestBtw<BtwState>(sessionId, 'GET');
67
+ }
68
+
69
+ export interface AskBtwInput {
70
+ topicId?: string;
71
+ question: string;
72
+ images?: AgentImage[];
73
+ }
74
+
75
+ export function askBtwQuestion(sessionId: string, input: AskBtwInput): Promise<BtwState> {
76
+ return requestBtw<BtwState>(sessionId, 'POST', {
77
+ action: 'ask',
78
+ question: input.question,
79
+ ...(input.topicId ? { topicId: input.topicId } : {}),
80
+ ...(input.images?.length ? { images: input.images } : {}),
81
+ });
82
+ }
83
+
84
+ export function abortBtwQuestion(sessionId: string, topicId: string): Promise<BtwState> {
85
+ return requestBtw<BtwState>(sessionId, 'POST', { action: 'abort', topicId });
86
+ }
87
+
88
+ export function promoteBtwTopic(sessionId: string, topicId: string): Promise<{ sessionId: string }> {
89
+ return requestBtw<{ sessionId: string }>(sessionId, 'POST', { action: 'promote', topicId });
90
+ }
91
+
92
+ export function deleteBtwTopic(sessionId: string, topicId: string): Promise<BtwState> {
93
+ return requestBtw<BtwState>(sessionId, 'POST', { action: 'delete', topicId });
94
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * The popup's command pool has two sources with different owners: omp's
6
+ * `get_available_commands` and the chamber's own commands. `/btw` exists only
7
+ * in the second — omp's registry entry for it is TUI-only, so RPC never
8
+ * advertises it — and the composer intercepts the token on send. If the entry
9
+ * ever disappears, the popup offers every command except the one being typed,
10
+ * and the user is left guessing why `/btw` matches nothing but `browser` by
11
+ * description.
12
+ */
13
+
14
+ import { describe, expect, test } from 'bun:test';
15
+
16
+ import type { CommandItem } from '@/shared/types';
17
+ import { mergeCommandAndSkillItems } from '@/shared/lib/chat/composer/client';
18
+
19
+ function command(over: Partial<CommandItem> & { name: string }): CommandItem {
20
+ return {
21
+ id: `cmd-${over.name}`,
22
+ description: '',
23
+ scope: 'system',
24
+ template: '',
25
+ ...over,
26
+ };
27
+ }
28
+
29
+ describe('mergeCommandAndSkillItems', () => {
30
+ test('offers the chamber-owned /btw with omp metadata', () => {
31
+ const items = mergeCommandAndSkillItems([command({ name: 'model', description: 'Show current model selection' })], []);
32
+
33
+ const btw = items.find((item) => item.name === 'btw');
34
+ expect(btw).toBeDefined();
35
+ expect(btw?.token).toBe('/btw');
36
+ expect(btw?.kind).toBe('command');
37
+ // The hint is folded into the description exactly as the popup renders it.
38
+ expect(btw?.description).toBe("[question] - Ask a side question, or browse this session's BTW history");
39
+ });
40
+
41
+ test('keeps the chamber entry when omp advertises the same name', () => {
42
+ const items = mergeCommandAndSkillItems([command({ name: 'btw', description: 'something else entirely' })], []);
43
+
44
+ const matching = items.filter((item) => item.name === 'btw');
45
+ expect(matching.length).toBe(1);
46
+ expect(matching[0].description).toContain('Ask a side question');
47
+ });
48
+ });
@@ -1,4 +1,5 @@
1
1
  import type { AgentItem, CommandItem, ComposerPickItem, ComposerPickKind, FsFileEntry, SkillItem } from '@/shared/types';
2
+ import { CHAMBER_COMMANDS } from '@/shared/lib/chat/composer/trigger';
2
3
 
3
4
  const CACHE_TTL_MS = 300_000;
4
5
 
@@ -61,6 +62,10 @@ export function toFilePickItems(files: FsFileEntry[]): ComposerPickItem[] {
61
62
  * `skill:`-namespaced name a command already covers is skipped, so the two
62
63
  * sources never double-list the same skill.
63
64
  *
65
+ * The chamber's own commands (`CHAMBER_COMMANDS`) lead, so a same-named entry
66
+ * from omp can never advertise behavior the composer overrides on send — `/btw`
67
+ * is reserved by omp in the TUI for the same reason.
68
+ *
64
69
  * The description carries the argument hint exactly as oh-my-pi renders it
65
70
  * (`[on|off|status] - Toggle fast mode`) so the popup matches the CLI.
66
71
  */
@@ -68,7 +73,7 @@ export function mergeCommandAndSkillItems(commands: CommandItem[], skills: Skill
68
73
  const result: ComposerPickItem[] = [];
69
74
  const seen = new Set<string>();
70
75
 
71
- for (const command of commands) {
76
+ for (const command of [...CHAMBER_COMMANDS, ...commands]) {
72
77
  const key = command.name.toLowerCase();
73
78
  if (seen.has(key)) continue;
74
79
  seen.add(key);
@@ -14,8 +14,9 @@
14
14
  import { describe, expect, test } from 'bun:test';
15
15
 
16
16
  import type { ComposerPickItem } from '@/shared/types';
17
- import { detectComposerTrigger, insertToken, tokenForItem } from '@/shared/lib/chat/composer/trigger';
17
+ import { detectComposerTrigger, insertToken, sendInsteadOfAccept, tokenForItem } from '@/shared/lib/chat/composer/trigger';
18
18
  import { filterComposerItems } from '@/shared/lib/chat/composer/filter';
19
+ import { mergeCommandAndSkillItems } from '@/shared/lib/chat/composer/client';
19
20
 
20
21
  function item(over: Partial<ComposerPickItem> & { name: string }): ComposerPickItem {
21
22
  const isSkill = over.name.startsWith('skill:');
@@ -155,3 +156,40 @@ describe('composer slash trigger', () => {
155
156
  expect(detectComposerTrigger('foo@bar', 7)).toBeNull();
156
157
  });
157
158
  });
159
+
160
+ describe('composer Enter on a complete command', () => {
161
+ /** The real popup pool: omp's commands plus the chamber's own. */
162
+ const CHAMBER_POOL: ComposerPickItem[] = mergeCommandAndSkillItems(
163
+ [{ id: 'c-model', name: 'model', description: 'Show current model selection', scope: 'system', template: '' }],
164
+ [],
165
+ );
166
+
167
+ /** Does Enter send here, or does the popup accept the highlighted item? */
168
+ const enterSends = (text: string): boolean => {
169
+ const trigger = detectComposerTrigger(text, text.length);
170
+ if (!trigger) throw new Error(`no trigger for ${text}`);
171
+ const [highlighted] = filterComposerItems(CHAMBER_POOL, trigger);
172
+ return sendInsteadOfAccept(trigger, highlighted);
173
+ };
174
+
175
+ test('the chamber-owned /btw sends, so Enter reaches the side-question form', () => {
176
+ expect(enterSends('/btw')).toBe(true);
177
+ expect(enterSends('/BTW')).toBe(true);
178
+ });
179
+
180
+ test('a command omp owns still accepts, however complete it looks', () => {
181
+ // `/model` takes no subcommands, but the chamber does not intercept it:
182
+ // accepting is what the CLI does, and it must stay that way.
183
+ expect(enterSends('/model')).toBe(false);
184
+ expect(enterSends('/mo')).toBe(false);
185
+ });
186
+
187
+ test('a command with subcommands still opens its arguments', () => {
188
+ expect(enterSends('/fast')).toBe(false);
189
+ });
190
+
191
+ test('arguments and mentions never yield Enter', () => {
192
+ expect(enterSends('/fast o')).toBe(false);
193
+ expect(enterSends('@sonic')).toBe(false);
194
+ });
195
+ });