ompchamber 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.b938f4a693.css +2 -0
  3. package/dist/client/static/js/77.8022a800b5.js +139 -0
  4. package/dist/client/static/js/async/{1k.5974df3b87.js → 1k.a411fe5b43.js} +2 -2
  5. package/dist/client/static/js/async/{3y.71f2ccf3b6.js → 3y.3186ecdd63.js} +5 -5
  6. package/dist/client/static/js/async/5b.d88304bc13.js +1 -0
  7. package/dist/client/static/js/async/66.86544ad741.js +93 -0
  8. package/dist/client/static/js/async/7g.db82aca7ba.js +1 -0
  9. package/dist/client/static/js/async/7l.fadbbc0583.js +1 -0
  10. package/dist/client/static/js/async/{dc.856fda6560.js → ar.ad5c8822a8.js} +2 -2
  11. package/dist/client/static/js/async/cx.9c8268d542.js +226 -0
  12. package/dist/client/static/js/async/e.6443e739a2.js +79 -0
  13. package/dist/client/static/js/async/f7.46971e9d6d.js +2 -0
  14. package/dist/client/static/js/async/g.ee41bfed7f.js +2 -0
  15. package/dist/client/static/js/async/h1.d7091cef78.js +1 -0
  16. package/dist/client/static/js/async/hs.e7c59d8090.js +41 -0
  17. package/dist/client/static/js/async/jl.4911fa3383.js +1 -0
  18. package/dist/client/static/js/async/{k.7466330d1a.js → k.d16cd09ae7.js} +2 -2
  19. package/dist/client/static/js/async/{m.c038c71b9f.js → m.2e1ea3db9e.js} +2 -2
  20. package/dist/client/static/js/async/m3.d2b05f9604.js +2 -0
  21. package/dist/client/static/js/async/mt.853be2b5aa.js +1 -0
  22. package/dist/client/static/js/async/{oc.9c060c9779.js → oc.eb22093f05.js} +1 -1
  23. package/dist/client/static/js/async/{og.634fba99ac.js → og.53f068804c.js} +1 -1
  24. package/dist/client/static/js/async/{5b.0d6ae50fe8.js → q.b453193538.js} +10 -10
  25. package/dist/client/static/js/async/q5.88b50ffa95.js +286 -0
  26. package/dist/client/static/js/async/q8.7f1b48e9cd.js +21 -0
  27. package/dist/client/static/js/async/{sy.d16be843a7.js → s.aad2ecebc6.js} +1 -1
  28. package/dist/client/static/js/async/{tr.e878658140.js → tr.2a0d2b7600.js} +1 -1
  29. package/dist/client/static/js/async/vt.20406a5604.js +1 -0
  30. package/dist/client/static/js/async/x1.fe8af17016.js +90 -0
  31. package/dist/client/static/js/async/yg.5a94e1fc8a.js +149 -0
  32. package/dist/client/static/js/async/yx.af2936c368.js +10 -0
  33. package/dist/client/static/js/index.57b39ffa7f.js +34 -0
  34. package/package.json +1 -1
  35. package/src/client/components/common/ComposerPicker.tsx +16 -4
  36. package/src/client/components/common/ComposerTextarea.tsx +1 -0
  37. package/src/client/components/common/file-icon/index.tsx +4 -1
  38. package/src/client/components/common/image-viewer/index.tsx +224 -0
  39. package/src/client/components/layout/RightActivityBar.tsx +1 -1
  40. package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
  41. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
  42. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
  43. package/src/client/components/layout/desktop-layout/index.tsx +25 -96
  44. package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
  45. package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
  46. package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
  47. package/src/client/components/layout/session-sidebar/SessionItem.tsx +26 -12
  48. package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
  49. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
  50. package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
  51. package/src/client/components/settings/Modal.tsx +1 -0
  52. package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
  53. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
  54. package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
  55. package/src/client/components/workspace/btw-panel/Composer.tsx +181 -0
  56. package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
  57. package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
  58. package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
  59. package/src/client/components/workspace/btw-panel/index.tsx +83 -0
  60. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +39 -20
  61. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
  62. package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
  63. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
  64. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
  65. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
  66. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
  67. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +67 -43
  68. package/src/client/components/workspace/chat-timeline/index.tsx +14 -9
  69. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
  70. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
  71. package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
  72. package/src/client/components/workspace/editor/index.tsx +45 -36
  73. package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
  74. package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
  75. package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
  76. package/src/client/hooks/chat/btw/index.ts +180 -0
  77. package/src/client/hooks/chat/btw/intercept.ts +43 -0
  78. package/src/client/hooks/chat/btw/mode.ts +179 -0
  79. package/src/client/hooks/chat/composer/attachments.ts +198 -0
  80. package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
  81. package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
  82. package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
  83. package/src/client/hooks/chat/composer/drop-references.ts +106 -0
  84. package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
  85. package/src/client/hooks/chat/composer/file-drop.ts +226 -0
  86. package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
  87. package/src/client/hooks/chat/composer/file-reads.ts +238 -0
  88. package/src/client/hooks/chat/composer/index.ts +35 -9
  89. package/src/client/hooks/chat/composer/pipeline.ts +181 -0
  90. package/src/client/hooks/chat/omp/index.ts +3 -3
  91. package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
  92. package/src/client/hooks/chat/timeline/actions.ts +40 -4
  93. package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
  94. package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
  95. package/src/client/hooks/chat/timeline/index.ts +29 -9
  96. package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
  97. package/src/client/hooks/chat/timeline/send.ts +45 -70
  98. package/src/client/hooks/chat/timeline/session-load.ts +2 -1
  99. package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
  100. package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
  101. package/src/client/hooks/editor/file-actions.ts +49 -0
  102. package/src/client/hooks/editor/use-file-editor.ts +38 -27
  103. package/src/client/hooks/editor/use-file-transfer.ts +78 -0
  104. package/src/client/hooks/workspace/available-width.ts +33 -0
  105. package/src/client/hooks/workspace/panel-widths.ts +34 -37
  106. package/src/client/tailwind.css +22 -0
  107. package/src/server/db.server.ts +21 -287
  108. package/src/server/lib/btw/frames.test.ts +68 -0
  109. package/src/server/lib/btw/frames.ts +56 -0
  110. package/src/server/lib/btw/prompt.ts +28 -0
  111. package/src/server/lib/btw/registry.server.ts +141 -0
  112. package/src/server/lib/btw/runtime.server.ts +324 -0
  113. package/src/server/lib/btw/schema.server.ts +44 -0
  114. package/src/server/lib/btw/service.server.ts +171 -0
  115. package/src/server/lib/btw/session-copy.server.ts +213 -0
  116. package/src/server/lib/btw/session-copy.test.ts +166 -0
  117. package/src/server/lib/btw/store.server.ts +240 -0
  118. package/src/server/lib/db/schema.ts +110 -0
  119. package/src/server/lib/db/seed.ts +160 -0
  120. package/src/server/lib/db/workspace-sync.ts +70 -0
  121. package/src/server/lib/fs/root.ts +37 -0
  122. package/src/server/lib/models/preferences.server.ts +110 -0
  123. package/src/server/lib/models/registry.server.ts +206 -0
  124. package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
  125. package/src/server/lib/omp/rpc/manager.ts +27 -123
  126. package/src/server/lib/omp/rpc/utility.ts +7 -1
  127. package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
  128. package/src/server/lib/omp/session/auto-title.server.ts +152 -0
  129. package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
  130. package/src/server/lib/omp/session/blobs.server.ts +76 -0
  131. package/src/server/lib/omp/session/file-scan.ts +174 -0
  132. package/src/server/lib/omp/session/files.test.ts +147 -0
  133. package/src/server/lib/omp/session/files.ts +52 -35
  134. package/src/server/lib/omp/session/locator.ts +2 -2
  135. package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
  136. package/src/server/lib/omp/session/merge-stored.ts +171 -0
  137. package/src/server/lib/omp/session/messages.ts +34 -0
  138. package/src/server/lib/omp/session/reader.ts +2 -2
  139. package/src/server/lib/omp/session/subagent-presence.ts +9 -6
  140. package/src/server/lib/queue/delivery.server.test.ts +260 -0
  141. package/src/server/lib/queue/delivery.server.ts +63 -11
  142. package/src/server/plugins/ssr.ts +29 -2
  143. package/src/server/routes/btw/events.ts +70 -0
  144. package/src/server/routes/btw/index.ts +8 -0
  145. package/src/server/routes/btw/topics.ts +87 -0
  146. package/src/server/routes/btw/ws.ts +136 -0
  147. package/src/server/routes/chat/index.ts +2 -0
  148. package/src/server/routes/chat/session.ts +1 -115
  149. package/src/server/routes/chat/turns.ts +87 -0
  150. package/src/server/routes/fs/index.ts +4 -1
  151. package/src/server/routes/fs/raw.ts +85 -0
  152. package/src/server/routes/fs/read.ts +58 -3
  153. package/src/server/routes/index.ts +7 -1
  154. package/src/server/routes/models/root.ts +88 -215
  155. package/src/server/routes/settings/commands.ts +59 -18
  156. package/src/shared/lib/chat/attachments.test.ts +208 -0
  157. package/src/shared/lib/chat/attachments.ts +200 -15
  158. package/src/shared/lib/chat/btw/client.ts +94 -0
  159. package/src/shared/lib/chat/composer/client.test.ts +48 -0
  160. package/src/shared/lib/chat/composer/client.ts +23 -9
  161. package/src/shared/lib/chat/composer/commands.ts +290 -0
  162. package/src/shared/lib/chat/composer/composer.test.ts +195 -0
  163. package/src/shared/lib/chat/composer/filter.ts +21 -7
  164. package/src/shared/lib/chat/composer/trigger.ts +154 -21
  165. package/src/shared/lib/chat/omp/agent-events.ts +33 -80
  166. package/src/shared/lib/chat/omp/socket.ts +11 -6
  167. package/src/shared/lib/chat/omp/sse.ts +11 -6
  168. package/src/shared/lib/chat/omp/tool-results.ts +102 -0
  169. package/src/shared/lib/chat/omp/transport.ts +19 -3
  170. package/src/shared/lib/chat/text-file.ts +148 -0
  171. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
  172. package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
  173. package/src/shared/lib/chat/timeline/turns.ts +7 -0
  174. package/src/shared/lib/fs/file-kind.ts +50 -0
  175. package/src/shared/lib/fs/paths.ts +19 -1
  176. package/src/shared/lib/markdown/math.test.ts +20 -3
  177. package/src/shared/lib/models/preferences.test.ts +131 -0
  178. package/src/shared/lib/models/preferences.ts +88 -0
  179. package/src/shared/lib/models/provider-models.ts +8 -1
  180. package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
  181. package/src/shared/lib/omp/session/mapper.ts +24 -4
  182. package/src/shared/lib/omp/session/messages-map.ts +16 -3
  183. package/src/shared/lib/omp/session/messages-parse.ts +2 -2
  184. package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
  185. package/src/shared/lib/omp/subagent/label.test.ts +94 -0
  186. package/src/shared/lib/omp/subagent/label.ts +84 -0
  187. package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
  188. package/src/shared/lib/workspace/panel-widths.ts +161 -30
  189. package/src/shared/lib/workspace/right-panels.ts +50 -11
  190. package/src/shared/lib/workspace/session-state/store.ts +3 -0
  191. package/src/shared/types/btw.ts +65 -0
  192. package/src/shared/types/chat.ts +77 -66
  193. package/src/shared/types/index.ts +2 -0
  194. package/src/shared/types/models.ts +101 -0
  195. package/src/shared/types/omp/agent.ts +14 -1
  196. package/src/shared/types/omp/session.ts +4 -1
  197. package/src/shared/types/omp/subagent.ts +4 -0
  198. package/src/shared/types/settings/command.ts +13 -0
  199. package/src/shared/types/settings/state.ts +5 -0
  200. package/dist/client/static/css/index.e880517fcd.css +0 -2
  201. package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
  202. package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
  203. package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
  204. package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
  205. package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
  206. package/dist/client/static/js/async/e.93214d622b.js +0 -79
  207. package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
  208. package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
  209. package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
  210. package/dist/client/static/js/async/jl.874d352278.js +0 -1
  211. package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
  212. package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
  213. package/dist/client/static/js/async/q.23af2a358f.js +0 -21
  214. package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
  215. package/dist/client/static/js/async/s.222ee974f6.js +0 -2
  216. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  217. package/dist/client/static/js/async/vt.42416b707d.js +0 -1
  218. package/dist/client/static/js/async/x1.da8687159b.js +0 -90
  219. package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
  220. package/dist/client/static/js/index.0de15c5e17.js +0 -33
  221. package/dist/client/static/js/jp.50817896f3.js +0 -139
  222. /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
  223. /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
  224. /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
  225. /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
  226. /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
  227. /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
  228. /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
  229. /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
  230. /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
  231. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
  232. /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
  233. /package/dist/client/static/js/{index.0de15c5e17.js.LICENSE.txt → index.57b39ffa7f.js.LICENSE.txt} +0 -0
