shadow-claw 1.23.4 → 1.24.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 (255) hide show
  1. package/README.md +42 -5
  2. package/bin/cli.mjs +166 -0
  3. package/bin/cli.test.mjs +60 -0
  4. package/bin/commands/backup.mjs +70 -0
  5. package/bin/commands/clients.mjs +41 -0
  6. package/bin/commands/peer-id.mjs +76 -0
  7. package/bin/commands/peer-id.test.mjs +104 -0
  8. package/bin/commands/send.mjs +51 -0
  9. package/bin/commands/tasks.mjs +59 -0
  10. package/bin/commands/webrtc-listen.mjs +254 -0
  11. package/bin/prerender-dsd-shell/prerender-dsd-shell.test.mjs +46 -0
  12. package/bin/rolldown.config.test.mjs +401 -8
  13. package/bin/utils/control-client.mjs +243 -0
  14. package/bin/utils/control-client.test.mjs +83 -0
  15. package/bin/utils/ulid.mjs +45 -0
  16. package/bin/utils/webrtc-control-client.mjs +699 -0
  17. package/bin/utils/webrtc-control-client.test.mjs +318 -0
  18. package/dist/electron/main.cjs +1 -1
  19. package/dist/public/AGENTS.md +5 -3
  20. package/dist/public/README.md +42 -5
  21. package/dist/public/agent.worker.js +1 -1
  22. package/dist/public/backup-controller-C_s9in2t.js +1 -0
  23. package/dist/public/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.css +1 -0
  24. package/dist/public/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.html +1 -0
  25. package/dist/public/components/settings/shadow-claw-task-server/shadow-claw-task-server.css +1 -1
  26. package/dist/public/components/settings/shadow-claw-task-server/shadow-claw-task-server.html +1 -1
  27. package/dist/public/components/shadow-claw-settings/shadow-claw-settings.html +1 -1
  28. package/dist/public/config-CrHchneq.js +1 -0
  29. package/dist/public/{configurePeerJs-B4Ym9VMj.js → configurePeerJs-CxpZeDJJ.js} +1 -1
  30. package/dist/public/{connections-BEn6JYdu.js → connections-DvbgdkaB.js} +1 -1
  31. package/dist/public/{custom-element-security-DdYlUozq.js → custom-element-security-NCqQ5lmC.js} +1 -1
  32. package/dist/public/docs/README.md +3 -1
  33. package/dist/public/docs/architecture/overview.md +12 -12
  34. package/dist/public/docs/decisions/bundled-typescript-architecture.md +1 -1
  35. package/dist/public/docs/decisions/indexeddb-and-opfs-storage.md +2 -2
  36. package/dist/public/docs/decisions/native-web-components-and-signals.md +1 -1
  37. package/dist/public/docs/example/article/index.html +1 -1
  38. package/dist/public/docs/guides/configuring-messaging-channels.md +41 -6
  39. package/dist/public/docs/guides/server-development-configuration.md +24 -0
  40. package/dist/public/docs/publishing/index.html +1 -1
  41. package/dist/public/docs/skill-creator/index.html +1 -1
  42. package/dist/public/docs/subsystems/backup.md +56 -0
  43. package/dist/public/docs/subsystems/cli.md +124 -0
  44. package/dist/public/docs/subsystems/control-plane.md +158 -0
  45. package/dist/public/docs/subsystems/notifications.md +54 -2
  46. package/dist/public/docs/subsystems/reactive-ui.md +1 -0
  47. package/dist/public/downloadGroupFile-By0GR6eV.js +1 -0
  48. package/dist/public/e2e/README.md +5 -1
  49. package/dist/public/{e2e-bridge-BQ4KzUt5.js → e2e-bridge-IGWsRl4i.js} +1 -1
  50. package/dist/public/file-viewer-D0eflgt6.js +1 -0
  51. package/dist/public/getAllTasks-vpBlgGsc.js +1 -0
  52. package/dist/public/getGroupDir-P1h9wl6S.js +1 -0
  53. package/dist/public/{git-DEUvQTUA.js → git-7Vq_SJSp.js} +2 -2
  54. package/dist/public/{iframe-sanitizer-0NS4XLOd.js → iframe-sanitizer-y2Z1pPwY.js} +1 -1
  55. package/dist/public/index.html +1 -1
  56. package/dist/public/index.js +1 -1
  57. package/dist/public/{initChatSplitResize-llGkSJXg.js → initChatSplitResize-CzV2E7Ul.js} +1 -1
  58. package/dist/public/initControlPlane-C9jpnWev.js +1 -0
  59. package/dist/public/llms.txt +1 -1
  60. package/dist/public/main/index.html +1 -1
  61. package/dist/public/main/memory/index.html +1 -1
  62. package/dist/public/{markdown-CyYR4App.js → markdown-BnpKxSxl.js} +1 -1
  63. package/dist/public/{mcp-reconnect-CSx4aMwy.js → mcp-reconnect-9emsqGRe.js} +1 -1
  64. package/dist/public/{memoryStorage-Dwg8NhAZ.js → memoryStorage-C0KvLNUp.js} +1 -1
  65. package/dist/public/{orchestrator-DRfEudXk.js → orchestrator-3h9xxHUm.js} +676 -676
  66. package/dist/public/orchestrator-nJj59RqD.js +15 -0
  67. package/dist/public/{peerjs-CurBuKvk.js → peerjs-BP4laMXj.js} +1 -1
  68. package/dist/public/{push-client-Dii-vPEh.js → push-client-CrjEXK3U.js} +1 -1
  69. package/dist/public/service-worker/push-handler.js +1 -1
  70. package/dist/public/service-worker.js +1 -1
  71. package/dist/public/{shadow-claw-5IHaDDMJ.js → shadow-claw-CbwETBWf.js} +2 -2
  72. package/dist/public/{shadow-claw-accounts-CZvz4iQz.js → shadow-claw-accounts-B4sBTQEj.js} +1 -1
  73. package/dist/public/{shadow-claw-channels-BKXFyRS3.js → shadow-claw-channels-BDWG5Qa3.js} +1 -1
  74. package/dist/public/{shadow-claw-chat-CCLuhWdI.js → shadow-claw-chat-Crv0DTCY.js} +6 -6
  75. package/dist/public/shadow-claw-control-plane-CX1IGbh9.js +199 -0
  76. package/dist/public/{shadow-claw-conversations-DV-87UUh.js → shadow-claw-conversations-_9qAaRXk.js} +1 -1
  77. package/dist/public/shadow-claw-dialog-BrOwAOdk.js +1 -0
  78. package/dist/public/{shadow-claw-file-viewer-Dk2dbAUd.js → shadow-claw-file-viewer-Blf6yGVD.js} +1 -1
  79. package/dist/public/shadow-claw-files-CYySBDCl.js +840 -0
  80. package/dist/public/{shadow-claw-git-DRA7_qck.js → shadow-claw-git-v8S0JjuR.js} +2 -2
  81. package/dist/public/{shadow-claw-integrations-1Xu5snuV.js → shadow-claw-integrations-D7ufpwhX.js} +1 -1
  82. package/dist/public/{shadow-claw-llm-6Aci1BDP.js → shadow-claw-llm-GWgoVfhI.js} +2 -2
  83. package/dist/public/{shadow-claw-mcp-remote-BUjAeVXC.js → shadow-claw-mcp-remote-kY4LMFNN.js} +1 -1
  84. package/dist/public/{shadow-claw-networking-FQe4nCp3.js → shadow-claw-networking-qHbP95z9.js} +1 -1
  85. package/dist/public/{shadow-claw-notifications-DV2wibL3.js → shadow-claw-notifications-YyHOK2Fr.js} +1 -1
  86. package/dist/public/{shadow-claw-page-header-B63S4HTU.js → shadow-claw-page-header-DMKbkv3s.js} +1 -1
  87. package/dist/public/{shadow-claw-pages-DE3jWj5K.js → shadow-claw-pages-DFZHhzFZ.js} +1 -1
  88. package/dist/public/{shadow-claw-settings-S6DAKfYx.js → shadow-claw-settings-DhxrKIfA.js} +10 -3
  89. package/dist/public/{shadow-claw-storage-DaacA3_K.js → shadow-claw-storage-BFynbP0j.js} +2 -2
  90. package/dist/public/{shadow-claw-task-server-IjKX042p.js → shadow-claw-task-server-3BIeb5oZ.js} +25 -4
  91. package/dist/public/{shadow-claw-tasks-Cy0JYmGU.js → shadow-claw-tasks-Caxc9atL.js} +1 -1
  92. package/dist/public/{shadow-claw-terminal-znAt7HyP.js → shadow-claw-terminal-Beb5qF-U.js} +1 -1
  93. package/dist/public/{shadow-claw-tools-Cutv9Si5.js → shadow-claw-tools-BfJx3A9W.js} +1 -1
  94. package/dist/public/{shadow-claw-webvm-BH8mPDEu.js → shadow-claw-webvm-BCS8ykeP.js} +1 -1
  95. package/dist/public/{syncWebMcpRegistration-BqJxaMGR.js → syncWebMcpRegistration-B_M2fCjH.js} +1 -1
  96. package/dist/server.js +286 -189
  97. package/package.json +2 -1
  98. package/src/__mocks__/jszip.cjs +15 -5
  99. package/src/components/settings/shadow-claw-accounts/shadow-claw-accounts.test.ts +35 -0
  100. package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.css +117 -0
  101. package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.html +81 -0
  102. package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.test.ts +127 -0
  103. package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.ts +292 -0
  104. package/src/components/settings/shadow-claw-git/shadow-claw-git.test.ts +72 -0
  105. package/src/components/settings/shadow-claw-integrations/connection-test-auth.test.ts +20 -30
  106. package/src/components/settings/shadow-claw-integrations/shadow-claw-integrations.test.ts +116 -0
  107. package/src/components/settings/shadow-claw-llm/model-ranking.test.ts +143 -51
  108. package/src/components/settings/shadow-claw-llm/shadow-claw-llm.test.ts +307 -4
  109. package/src/components/settings/shadow-claw-llm/shadow-claw-llm.ts +8 -8
  110. package/src/components/settings/shadow-claw-mcp-remote/shadow-claw-mcp-remote.test.ts +31 -0
  111. package/src/components/settings/shadow-claw-peerjs/shadow-claw-peerjs.test.ts +186 -0
  112. package/src/components/settings/shadow-claw-storage/shadow-claw-storage.test.ts +76 -0
  113. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.css +23 -2
  114. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.html +1 -1
  115. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.test.ts +19 -0
  116. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.ts +2 -1
  117. package/src/components/shadow-claw/utils/initChatSplitResize.test.ts +180 -13
  118. package/src/components/shadow-claw/utils/setupEffects.test.ts +39 -0
  119. package/src/components/shadow-claw-a2ui/catalog/basic/shared.test.ts +117 -0
  120. package/src/components/shadow-claw-a2ui/shadow-claw-a2ui.test.ts +1159 -1
  121. package/src/components/shadow-claw-chat/shadow-claw-chat.test.ts +1523 -19
  122. package/src/components/shadow-claw-chat/utils/escapeHtml.test.ts +13 -0
  123. package/src/components/shadow-claw-chat/utils/getPeerChatDisplayStatus.test.ts +20 -0
  124. package/src/components/shadow-claw-conversations/shadow-claw-conversations.test.ts +494 -0
  125. package/src/components/shadow-claw-dialog/shadow-claw-dialog.ts +13 -2
  126. package/src/components/shadow-claw-file-viewer/shadow-claw-file-viewer.test.ts +273 -0
  127. package/src/components/shadow-claw-files/shadow-claw-files.test.ts +408 -1
  128. package/src/components/shadow-claw-pdf-viewer/utils/installGetOrInsertComputedPolyfill.test.ts +65 -0
  129. package/src/components/shadow-claw-settings/shadow-claw-settings.html +7 -0
  130. package/src/components/shadow-claw-settings/shadow-claw-settings.test.ts +221 -0
  131. package/src/components/shadow-claw-settings/shadow-claw-settings.ts +2 -0
  132. package/src/components/shadow-claw-tasks/shadow-claw-tasks.test.ts +407 -0
  133. package/src/components/shadow-claw-terminal/utils/consumeEscapeSequence.test.ts +58 -0
  134. package/src/components/shadow-claw-tools/shadow-claw-tools.test.ts +80 -0
  135. package/src/config/config.ts +10 -0
  136. package/src/core/backup-controller.test.ts +101 -0
  137. package/src/core/backup-controller.ts +137 -0
  138. package/src/core/control-plane-client.test.ts +408 -0
  139. package/src/core/control-plane-client.ts +425 -0
  140. package/src/core/orchestrator/orchestrator.ts +4 -0
  141. package/src/core/orchestrator/utils/compactContext.test.ts +78 -0
  142. package/src/core/orchestrator/utils/operations/channel.test.ts +254 -9
  143. package/src/core/orchestrator/utils/operations/provider.test.ts +326 -34
  144. package/src/core/orchestrator/utils/operations/vm.test.ts +114 -2
  145. package/src/core/orchestrator/utils/syncProxyConfigToServiceWorker.test.ts +60 -0
  146. package/src/core/utils/initControlPlane.test.ts +276 -0
  147. package/src/core/utils/initControlPlane.ts +437 -0
  148. package/src/core/utils/initializeApp.ts +18 -0
  149. package/src/db/getSession.test.ts +13 -1
  150. package/src/server/app.test.ts +12 -0
  151. package/src/server/app.ts +18 -8
  152. package/src/server/client-registry.test.ts +250 -0
  153. package/src/server/client-registry.ts +477 -0
  154. package/src/server/config.ts +15 -0
  155. package/src/server/control-plane-types.ts +120 -0
  156. package/src/server/control-plane.test.ts +472 -0
  157. package/src/server/control-plane.ts +581 -0
  158. package/src/server/routes/backup.test.ts +263 -0
  159. package/src/server/routes/backup.ts +262 -0
  160. package/src/server/routes/integrations-email.test.ts +532 -0
  161. package/src/server/routes/llamafile.test.ts +172 -48
  162. package/src/server/routes/mesh-llm.test.ts +192 -0
  163. package/src/server/routes/ollama.test.ts +49 -0
  164. package/src/server/server-peer.test.ts +86 -0
  165. package/src/server/server-peer.ts +330 -0
  166. package/src/server/server.ts +35 -0
  167. package/src/server/services/llamafile-manager.test.ts +230 -6
  168. package/src/server/services/transformers-runtime.test.ts +186 -18
  169. package/src/server/webrtc.test.ts +21 -0
  170. package/src/server/webrtc.ts +72 -0
  171. package/src/service-worker/fetch-proxy.test.ts +64 -1
  172. package/src/service-worker/push-handler.ts +39 -0
  173. package/src/shell/vm.test.ts +744 -12
  174. package/src/storage/ensureMainGroupIndex.test.ts +153 -0
  175. package/src/storage/getGroupFile.test.ts +66 -0
  176. package/src/storage/memoryStorage.test.ts +82 -2
  177. package/src/storage/writeGroupFileBytes.test.ts +143 -0
  178. package/src/stores/chat-ui.test.ts +20 -0
  179. package/src/stores/orchestrator.test.ts +134 -0
  180. package/src/stores/toast.test.ts +50 -0
  181. package/src/stores/tools.test.ts +147 -10
  182. package/src/subsystems/channels/imessage.test.ts +59 -0
  183. package/src/subsystems/channels/peerjs.test.ts +500 -62
  184. package/src/subsystems/channels/peerjs.ts +71 -4
  185. package/src/subsystems/channels/telegram.test.ts +177 -0
  186. package/src/subsystems/email/catalog.test.ts +1 -0
  187. package/src/subsystems/email/connections.test.ts +39 -1
  188. package/src/subsystems/git/credentials.test.ts +160 -825
  189. package/src/subsystems/git/git.test.ts +463 -0
  190. package/src/subsystems/notifications/__mocks__/node-sqlite.cjs +12 -1
  191. package/src/subsystems/notifications/push-routes.test.ts +33 -0
  192. package/src/subsystems/notifications/push-routes.ts +23 -0
  193. package/src/subsystems/notifications/push-store.ts +1 -1
  194. package/src/subsystems/providers/builtin-ai-tasks.test.ts +74 -0
  195. package/src/subsystems/providers/litert-lm-provider.test.ts +212 -51
  196. package/src/subsystems/providers/model-registry.test.ts +115 -0
  197. package/src/subsystems/providers/providers.test.ts +94 -1
  198. package/src/subsystems/providers/transformers-js-provider.test.ts +279 -0
  199. package/src/subsystems/providers/utils/createModelCacheFetch.test.ts +2 -2
  200. package/src/subsystems/providers/utils/downloadModelToCache.test.ts +2 -2
  201. package/src/subsystems/providers/utils/loadModelStream.test.ts +2 -2
  202. package/src/subsystems/skills/activateSkill.test.ts +101 -0
  203. package/src/subsystems/skills/discoverSkills.test.ts +251 -0
  204. package/src/subsystems/tools/cron.test.ts +64 -0
  205. package/src/subsystems/tools/declarative.test.ts +255 -89
  206. package/src/testing/assets/v86.9pfs/libv86.mjs +63 -0
  207. package/src/testing/assets/v86.ext2/libv86.mjs +3 -0
  208. package/src/testing/assets_missing_v86/assets/v86.9pfs/libv86.mjs +1 -0
  209. package/src/testing/jest-setup.ts +328 -40
  210. package/src/testing/mock-v86.mjs +49 -0
  211. package/src/ui/a2ui/types.ts +4 -3
  212. package/src/ui/a2ui/utils/normaliseComponentsToMap.test.ts +102 -0
  213. package/src/ui/a2ui/utils/resolveChildIds.test.ts +63 -0
  214. package/src/ui/a2ui/utils/resolveDynamicNumber.test.ts +13 -1
  215. package/src/worker/tools/bash/utils/waitForVMReady.test.ts +40 -0
  216. package/src/worker/tools/email/email.test.ts +193 -8
  217. package/src/worker/tools/email/utils/asEmailAttachmentInputs.test.ts +47 -0
  218. package/src/worker/tools/git/git.test.ts +214 -3
  219. package/src/worker/tools/rooms/invite-to-room.test.ts +54 -0
  220. package/src/worker/tools/rooms/leave-room.test.ts +40 -0
  221. package/src/worker/tools/rooms/list-room-members.test.ts +75 -0
  222. package/src/worker/tools/tasks/tasks-utils.test.ts +22 -0
  223. package/src/worker/tools/ui/ask-user.test.ts +50 -0
  224. package/src/worker/tools/ui/clear-chat.test.ts +19 -0
  225. package/src/worker/tools/ui/get-current-time.test.ts +19 -0
  226. package/src/worker/tools/ui/javascript.test.ts +116 -0
  227. package/src/worker/tools/ui/list-components.test.ts +10 -0
  228. package/src/worker/tools/ui/list-tool-profiles.test.ts +42 -0
  229. package/src/worker/tools/ui/manage-tools.test.ts +34 -0
  230. package/src/worker/tools/ui/render-component.test.ts +346 -0
  231. package/src/worker/tools/ui/send-notification.test.ts +40 -0
  232. package/src/worker/tools/ui/utils/getAllowFullInternetAccess.test.ts +31 -0
  233. package/src/worker/tools/ui/utils/parseBooleanConfig.test.ts +25 -0
  234. package/src/worker/tools/workspace/attach-file.test.ts +84 -0
  235. package/src/worker/tools/workspace/copy-file.test.ts +69 -0
  236. package/src/worker/tools/workspace/create-directory.test.ts +40 -0
  237. package/src/worker/tools/workspace/delete-file.test.ts +73 -0
  238. package/src/worker/tools/workspace/diff-files.test.ts +94 -0
  239. package/src/worker/tools/workspace/list-files.test.ts +36 -0
  240. package/src/worker/tools/workspace/move-file.test.ts +66 -0
  241. package/src/worker/tools/workspace/open-file.test.ts +57 -0
  242. package/src/worker/tools/workspace/patch-file.test.ts +81 -0
  243. package/src/worker/tools/workspace/read-file.test.ts +116 -0
  244. package/src/worker/tools/workspace/send-file.test.ts +82 -0
  245. package/src/worker/tools/workspace/update-memory.test.ts +29 -0
  246. package/src/worker/tools/workspace/write-file.test.ts +28 -0
  247. package/src/worker/utils/rate-limit.test.ts +199 -0
  248. package/src/worker/utils/tool-state.test.ts +29 -0
  249. package/src/worker/utils/transformers-js-utils.test.ts +59 -0
  250. package/dist/public/config-BYAuxx3j.js +0 -1
  251. package/dist/public/downloadGroupFile-Drdvg1Ev.js +0 -1
  252. package/dist/public/file-viewer-CcZYw-PS.js +0 -1
  253. package/dist/public/orchestrator-o9Fj67BN.js +0 -15
  254. package/dist/public/shadow-claw-dialog-ByyHgxM9.js +0 -1
  255. package/dist/public/shadow-claw-files-BpfpJIuP.js +0 -840
