ompchamber 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.b938f4a693.css +2 -0
  3. package/dist/client/static/js/77.8022a800b5.js +139 -0
  4. package/dist/client/static/js/async/{1k.5974df3b87.js → 1k.a411fe5b43.js} +2 -2
  5. package/dist/client/static/js/async/{3y.71f2ccf3b6.js → 3y.3186ecdd63.js} +5 -5
  6. package/dist/client/static/js/async/5b.d88304bc13.js +1 -0
  7. package/dist/client/static/js/async/66.86544ad741.js +93 -0
  8. package/dist/client/static/js/async/7g.db82aca7ba.js +1 -0
  9. package/dist/client/static/js/async/7l.fadbbc0583.js +1 -0
  10. package/dist/client/static/js/async/{dc.856fda6560.js → ar.ad5c8822a8.js} +2 -2
  11. package/dist/client/static/js/async/cx.9c8268d542.js +226 -0
  12. package/dist/client/static/js/async/e.6443e739a2.js +79 -0
  13. package/dist/client/static/js/async/f7.46971e9d6d.js +2 -0
  14. package/dist/client/static/js/async/g.ee41bfed7f.js +2 -0
  15. package/dist/client/static/js/async/h1.d7091cef78.js +1 -0
  16. package/dist/client/static/js/async/hs.e7c59d8090.js +41 -0
  17. package/dist/client/static/js/async/jl.4911fa3383.js +1 -0
  18. package/dist/client/static/js/async/{k.7466330d1a.js → k.d16cd09ae7.js} +2 -2
  19. package/dist/client/static/js/async/{m.c038c71b9f.js → m.2e1ea3db9e.js} +2 -2
  20. package/dist/client/static/js/async/m3.d2b05f9604.js +2 -0
  21. package/dist/client/static/js/async/mt.853be2b5aa.js +1 -0
  22. package/dist/client/static/js/async/{oc.9c060c9779.js → oc.eb22093f05.js} +1 -1
  23. package/dist/client/static/js/async/{og.634fba99ac.js → og.53f068804c.js} +1 -1
  24. package/dist/client/static/js/async/{5b.a5ccbc19dc.js → q.b453193538.js} +10 -10
  25. package/dist/client/static/js/async/q5.88b50ffa95.js +286 -0
  26. package/dist/client/static/js/async/q8.7f1b48e9cd.js +21 -0
  27. package/dist/client/static/js/async/{sy.d16be843a7.js → s.aad2ecebc6.js} +1 -1
  28. package/dist/client/static/js/async/{tr.e878658140.js → tr.2a0d2b7600.js} +1 -1
  29. package/dist/client/static/js/async/vt.20406a5604.js +1 -0
  30. package/dist/client/static/js/async/x1.fe8af17016.js +90 -0
  31. package/dist/client/static/js/async/yg.5a94e1fc8a.js +149 -0
  32. package/dist/client/static/js/async/yx.af2936c368.js +10 -0
  33. package/dist/client/static/js/index.57b39ffa7f.js +34 -0
  34. package/package.json +1 -1
  35. package/src/client/components/common/file-icon/index.tsx +4 -1
  36. package/src/client/components/common/image-viewer/index.tsx +224 -0
  37. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
  38. package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
  39. package/src/client/components/settings/Modal.tsx +1 -0
  40. package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
  41. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
  42. package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
  43. package/src/client/components/workspace/btw-panel/Composer.tsx +181 -0
  44. package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
  45. package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
  46. package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
  47. package/src/client/components/workspace/btw-panel/index.tsx +83 -0
  48. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +37 -21
  49. package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
  50. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
  51. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
  52. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
  53. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +44 -40
  54. package/src/client/components/workspace/chat-timeline/index.tsx +11 -9
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
  56. package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
  57. package/src/client/components/workspace/editor/index.tsx +45 -36
  58. package/src/client/hooks/chat/btw/index.ts +180 -0
  59. package/src/client/hooks/chat/btw/intercept.ts +43 -0
  60. package/src/client/hooks/chat/btw/mode.ts +179 -0
  61. package/src/client/hooks/chat/composer/attachments.ts +198 -0
  62. package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
  63. package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
  64. package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
  65. package/src/client/hooks/chat/composer/drop-references.ts +106 -0
  66. package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
  67. package/src/client/hooks/chat/composer/file-drop.ts +226 -0
  68. package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
  69. package/src/client/hooks/chat/composer/file-reads.ts +238 -0
  70. package/src/client/hooks/chat/composer/index.ts +7 -1
  71. package/src/client/hooks/chat/composer/pipeline.ts +181 -0
  72. package/src/client/hooks/chat/omp/index.ts +3 -3
  73. package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
  74. package/src/client/hooks/chat/timeline/actions.ts +16 -1
  75. package/src/client/hooks/chat/timeline/index.ts +16 -8
  76. package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
  77. package/src/client/hooks/chat/timeline/send.ts +29 -65
  78. package/src/client/hooks/chat/timeline/session-load.ts +2 -1
  79. package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
  80. package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
  81. package/src/client/hooks/editor/file-actions.ts +49 -0
  82. package/src/client/hooks/editor/use-file-editor.ts +38 -27
  83. package/src/client/hooks/editor/use-file-transfer.ts +78 -0
  84. package/src/client/tailwind.css +22 -0
  85. package/src/server/db.server.ts +21 -287
  86. package/src/server/lib/btw/frames.test.ts +68 -0
  87. package/src/server/lib/btw/frames.ts +56 -0
  88. package/src/server/lib/btw/prompt.ts +28 -0
  89. package/src/server/lib/btw/registry.server.ts +141 -0
  90. package/src/server/lib/btw/runtime.server.ts +324 -0
  91. package/src/server/lib/btw/schema.server.ts +44 -0
  92. package/src/server/lib/btw/service.server.ts +171 -0
  93. package/src/server/lib/btw/session-copy.server.ts +213 -0
  94. package/src/server/lib/btw/session-copy.test.ts +166 -0
  95. package/src/server/lib/btw/store.server.ts +240 -0
  96. package/src/server/lib/db/schema.ts +110 -0
  97. package/src/server/lib/db/seed.ts +160 -0
  98. package/src/server/lib/db/workspace-sync.ts +70 -0
  99. package/src/server/lib/fs/root.ts +37 -0
  100. package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
  101. package/src/server/lib/omp/rpc/manager.ts +27 -123
  102. package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
  103. package/src/server/lib/omp/session/auto-title.server.ts +152 -0
  104. package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
  105. package/src/server/lib/omp/session/blobs.server.ts +76 -0
  106. package/src/server/lib/omp/session/file-scan.ts +174 -0
  107. package/src/server/lib/omp/session/files.test.ts +147 -0
  108. package/src/server/lib/omp/session/files.ts +52 -35
  109. package/src/server/lib/omp/session/locator.ts +2 -2
  110. package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
  111. package/src/server/lib/omp/session/merge-stored.ts +171 -0
  112. package/src/server/lib/omp/session/messages.ts +34 -0
  113. package/src/server/lib/omp/session/reader.ts +2 -2
  114. package/src/server/lib/omp/session/subagent-presence.ts +9 -6
  115. package/src/server/lib/queue/delivery.server.test.ts +57 -0
  116. package/src/server/lib/queue/delivery.server.ts +25 -3
  117. package/src/server/plugins/ssr.ts +29 -2
  118. package/src/server/routes/btw/events.ts +70 -0
  119. package/src/server/routes/btw/index.ts +8 -0
  120. package/src/server/routes/btw/topics.ts +87 -0
  121. package/src/server/routes/btw/ws.ts +136 -0
  122. package/src/server/routes/chat/index.ts +2 -0
  123. package/src/server/routes/chat/session.ts +1 -115
  124. package/src/server/routes/chat/turns.ts +87 -0
  125. package/src/server/routes/fs/index.ts +4 -1
  126. package/src/server/routes/fs/raw.ts +85 -0
  127. package/src/server/routes/fs/read.ts +58 -3
  128. package/src/server/routes/index.ts +7 -1
  129. package/src/shared/lib/chat/attachments.test.ts +208 -0
  130. package/src/shared/lib/chat/attachments.ts +200 -15
  131. package/src/shared/lib/chat/btw/client.ts +94 -0
  132. package/src/shared/lib/chat/composer/client.test.ts +48 -0
  133. package/src/shared/lib/chat/composer/client.ts +6 -1
  134. package/src/shared/lib/chat/composer/composer.test.ts +39 -1
  135. package/src/shared/lib/chat/composer/trigger.ts +45 -1
  136. package/src/shared/lib/chat/omp/agent-events.ts +33 -80
  137. package/src/shared/lib/chat/omp/socket.ts +11 -6
  138. package/src/shared/lib/chat/omp/sse.ts +11 -6
  139. package/src/shared/lib/chat/omp/tool-results.ts +102 -0
  140. package/src/shared/lib/chat/omp/transport.ts +19 -3
  141. package/src/shared/lib/chat/text-file.ts +148 -0
  142. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
  143. package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
  144. package/src/shared/lib/chat/timeline/turns.ts +7 -0
  145. package/src/shared/lib/fs/file-kind.ts +50 -0
  146. package/src/shared/lib/fs/paths.ts +19 -1
  147. package/src/shared/lib/markdown/math.test.ts +20 -3
  148. package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
  149. package/src/shared/lib/omp/session/mapper.ts +24 -4
  150. package/src/shared/lib/omp/session/messages-map.ts +16 -3
  151. package/src/shared/lib/omp/session/messages-parse.ts +2 -2
  152. package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
  153. package/src/shared/types/btw.ts +65 -0
  154. package/src/shared/types/chat.ts +39 -1
  155. package/src/shared/types/index.ts +1 -0
  156. package/src/shared/types/omp/agent.ts +14 -1
  157. package/src/shared/types/omp/session.ts +4 -1
  158. package/src/shared/types/settings/state.ts +5 -0
  159. package/dist/client/static/css/index.b859cc1cc5.css +0 -2
  160. package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
  161. package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
  162. package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
  163. package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
  164. package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
  165. package/dist/client/static/js/async/e.93214d622b.js +0 -79
  166. package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
  167. package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
  168. package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
  169. package/dist/client/static/js/async/jl.874d352278.js +0 -1
  170. package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
  171. package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
  172. package/dist/client/static/js/async/q.23af2a358f.js +0 -21
  173. package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
  174. package/dist/client/static/js/async/s.222ee974f6.js +0 -2
  175. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  176. package/dist/client/static/js/async/vt.42416b707d.js +0 -1
  177. package/dist/client/static/js/async/x1.da8687159b.js +0 -90
  178. package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
  179. package/dist/client/static/js/index.6e065017eb.js +0 -33
  180. package/dist/client/static/js/jp.50817896f3.js +0 -139
  181. /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
  182. /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
  183. /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
  184. /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
  185. /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
  186. /package/dist/client/static/js/async/{5b.a5ccbc19dc.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
  187. /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
  188. /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
  189. /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
  190. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
  191. /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
  192. /package/dist/client/static/js/{index.6e065017eb.js.LICENSE.txt → index.57b39ffa7f.js.LICENSE.txt} +0 -0
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Turning a dropped file REFERENCE into an attachable File.
8
+ *
9
+ * A download dragged out of a web page (WhatsApp Web, Gmail, Slack) hands the
10
+ * composer a URL, not bytes: there is no `File` in the drag at all. The browser
11
+ * cannot read it either — a cross-origin `file://` path and a remote URL are
12
+ * both outside what the page may fetch. The chamber server can, so the
13
+ * reference is resolved there and returned as text the composer attaches like
14
+ * any other file.
15
+ *
16
+ * Only text is supported on this path. An image or an arbitrary binary cannot
17
+ * round-trip through JSON as a string, and the drop's own `File` (when the host
18
+ * provides one) is the path that carries those.
19
+ */
20
+
21
+
22
+ /** Last path segment of a URL or path, for the attachment's display name. */
23
+ export function referenceName(reference: string): string {
24
+ const withoutQuery = reference.split(/[?#]/)[0] ?? reference;
25
+ const segment = withoutQuery.replace(/\/+$/, '').split('/').pop();
26
+ if (!segment) return 'attachment';
27
+ try {
28
+ return decodeURIComponent(segment);
29
+ } catch {
30
+ return segment;
31
+ }
32
+ }
33
+
34
+ /** Why a dropped reference could not be turned into a file. */
35
+ export type ReferenceFailure =
36
+ /** The path exists but lies outside the roots the chamber may read. */
37
+ | 'outside-roots'
38
+ /** A URL from a web page; the chamber does not fetch outbound. */
39
+ | 'remote'
40
+ /** The path exists but its contents are not text (an image, an archive). */
41
+ | 'binary'
42
+ /** The server could not read it at all — gone, or an unreadable permission. */
43
+ | 'unavailable';
44
+
45
+ export type ReferenceReadResult =
46
+ | { ok: true; file: File }
47
+ | { ok: false; reason: ReferenceFailure };
48
+
49
+ /**
50
+ * Read a dropped reference through the server.
51
+ *
52
+ * `path` is used as the filesystem root hint so a relative reference resolves
53
+ * inside the active workspace; an absolute path or URL ignores it. The failure
54
+ * carries its REASON because the three cases need three different actions from
55
+ * the user, and a single "could not be read" left them with no way to tell them
56
+ * apart — or the developer with no way to tell which branch ran.
57
+ */
58
+ export async function readDroppedReference(reference: string, rootPath: string | null): Promise<ReferenceReadResult> {
59
+ const params = new URLSearchParams({ path: reference });
60
+ if (rootPath) params.set('root', rootPath);
61
+ const url = `/api/fs/read-reference?${params.toString()}`;
62
+ let res: Response;
63
+ try {
64
+ res = await fetch(url);
65
+ } catch (error) {
66
+ return { ok: false, reason: 'unavailable' };
67
+ }
68
+ const body = (await res.json().catch(() => null)) as { content?: unknown; name?: unknown; error?: unknown } | null;
69
+ if (!res.ok) {
70
+ const error = typeof body?.error === 'string' ? body.error : '';
71
+ if (/remote/i.test(error)) return { ok: false, reason: 'remote' };
72
+ if (/image|raw/i.test(error)) return { ok: false, reason: 'binary' };
73
+ if (res.status === 404 || /not found/i.test(error)) return { ok: false, reason: 'outside-roots' };
74
+ return { ok: false, reason: 'unavailable' };
75
+ }
76
+ if (!body || typeof body.content !== 'string') return { ok: false, reason: 'unavailable' };
77
+ // The server reports the basename it actually read, which is more reliable
78
+ // than re-deriving one from a percent-encoded URL.
79
+ const name = typeof body.name === 'string' && body.name ? body.name : referenceName(reference);
80
+ return { ok: true, file: new File([body.content], name, { type: 'text/plain' }) };
81
+ }
82
+
83
+ /** User-facing reason for files whose contents could not be read. */
84
+ export function describeUnreadable(names: string[]): string {
85
+ if (names.length === 1) {
86
+ return `"${names[0]}" could not be read and was not attached.`;
87
+ }
88
+ return `${names.length} files could not be read and were not attached: ${names.slice(0, 3).join(', ')}${names.length > 3 ? '…' : ''}.`;
89
+ }
90
+
91
+ /** User-facing reason for a dropped reference the server refused, per cause. */
92
+ export function describeReferenceFailure(names: string[], reason: ReferenceFailure): string {
93
+ const list = names.slice(0, 3).join(', ');
94
+ const subject = names.length === 1 ? `"${names[0]}"` : `${names.length} files`;
95
+ const tail = names.length > 3 ? '…' : '';
96
+ switch (reason) {
97
+ case 'outside-roots':
98
+ return `${subject} lie${names.length === 1 ? 's' : ''} outside the folders the chamber may read, so ${names.length === 1 ? 'it was' : 'they were'} not attached (${list}${tail}).`;
99
+ case 'remote':
100
+ return `${subject} ${names.length === 1 ? 'is a web download' : 'are web downloads'}; save ${names.length === 1 ? 'it' : 'them'} into a workspace folder first (${list}${tail}).`;
101
+ case 'binary':
102
+ return `${subject} ${names.length === 1 ? 'is not' : 'are not'} a text file and ${names.length === 1 ? 'was' : 'were'} not attached (${list}${tail}).`;
103
+ default:
104
+ return describeUnreadable(names);
105
+ }
106
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Dropped-directory resolution for the composer.
8
+ *
9
+ * The browser APIs this reads (`webkitGetAsEntry`, `readEntries`) are awkward
10
+ * in ways that fail silently: a directory's `dataTransfer.files` entry is a
11
+ * size-0 stub, and `readEntries` returns at most 100 children per call, so a
12
+ * single call looks like a complete directory of ≤100 files. These tests pin
13
+ * the contract those quirks break — recursive expansion, paged reads, and the
14
+ * caps that keep a dropped repo root from queueing thousands of attachments.
15
+ */
16
+
17
+ import { describe, expect, test } from 'bun:test';
18
+ import { carriesBytes } from '@/client/hooks/chat/composer/file-drop';
19
+ import {
20
+ expandDroppedFiles,
21
+ readDroppedEntries,
22
+ MAX_DROPPED_FILES,
23
+ } from '@/client/hooks/chat/composer/drop-entries';
24
+
25
+ function fileEntry(name: string, content = 'x'): FileSystemFileEntry {
26
+ return {
27
+ isFile: true,
28
+ isDirectory: false,
29
+ name,
30
+ file: (ok: (f: File) => void) => ok(new File([content], name, { type: 'text/plain' })),
31
+ } as unknown as FileSystemFileEntry;
32
+ }
33
+
34
+ /** Directory whose reader hands back `children` in fixed-size batches. */
35
+ function dirEntry(name: string, children: FileSystemEntry[], batchSize = 100): FileSystemDirectoryEntry {
36
+ return {
37
+ isFile: false,
38
+ isDirectory: true,
39
+ name,
40
+ createReader: () => {
41
+ let cursor = 0;
42
+ return {
43
+ readEntries: (ok: (batch: FileSystemEntry[]) => void) => {
44
+ const batch = children.slice(cursor, cursor + batchSize);
45
+ cursor += batchSize;
46
+ ok(batch);
47
+ },
48
+ } as unknown as FileSystemDirectoryReader;
49
+ },
50
+ } as unknown as FileSystemDirectoryEntry;
51
+ }
52
+
53
+ function fakeDataTransfer(
54
+ items: { entry: FileSystemEntry | null; file?: File }[],
55
+ files: File[] = [],
56
+ ): DataTransfer {
57
+ return {
58
+ items: items.map(({ entry, file }) => ({
59
+ kind: 'file',
60
+ webkitGetAsEntry: () => entry,
61
+ getAsFile: () => file ?? null,
62
+ })),
63
+ files,
64
+ } as unknown as DataTransfer;
65
+ }
66
+
67
+ describe('readDroppedEntries', () => {
68
+ // The two views are not interchangeable: the item entry can carry correct
69
+ // metadata over a backing store that no longer reads, while `files` hands over
70
+ // one that does. A drop in an installed app window exposed exactly that — the
71
+ // chip had the right name and size and its contents never arrived, because the
72
+ // item-list File was the one being read.
73
+ test('the file list wins over the item list when it holds bytes', () => {
74
+ const fromItems = new File(['from items'], 'notes.md', { type: 'text/markdown' });
75
+ const fromFiles = new File(['from files'], 'notes.md', { type: 'text/markdown' });
76
+ const entries = readDroppedEntries(fakeDataTransfer([{ entry: fileEntry('notes.md'), file: fromItems }], [fromFiles]));
77
+
78
+ expect(entries.files.length).toBe(1);
79
+ expect(entries.files[0]).toBe(fromFiles);
80
+ });
81
+
82
+ // A 0-byte stub in `files` is how a DIRECTORY is listed, so taking it would
83
+ // attach an empty file in place of the tree the walk is about to expand.
84
+ test('a stub in the file list falls through to the item entry', () => {
85
+ const stub = new File([], 'notes.md', { type: '' });
86
+ const real = new File(['# Title\n\nBody'], 'notes.md', { type: 'text/markdown' });
87
+ const entries = readDroppedEntries(fakeDataTransfer([{ entry: fileEntry('notes.md'), file: real }], [stub]));
88
+
89
+ expect(entries.files.length).toBe(1);
90
+ expect(entries.files[0].size).toBeGreaterThan(0);
91
+ });
92
+
93
+ test('falls back to the item list when the file list is empty', () => {
94
+ const fromItems = new File(['item body'], 'only-items.txt', { type: 'text/plain' });
95
+ const entries = readDroppedEntries(fakeDataTransfer([{ entry: fileEntry('only-items.txt'), file: fromItems }]));
96
+
97
+ expect(entries.files.map((f) => f.name)).toEqual(['only-items.txt']);
98
+ });
99
+
100
+ test('stubs are kept when nothing else holds bytes', () => {
101
+ const stub = new File([], 'from-a-page.txt', { type: 'text/plain' });
102
+ const entries = readDroppedEntries(fakeDataTransfer([], [stub]));
103
+
104
+ expect(entries.files.map((f) => f.name)).toEqual(['from-a-page.txt']);
105
+ });
106
+
107
+ test('passes plain files through untouched', async () => {
108
+ const file = new File(['a'], 'a.txt', { type: 'text/plain' });
109
+ const { files, incomplete } = await expandDroppedFiles(
110
+ readDroppedEntries(fakeDataTransfer([{ entry: fileEntry('a.txt'), file }])),
111
+ );
112
+ expect(files.map((f: File) => f.name)).toEqual(['a.txt']);
113
+ expect(incomplete).toBe(false);
114
+ });
115
+
116
+ test('expands a dropped directory recursively', async () => {
117
+ const root = dirEntry('root', [
118
+ fileEntry('a.txt'),
119
+ dirEntry('sub', [fileEntry('b.txt'), dirEntry('deeper', [fileEntry('c.txt')])]),
120
+ ]);
121
+ const { files, incomplete } = await expandDroppedFiles(readDroppedEntries(fakeDataTransfer([{ entry: root }])));
122
+ expect(files.map((f: File) => f.name).sort()).toEqual(['a.txt', 'b.txt', 'c.txt']);
123
+ expect(incomplete).toBe(false);
124
+ });
125
+
126
+ test('keeps paging past the 100-children-per-read limit', async () => {
127
+ const many = Array.from({ length: 250 }, (_, i) => fileEntry(`f${i}.txt`));
128
+ const { files, incomplete } = await expandDroppedFiles(readDroppedEntries(fakeDataTransfer([{ entry: dirEntry('big', many) }])));
129
+ expect(files.length).toBe(MAX_DROPPED_FILES);
130
+ // More files existed than were taken, and the caller must be able to say so.
131
+ expect(incomplete).toBe(true);
132
+ });
133
+
134
+ test('stops at the nesting cap instead of walking a cycle forever', async () => {
135
+ let nested: FileSystemEntry = dirEntry('leaf', [fileEntry('deep.txt')]);
136
+ for (let i = 0; i < 14; i += 1) nested = dirEntry(`wrap${i}`, [nested]);
137
+ const { incomplete } = await expandDroppedFiles(readDroppedEntries(fakeDataTransfer([{ entry: nested }])));
138
+ expect(incomplete).toBe(true);
139
+ });
140
+
141
+ test('falls back to the flat file list when no item entries exist', async () => {
142
+ const listed = Array.from({ length: MAX_DROPPED_FILES + 10 }, (_, i) => new File(['x'], `g${i}.txt`));
143
+ const { files, incomplete } = await expandDroppedFiles(readDroppedEntries(fakeDataTransfer([], listed)));
144
+ expect(files.length).toBe(MAX_DROPPED_FILES);
145
+ expect(incomplete).toBe(true);
146
+ });
147
+
148
+ test('tolerates a directory whose read fails', async () => {
149
+ const broken = {
150
+ isFile: false,
151
+ isDirectory: true,
152
+ name: 'broken',
153
+ createReader: () => ({
154
+ readEntries: (_ok: unknown, err?: (e: unknown) => void) => err?.(new Error('denied')),
155
+ }),
156
+ } as unknown as FileSystemDirectoryEntry;
157
+ const { files, incomplete } = await expandDroppedFiles(readDroppedEntries(fakeDataTransfer([{ entry: broken }])));
158
+ expect(files).toEqual([]);
159
+ expect(incomplete).toBe(false);
160
+ });
161
+ });
162
+
163
+ describe('carriesBytes', () => {
164
+ test('a file whose declared size is 0 but holds bytes counts', async () => {
165
+ // A .md dropped from Finder can report size 0 while its bytes are present.
166
+ // Treating that as a "path stub" routed it to the reference resolver, which
167
+ // only reads inside the allow-list — a file in ~/Downloads was refused with
168
+ // no way for the user to tell why.
169
+ const real = new File([new TextEncoder().encode('# Title\n\nBody')], 'notes.md', { type: '' });
170
+ Object.defineProperty(real, 'size', { value: 0 });
171
+ expect(real.size).toBe(0);
172
+ expect(await carriesBytes([real])).toBe(true);
173
+ });
174
+
175
+ test('a declared size above zero is enough without reading', async () => {
176
+ const file = new File([new Uint8Array([1, 2, 3])], 'a.bin', { type: 'application/octet-stream' });
177
+ expect(await carriesBytes([file])).toBe(true);
178
+ });
179
+
180
+ test('a genuine stub holds nothing, so the reference path still runs', async () => {
181
+ const stub = new File([], 'from-a-web-page.txt', { type: 'text/plain' });
182
+ expect(await carriesBytes([stub])).toBe(false);
183
+ });
184
+
185
+ test('one readable file in a batch is enough', async () => {
186
+ const empty = new File([], 'empty.txt', { type: 'text/plain' });
187
+ const real = new File([new Uint8Array([65])], 'real.txt', { type: 'text/plain' });
188
+ expect(await carriesBytes([empty, real])).toBe(true);
189
+ });
190
+
191
+ test('an empty batch carries nothing', async () => {
192
+ expect(await carriesBytes([])).toBe(false);
193
+ });
194
+ });
@@ -0,0 +1,226 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * OS file drag-and-drop for the chat composer.
8
+ *
9
+ * Three browser rules shape this module:
10
+ *
11
+ * 1. A `drop` is only delivered when the preceding `dragover` was cancelled, so
12
+ * the handlers belong on the whole composer card — a drop aimed at the
13
+ * textarea bubbles up to it, and cancelling there also suppresses the
14
+ * browser's own "insert the dropped file's path as text" default.
15
+ * 2. A dropped DIRECTORY is not a readable File: `dataTransfer.files` carries a
16
+ * size-0 stub for it. Only `DataTransferItem.webkitGetAsEntry()` reveals it
17
+ * as a directory, so those are walked recursively and attached file by file.
18
+ * 3. A file dropped anywhere the app does not cancel lands in the tab as
19
+ * `file://` navigation, which throws the chamber away. The guard below is
20
+ * window-wide for exactly that reason; it only ever cancels file drags, so
21
+ * text and URL drops keep their native behaviour.
22
+ *
23
+ * What counts as a file drag is decided in `drop-payload.ts`, because hosts
24
+ * disagree: a download dragged out of a web page offers no File at all, only a
25
+ * URL, and those references are handed back to the caller to resolve.
26
+ *
27
+ * The drag data store is cleared the moment the drop handler returns, so every
28
+ * store-bound read (`getAsFile`, `webkitGetAsEntry`, `getData`) happens
29
+ * synchronously; the entries and Files they return stay valid while the walk
30
+ * awaits.
31
+ */
32
+
33
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
34
+ import type { TargetedDragEvent } from 'preact';
35
+ import { collectDroppedFileUris, hasDraggedFiles } from '@/client/hooks/chat/composer/drop-payload';
36
+ import { primeFileReads, primedPrefix } from '@/client/hooks/chat/composer/file-reads';
37
+ import type { PrimedReads } from '@/client/hooks/chat/composer/file-reads';
38
+ import {
39
+ expandDroppedFiles,
40
+ readDroppedEntries,
41
+ MAX_DROP_DEPTH,
42
+ MAX_DROPPED_FILES,
43
+ } from '@/client/hooks/chat/composer/drop-entries';
44
+ import type { DroppedEntries } from '@/client/hooks/chat/composer/drop-entries';
45
+
46
+ /** Reason shown when a drop yielded more than the composer can take. */
47
+ export const DROP_LIMIT_NOTICE =
48
+ `Some files were skipped — a drop is limited to ${MAX_DROPPED_FILES} files and ${MAX_DROP_DEPTH} folder levels deep.`;
49
+
50
+ export interface UseFileDropOptions {
51
+ /**
52
+ * Called once per drop with every file it resolved to (directories expanded),
53
+ * plus any file REFERENCES the host offered instead of bytes (a download
54
+ * dragged out of a web page) for the caller to resolve server-side.
55
+ */
56
+ onFiles: (files: File[], incomplete: boolean, references: string[], primed: PrimedReads) => void;
57
+ /** While true a drop is cancelled but not attached — the composer is closed. */
58
+ disabled?: boolean;
59
+ }
60
+
61
+ /** Drag handlers to spread onto the drop target. */
62
+ export interface FileDropProps {
63
+ onDragEnter: (e: TargetedDragEvent<HTMLElement>) => void;
64
+ onDragOver: (e: TargetedDragEvent<HTMLElement>) => void;
65
+ onDragLeave: (e: TargetedDragEvent<HTMLElement>) => void;
66
+ onDrop: (e: TargetedDragEvent<HTMLElement>) => void;
67
+ }
68
+
69
+ export interface UseFileDropResult {
70
+ /** True while a file drag hovers the bound element (drives the overlay). */
71
+ isDragging: boolean;
72
+ dropProps: FileDropProps;
73
+ }
74
+
75
+ export function useFileDrop({ onFiles, disabled = false }: UseFileDropOptions): UseFileDropResult {
76
+ const [isDragging, setIsDragging] = useState(false);
77
+ // dragenter/dragleave fire for every nested child the pointer crosses, so a
78
+ // boolean flag drops to false the instant the cursor moves from the card onto
79
+ // the textarea. Counting them keeps the overlay steady.
80
+ const depthRef = useRef(0);
81
+ // Latest callback and flag behind refs, so the handlers stay referentially
82
+ // stable and the window guard sees the current `disabled`.
83
+ const onFilesRef = useRef(onFiles);
84
+ onFilesRef.current = onFiles;
85
+ const disabledRef = useRef(disabled);
86
+ disabledRef.current = disabled;
87
+
88
+ const reset = useCallback(() => {
89
+ depthRef.current = 0;
90
+ setIsDragging(false);
91
+ }, []);
92
+
93
+ // A drag can end without reaching this element again — released over the
94
+ // page, cancelled with Escape, or its source node unmounted mid-drag. The
95
+ // composer's own handlers have already run by the time these fire, so this
96
+ // only clears state the drop handler did not.
97
+ useEffect(() => {
98
+ window.addEventListener('dragend', reset);
99
+ window.addEventListener('drop', reset);
100
+ return () => {
101
+ window.removeEventListener('dragend', reset);
102
+ window.removeEventListener('drop', reset);
103
+ };
104
+ }, [reset]);
105
+
106
+ // Files dropped anywhere else would navigate the tab to `file://` and discard
107
+ // the chamber, so the default is cancelled app-wide. This runs after the
108
+ // composer's own drop handler (the event bubbles up to window), by which point
109
+ // that handler has already read the data store it needed.
110
+ useEffect(() => {
111
+ const guard = (e: DragEvent) => {
112
+ if (hasDraggedFiles(e.dataTransfer)) e.preventDefault();
113
+ };
114
+ window.addEventListener('dragover', guard);
115
+ window.addEventListener('drop', guard);
116
+ return () => {
117
+ window.removeEventListener('dragover', guard);
118
+ window.removeEventListener('drop', guard);
119
+ };
120
+ }, []);
121
+
122
+ const onDragEnter = useCallback((e: TargetedDragEvent<HTMLElement>) => {
123
+ if (!hasDraggedFiles(e.dataTransfer)) return;
124
+ e.preventDefault();
125
+ if (disabledRef.current) return;
126
+ depthRef.current += 1;
127
+ setIsDragging(true);
128
+ }, []);
129
+
130
+ const onDragOver = useCallback((e: TargetedDragEvent<HTMLElement>) => {
131
+ if (!hasDraggedFiles(e.dataTransfer)) return;
132
+ // Required: without it the browser refuses the drop and navigates to the
133
+ // file instead of handing it to the app.
134
+ e.preventDefault();
135
+ if (!e.dataTransfer) return;
136
+ // `none` is the only cursor that tells the truth about a closed composer.
137
+ e.dataTransfer.dropEffect = disabledRef.current ? 'none' : 'copy';
138
+ }, []);
139
+
140
+ // Not gated on the payload: `dataTransfer.types` can already be empty on
141
+ // dragleave. The counter is the gate — a text drag never raised it.
142
+ const onDragLeave = useCallback(() => {
143
+ if (depthRef.current === 0) return;
144
+ depthRef.current -= 1;
145
+ if (depthRef.current === 0) setIsDragging(false);
146
+ }, []);
147
+
148
+ const onDrop = useCallback((e: TargetedDragEvent<HTMLElement>) => {
149
+ const dt = e.dataTransfer;
150
+ // Read the payload in ONE pass before anything else touches it:
151
+ // `getAsFile()` and `webkitGetAsEntry()` are not pure accessors on a
152
+ // file-manager drag, and each call can consume state the other needs.
153
+ const entries: DroppedEntries = dt ? readDroppedEntries(dt) : { files: [], directories: [], fallbacks: new Map() };
154
+ const references = dt ? collectDroppedFileUris(dt) : [];
155
+
156
+ if (!dt || !hasDraggedFiles(dt)) return;
157
+ e.preventDefault();
158
+ reset();
159
+ if (disabledRef.current) return;
160
+
161
+ // START THE READS NOW, in the drop handler's own tick. A dropped File's
162
+ // read permission is released when this handler returns, and a read started
163
+ // afterwards fails permanently — the chip rendered with the right name and
164
+ // size while its contents never arrived, on every retry. `FileReader` takes
165
+ // the permission at call time, so starting here is what makes the bytes
166
+ // reachable; the pipeline only awaits these later.
167
+ const primed = primeFileReads(entries.files, entries.fallbacks);
168
+
169
+ if (entries.files.length === 0 && entries.directories.length === 0) {
170
+ // No File at all: the host named the file without handing it over, and the
171
+ // reference resolver is the only route left to its contents.
172
+ if (references.length > 0) onFilesRef.current([], false, references, primed);
173
+ return;
174
+ }
175
+
176
+ void expandDroppedFiles(entries).then(async ({ files: resolved, incomplete }) => {
177
+ const carries = resolved.length > 0 ? await carriesBytes(resolved, primed) : false;
178
+ // A File that carries bytes is attached directly. Deciding from metadata
179
+ // instead — "size 0 and no type, so it must be a path stub" — misread a
180
+ // real file whose size the browser had not filled in, and sent it to the
181
+ // reference resolver, which only reads inside the allow-list. The file
182
+ // was then refused with no way for the user to tell why. Bytes settle it.
183
+ if (resolved.length > 0 && carries) {
184
+ onFilesRef.current(resolved, incomplete, [], primed);
185
+ return;
186
+ }
187
+ if (references.length > 0) {
188
+ onFilesRef.current([], incomplete, references, primed);
189
+ return;
190
+ }
191
+ // Files that declare no bytes are attached as-is; the attachment hook
192
+ // reports the ones that never produced content.
193
+ if (resolved.length > 0) onFilesRef.current(resolved, incomplete, [], primed);
194
+ });
195
+ }, [reset]);
196
+
197
+ return { isDragging, dropProps: { onDragEnter, onDragOver, onDragLeave, onDrop } };
198
+ }
199
+
200
+ /**
201
+ * Whether any dropped file actually holds bytes.
202
+ *
203
+ * A promised file (another app's drag) reports `size: 0` until its contents
204
+ * land, so the check reads rather than trusting the declared size. One readable
205
+ * file is enough: the batch is attached as a unit, and a member that stays
206
+ * empty is reported by the attachment hook rather than by re-routing the whole
207
+ * drop.
208
+ */
209
+ export async function carriesBytes(files: File[], primed?: PrimedReads): Promise<boolean> {
210
+ const sample = files.slice(0, 4);
211
+ const results = await Promise.all(sample.map(async (file) => {
212
+ if (file.size > 0) return true;
213
+ // A stub declaring no size is settled by its bytes — read from the primed
214
+ // prefix where the drop provided one, because a `slice()` started after the
215
+ // drop window closes is not authorized to read a dropped file at all.
216
+ const bytes = await primedPrefix(file, primed);
217
+ if (bytes !== undefined) return bytes.byteLength > 0;
218
+ try {
219
+ const raw = await file.slice(0, 1).arrayBuffer();
220
+ return raw.byteLength > 0;
221
+ } catch {
222
+ return false;
223
+ }
224
+ }));
225
+ return results.some(Boolean);
226
+ }