@@ -0,0 +1,179 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * BTW mode: the state behind the side-question form.
8
+ *
9
+ * Entering the mode replaces the chat composer with the form itself — an ask
10
+ * card ("Ask your question", always a new topic) above the side session's
11
+ * composer (follow-ups to the topic in view). The mode flag, both drafts and
12
+ * the selected topic live in per-session state, so a reload lands back in the
13
+ * form the user left and leaving the mode loses nothing.
14
+ *
15
+ * `/btw [question]` in the main composer is the entry point; a question that
16
+ * arrived with the command is asked immediately, which is what the TUI does.
17
+ */
18
+
19
+ import { useCallback } from 'preact/hooks';
20
+ import type { BtwState, BtwTopic } from '@/shared/types';
21
+ import { useBtwSession, type BtwImage, type BtwLiveAnswer } from '@/client/hooks/chat/btw';
22
+ import { useChamberEvent } from '@/client/hooks/ui/window-event';
23
+ import { useSessionState } from '@/client/hooks/workspace/session-state';
24
+ import { useSearchParams } from '@/client/lib/router/search-params';
25
+ import { readStreamTransport } from '@/shared/lib/chat/omp/transport';
26
+
27
+ /** Question sent when a question arrived carrying only images. */
28
+ const ATTACHMENT_ONLY_QUESTION = 'Describe the attached image.';
29
+
30
+ export interface BtwMode {
31
+ open: boolean;
32
+ topics: BtwTopic[];
33
+ activeTopic: BtwTopic | null;
34
+ /** Topic with a turn in flight, if any. */
35
+ runningTopicId: string | null;
36
+ /** A command is still in flight — the panel holds its actions meanwhile. */
37
+ busy: boolean;
38
+ /** Partial answer of the running turn, when it belongs to the active topic. */
39
+ liveAnswer: string;
40
+ error: string | null;
41
+ askDraft: string;
42
+ followUpDraft: string;
43
+ setAskDraft: (value: string) => void;
44
+ setFollowUpDraft: (value: string) => void;
45
+ /** Open the form on the current history (bare `/btw`). */
46
+ enter: () => void;
47
+ /** Leave the form; drafts and history stay. */
48
+ exit: () => void;
49
+ /** ⟳ — clear the question in flight and let the newest topic show. */
50
+ resetQuestion: () => void;
51
+ /** Submit the ask card: a question of its own. */
52
+ submitAsk: () => void;
53
+ /** Submit the side session's composer: a follow-up to the active topic. */
54
+ submitFollowUp: (question: string, images?: BtwImage[]) => void;
55
+ abort: () => void;
56
+ /** Returns the session id to open, or null when promotion was refused. */
57
+ promote: () => Promise<string | null>;
58
+ removeTopic: (topicId: string) => void;
59
+ selectTopic: (topicId: string) => void;
60
+ clearError: () => void;
61
+ }
62
+
63
+ export function useBtwMode(sessionId: string | null, appSettings: Record<string, unknown>): BtwMode {
64
+ const [open, setOpen] = useSessionState<boolean>('chat.btwOpen', false);
65
+ const [askDraft, setAskDraft] = useSessionState<string>('chat.btwAsk', '');
66
+ const [followUpDraft, setFollowUpDraft] = useSessionState<string>('chat.btwFollowUp', '');
67
+ const [selectedTopicId, setSelectedTopicId] = useSessionState<string | null>('chat.btwTopicId', null);
68
+ const [, setSearchParams] = useSearchParams();
69
+
70
+ // `enabled` only gates the stream: the commands must work the moment the form
71
+ // opens, which is before the stream has attached.
72
+ const session = useBtwSession(sessionId, {
73
+ enabled: open && Boolean(sessionId),
74
+ transport: readStreamTransport(appSettings),
75
+ });
76
+
77
+ const topics = session.state?.topics ?? [];
78
+ const activeTopic = topics.find((topic) => topic.id === selectedTopicId) ?? topics[topics.length - 1] ?? null;
79
+ const runningTopicId = session.state?.runningTopicId ?? null;
80
+ const live: BtwLiveAnswer | null = session.live && session.live.topicId === activeTopic?.id ? session.live : null;
81
+
82
+ const selectNewest = useCallback(
83
+ (next: BtwState | null) => {
84
+ if (!next) return;
85
+ setSelectedTopicId(next.topics[next.topics.length - 1]?.id ?? null);
86
+ },
87
+ [setSelectedTopicId],
88
+ );
89
+
90
+ // `/btw [question]` hands the question over; a bare `/btw` only opens the
91
+ // form, which is the history view.
92
+ useChamberEvent('omp:btw', (event) => {
93
+ const detail = (event as CustomEvent<{ question?: string; images?: BtwImage[] }>).detail;
94
+ const question = detail?.question?.trim();
95
+ setOpen(true);
96
+ if (question || detail?.images?.length) {
97
+ setAskDraft('');
98
+ void session.ask(question || ATTACHMENT_ONLY_QUESTION, detail?.images).then(selectNewest);
99
+ }
100
+ });
101
+
102
+ const submitAsk = useCallback(() => {
103
+ const question = askDraft.trim();
104
+ if (!question) return;
105
+ setAskDraft('');
106
+ void session.ask(question).then(selectNewest);
107
+ }, [askDraft, session, setAskDraft, selectNewest]);
108
+
109
+ const submitFollowUp = useCallback(
110
+ (question: string, images?: BtwImage[]) => {
111
+ const text = question.trim();
112
+ if (!text && !images?.length) return;
113
+ setFollowUpDraft('');
114
+ // With no topic in view yet, the side session's first question is the
115
+ // topic — there is nothing to follow up on.
116
+ void session.ask(text || ATTACHMENT_ONLY_QUESTION, images, activeTopic?.id).then((next) => {
117
+ if (!activeTopic) selectNewest(next);
118
+ });
119
+ },
120
+ [activeTopic, session, setFollowUpDraft, selectNewest],
121
+ );
122
+
123
+ const promote = useCallback(async () => {
124
+ if (!activeTopic) return null;
125
+ const created = await session.promote(activeTopic.id);
126
+ if (!created) return null;
127
+ setOpen(false);
128
+ setSelectedTopicId(null);
129
+ setSearchParams((prev) => {
130
+ const next = new URLSearchParams(prev);
131
+ next.set('sessionId', created);
132
+ if (next.has('subagent')) next.delete('subagent');
133
+ return next;
134
+ });
135
+ window.dispatchEvent(new CustomEvent('omp:session-updated', { detail: { sessionId: created } }));
136
+ return created;
137
+ }, [activeTopic, session, setOpen, setSelectedTopicId, setSearchParams]);
138
+
139
+ const removeTopic = useCallback(
140
+ (topicId: string) => {
141
+ if (topicId === selectedTopicId) setSelectedTopicId(null);
142
+ void session.remove(topicId);
143
+ },
144
+ [selectedTopicId, session, setSelectedTopicId],
145
+ );
146
+
147
+ return {
148
+ open,
149
+ topics,
150
+ activeTopic,
151
+ runningTopicId,
152
+ busy: session.busy,
153
+ liveAnswer: live ? live.text : '',
154
+ error: session.error,
155
+ askDraft,
156
+ followUpDraft,
157
+ setAskDraft,
158
+ setFollowUpDraft,
159
+ enter: () => setOpen(true),
160
+ exit: () => setOpen(false),
161
+ resetQuestion: () => {
162
+ setAskDraft('');
163
+ setSelectedTopicId(null);
164
+ session.clearError();
165
+ },
166
+ submitAsk,
167
+ submitFollowUp,
168
+ abort: () => {
169
+ if (runningTopicId) void session.abort(runningTopicId);
170
+ },
171
+ promote,
172
+ removeTopic,
173
+ selectTopic: (topicId: string) => {
174
+ setSelectedTopicId(topicId);
175
+ session.clearError();
176
+ },
177
+ clearError: session.clearError,
178
+ };
179
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Attachment lifecycle for the composer: accept a batch of files (picker,
8
+ * paste, or OS drop), gate the text ones against the inline budget, hold blob
9
+ * previews, and read the bytes into the attachment.
10
+ *
11
+ * Bytes are read AT ATTACH TIME, not at send time. A `File` handed over by a
12
+ * drop or a paste is a live handle onto the drag data store, and reading it
13
+ * later — after the composer has re-rendered, or after the drag that produced
14
+ * it has ended — is what made a dropped file arrive with an empty body while
15
+ * its chip still rendered. Reading once, here, means every later step (send,
16
+ * queue, retry) works from the persisted copy.
17
+ *
18
+ * Two shapes of "no content yet" have to be told apart, and getting it wrong is
19
+ * what produced a prompt sent with no message at all:
20
+ *
21
+ * - A file from another app arrives as a PROMISE: the entry exists before its
22
+ * bytes do, so the first read comes back empty. That read is retried.
23
+ * - A genuinely empty file reads empty forever. It is accepted as-is.
24
+ *
25
+ * Only a read that stays empty while the file claims bytes is a failure, and a
26
+ * failure is always reported — never silently omitted.
27
+ */
28
+
29
+ import { useCallback, useRef } from 'preact/hooks';
30
+ import type { SetStateAction } from 'preact/compat';
31
+ import type { Attachment } from '@/shared/types';
32
+ import { applyTextAttachmentBudget, isTextAttachment, looksLikeTextFile } from '@/shared/lib/chat/attachments';
33
+ import { delay, primedPrefix, readAsDataUrl, readFileTextWithRetry } from '@/client/hooks/chat/composer/file-reads';
34
+ import type { PrimedReads } from '@/client/hooks/chat/composer/file-reads';
35
+
36
+ export interface UseComposerAttachmentsOptions {
37
+ /** Current attachments; the text budget is measured against them. */
38
+ attachments: Attachment[];
39
+ setAttachments: (updater: SetStateAction<Attachment[]>) => void;
40
+ }
41
+
42
+ export interface AddFilesOutcome {
43
+ /** How many files were attached. */
44
+ added: number;
45
+ /** Files refused by the inline text budget, for the caller to report. */
46
+ skipped: File[];
47
+ /** Names of files whose contents could not be read, for the caller to report. */
48
+ unreadable: string[];
49
+ }
50
+
51
+ export interface ComposerAttachments {
52
+ /**
53
+ * Reads every accepted file before resolving, so `unreadable` is complete.
54
+ *
55
+ * `primed` carries the reads already started inside the drop handler. Passing
56
+ * it is not an optimization: a dropped `File` can only be read from a read
57
+ * begun while the drop's permission was live, so a batch that arrives without
58
+ * one is the batch that fails. See `file-reads.ts`.
59
+ */
60
+ addFiles: (files: File[], primed?: PrimedReads) => Promise<AddFilesOutcome>;
61
+ removeAttachment: (id: string) => void;
62
+ /**
63
+ * Wait for every in-flight read, then return the attachments AS THEY STAND
64
+ * once those reads have landed.
65
+ *
66
+ * The list is returned rather than left to the caller because a read patches
67
+ * the stored attachment: a caller that captured the array before waiting
68
+ * would still hold the pre-read entry — the one with no content — and send
69
+ * that.
70
+ */
71
+ readyForSend: () => Promise<Attachment[]>;
72
+ /**
73
+ * The attachment list as of right now, read synchronously.
74
+ *
75
+ * A drop registers its files in this hook before the enclosing component has
76
+ * re-rendered, so the component's own `attachments` prop lags by a frame.
77
+ * Deciding a send from that prop lost the attachment entirely: a send clicked
78
+ * immediately after a drop saw an empty list, cleared the composer and
79
+ * dispatched a prompt with nothing in it.
80
+ */
81
+ peek: () => Attachment[];
82
+ /** Empty the composer (state and the hook's own list together). */
83
+ clear: () => void;
84
+ }
85
+
86
+ export function useComposerAttachments({
87
+ attachments,
88
+ setAttachments,
89
+ }: UseComposerAttachmentsOptions): ComposerAttachments {
90
+ // In-flight work, so a send can wait for a batch it cannot see the end of.
91
+ const pendingReadsRef = useRef(new Set<Promise<void>>());
92
+ // The authoritative list. It is mirrored into the prop on every render, and
93
+ // updated here the moment this hook changes it — the component has not
94
+ // re-rendered yet at that point, so the prop is a frame behind.
95
+ const attachmentsRef = useRef(attachments);
96
+ attachmentsRef.current = attachments;
97
+ const setBoth = useCallback((next: Attachment[]) => {
98
+ attachmentsRef.current = next;
99
+ setAttachments(next);
100
+ }, [setAttachments]);
101
+
102
+ const addFiles = useCallback(async (files: File[], primed?: PrimedReads): Promise<AddFilesOutcome> => {
103
+ const { accepted, skipped } = applyTextAttachmentBudget(files, attachments);
104
+ if (accepted.length === 0) return { added: 0, skipped, unreadable: [] };
105
+
106
+ // Display fields are captured at creation: a File does not survive the
107
+ // queue's JSON round trip or committed history, and every replay path reads
108
+ // these instead of `file`.
109
+ const newAttachments: Attachment[] = accepted.map((file) => ({
110
+ id: Math.random().toString(36).substring(7),
111
+ name: file.name,
112
+ type: file.type,
113
+ size: file.size,
114
+ file,
115
+ preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : '',
116
+ }));
117
+ setBoth([...attachmentsRef.current, ...newAttachments]);
118
+
119
+ const { promise: batch, resolve } = Promise.withResolvers<void>();
120
+ pendingReadsRef.current.add(batch);
121
+ const unreadable: string[] = [];
122
+ try {
123
+ await Promise.all(newAttachments.map(async (att) => {
124
+ const file = att.file;
125
+ if (!file) return;
126
+ // The read resolves after this call returns, so it addresses the entry
127
+ // by id — a captured array would be a pre-update snapshot.
128
+ const store = (patch: Partial<Attachment>) => {
129
+ setBoth(attachmentsRef.current.map((a) => (a.id === att.id ? { ...a, ...patch } : a)));
130
+ };
131
+ const label = att.name ?? file.name;
132
+
133
+ if (file.type.startsWith('image/')) {
134
+ const base64 = await readAsDataUrl(file, primed);
135
+ if (base64) store({ dataBase64: base64 });
136
+ else unreadable.push(label);
137
+ return;
138
+ }
139
+
140
+ // Metadata answers for a classified file; anything else is decided by
141
+ // its bytes, because a promised-file drag names itself `document` and
142
+ // reports no type.
143
+ const classified = isTextAttachment(att);
144
+ const content = await readFileTextWithRetry(file, primed);
145
+ if (content === null) {
146
+ unreadable.push(label);
147
+ return;
148
+ }
149
+ if (content.length === 0) {
150
+ // Nothing came back. A promised file whose bytes never arrive reads
151
+ // empty on every attempt, so this is the only place the user can be
152
+ // told — waiting for the send would just produce an empty prompt.
153
+ unreadable.push(label);
154
+ return;
155
+ }
156
+ const prefix = await primedPrefix(file, primed);
157
+ if (!classified && !(await looksLikeTextFile(file, prefix))) {
158
+ return;
159
+ }
160
+ // The marker travels with the content: without it the send path would
161
+ // re-classify from the same uninformative name and drop it again.
162
+ store({ content, ...(classified ? {} : { sniffedText: true }) });
163
+ }));
164
+ } finally {
165
+ pendingReadsRef.current.delete(batch);
166
+ resolve();
167
+ }
168
+
169
+ return { added: newAttachments.length, skipped, unreadable };
170
+ }, [attachments, setAttachments]);
171
+
172
+ const removeAttachment = useCallback((id: string) => {
173
+ const att = attachmentsRef.current.find((p) => p.id === id);
174
+ if (att?.preview.startsWith('blob:')) URL.revokeObjectURL(att.preview);
175
+ setBoth(attachmentsRef.current.filter((p) => p.id !== id));
176
+ }, [setBoth]);
177
+
178
+ const peek = useCallback(() => attachmentsRef.current, []);
179
+
180
+ const clear = useCallback(() => {
181
+ attachmentsRef.current = [];
182
+ setAttachments([]);
183
+ }, [setAttachments]);
184
+
185
+ const readyForSend = useCallback(async (): Promise<Attachment[]> => {
186
+ // A batch that finishes during this loop is removed from the set, so the
187
+ // snapshot below is the complete in-flight set at the time of the call.
188
+ while (pendingReadsRef.current.size > 0) {
189
+ await Promise.all([...pendingReadsRef.current]);
190
+ }
191
+ // Yield once so the state updates those reads queued are committed before
192
+ // the list is read back.
193
+ await delay(0);
194
+ return attachmentsRef.current;
195
+ }, []);
196
+
197
+ return { addFiles, removeAttachment, readyForSend, peek, clear };
198
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Turning a `DataTransfer` into the flat list of `File`s a drop attaches.
8
+ *
9
+ * Split out of `file-drop.ts`, which owns the event wiring: everything here is
10
+ * about reading the payload, and it is the part with the browser rules worth
11
+ * documenting.
12
+ *
13
+ * Two rules, both of which produced a bug when ignored:
14
+ *
15
+ * 1. 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
+ * 2. `getAsFile()` and `webkitGetAsEntry()` are read from the SAME item, and on
19
+ * a file-manager drag each call can consume state the other needs — so the
20
+ * item list is read exactly ONCE, capturing both views in a single pass.
21
+ *
22
+ * The `File`s this returns are only as good as the tick they were captured in:
23
+ * a dropped file's read permission is released when the drop handler returns,
24
+ * so `file-drop.ts` primes the reads before awaiting anything. See `file-reads.ts`.
25
+ */
26
+
27
+ /**
28
+ * Safety valve: a dropped tree (a repo root, `node_modules`) must not queue
29
+ * thousands of attachments. The walk stops as soon as it is reached.
30
+ */
31
+ export const MAX_DROPPED_FILES = 50;
32
+
33
+ /** Directory nesting limit — a symlinked cycle would otherwise never end. */
34
+ export const MAX_DROP_DEPTH = 10;
35
+
36
+ function readEntryFile(entry: FileSystemFileEntry): Promise<File | null> {
37
+ const { promise, resolve } = Promise.withResolvers<File | null>();
38
+ entry.file(resolve, () => resolve(null));
39
+ return promise;
40
+ }
41
+
42
+ /** `readEntries` yields at most 100 children per call; keep pulling until empty. */
43
+ function readAllEntries(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
44
+ const { promise, resolve } = Promise.withResolvers<FileSystemEntry[]>();
45
+ const all: FileSystemEntry[] = [];
46
+ const readBatch = () => {
47
+ reader.readEntries((batch) => {
48
+ if (batch.length === 0) {
49
+ resolve(all);
50
+ return;
51
+ }
52
+ all.push(...batch);
53
+ readBatch();
54
+ }, () => resolve(all));
55
+ };
56
+ readBatch();
57
+ return promise;
58
+ }
59
+
60
+ /** Walk one directory tree into `out`; true when a cap stopped the walk early. */
61
+ async function walkDirectory(
62
+ directory: FileSystemDirectoryEntry,
63
+ out: File[],
64
+ depth: number,
65
+ ): Promise<boolean> {
66
+ if (depth >= MAX_DROP_DEPTH) return true;
67
+ const children = await readAllEntries(directory.createReader());
68
+ for (const child of children) {
69
+ if (out.length >= MAX_DROPPED_FILES) return true;
70
+ if (child.isFile) {
71
+ const file = await readEntryFile(child as FileSystemFileEntry);
72
+ if (file) out.push(file);
73
+ } else if (child.isDirectory) {
74
+ if (await walkDirectory(child as FileSystemDirectoryEntry, out, depth + 1)) return true;
75
+ }
76
+ }
77
+ return false;
78
+ }
79
+
80
+ /**
81
+ * The two views of a drop's item list, captured in ONE pass.
82
+ *
83
+ * `getAsFile()` and `webkitGetAsEntry()` must both be read from the same item,
84
+ * and on a file-manager drag each call can consume state the other needs. Two
85
+ * separate loops over `items` therefore lose one of them; this captures both
86
+ * while the item is still fresh.
87
+ *
88
+ * `dataTransfer.files` is the PREFERRED source of the `File` objects, not the
89
+ * item list. On a file-manager drag the two are not interchangeable: the item
90
+ * entry can carry correct metadata (name, size) over a backing store that no
91
+ * longer reads, while `files` hands over one that does. Preferring the item list
92
+ * produced a chip with the right name and size whose contents never arrived, and
93
+ * the failure survived every retry because the object itself was unreadable.
94
+ * `files` is also what the working reference implementations read first.
95
+ *
96
+ * The item list still earns its place: it is the ONLY way to see a dropped
97
+ * DIRECTORY (`files` carries a size-0 stub for one), and it is the fallback for
98
+ * a host that populates only `items`.
99
+ */
100
+ export interface DroppedEntries {
101
+ /** Real `File` objects, stubs excluded when a better entry exists. */
102
+ files: File[];
103
+ /** Directory entries, for the recursive walk. */
104
+ directories: FileSystemDirectoryEntry[];
105
+ /**
106
+ * The other `File` object for the same dropped file, when the payload exposed
107
+ * it twice — once through `dataTransfer.files`, once through the item list.
108
+ *
109
+ * Which of the two is readable is not knowable up front and differs by
110
+ * context: a drop that reads fine in a browser tab has been observed to fail
111
+ * in an installed app window, with the failing object still reporting the
112
+ * correct name and size. So this is a pair to try, not a preference to settle,
113
+ * and the read path falls back to this entry when the primary yields nothing.
114
+ */
115
+ fallbacks: Map<File, File>;
116
+ }
117
+
118
+ export function readDroppedEntries(dt: DataTransfer): DroppedEntries {
119
+ const itemFiles: File[] = [];
120
+ const directories: FileSystemDirectoryEntry[] = [];
121
+
122
+ for (const item of Array.from(dt.items ?? [])) {
123
+ if (item.kind !== 'file') continue;
124
+ // Order matters: the entry first, then the file, in the same iteration.
125
+ const entry = typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null;
126
+ if (entry?.isDirectory) {
127
+ directories.push(entry as FileSystemDirectoryEntry);
128
+ continue;
129
+ }
130
+ const file = typeof item.getAsFile === 'function' ? item.getAsFile() : null;
131
+ // A directory arrives as a 0-byte, type-less stub; it is not a file to read.
132
+ if (file && (file.size > 0 || file.type !== '')) itemFiles.push(file);
133
+ }
134
+
135
+ const direct = Array.from(dt.files ?? []);
136
+ const directWithBytes = direct.filter((file) => file.size > 0);
137
+ const itemsWithBytes = itemFiles.filter((file) => file.size > 0);
138
+
139
+ // `files` first, then the item list, then whatever exists. Every step keeps
140
+ // the counterpart as a fallback so a read can try the other object rather than
141
+ // depend on this choice being the right one.
142
+ if (directWithBytes.length > 0) {
143
+ return { files: directWithBytes, directories, fallbacks: pairByName(directWithBytes, itemFiles) };
144
+ }
145
+ if (itemsWithBytes.length > 0) {
146
+ return { files: itemsWithBytes, directories, fallbacks: pairByName(itemsWithBytes, direct) };
147
+ }
148
+ if (itemFiles.length > 0) {
149
+ return { files: itemFiles, directories, fallbacks: pairByName(itemFiles, direct) };
150
+ }
151
+ return { files: direct, directories, fallbacks: new Map() };
152
+ }
153
+
154
+ /**
155
+ * Pair each primary file with its counterpart of the same name, so the read path
156
+ * has something else to try. Matching is by name because that is all the two
157
+ * views of one dropped file reliably share.
158
+ */
159
+ function pairByName(primary: readonly File[], others: readonly File[]): Map<File, File> {
160
+ const byName = new Map<string, File>();
161
+ for (const file of others) {
162
+ if (!byName.has(file.name)) byName.set(file.name, file);
163
+ }
164
+ const pairs = new Map<File, File>();
165
+ for (const file of primary) {
166
+ const other = byName.get(file.name);
167
+ if (other && other !== file) pairs.set(file, other);
168
+ }
169
+ return pairs;
170
+ }
171
+
172
+ /**
173
+ * Expand a drop into the flat file list the composer attaches: the files it
174
+ * already carries plus every directory's contents, walked recursively.
175
+ * `incomplete` reports that a cap cut the drop short, so the composer can say so
176
+ * instead of silently attaching a prefix.
177
+ *
178
+ * Takes the entries rather than the `DataTransfer`: see {@link readDroppedEntries}
179
+ * for why the item list may only be read once.
180
+ */
181
+ export async function expandDroppedFiles(
182
+ entries: DroppedEntries,
183
+ ): Promise<{ files: File[]; incomplete: boolean }> {
184
+ const files: File[] = [...entries.files];
185
+
186
+ // No directory in the payload: the flat list is already the answer.
187
+ if (entries.directories.length === 0) {
188
+ return { files: files.slice(0, MAX_DROPPED_FILES), incomplete: files.length > MAX_DROPPED_FILES };
189
+ }
190
+
191
+ let incomplete = false;
192
+ for (const directory of entries.directories) {
193
+ if (files.length >= MAX_DROPPED_FILES) {
194
+ incomplete = true;
195
+ break;
196
+ }
197
+ if (await walkDirectory(directory, files, 0)) {
198
+ incomplete = true;
199
+ break;
200
+ }
201
+ }
202
+ return { files: files.slice(0, MAX_DROPPED_FILES), incomplete };
203
+ }
204
+
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Reading a drop's payload.
8
+ *
9
+ * Hosts describe a dragged file in incompatible ways, and the difference decides
10
+ * whether the drop is accepted at all. Two failures this pins:
11
+ *
12
+ * - Gating on the `Files` type alone rejected every drag that carries no File —
13
+ * a download dragged out of WhatsApp Web offers only a URL in `DownloadURL`,
14
+ * and a bare path arrives as `text/plain`.
15
+ * - Accepting `text/plain` on its own would turn every text selection into a
16
+ * file drag, so a path-bearing payload only counts when it actually looks like
17
+ * a path or URL.
18
+ */
19
+
20
+ import { describe, expect, test } from 'bun:test';
21
+ import { collectDroppedFileUris, hasDraggedFiles } from '@/client/hooks/chat/composer/drop-payload';
22
+
23
+ function transfer(types: Record<string, string>, files: File[] = [], items: DataTransferItem[] = []): DataTransfer {
24
+ return {
25
+ types: Object.keys(types),
26
+ files: files as unknown as FileList,
27
+ items: items as unknown as DataTransferItemList,
28
+ getData: (type: string) => types[type] ?? types[type.toLowerCase()] ?? '',
29
+ } as unknown as DataTransfer;
30
+ }
31
+
32
+ describe('hasDraggedFiles', () => {
33
+ test('a real file list is always a file drag', () => {
34
+ const file = new File(['x'], 'a.txt', { type: 'text/plain' });
35
+ expect(hasDraggedFiles(transfer({}, [file]))).toBe(true);
36
+ });
37
+
38
+ test('a browser download with no File is still a file drag', () => {
39
+ // WhatsApp Web / Gmail / Slack: the payload is a URL, nothing else.
40
+ expect(hasDraggedFiles(transfer({
41
+ 'text/plain': 'https://web.whatsapp.com/download/xyz.txt',
42
+ DownloadURL: 'text/plain:xyz.txt:https://web.whatsapp.com/download/xyz.txt',
43
+ }))).toBe(true);
44
+ });
45
+
46
+ test('a uri-list is a file drag', () => {
47
+ expect(hasDraggedFiles(transfer({ 'text/uri-list': 'file:///repo/a.ts' }))).toBe(true);
48
+ });
49
+
50
+ test('a bare absolute path is a file drag', () => {
51
+ expect(hasDraggedFiles(transfer({ 'text/plain': '/repo/a.ts' }))).toBe(true);
52
+ expect(hasDraggedFiles(transfer({ 'text/plain': '~/notes.md' }))).toBe(true);
53
+ expect(hasDraggedFiles(transfer({ 'text/plain': 'C:\\repo\\a.ts' }))).toBe(true);
54
+ });
55
+
56
+ test('selected prose is NOT a file drag', () => {
57
+ // Otherwise every text selection would light up the drop target.
58
+ expect(hasDraggedFiles(transfer({ 'text/plain': 'just some selected words' }))).toBe(false);
59
+ expect(hasDraggedFiles(transfer({ 'text/plain': 'see /repo/a.ts for details' }))).toBe(false);
60
+ expect(hasDraggedFiles(transfer({}))).toBe(false);
61
+ expect(hasDraggedFiles(null)).toBe(false);
62
+ });
63
+
64
+ test('the check is case-insensitive', () => {
65
+ // Chrome lowercases the declared types; some hosts do not.
66
+ expect(hasDraggedFiles(transfer({ Files: '' }))).toBe(true);
67
+ expect(hasDraggedFiles(transfer({ DownloadURL: 'https://x/y.txt' }))).toBe(true);
68
+ });
69
+ });
70
+
71
+ describe('collectDroppedFileUris', () => {
72
+ test('reads a uri-list, skipping comments and blanks', () => {
73
+ const dt = transfer({ 'text/uri-list': '# a comment\nfile:///repo/a.ts\n\nfile:///repo/b.ts' });
74
+ expect(collectDroppedFileUris(dt)).toEqual(['file:///repo/a.ts', 'file:///repo/b.ts']);
75
+ });
76
+
77
+ test('prefers a file URL over the bare path on the same drag', () => {
78
+ // macOS puts both on a Finder drag; one file must not become two entries.
79
+ const dt = transfer({ 'text/uri-list': 'file:///repo/a.ts', 'text/plain': '/repo/a.ts' });
80
+ expect(collectDroppedFileUris(dt)).toEqual(['file:///repo/a.ts']);
81
+ });
82
+
83
+ test('ignores a payload that is not a path or URL', () => {
84
+ expect(collectDroppedFileUris(transfer({ 'text/plain': 'hello world' }))).toEqual([]);
85
+ expect(collectDroppedFileUris(transfer({ 'text/plain': 'line one\nline two' }))).toEqual([]);
86
+ });
87
+
88
+ test('keeps a remote download URL', () => {
89
+ expect(collectDroppedFileUris(transfer({ DownloadURL: 'https://x.example/f.txt' }))).toEqual(['https://x.example/f.txt']);
90
+ });
91
+
92
+ test('an unreadable data store yields nothing instead of throwing', () => {
93
+ const dt = { getData: () => { throw new Error('not allowed during dragover'); } } as unknown as DataTransfer;
94
+ expect(collectDroppedFileUris(dt)).toEqual([]);
95
+ });
96
+ });
97
+