@@ -1,7 +1,51 @@
1
1
  import { jest } from "@jest/globals";
2
2
 
3
- // We'll test the DB layer and orchestrator store directly
4
- // since the component has browser-only dependencies (jszip from CDN)
3
+ jest.unstable_mockModule("jszip", () => {
4
+ class MockJSZip {
5
+ static async loadAsync(_file: any) {
6
+ return new MockJSZip();
7
+ }
8
+ file(_name: string) {
9
+ return {
10
+ async: async (_type: string) =>
11
+ JSON.stringify({ messages: [{ id: "1", content: "hi" }] }),
12
+ };
13
+ }
14
+ async generateAsync() {
15
+ return new Blob([]);
16
+ }
17
+ }
18
+ return {
19
+ default: MockJSZip,
20
+ __esModule: true,
21
+ };
22
+ });
23
+
24
+ jest.unstable_mockModule("../../core/effect.js", () => ({
25
+ effect: jest.fn((cb: any) => {
26
+ try {
27
+ if (typeof cb === "function") cb();
28
+ } catch {}
29
+ return () => {};
30
+ }),
31
+ }));
32
+
33
+ jest.unstable_mockModule("../../storage/readGroupFileBytes.js", () => ({
34
+ readGroupFileBytes: jest.fn(async () => new Uint8Array([1, 2, 3])),
35
+ }));
36
+
37
+ jest.unstable_mockModule("../../storage/downloadGroupFile.js", () => ({
38
+ downloadGroupFile: jest.fn(async () => undefined),
39
+ }));
40
+
41
+ jest.unstable_mockModule("../../db/exportChatData.js", () => ({
42
+ exportChatData: jest.fn(async () => ({ messages: [], version: 1 })),
43
+ }));
44
+
45
+ jest.unstable_mockModule("../../db/importChatData.js", () => ({
46
+ importChatData: jest.fn(async () => undefined),
47
+ }));
48
+
5
49
  const { orchestratorStore } = await import("../../stores/orchestrator.js");
6
50
  const { clearGroupMessages } = await import("../../db/clearGroupMessages.js");
7
51
  const { setDB } = await import("../../db/db.js");
@@ -250,7 +294,7 @@ describe("shadow-claw-chat UX enhancements (issue #10)", () => {
250
294
  beforeAll(async () => {
251
295
  // Import the class to access static method
252
296
  // We use a dynamic import with a mock to avoid browser-only deps
253
- jest.unstable_mockModule("jszip", () => ({ default: {} }));
297
+ // jszip mocked at top level
254
298
  jest.unstable_mockModule("../../db/exportChatData.js", () => ({
255
299
  exportChatData: jest.fn(),
256
300
  }));
@@ -263,9 +307,7 @@ describe("shadow-claw-chat UX enhancements (issue #10)", () => {
263
307
  importChatData: jest.fn(),
264
308
  }));
265
309
 
266
- jest.unstable_mockModule("../../core/effect.js", () => ({
267
- effect: jest.fn(),
268
- }));
310
+ // effect mocked at top level
269
311
 
270
312
  jest.unstable_mockModule("../../content/markdown.js", () => ({
271
313
  renderMarkdown: jest.fn((str: string) => `<p>${str}</p>`),
@@ -283,6 +325,8 @@ describe("shadow-claw-chat UX enhancements (issue #10)", () => {
283
325
  sendMessage: jest.fn(),
284
326
  stopCurrentRequest: jest.fn(),
285
327
  deleteMessage: jest.fn(),
328
+ newSession: jest.fn(),
329
+ compactContext: jest.fn(),
286
330
  },
287
331
  }));
288
332
 
@@ -369,7 +413,7 @@ describe("shadow-claw-chat UX enhancements (issue #10)", () => {
369
413
  // The constant is module-scoped, so we verify its effect indirectly.
370
414
  // The key behavior: if user scrolls up, auto-scroll should NOT happen.
371
415
  // We verify the template includes the messages container which is the scroll target.
372
- jest.unstable_mockModule("jszip", () => ({ default: {} }));
416
+ // jszip mocked at top level
373
417
 
374
418
  const { ShadowClawChat: _ShadowClawChat } =
375
419
  await import("./shadow-claw-chat.js");
@@ -410,7 +454,7 @@ describe("message copy button", () => {
410
454
  let mockOrchestratorStore;
411
455
 
412
456
  beforeAll(async () => {
413
- jest.unstable_mockModule("jszip", () => ({ default: {} }));
457
+ // jszip mocked at top level
414
458
 
415
459
  jest.unstable_mockModule("../../db/exportChatData.js", () => ({
416
460
  exportChatData: jest.fn(),
@@ -424,9 +468,7 @@ describe("message copy button", () => {
424
468
  importChatData: jest.fn(),
425
469
  }));
426
470
 
427
- jest.unstable_mockModule("../../core/effect.js", () => ({
428
- effect: jest.fn(),
429
- }));
471
+ // effect mocked at top level
430
472
 
431
473
  jest.unstable_mockModule("../../content/markdown.js", () => ({
432
474
  renderMarkdown: jest.fn((str: string) => `<p>${str}</p>`),
@@ -443,6 +485,8 @@ describe("message copy button", () => {
443
485
  sendMessage: jest.fn(),
444
486
  stopCurrentRequest: jest.fn(),
445
487
  deleteMessage: jest.fn(),
488
+ newSession: jest.fn(),
489
+ compactContext: jest.fn(),
446
490
  };
447
491
 
448
492
  jest.unstable_mockModule("../../stores/orchestrator.js", () => ({
@@ -857,7 +901,7 @@ describe("chat workspace link resolution", () => {
857
901
  let ShadowClawChat;
858
902
 
859
903
  beforeAll(async () => {
860
- jest.unstable_mockModule("jszip", () => ({ default: {} }));
904
+ // jszip mocked at top level
861
905
 
862
906
  jest.unstable_mockModule("../../db/exportChatData.js", () => ({
863
907
  exportChatData: jest.fn(),
@@ -871,9 +915,7 @@ describe("chat workspace link resolution", () => {
871
915
  importChatData: jest.fn(),
872
916
  }));
873
917
 
874
- jest.unstable_mockModule("../../core/effect.js", () => ({
875
- effect: jest.fn(),
876
- }));
918
+ // effect mocked at top level
877
919
 
878
920
  jest.unstable_mockModule("../../content/markdown.js", () => ({
879
921
  renderMarkdown: jest.fn((str: string) => `<p>${str}</p>`),
@@ -890,6 +932,8 @@ describe("chat workspace link resolution", () => {
890
932
  activeGroupId: "test-group",
891
933
  sendMessage: jest.fn(),
892
934
  stopCurrentRequest: jest.fn(),
935
+ newSession: jest.fn(),
936
+ compactContext: jest.fn(),
893
937
  },
894
938
  }));
895
939
 
@@ -984,7 +1028,7 @@ describe("chat attachment helpers", () => {
984
1028
  let ShadowClawChat;
985
1029
 
986
1030
  beforeAll(async () => {
987
- jest.unstable_mockModule("jszip", () => ({ default: {} }));
1031
+ // jszip mocked at top level
988
1032
  jest.unstable_mockModule("../../db/exportChatData.js", () => ({
989
1033
  exportChatData: jest.fn(),
990
1034
  }));
@@ -994,9 +1038,7 @@ describe("chat attachment helpers", () => {
994
1038
  jest.unstable_mockModule("../../db/importChatData.js", () => ({
995
1039
  importChatData: jest.fn(),
996
1040
  }));
997
- jest.unstable_mockModule("../../core/effect.js", () => ({
998
- effect: jest.fn(),
999
- }));
1041
+ // effect mocked at top level
1000
1042
  jest.unstable_mockModule("../../content/markdown.js", () => ({
1001
1043
  renderMarkdown: jest.fn((str: string) => `<p>${str}</p>`),
1002
1044
  }));
@@ -1010,6 +1052,8 @@ describe("chat attachment helpers", () => {
1010
1052
  activeGroupId: "test-group",
1011
1053
  sendMessage: jest.fn(),
1012
1054
  stopCurrentRequest: jest.fn(),
1055
+ newSession: jest.fn(),
1056
+ compactContext: jest.fn(),
1013
1057
  },
1014
1058
  }));
1015
1059
  jest.unstable_mockModule("../../storage/readGroupFileBytes.js", () => ({
@@ -1151,12 +1195,15 @@ describe("shadow-claw-chat utility methods", () => {
1151
1195
 
1152
1196
  describe("shadow-claw-chat prompt api onboarding", () => {
1153
1197
  let ShadowClawChat: any;
1198
+ let currentOrchestratorStore: any;
1154
1199
  let mockDb: any;
1155
1200
  let storedConfig: Record<string, string>;
1156
1201
 
1157
1202
  beforeAll(async () => {
1158
1203
  const mod = await import("./shadow-claw-chat.js");
1159
1204
  ShadowClawChat = mod.ShadowClawChat;
1205
+ const orchMod = await import("../../stores/orchestrator.js");
1206
+ currentOrchestratorStore = orchMod.orchestratorStore;
1160
1207
  });
1161
1208
 
1162
1209
  beforeEach(() => {
@@ -1317,4 +1364,1461 @@ describe("shadow-claw-chat prompt api onboarding", () => {
1317
1364
  document.removeEventListener("shadow-claw-navigate", navListener);
1318
1365
  }
1319
1366
  });
1367
+
1368
+ describe("ShadowClawChat helper methods", () => {
1369
+ let instance: any;
1370
+
1371
+ beforeEach(() => {
1372
+ instance = Object.create(ShadowClawChat.prototype);
1373
+ });
1374
+
1375
+ it("formats attachment sizes accurately", () => {
1376
+ expect(instance.formatAttachmentSize(500)).toBe("500 B");
1377
+ expect(instance.formatAttachmentSize(2048)).toBe("2.0 KB");
1378
+ expect(instance.formatAttachmentSize(5 * 1024 * 1024)).toBe("5.0 MB");
1379
+ });
1380
+
1381
+ it("determines correct attachment icons", () => {
1382
+ expect(instance.getAttachmentIcon("image/png")).toBe("IMG");
1383
+ expect(instance.getAttachmentIcon("video/mp4")).toBe("VID");
1384
+ expect(instance.getAttachmentIcon("audio/mp3")).toBe("AUD");
1385
+ expect(instance.getAttachmentIcon("application/pdf")).toBe("PDF");
1386
+ expect(instance.getAttachmentIcon("text/plain")).toBe("TXT");
1387
+ expect(instance.getAttachmentIcon("application/json")).toBe("TXT");
1388
+ expect(instance.getAttachmentIcon("application/zip")).toBe("ZIP");
1389
+ expect(instance.getAttachmentIcon("application/octet-stream")).toBe(
1390
+ "BIN",
1391
+ );
1392
+ });
1393
+
1394
+ it("determines attachment transport labels based on capabilities", () => {
1395
+ const textCaps = {
1396
+ images: false,
1397
+ audio: false,
1398
+ documents: false,
1399
+ routerByFeatures: false,
1400
+ };
1401
+ const multimodalCaps = {
1402
+ images: true,
1403
+ audio: true,
1404
+ documents: true,
1405
+ routerByFeatures: false,
1406
+ };
1407
+
1408
+ expect(
1409
+ instance.getAttachmentTransportLabel("text/plain", "doc.txt", textCaps),
1410
+ ).toBe("text");
1411
+ expect(
1412
+ instance.getAttachmentTransportLabel(
1413
+ "image/png",
1414
+ "img.png",
1415
+ multimodalCaps,
1416
+ ),
1417
+ ).toBe("native");
1418
+ expect(
1419
+ instance.getAttachmentTransportLabel("image/png", "img.png", textCaps),
1420
+ ).toBe("fallback");
1421
+ expect(
1422
+ instance.getAttachmentTransportLabel(
1423
+ "audio/wav",
1424
+ "clip.wav",
1425
+ multimodalCaps,
1426
+ ),
1427
+ ).toBe("native");
1428
+ expect(
1429
+ instance.getAttachmentTransportLabel("audio/wav", "clip.wav", textCaps),
1430
+ ).toBe("fallback");
1431
+ expect(
1432
+ instance.getAttachmentTransportLabel(
1433
+ "application/pdf",
1434
+ "doc.pdf",
1435
+ multimodalCaps,
1436
+ ),
1437
+ ).toBe("native");
1438
+ expect(
1439
+ instance.getAttachmentTransportLabel(
1440
+ "application/pdf",
1441
+ "doc.pdf",
1442
+ textCaps,
1443
+ ),
1444
+ ).toBe("fallback");
1445
+ });
1446
+
1447
+ it("clamps input area height properly", () => {
1448
+ expect(instance.clampInputAreaHeight(10)).toBe(40);
1449
+ expect(instance.clampInputAreaHeight(100)).toBe(100);
1450
+ });
1451
+
1452
+ it("formats token counts for human display", () => {
1453
+ expect(instance.formatTokenCount(1234)).toBe("1,234");
1454
+ expect(instance.formatTokenCount(undefined as any)).toBe("–");
1455
+ });
1456
+
1457
+ it("resolves workspace links and blocks traversal or external schemes", () => {
1458
+ expect(instance.resolveWorkspaceLinkPath("docs/readme.md")).toBe(
1459
+ "docs/readme.md",
1460
+ );
1461
+ expect(instance.resolveWorkspaceLinkPath("./docs/readme.md#sec")).toBe(
1462
+ "docs/readme.md",
1463
+ );
1464
+ expect(instance.resolveWorkspaceLinkPath("../secret.txt")).toBeNull();
1465
+ expect(
1466
+ instance.resolveWorkspaceLinkPath("https://external.com/file"),
1467
+ ).toBeNull();
1468
+ expect(instance.resolveWorkspaceLinkPath("#anchor")).toBeNull();
1469
+ });
1470
+
1471
+ it("checks for droppable data transfer items", () => {
1472
+ expect(instance.hasDroppableData(null)).toBe(false);
1473
+ expect(
1474
+ instance.hasDroppableData({ files: [new File([], "test.png")] } as any),
1475
+ ).toBe(true);
1476
+ expect(
1477
+ instance.hasDroppableData({ files: [], types: ["text/plain"] } as any),
1478
+ ).toBe(true);
1479
+ });
1480
+
1481
+ it("defers workspace image loads", () => {
1482
+ const html =
1483
+ '<p><img src="docs/image.png" alt="test"><img src="https://example.com/ext.png"></p>';
1484
+ const deferred = instance.deferWorkspaceImageLoads(html);
1485
+ expect(deferred).toContain('data-inline-workspace-src="docs/image.png"');
1486
+ expect(deferred).toContain('src="https://example.com/ext.png"');
1487
+ });
1488
+
1489
+ it("builds queued attachments from File array", () => {
1490
+ const file = new File(["test"], "sample.txt", { type: "text/plain" });
1491
+ const queued = instance.buildQueuedAttachmentsFromFiles([file]);
1492
+ expect(queued).toHaveLength(1);
1493
+ expect(queued[0].fileName).toBe("sample.txt");
1494
+ expect(queued[0].mimeType).toBe("text/plain");
1495
+ expect(queued[0].source.kind).toBe("local-file");
1496
+ });
1497
+
1498
+ it("clears chat messages and starts new session via handleClearChat", async () => {
1499
+ const mockContainer = document.createElement("div");
1500
+ mockContainer.className = "chat__messages";
1501
+ mockContainer.innerHTML = "<p>Msg 1</p><p>Msg 2</p>";
1502
+
1503
+ const mockShadow = {
1504
+ querySelector: jest.fn().mockReturnValue(mockContainer),
1505
+ };
1506
+ Object.defineProperty(instance, "shadowRoot", {
1507
+ value: mockShadow,
1508
+ configurable: true,
1509
+ });
1510
+ instance.db = {};
1511
+
1512
+ currentOrchestratorStore.newSession.mockResolvedValue(undefined);
1513
+
1514
+ await instance.handleClearChat();
1515
+
1516
+ expect(mockContainer.children.length).toBe(0);
1517
+ expect(currentOrchestratorStore.newSession).toHaveBeenCalledWith(
1518
+ instance.db,
1519
+ );
1520
+ });
1521
+
1522
+ it("stops chat stream via handleStopChat", async () => {
1523
+ await instance.handleStopChat();
1524
+
1525
+ expect(currentOrchestratorStore.stopCurrentRequest).toHaveBeenCalled();
1526
+ });
1527
+
1528
+ it("compacts chat context when confirmed via handleCompactChat", async () => {
1529
+ instance.db = {};
1530
+ instance.showAttachmentDialog = (jest.fn() as any).mockResolvedValue(
1531
+ true,
1532
+ );
1533
+ currentOrchestratorStore.compactContext.mockResolvedValue(undefined);
1534
+
1535
+ await instance.handleCompactChat();
1536
+
1537
+ expect(instance.showAttachmentDialog).toHaveBeenCalled();
1538
+ expect(currentOrchestratorStore.compactContext).toHaveBeenCalledWith(
1539
+ instance.db,
1540
+ );
1541
+ });
1542
+
1543
+ it("persists and restores input area height", async () => {
1544
+ instance.db = {};
1545
+ await instance.persistInputAreaHeight(150);
1546
+
1547
+ const textarea = document.createElement("textarea");
1548
+ textarea.className = "chat__input-textarea";
1549
+ const inputWrap = document.createElement("div");
1550
+ inputWrap.className = "chat__input-wrapper";
1551
+
1552
+ const mockShadow = {
1553
+ querySelector: jest.fn().mockImplementation((sel) => {
1554
+ if (sel === ".chat__input-textarea") return textarea;
1555
+ if (sel === ".chat__input-wrapper") return inputWrap;
1556
+ return null;
1557
+ }),
1558
+ };
1559
+ Object.defineProperty(instance, "shadowRoot", {
1560
+ value: mockShadow,
1561
+ configurable: true,
1562
+ });
1563
+
1564
+ await instance.restoreInputAreaHeight();
1565
+ expect(instance.clampInputAreaHeight(150)).toBe(150);
1566
+ });
1567
+ });
1568
+
1569
+ describe("ShadowClawChat comprehensive coverage suite", () => {
1570
+ let ShadowClawChatClass: any;
1571
+ let chatInstance: any;
1572
+ let mockDbInstance: any;
1573
+
1574
+ beforeAll(async () => {
1575
+ const mod = await import("./shadow-claw-chat.js");
1576
+ ShadowClawChatClass = mod.ShadowClawChat;
1577
+ });
1578
+
1579
+ beforeEach(() => {
1580
+ document.body.innerHTML = "";
1581
+ mockDbInstance = {
1582
+ transaction: jest.fn().mockReturnValue({
1583
+ objectStore: jest.fn().mockReturnValue({
1584
+ get: jest.fn().mockReturnValue({ onsuccess: null }),
1585
+ put: jest.fn().mockReturnValue({ onsuccess: null }),
1586
+ delete: jest.fn().mockReturnValue({ onsuccess: null }),
1587
+ }),
1588
+ }),
1589
+ };
1590
+ setDB(mockDbInstance);
1591
+ chatInstance = new ShadowClawChatClass();
1592
+ });
1593
+
1594
+ afterEach(() => {
1595
+ if (chatInstance.isConnected) {
1596
+ chatInstance.remove();
1597
+ }
1598
+ document.body.innerHTML = "";
1599
+ });
1600
+
1601
+ it("registers custom element and initializes constructor properties", () => {
1602
+ expect(customElements.get("shadow-claw-chat")).toBe(ShadowClawChatClass);
1603
+ expect(chatInstance.db).toBeNull();
1604
+ expect(chatInstance.activityLogCollapsedOverride).toBeNull();
1605
+ });
1606
+
1607
+ it("connects to DOM and dispatches slot ready event", async () => {
1608
+ const slotReadySpy = jest.fn();
1609
+ chatInstance.addEventListener(
1610
+ "shadow-claw-terminal-slot-ready",
1611
+ slotReadySpy,
1612
+ );
1613
+
1614
+ document.body.appendChild(chatInstance);
1615
+ await new Promise((r) => setTimeout(r, 10));
1616
+
1617
+ expect(slotReadySpy).toHaveBeenCalled();
1618
+ expect(chatInstance.shadowRoot).toBeTruthy();
1619
+
1620
+ chatInstance.disconnectedCallback();
1621
+ });
1622
+
1623
+ it("handles media queries and activity log visibility toggle", () => {
1624
+ const root = chatInstance.shadowRoot;
1625
+ const logEl = root.querySelector(".chat__activity-log");
1626
+
1627
+ chatInstance.setupActivityLogVisibility();
1628
+
1629
+ chatInstance.setActivityLogCollapsedOverride(true);
1630
+ expect(logEl.classList.contains("chat__activity-log--collapsed")).toBe(
1631
+ true,
1632
+ );
1633
+
1634
+ chatInstance.setActivityLogCollapsedOverride(false);
1635
+ expect(logEl.classList.contains("chat__activity-log--collapsed")).toBe(
1636
+ false,
1637
+ );
1638
+
1639
+ chatInstance.setActivityLogCollapsedOverride(null);
1640
+ expect(chatInstance.activityLogCollapsedOverride).toBeNull();
1641
+
1642
+ chatInstance.setDropOverlayVisible(true);
1643
+ const overlay = root.querySelector("[data-drop-overlay]");
1644
+ if (overlay) {
1645
+ expect(overlay.hidden).toBe(false);
1646
+ }
1647
+
1648
+ chatInstance.setDropOverlayVisible(false);
1649
+ if (overlay) {
1650
+ expect(overlay.hidden).toBe(true);
1651
+ }
1652
+ });
1653
+
1654
+ it("handles input resize pointer, double click, and keyboard events", async () => {
1655
+ const inputArea = document.createElement("div");
1656
+ inputArea.className = "chat__input-area";
1657
+ const wrapper = document.createElement("div");
1658
+ wrapper.className = "chat__input-wrapper";
1659
+ inputArea.appendChild(wrapper);
1660
+
1661
+ const handle = document.createElement("div");
1662
+ handle.className = "chat__input-resize-handle";
1663
+ inputArea.appendChild(handle);
1664
+ document.body.appendChild(inputArea);
1665
+
1666
+ chatInstance.bindInputResizeEvents(inputArea, handle);
1667
+
1668
+ expect(handle.getAttribute("role")).toBe("separator");
1669
+ expect(handle.getAttribute("tabindex")).toBe("0");
1670
+
1671
+ handle.setPointerCapture = jest.fn();
1672
+
1673
+ // Pointer down ignoring non-main mouse button
1674
+ handle.dispatchEvent(
1675
+ new PointerEvent("pointerdown", { pointerType: "mouse", button: 2 }),
1676
+ );
1677
+ expect(handle.classList.contains("active")).toBe(false);
1678
+
1679
+ // Pointer down with main button
1680
+ handle.dispatchEvent(
1681
+ new PointerEvent("pointerdown", {
1682
+ pointerType: "mouse",
1683
+ button: 0,
1684
+ clientY: 100,
1685
+ pointerId: 1,
1686
+ }),
1687
+ );
1688
+ expect(handle.classList.contains("active")).toBe(true);
1689
+
1690
+ // Pointer move
1691
+ document.dispatchEvent(
1692
+ new PointerEvent("pointermove", {
1693
+ clientY: 50,
1694
+ pointerId: 1,
1695
+ }),
1696
+ );
1697
+
1698
+ // Pointer up
1699
+ handle.dispatchEvent(
1700
+ new PointerEvent("pointerup", {
1701
+ pointerId: 1,
1702
+ }),
1703
+ );
1704
+ expect(handle.classList.contains("active")).toBe(false);
1705
+
1706
+ // Double click resets height
1707
+ handle.dispatchEvent(new MouseEvent("dblclick"));
1708
+ expect(inputArea.classList.contains("chat__input-area--resized")).toBe(
1709
+ false,
1710
+ );
1711
+
1712
+ // Keydown ArrowUp, ArrowDown, Home, End
1713
+ handle.dispatchEvent(
1714
+ new KeyboardEvent("keydown", { key: "ArrowUp", shiftKey: true }),
1715
+ );
1716
+ handle.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" }));
1717
+ handle.dispatchEvent(new KeyboardEvent("keydown", { key: "Home" }));
1718
+ handle.dispatchEvent(new KeyboardEvent("keydown", { key: "End" }));
1719
+ handle.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); // Ignored
1720
+
1721
+ inputArea.remove();
1722
+ });
1723
+
1724
+ it("handles setInputAreaHeight, resetInputAreaHeight, isCollapsedInputAreaHeight", () => {
1725
+ const inputArea = document.createElement("div");
1726
+ chatInstance.setInputAreaHeight(inputArea, 20); // Collapsed
1727
+ expect(inputArea.classList.contains("chat__input-area--resized")).toBe(
1728
+ false,
1729
+ );
1730
+
1731
+ chatInstance.setInputAreaHeight(inputArea, 120); // Expanded
1732
+ expect(inputArea.classList.contains("chat__input-area--resized")).toBe(
1733
+ true,
1734
+ );
1735
+ expect(inputArea.style.getPropertyValue("--chat-input-area-height")).toBe(
1736
+ "120px",
1737
+ );
1738
+
1739
+ chatInstance.resetInputAreaHeight(inputArea);
1740
+ expect(inputArea.classList.contains("chat__input-area--resized")).toBe(
1741
+ false,
1742
+ );
1743
+ });
1744
+
1745
+ it("handles queueing and rendering attachments correctly", async () => {
1746
+ document.body.appendChild(chatInstance);
1747
+ const root = chatInstance.shadowRoot;
1748
+
1749
+ const file = new File(["test-content"], "test.txt", {
1750
+ type: "text/plain",
1751
+ });
1752
+ const input = root.querySelector(".chat__attachment-input");
1753
+ Object.defineProperty(input, "files", {
1754
+ value: [file],
1755
+ writable: true,
1756
+ });
1757
+
1758
+ await chatInstance.queueSelectedFiles(input);
1759
+
1760
+ const pendingContainer = root.querySelector(".chat__pending-attachments");
1761
+ expect(pendingContainer.hidden).toBe(false);
1762
+ expect(
1763
+ pendingContainer.querySelectorAll(".chat__pending-attachment").length,
1764
+ ).toBe(1);
1765
+
1766
+ // Remove attachment chip
1767
+ const removeBtn = pendingContainer.querySelector(
1768
+ ".chat__pending-attachment-remove",
1769
+ );
1770
+ removeBtn?.dispatchEvent(new MouseEvent("click"));
1771
+ expect(pendingContainer.hidden).toBe(true);
1772
+
1773
+ // Queue dropped data with files and plain text
1774
+ const dropFile = new File(["img-data"], "photo.png", {
1775
+ type: "image/png",
1776
+ });
1777
+ const dataTransfer: any = {
1778
+ files: [dropFile],
1779
+ types: ["Files", "text/plain"],
1780
+ items: [
1781
+ {
1782
+ kind: "string",
1783
+ type: "text/plain",
1784
+ getAsString: (cb: any) => cb("pasted text"),
1785
+ },
1786
+ ],
1787
+ };
1788
+
1789
+ await chatInstance.queueDroppedData(dataTransfer);
1790
+ expect(
1791
+ pendingContainer.querySelectorAll(".chat__pending-attachment").length,
1792
+ ).toBe(2);
1793
+
1794
+ // Empty drop data
1795
+ await chatInstance.queueDroppedData(null);
1796
+ });
1797
+
1798
+ it("evaluates attachment subtitle, icons, transport labels, and model support", () => {
1799
+ const caps = {
1800
+ images: true,
1801
+ audio: false,
1802
+ video: false,
1803
+ documents: true,
1804
+ routerByFeatures: false,
1805
+ };
1806
+
1807
+ expect(
1808
+ chatInstance.formatAttachmentSubtitle({
1809
+ id: "1",
1810
+ fileName: "a.png",
1811
+ mimeType: "image/png",
1812
+ size: 1024,
1813
+ }),
1814
+ ).toBe("image/png · 1.0 KB");
1815
+
1816
+ expect(
1817
+ chatInstance.formatAttachmentSubtitle({
1818
+ id: "2",
1819
+ fileName: "a.bin",
1820
+ }),
1821
+ ).toBe("Attachment");
1822
+
1823
+ expect(chatInstance.getAttachmentIcon("image/jpeg")).toBe("IMG");
1824
+ expect(chatInstance.getAttachmentIcon("video/webm")).toBe("VID");
1825
+ expect(chatInstance.getAttachmentIcon("audio/ogg")).toBe("AUD");
1826
+ expect(chatInstance.getAttachmentIcon("application/pdf")).toBe("PDF");
1827
+ expect(chatInstance.getAttachmentIcon("application/x-tar")).toBe("ZIP");
1828
+ expect(chatInstance.getAttachmentIcon("unknown/binary")).toBe("BIN");
1829
+
1830
+ expect(
1831
+ chatInstance.getAttachmentTransportLabel(
1832
+ "application/custom",
1833
+ "file.custom",
1834
+ caps,
1835
+ ),
1836
+ ).toBe("fallback");
1837
+
1838
+ expect(
1839
+ chatInstance.inferAttachmentModelSupport([
1840
+ { id: "1", fileName: "a.txt", mimeType: "text/plain" },
1841
+ { id: "2", fileName: "b.unknown", mimeType: "application/unknown" },
1842
+ ]),
1843
+ ).toBe(false);
1844
+
1845
+ expect(chatInstance.isInlineTextMimeType("application/javascript")).toBe(
1846
+ true,
1847
+ );
1848
+ expect(chatInstance.isInlineTextMimeType("application/xml")).toBe(true);
1849
+ expect(chatInstance.isInlineTextMimeType("image/png")).toBe(false);
1850
+ });
1851
+
1852
+ it("renders streaming bubble with formatting and handles updates", async () => {
1853
+ document.body.appendChild(chatInstance);
1854
+ const root = chatInstance.shadowRoot;
1855
+ const messages = root.querySelector(".chat__messages");
1856
+
1857
+ // Start streaming with text containing newlines, backticks, bold
1858
+ await chatInstance.renderStreamingBubble(
1859
+ "Hello\nWorld `code snippet` and **bold text**",
1860
+ );
1861
+
1862
+ const streamMsg = messages.querySelector(".chat__message--streaming");
1863
+ expect(streamMsg).toBeTruthy();
1864
+ expect(streamMsg?.innerHTML).toContain("<code>code snippet</code>");
1865
+ expect(streamMsg?.innerHTML).toContain("<b>bold text</b>");
1866
+ expect(streamMsg?.innerHTML).toContain("<br>");
1867
+
1868
+ // Update streaming bubble
1869
+ await chatInstance.renderStreamingBubble("Updated stream chunk");
1870
+ expect(streamMsg?.textContent).toContain("Updated stream chunk");
1871
+
1872
+ // End streaming with null
1873
+ await chatInstance.renderStreamingBubble(null);
1874
+ expect(messages.querySelector(".chat__message--streaming")).toBeNull();
1875
+ });
1876
+
1877
+ it("handles draft payload generation and budget checks", async () => {
1878
+ document.body.appendChild(chatInstance);
1879
+
1880
+ // 1. Without attachments
1881
+ const draft1 = await chatInstance.buildMessageDraftPayload("Hello agent");
1882
+ expect(draft1?.text).toBe("Hello agent");
1883
+ expect(draft1?.attachments).toEqual([]);
1884
+
1885
+ // 2. With small text file attachment
1886
+ const smallText: any = new File(["const x = 10;"], "code.js", {
1887
+ type: "application/javascript",
1888
+ });
1889
+ smallText.text = async () => "const x = 10;";
1890
+ const input = chatInstance.shadowRoot.querySelector(
1891
+ ".chat__attachment-input",
1892
+ );
1893
+ Object.defineProperty(input, "files", {
1894
+ value: [smallText],
1895
+ writable: true,
1896
+ });
1897
+ await chatInstance.queueSelectedFiles(input);
1898
+
1899
+ const draft2 = await chatInstance.buildMessageDraftPayload("Review this");
1900
+ expect(draft2?.text).toContain("Attached files:");
1901
+ expect(draft2?.text).toContain("Attached text excerpts:");
1902
+ expect(draft2?.text).toContain("const x = 10;");
1903
+
1904
+ // 3. Binary attachment with dialog prompt
1905
+ chatInstance.showAttachmentDialog = (jest.fn() as any).mockResolvedValue(
1906
+ false,
1907
+ );
1908
+ const binaryFile = new File([new Uint8Array(500)], "blob.bin", {
1909
+ type: "application/octet-stream",
1910
+ });
1911
+ Object.defineProperty(input, "files", {
1912
+ value: [binaryFile],
1913
+ writable: true,
1914
+ });
1915
+ await chatInstance.queueSelectedFiles(input);
1916
+
1917
+ const draft3Cancelled =
1918
+ await chatInstance.buildMessageDraftPayload("Process binary");
1919
+ expect(draft3Cancelled).toBeNull();
1920
+ });
1921
+
1922
+ it("handles button clicks, message links, and textarea shortcuts", async () => {
1923
+ document.body.appendChild(chatInstance);
1924
+ await new Promise((r) => setTimeout(r, 25));
1925
+ const root = chatInstance.shadowRoot;
1926
+
1927
+ const textarea = root.querySelector(
1928
+ ".chat__input",
1929
+ ) as HTMLTextAreaElement;
1930
+ const sendBtn = root.querySelector('[data-action="send-message"]');
1931
+ const clearBtn = root.querySelector('[data-action="clear-chat"]');
1932
+ const stopBtn = root.querySelector('[data-action="stop-chat"]');
1933
+ const compactBtn = root.querySelector('[data-action="compact-chat"]');
1934
+ const attachBtn = root.querySelector('[data-action="attach-files"]');
1935
+ const restoreBtn = root.querySelector('[data-action="restore-chat"]');
1936
+
1937
+ const sendSpy = jest
1938
+ .spyOn(chatInstance, "sendMessage")
1939
+ .mockImplementation(async () => {});
1940
+ const clearSpy = jest
1941
+ .spyOn(chatInstance, "handleClearChat")
1942
+ .mockImplementation(async () => {});
1943
+ const stopSpy = jest
1944
+ .spyOn(chatInstance, "handleStopChat")
1945
+ .mockImplementation(async () => {});
1946
+ const compactSpy = jest
1947
+ .spyOn(chatInstance, "handleCompactChat")
1948
+ .mockImplementation(async () => {});
1949
+
1950
+ // Textarea keydown
1951
+ textarea.dispatchEvent(
1952
+ new KeyboardEvent("keydown", { key: "Enter", shiftKey: false }),
1953
+ );
1954
+ expect(sendSpy).toHaveBeenCalled();
1955
+
1956
+ textarea.dispatchEvent(
1957
+ new KeyboardEvent("keydown", {
1958
+ key: "Enter",
1959
+ ctrlKey: true,
1960
+ shiftKey: true,
1961
+ }),
1962
+ );
1963
+ expect(sendSpy).toHaveBeenCalledTimes(2);
1964
+
1965
+ textarea.dispatchEvent(
1966
+ new KeyboardEvent("keydown", { key: "Enter", shiftKey: true }),
1967
+ );
1968
+ expect(sendSpy).toHaveBeenCalledTimes(2);
1969
+
1970
+ // Buttons
1971
+ sendBtn?.dispatchEvent(new MouseEvent("click"));
1972
+ expect(sendSpy).toHaveBeenCalledTimes(3);
1973
+
1974
+ clearBtn?.dispatchEvent(new MouseEvent("click"));
1975
+ expect(clearSpy).toHaveBeenCalled();
1976
+
1977
+ stopBtn?.dispatchEvent(new MouseEvent("click"));
1978
+ expect(stopSpy).toHaveBeenCalled();
1979
+
1980
+ compactBtn?.dispatchEvent(new MouseEvent("click"));
1981
+ expect(compactSpy).toHaveBeenCalled();
1982
+
1983
+ const attachmentInput = root.querySelector(
1984
+ ".chat__attachment-input",
1985
+ ) as HTMLInputElement;
1986
+ attachmentInput.click = jest.fn();
1987
+ attachBtn?.dispatchEvent(new MouseEvent("click"));
1988
+ expect(attachmentInput.click).toHaveBeenCalled();
1989
+
1990
+ const restoreInput = root.querySelector(
1991
+ ".chat__restore-input",
1992
+ ) as HTMLInputElement;
1993
+ restoreInput.click = jest.fn();
1994
+ restoreBtn?.dispatchEvent(new MouseEvent("click"));
1995
+ expect(restoreInput.click).toHaveBeenCalled();
1996
+
1997
+ sendSpy.mockRestore();
1998
+ clearSpy.mockRestore();
1999
+ stopSpy.mockRestore();
2000
+ compactSpy.mockRestore();
2001
+ });
2002
+
2003
+ it("handles drag & drop events on chat body", async () => {
2004
+ document.body.appendChild(chatInstance);
2005
+ await new Promise((r) => setTimeout(r, 25));
2006
+ const root = chatInstance.shadowRoot;
2007
+ const chatBody = root.querySelector(".chat__body");
2008
+
2009
+ const queueSpy = jest
2010
+ .spyOn(chatInstance, "queueDroppedData")
2011
+ .mockImplementation(async () => {});
2012
+
2013
+ const validTransfer: any = {
2014
+ types: ["Files"],
2015
+ files: [new File([""], "doc.txt")],
2016
+ };
2017
+
2018
+ chatBody?.dispatchEvent(
2019
+ new DragEvent("dragenter", { dataTransfer: validTransfer }),
2020
+ );
2021
+ chatBody?.dispatchEvent(
2022
+ new DragEvent("dragover", { dataTransfer: validTransfer }),
2023
+ );
2024
+ chatBody?.dispatchEvent(
2025
+ new DragEvent("dragleave", { dataTransfer: validTransfer }),
2026
+ );
2027
+ chatBody?.dispatchEvent(
2028
+ new DragEvent("drop", { dataTransfer: validTransfer }),
2029
+ );
2030
+
2031
+ expect(queueSpy).toHaveBeenCalled();
2032
+ queueSpy.mockRestore();
2033
+ });
2034
+
2035
+ it("handles A2UI action routing for room, peer, and standard groups", async () => {
2036
+ document.body.appendChild(chatInstance);
2037
+ await new Promise((r) => setTimeout(r, 25));
2038
+ const root = chatInstance.shadowRoot;
2039
+ const messagesEl = root.querySelector(".chat__messages");
2040
+
2041
+ const mockRouteRoom = jest.fn();
2042
+ const mockSendPeer = jest.fn();
2043
+ const mockSendMessage = jest.fn();
2044
+
2045
+ const orch = {
2046
+ routeRoomA2UIAction: mockRouteRoom,
2047
+ router: {
2048
+ findChannel: () => ({ sendA2UIAction: mockSendPeer }),
2049
+ },
2050
+ };
2051
+
2052
+ currentOrchestratorStore.orchestrator = orch;
2053
+ currentOrchestratorStore.sendMessage = mockSendMessage;
2054
+
2055
+ // 1. Room A2UI action
2056
+ messagesEl?.dispatchEvent(
2057
+ new CustomEvent("shadow-claw-a2ui-action", {
2058
+ detail: {
2059
+ groupId: "room:general",
2060
+ action: { surfaceId: "s1", actionId: "submit", dataModel: {} },
2061
+ },
2062
+ }),
2063
+ );
2064
+ await new Promise((r) => setTimeout(r, 10));
2065
+ expect(mockRouteRoom).toHaveBeenCalledWith(
2066
+ "room:general",
2067
+ expect.any(Object),
2068
+ );
2069
+
2070
+ // 2. Peer A2UI action
2071
+ messagesEl?.dispatchEvent(
2072
+ new CustomEvent("shadow-claw-a2ui-action", {
2073
+ detail: {
2074
+ groupId: "peer:bob",
2075
+ action: { surfaceId: "s2", actionId: "click", dataModel: {} },
2076
+ },
2077
+ }),
2078
+ );
2079
+ await new Promise((r) => setTimeout(r, 10));
2080
+ expect(mockSendPeer).toHaveBeenCalledWith("peer:bob", expect.any(Object));
2081
+
2082
+ // 3. Local group A2UI action
2083
+ messagesEl?.dispatchEvent(
2084
+ new CustomEvent("shadow-claw-a2ui-action", {
2085
+ detail: {
2086
+ groupId: "main",
2087
+ action: {
2088
+ surfaceId: "s3",
2089
+ actionId: "calc",
2090
+ dataModel: { count: 5 },
2091
+ },
2092
+ },
2093
+ }),
2094
+ );
2095
+ await new Promise((r) => setTimeout(r, 10));
2096
+ expect(mockSendMessage).toHaveBeenCalledWith(
2097
+ expect.stringContaining("[A2UI ACTION]"),
2098
+ [],
2099
+ expect.any(Object),
2100
+ );
2101
+
2102
+ // 4. Function response event
2103
+ messagesEl?.dispatchEvent(
2104
+ new CustomEvent("shadow-claw-a2ui-function-response", {
2105
+ detail: {
2106
+ groupId: "peer:bob",
2107
+ response: { result: "ok" },
2108
+ },
2109
+ }),
2110
+ );
2111
+ await new Promise((r) => setTimeout(r, 10));
2112
+ expect(mockSendPeer).toHaveBeenCalledWith("peer:bob", { result: "ok" });
2113
+ });
2114
+
2115
+ it("handles downloadChat and restoreChat error and success paths", async () => {
2116
+ document.body.appendChild(chatInstance);
2117
+ chatInstance.db = mockDbInstance;
2118
+
2119
+ const { exportChatData } = await import("../../db/exportChatData.js");
2120
+ const { importChatData } = await import("../../db/importChatData.js");
2121
+ const JSZip = (await import("jszip")).default as any;
2122
+
2123
+ global.URL.createObjectURL = jest
2124
+ .fn<(obj: Blob | MediaSource) => string>()
2125
+ .mockReturnValue("blob:mock-zip");
2126
+ global.URL.revokeObjectURL = jest.fn<(url: string) => void>();
2127
+
2128
+ // 1. downloadChat without db
2129
+ chatInstance.db = null;
2130
+ await chatInstance.downloadChat();
2131
+
2132
+ // 2. downloadChat when exportChatData succeeds
2133
+ chatInstance.db = mockDbInstance;
2134
+ (exportChatData as jest.MockedFunction<any>).mockResolvedValueOnce({
2135
+ messages: [{ id: "1", content: "hello" }],
2136
+ version: 1,
2137
+ });
2138
+ await chatInstance.downloadChat();
2139
+
2140
+ // 3. downloadChat when exportChatData returns null
2141
+ (exportChatData as jest.MockedFunction<any>).mockResolvedValueOnce(null);
2142
+ await chatInstance.downloadChat();
2143
+
2144
+ // 4. downloadChat when error is thrown
2145
+ (exportChatData as jest.MockedFunction<any>).mockRejectedValueOnce(
2146
+ new Error("disk full"),
2147
+ );
2148
+ await chatInstance.downloadChat();
2149
+
2150
+ // 5. restoreChat without db
2151
+ chatInstance.db = null;
2152
+ const inputNoDb = document.createElement("input");
2153
+ await chatInstance.restoreChat(inputNoDb);
2154
+
2155
+ // 6. restoreChat without files
2156
+ chatInstance.db = mockDbInstance;
2157
+ const inputNoFiles = document.createElement("input");
2158
+ await chatInstance.restoreChat(inputNoFiles);
2159
+
2160
+ // 7. restoreChat with non-zip
2161
+ const invalidFileInput = document.createElement("input");
2162
+ Object.defineProperty(invalidFileInput, "files", {
2163
+ value: [new File([""], "notes.txt")],
2164
+ });
2165
+ await chatInstance.restoreChat(invalidFileInput);
2166
+ expect(invalidFileInput.value).toBe("");
2167
+
2168
+ // 8. restoreChat with valid zip
2169
+ const validFileInput = document.createElement("input");
2170
+ Object.defineProperty(validFileInput, "files", {
2171
+ value: [new File([""], "backup.zip")],
2172
+ });
2173
+ jest.spyOn(orchestratorStore, "loadHistory").mockResolvedValue(undefined);
2174
+ await chatInstance.restoreChat(validFileInput);
2175
+ expect(importChatData).toHaveBeenCalled();
2176
+
2177
+ // 9. restoreChat missing chat-data.json
2178
+ const origLoadAsync = JSZip.loadAsync;
2179
+ JSZip.loadAsync = jest
2180
+ .fn<(...args: any[]) => Promise<any>>()
2181
+ .mockResolvedValue({
2182
+ file: () => null,
2183
+ });
2184
+ const inputNoData = document.createElement("input");
2185
+ Object.defineProperty(inputNoData, "files", {
2186
+ value: [new File([""], "empty.zip")],
2187
+ });
2188
+ await chatInstance.restoreChat(inputNoData);
2189
+
2190
+ // 10. restoreChat invalid messages array
2191
+ JSZip.loadAsync = jest
2192
+ .fn<(...args: any[]) => Promise<any>>()
2193
+ .mockResolvedValue({
2194
+ file: () => ({
2195
+ async: async () => JSON.stringify({ notAnArray: true }),
2196
+ }),
2197
+ });
2198
+ const inputBadData = document.createElement("input");
2199
+ Object.defineProperty(inputBadData, "files", {
2200
+ value: [new File([""], "baddata.zip")],
2201
+ });
2202
+ await chatInstance.restoreChat(inputBadData);
2203
+
2204
+ // 11. restoreChat throws error
2205
+ JSZip.loadAsync = jest
2206
+ .fn<(...args: any[]) => Promise<any>>()
2207
+ .mockRejectedValue(new Error("corrupted zip"));
2208
+ const inputCorrupt = document.createElement("input");
2209
+ Object.defineProperty(inputCorrupt, "files", {
2210
+ value: [new File([""], "corrupt.zip")],
2211
+ });
2212
+ await chatInstance.restoreChat(inputCorrupt);
2213
+
2214
+ JSZip.loadAsync = origLoadAsync;
2215
+ });
2216
+
2217
+ it("handles downloadAttachment and openAttachment errors and edge cases", async () => {
2218
+ // 1. Without db
2219
+ chatInstance.db = null;
2220
+ await chatInstance.downloadAttachment("main", {
2221
+ id: "1",
2222
+ fileName: "a.txt",
2223
+ });
2224
+ await chatInstance.openAttachment("main", { id: "1", fileName: "a.txt" });
2225
+
2226
+ // 2. With db but without path
2227
+ chatInstance.db = mockDbInstance;
2228
+ await chatInstance.downloadAttachment("main", {
2229
+ id: "1",
2230
+ fileName: "a.txt",
2231
+ });
2232
+ await chatInstance.openAttachment("main", { id: "1", fileName: "a.txt" });
2233
+
2234
+ // 3. With path and error thrown
2235
+ const { downloadGroupFile } =
2236
+ await import("../../storage/downloadGroupFile.js");
2237
+ const { fileViewerStore } = await import("../../stores/file-viewer.js");
2238
+
2239
+ (downloadGroupFile as jest.MockedFunction<any>).mockRejectedValueOnce(
2240
+ new Error("download error"),
2241
+ );
2242
+ await chatInstance.downloadAttachment("main", {
2243
+ id: "1",
2244
+ fileName: "a.txt",
2245
+ path: "a.txt",
2246
+ });
2247
+
2248
+ (
2249
+ fileViewerStore.openFile as jest.MockedFunction<any>
2250
+ ).mockRejectedValueOnce(new Error("open error"));
2251
+ await chatInstance.openAttachment("main", {
2252
+ id: "1",
2253
+ fileName: "a.txt",
2254
+ path: "a.txt",
2255
+ });
2256
+ });
2257
+
2258
+ it("handles showAttachmentDialog fallbacks with and without host", async () => {
2259
+ // 1. Without shadow-claw host in DOM
2260
+ const infoResult = await chatInstance.showAttachmentDialog({
2261
+ mode: "info",
2262
+ title: "Info Dialog",
2263
+ message: "Some info message",
2264
+ });
2265
+ expect(infoResult).toBe(true);
2266
+
2267
+ const confirmResult = await chatInstance.showAttachmentDialog({
2268
+ mode: "confirm",
2269
+ title: "Confirm Dialog",
2270
+ message: "Are you sure?",
2271
+ });
2272
+ expect(confirmResult).toBe(false);
2273
+
2274
+ // 2. With shadow-claw host in DOM
2275
+ const host = document.createElement("shadow-claw") as any;
2276
+ host.requestDialog = (jest.fn() as any).mockResolvedValue(true);
2277
+ document.body.appendChild(host);
2278
+
2279
+ const hostResult = await chatInstance.showAttachmentDialog({
2280
+ mode: "confirm",
2281
+ title: "Confirm",
2282
+ message: "Test",
2283
+ });
2284
+ expect(hostResult).toBe(true);
2285
+ expect(host.requestDialog).toHaveBeenCalled();
2286
+ host.remove();
2287
+ });
2288
+
2289
+ it("handles sendMessage execution, initializing guard, and errors", async () => {
2290
+ document.body.appendChild(chatInstance);
2291
+ const root = chatInstance.shadowRoot;
2292
+ const input = root.querySelector(".chat__input") as HTMLTextAreaElement;
2293
+
2294
+ // 1. Empty message and no queued attachments
2295
+ input.value = " ";
2296
+ await chatInstance.sendMessage();
2297
+
2298
+ // 2. Ready is false
2299
+ input.value = "hello";
2300
+ currentOrchestratorStore.ready = false;
2301
+ await chatInstance.sendMessage();
2302
+
2303
+ // 3. Ready is true - successful send
2304
+ currentOrchestratorStore.ready = true;
2305
+ currentOrchestratorStore.sendMessage = jest.fn();
2306
+ await chatInstance.sendMessage();
2307
+
2308
+ expect(currentOrchestratorStore.sendMessage).toHaveBeenCalledWith(
2309
+ "hello",
2310
+ [],
2311
+ );
2312
+ expect(input.value).toBe("");
2313
+
2314
+ // 4. Draft generation returns null
2315
+ input.value = "draft fail";
2316
+ chatInstance.buildMessageDraftPayload = (
2317
+ jest.fn() as any
2318
+ ).mockResolvedValue(null);
2319
+ await chatInstance.sendMessage();
2320
+
2321
+ // 5. Send message error
2322
+ input.value = "error message";
2323
+ chatInstance.buildMessageDraftPayload = (
2324
+ jest.fn() as any
2325
+ ).mockResolvedValue({ text: "error", attachments: [] });
2326
+ currentOrchestratorStore.sendMessage = jest
2327
+ .fn()
2328
+ .mockImplementation(() => {
2329
+ throw new Error("send failure");
2330
+ });
2331
+ await chatInstance.sendMessage();
2332
+ });
2333
+
2334
+ it("handles handleMessageLinkClick navigation and modifier keys", async () => {
2335
+ const container = document.createElement("div");
2336
+ const anchor = document.createElement("a");
2337
+ anchor.setAttribute("href", "docs/guide.md");
2338
+ container.appendChild(anchor);
2339
+
2340
+ const { handleSpecialLinkNavigation } =
2341
+ await import("../../utils/utils.js");
2342
+ (handleSpecialLinkNavigation as jest.MockedFunction<any>).mockReturnValue(
2343
+ true,
2344
+ );
2345
+
2346
+ const evt1 = new MouseEvent("click", { button: 0, cancelable: true });
2347
+ anchor.dispatchEvent(evt1);
2348
+ await chatInstance.handleMessageLinkClick(evt1);
2349
+ expect(evt1.defaultPrevented).toBe(true);
2350
+
2351
+ // With modifier key (e.g. metaKey)
2352
+ const evt2 = new MouseEvent("click", {
2353
+ button: 0,
2354
+ metaKey: true,
2355
+ cancelable: true,
2356
+ });
2357
+ await chatInstance.handleMessageLinkClick(evt2);
2358
+ expect(evt2.defaultPrevented).toBe(false);
2359
+
2360
+ // Middle click
2361
+ const evt3 = new MouseEvent("click", { button: 1, cancelable: true });
2362
+ await chatInstance.handleMessageLinkClick(evt3);
2363
+
2364
+ // Non anchor target
2365
+ const evt4 = new MouseEvent("click", { button: 0, cancelable: true });
2366
+ container.dispatchEvent(evt4);
2367
+ await chatInstance.handleMessageLinkClick(evt4);
2368
+ });
2369
+
2370
+ it("handles resolveImagePaths for workspace images and PDFs", async () => {
2371
+ chatInstance.db = mockDbInstance;
2372
+ const container = document.createElement("div");
2373
+ container.innerHTML = `
2374
+ <img data-inline-workspace-src="photo.png" alt="photo">
2375
+ <img data-inline-workspace-src="photo.svg" alt="photo">
2376
+ <img data-inline-workspace-src="doc.pdf" alt="doc">
2377
+ <a href="manual.pdf">User Manual</a>
2378
+ `;
2379
+
2380
+ await chatInstance.resolveImagePaths("main", container);
2381
+
2382
+ expect(container.querySelector("shadow-claw-pdf-viewer")).toBeTruthy();
2383
+ const img = container.querySelector("img") as HTMLImageElement;
2384
+ if (img) {
2385
+ img.dispatchEvent(new Event("load"));
2386
+ }
2387
+ });
2388
+
2389
+ it("handles renderInlineAttachmentPreview and renderMessageAttachments", async () => {
2390
+ chatInstance.db = mockDbInstance;
2391
+
2392
+ const msg = {
2393
+ id: "m1",
2394
+ groupId: "main",
2395
+ attachments: [
2396
+ {
2397
+ id: "a1",
2398
+ fileName: "screenshot.png",
2399
+ mimeType: "image/png",
2400
+ path: "screenshot.png",
2401
+ previewDisposition: "inline",
2402
+ },
2403
+ {
2404
+ id: "a2",
2405
+ fileName: "data.csv",
2406
+ mimeType: "text/csv",
2407
+ path: "data.csv",
2408
+ previewDisposition: "file",
2409
+ },
2410
+ ],
2411
+ } as any;
2412
+
2413
+ const preview = await chatInstance.renderInlineAttachmentPreview(
2414
+ msg,
2415
+ msg.attachments[0],
2416
+ );
2417
+ expect(preview).toBeTruthy();
2418
+
2419
+ const renderedAttachments =
2420
+ await chatInstance.renderMessageAttachments(msg);
2421
+ expect(renderedAttachments).toBeTruthy();
2422
+ expect(
2423
+ renderedAttachments?.querySelectorAll(".chat__attachment").length,
2424
+ ).toBe(2);
2425
+
2426
+ // Null attachments
2427
+ expect(
2428
+ await chatInstance.renderMessageAttachments({ id: "m2" } as any),
2429
+ ).toBeNull();
2430
+ });
2431
+
2432
+ it("renders code copy buttons and handles copy clicks", async () => {
2433
+ const container = document.createElement("div");
2434
+ container.innerHTML = "<pre><code>console.log('test')</code></pre>";
2435
+
2436
+ Object.assign(navigator, {
2437
+ clipboard: {
2438
+ writeText: jest
2439
+ .fn<(text: string) => Promise<void>>()
2440
+ .mockResolvedValue(undefined),
2441
+ },
2442
+ });
2443
+
2444
+ chatInstance.injectCopyButtons(container);
2445
+
2446
+ const copyBtn = container.querySelector(
2447
+ ".chat__code-copy-btn",
2448
+ ) as HTMLButtonElement;
2449
+ expect(copyBtn).toBeTruthy();
2450
+
2451
+ copyBtn.click();
2452
+ await new Promise((r) => setTimeout(r, 10));
2453
+
2454
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
2455
+ "console.log('test')",
2456
+ );
2457
+
2458
+ // Failure branch
2459
+ Object.assign(navigator, {
2460
+ clipboard: {
2461
+ writeText: jest
2462
+ .fn<(text: string) => Promise<void>>()
2463
+ .mockRejectedValue(new Error("denied")),
2464
+ },
2465
+ });
2466
+ copyBtn.click();
2467
+ await new Promise((r) => setTimeout(r, 10));
2468
+ });
2469
+
2470
+ it("handles setupEffects message rendering and all reactive panels", async () => {
2471
+ document.body.appendChild(chatInstance);
2472
+ chatInstance.db = mockDbInstance;
2473
+ await new Promise((r) => setTimeout(r, 25));
2474
+
2475
+ const root = chatInstance.shadowRoot;
2476
+
2477
+ // 1. Token usage accumulator
2478
+ Object.defineProperty(currentOrchestratorStore, "tokenUsageAccumulator", {
2479
+ get: () => ({
2480
+ inputTokens: 150,
2481
+ outputTokens: 75,
2482
+ cacheReadTokens: 50,
2483
+ totalTokens: 225,
2484
+ }),
2485
+ configurable: true,
2486
+ });
2487
+ chatInstance.setupEffects();
2488
+ const tokenUsageEl = root.querySelector(".chat__token-usage");
2489
+ expect(
2490
+ tokenUsageEl.classList.contains("chat__token-usage--visible"),
2491
+ ).toBe(true);
2492
+
2493
+ // 2. Token usage reset
2494
+ Object.defineProperty(currentOrchestratorStore, "tokenUsageAccumulator", {
2495
+ get: () => null,
2496
+ configurable: true,
2497
+ });
2498
+ chatInstance.setupEffects();
2499
+ expect(
2500
+ tokenUsageEl.classList.contains("chat__token-usage--visible"),
2501
+ ).toBe(false);
2502
+
2503
+ // 3. Context usage
2504
+ Object.defineProperty(currentOrchestratorStore, "contextUsage", {
2505
+ get: () => ({
2506
+ estimatedTokens: 900,
2507
+ contextLimit: 1000,
2508
+ usagePercent: 90,
2509
+ truncatedCount: 2,
2510
+ }),
2511
+ configurable: true,
2512
+ });
2513
+ chatInstance.setupEffects();
2514
+ const contextUsageEl = root.querySelector(".chat__context-usage");
2515
+ expect(
2516
+ contextUsageEl.classList.contains("chat__context-usage--visible"),
2517
+ ).toBe(true);
2518
+
2519
+ Object.defineProperty(currentOrchestratorStore, "contextUsage", {
2520
+ get: () => null,
2521
+ configurable: true,
2522
+ });
2523
+ chatInstance.setupEffects();
2524
+ expect(
2525
+ contextUsageEl.classList.contains("chat__context-usage--visible"),
2526
+ ).toBe(false);
2527
+
2528
+ // 4. AGUI and Tool activity
2529
+ Object.defineProperty(currentOrchestratorStore, "aguiEvent", {
2530
+ get: () => ({
2531
+ event: { type: "TOOL_CALL_START", toolCallName: "file_search" },
2532
+ }),
2533
+ configurable: true,
2534
+ });
2535
+ chatInstance.setupEffects();
2536
+ const toolEl = root.querySelector(".chat__tool-activity");
2537
+ expect(toolEl.classList.contains("chat__tool-activity--active")).toBe(
2538
+ true,
2539
+ );
2540
+
2541
+ Object.defineProperty(currentOrchestratorStore, "aguiEvent", {
2542
+ get: () => null,
2543
+ configurable: true,
2544
+ });
2545
+ Object.defineProperty(currentOrchestratorStore, "toolActivity", {
2546
+ get: () => ({ tool: "bash" }),
2547
+ configurable: true,
2548
+ });
2549
+ chatInstance.setupEffects();
2550
+ expect(toolEl.textContent).toContain("bash");
2551
+
2552
+ Object.defineProperty(currentOrchestratorStore, "toolActivity", {
2553
+ get: () => null,
2554
+ configurable: true,
2555
+ });
2556
+ chatInstance.setupEffects();
2557
+ expect(toolEl.classList.contains("chat__tool-activity--active")).toBe(
2558
+ false,
2559
+ );
2560
+
2561
+ // 5. Model download progress
2562
+ Object.defineProperty(currentOrchestratorStore, "modelDownloadProgress", {
2563
+ get: () => ({ progress: 0.8, message: "Loading model..." }),
2564
+ configurable: true,
2565
+ });
2566
+ chatInstance.setupEffects();
2567
+ const modelProgressEl = root.querySelector(".chat__model-progress");
2568
+ expect(
2569
+ modelProgressEl.classList.contains("chat__model-progress--active"),
2570
+ ).toBe(true);
2571
+
2572
+ Object.defineProperty(currentOrchestratorStore, "modelDownloadProgress", {
2573
+ get: () => null,
2574
+ configurable: true,
2575
+ });
2576
+ chatInstance.setupEffects();
2577
+ expect(
2578
+ modelProgressEl.classList.contains("chat__model-progress--active"),
2579
+ ).toBe(false);
2580
+
2581
+ // 6. Activity log
2582
+ Object.defineProperty(currentOrchestratorStore, "activityLog", {
2583
+ get: () => [
2584
+ { level: "info", label: "Planner", message: "Starting plan" },
2585
+ { level: "warn", label: "Tool", message: "Retry attempt" },
2586
+ ],
2587
+ configurable: true,
2588
+ });
2589
+ chatInstance.setupEffects();
2590
+ const actLogEl = root.querySelector(".chat__activity-log");
2591
+ expect(actLogEl.classList.contains("chat__activity-log--active")).toBe(
2592
+ true,
2593
+ );
2594
+ const copyActBtn = actLogEl.querySelector(
2595
+ ".chat__activity-log__copy-btn",
2596
+ ) as HTMLButtonElement;
2597
+ copyActBtn?.click();
2598
+
2599
+ Object.defineProperty(currentOrchestratorStore, "activityLog", {
2600
+ get: () => [],
2601
+ configurable: true,
2602
+ });
2603
+ chatInstance.setupEffects();
2604
+ expect(actLogEl.classList.contains("chat__activity-log--active")).toBe(
2605
+ false,
2606
+ );
2607
+
2608
+ // 7. Shared state
2609
+ currentOrchestratorStore.getPeerState = jest
2610
+ .fn()
2611
+ .mockReturnValue({ topic: "AI" });
2612
+ chatInstance.setupEffects();
2613
+ const sharedStateEl = root.querySelector(".chat__shared-state");
2614
+ expect(sharedStateEl.hidden).toBe(false);
2615
+
2616
+ currentOrchestratorStore.getPeerState = jest.fn().mockReturnValue({});
2617
+ chatInstance.setupEffects();
2618
+ expect(sharedStateEl.hidden).toBe(true);
2619
+
2620
+ // 8. Orchestrator states: thinking, error
2621
+ Object.defineProperty(currentOrchestratorStore, "state", {
2622
+ get: () => "thinking",
2623
+ configurable: true,
2624
+ });
2625
+ chatInstance.setupEffects();
2626
+ const statusText = root.querySelector(".chat__status-text");
2627
+ expect(statusText.textContent).toBe("Thinking");
2628
+
2629
+ Object.defineProperty(currentOrchestratorStore, "state", {
2630
+ get: () => "error",
2631
+ configurable: true,
2632
+ });
2633
+ chatInstance.setupEffects();
2634
+ expect(statusText.textContent).toBe("Error");
2635
+
2636
+ // 9. Error toast
2637
+ currentOrchestratorStore.clearError = jest.fn();
2638
+ Object.defineProperty(currentOrchestratorStore, "error", {
2639
+ get: () => "Connection lost",
2640
+ configurable: true,
2641
+ });
2642
+ chatInstance.setupEffects();
2643
+ expect(currentOrchestratorStore.clearError).toHaveBeenCalled();
2644
+
2645
+ Object.defineProperty(currentOrchestratorStore, "error", {
2646
+ get: () => null,
2647
+ configurable: true,
2648
+ });
2649
+
2650
+ // 10. Messages rendering effect with user, assistant, a2ui, attachments
2651
+ Object.defineProperty(currentOrchestratorStore, "messages", {
2652
+ get: () => [
2653
+ {
2654
+ id: "m-user",
2655
+ groupId: "main",
2656
+ isFromMe: false,
2657
+ sender: "User",
2658
+ content: "Hello assistant",
2659
+ timestamp: 1700000000000,
2660
+ },
2661
+ {
2662
+ id: "m-asst",
2663
+ groupId: "main",
2664
+ isFromMe: true,
2665
+ content: "```js\nconsole.log(1)\n```\nHere is response",
2666
+ timestamp: 1700000001000,
2667
+ attachments: [
2668
+ {
2669
+ id: "att-1",
2670
+ fileName: "data.txt",
2671
+ mimeType: "text/plain",
2672
+ path: "data.txt",
2673
+ },
2674
+ ],
2675
+ a2uiEnvelopes: [{ surfaceId: "s1", component: "card" }],
2676
+ },
2677
+ {
2678
+ id: "m-a2ui",
2679
+ groupId: "main",
2680
+ isFromMe: false,
2681
+ a2uiAction: { surfaceId: "s1" },
2682
+ content: "[A2UI ACTION] dummy",
2683
+ },
2684
+ ],
2685
+ configurable: true,
2686
+ });
2687
+ chatInstance.setupEffects();
2688
+ await new Promise((r) => setTimeout(r, 60));
2689
+
2690
+ const messagesContainer = root.querySelector(".chat__messages");
2691
+ expect(
2692
+ messagesContainer.querySelectorAll(".chat__message").length,
2693
+ ).toBeGreaterThanOrEqual(2);
2694
+
2695
+ // Test copy and delete buttons injected on message
2696
+ const msgCopyBtn = messagesContainer.querySelector(
2697
+ ".chat__message-copy-btn",
2698
+ ) as HTMLButtonElement;
2699
+ msgCopyBtn?.click();
2700
+
2701
+ const msgDeleteBtn = messagesContainer.querySelector(
2702
+ ".chat__message-delete-btn",
2703
+ ) as HTMLButtonElement;
2704
+ chatInstance.showAttachmentDialog = jest
2705
+ .fn<(...args: any[]) => Promise<boolean>>()
2706
+ .mockResolvedValue(true);
2707
+ msgDeleteBtn?.click();
2708
+ });
2709
+
2710
+ it("handles restoreInputAreaHeight with saved configurations", async () => {
2711
+ document.body.appendChild(chatInstance);
2712
+
2713
+ const createConfigDb = (val: any) => ({
2714
+ transaction: jest.fn().mockReturnValue({
2715
+ objectStore: jest.fn().mockReturnValue({
2716
+ get: jest
2717
+ .fn<(...args: any[]) => any>()
2718
+ .mockImplementation((...getArgs: any[]) => {
2719
+ const key = getArgs[0];
2720
+ const req: any = {
2721
+ result: { key, value: val },
2722
+ };
2723
+ queueMicrotask(() => req.onsuccess?.({ target: req }));
2724
+ return req;
2725
+ }),
2726
+ }),
2727
+ }),
2728
+ });
2729
+
2730
+ const inputArea =
2731
+ chatInstance.shadowRoot.querySelector(".chat__input-area");
2732
+
2733
+ // 1. Without db
2734
+ chatInstance.db = null;
2735
+ await chatInstance.restoreInputAreaHeight();
2736
+
2737
+ // 2. With finite positive height > 40
2738
+ chatInstance.db = createConfigDb(120);
2739
+ await chatInstance.restoreInputAreaHeight();
2740
+ expect(inputArea.style.getPropertyValue("--chat-input-area-height")).toBe(
2741
+ "120px",
2742
+ );
2743
+
2744
+ // 3. With collapsed height (<= 40)
2745
+ chatInstance.db = createConfigDb(30);
2746
+ await chatInstance.restoreInputAreaHeight();
2747
+ expect(inputArea.style.getPropertyValue("--chat-input-area-height")).toBe(
2748
+ "",
2749
+ );
2750
+ });
2751
+
2752
+ it("handles large text attachment budget exceed and large file skips in draft", async () => {
2753
+ document.body.appendChild(chatInstance);
2754
+
2755
+ // 1. Text attachment larger than INLINE_ATTACHMENT_MAX_BYTES (128KB)
2756
+ const largeText: any = new File(["x".repeat(130 * 1024)], "large.txt", {
2757
+ type: "text/plain",
2758
+ });
2759
+ largeText.text = async () => "x".repeat(130 * 1024);
2760
+
2761
+ const input = chatInstance.shadowRoot.querySelector(
2762
+ ".chat__attachment-input",
2763
+ );
2764
+ Object.defineProperty(input, "files", {
2765
+ value: [largeText],
2766
+ writable: true,
2767
+ });
2768
+ await chatInstance.queueSelectedFiles(input);
2769
+
2770
+ const draft = await chatInstance.buildMessageDraftPayload("Prompt");
2771
+ expect(draft?.text).toContain(
2772
+ "Skipped inline content because the file is larger than",
2773
+ );
2774
+
2775
+ // 2. Budget exceeded dialog (> 80_000 chars)
2776
+ chatInstance.showAttachmentDialog = (jest.fn() as any).mockResolvedValue(
2777
+ true,
2778
+ );
2779
+ const mediumText: any = new File(["a".repeat(85000)], "med.txt", {
2780
+ type: "text/plain",
2781
+ });
2782
+ mediumText.text = async () => "a".repeat(85000);
2783
+
2784
+ Object.defineProperty(input, "files", {
2785
+ value: [mediumText],
2786
+ writable: true,
2787
+ });
2788
+ await chatInstance.queueSelectedFiles(input);
2789
+
2790
+ const draftOverBudget =
2791
+ await chatInstance.buildMessageDraftPayload("Prompt 2");
2792
+ expect(draftOverBudget).toBeNull();
2793
+ expect(chatInstance.showAttachmentDialog).toHaveBeenCalled();
2794
+ });
2795
+
2796
+ it("exercises checkPromptApiOnboarding, confirmPromptApiOnboarding, bypassPromptApiOnboardingToSettings", async () => {
2797
+ document.body.appendChild(chatInstance);
2798
+ chatInstance.db = mockDbInstance;
2799
+
2800
+ const checkSpy = jest
2801
+ .spyOn(chatInstance, "checkPromptApiOnboarding")
2802
+ .mockImplementation(async () => {});
2803
+ const confirmSpy = jest
2804
+ .spyOn(chatInstance, "confirmPromptApiOnboarding")
2805
+ .mockImplementation(async () => {});
2806
+ const bypassSpy = jest
2807
+ .spyOn(chatInstance, "bypassPromptApiOnboardingToSettings")
2808
+ .mockImplementation(async () => {});
2809
+
2810
+ await chatInstance.checkPromptApiOnboarding();
2811
+ expect(checkSpy).toHaveBeenCalled();
2812
+
2813
+ await chatInstance.confirmPromptApiOnboarding();
2814
+ expect(confirmSpy).toHaveBeenCalled();
2815
+
2816
+ await chatInstance.bypassPromptApiOnboardingToSettings();
2817
+ expect(bypassSpy).toHaveBeenCalled();
2818
+
2819
+ checkSpy.mockRestore();
2820
+ confirmSpy.mockRestore();
2821
+ bypassSpy.mockRestore();
2822
+ });
2823
+ });
1320
2824
  });