@cuongtran001/kanna 0.39.2

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 (473) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +355 -0
  3. package/bin/kanna +9 -0
  4. package/dist/client/apple-touch-icon.png +0 -0
  5. package/dist/client/assets/abap-BdImnpbu.js +1 -0
  6. package/dist/client/assets/actionscript-3-CoDkCxhg.js +1 -0
  7. package/dist/client/assets/ada-bCR0ucgS.js +1 -0
  8. package/dist/client/assets/andromeeda-C4gqWexZ.js +1 -0
  9. package/dist/client/assets/angular-html-CU67Zn6k.js +1 -0
  10. package/dist/client/assets/angular-ts-BwZT4LLn.js +1 -0
  11. package/dist/client/assets/apache-Pmp26Uib.js +1 -0
  12. package/dist/client/assets/apex-D8_7TLub.js +1 -0
  13. package/dist/client/assets/apl-dKokRX4l.js +1 -0
  14. package/dist/client/assets/applescript-Co6uUVPk.js +1 -0
  15. package/dist/client/assets/ara-BRHolxvo.js +1 -0
  16. package/dist/client/assets/asciidoc-Ve4PFQV2.js +1 -0
  17. package/dist/client/assets/asm-D_Q5rh1f.js +1 -0
  18. package/dist/client/assets/astro-CbQHKStN.js +1 -0
  19. package/dist/client/assets/aurora-x-D-2ljcwZ.js +1 -0
  20. package/dist/client/assets/awk-DMzUqQB5.js +1 -0
  21. package/dist/client/assets/ayu-dark-DYE7WIF3.js +1 -0
  22. package/dist/client/assets/ayu-light-BA47KaF1.js +1 -0
  23. package/dist/client/assets/ayu-mirage-32ctXXKs.js +1 -0
  24. package/dist/client/assets/ballerina-BFfxhgS-.js +1 -0
  25. package/dist/client/assets/bat-BkioyH1T.js +1 -0
  26. package/dist/client/assets/beancount-k_qm7-4y.js +1 -0
  27. package/dist/client/assets/berry-uYugtg8r.js +1 -0
  28. package/dist/client/assets/bibtex-CHM0blh-.js +1 -0
  29. package/dist/client/assets/bicep-Bmn6On1c.js +1 -0
  30. package/dist/client/assets/bird2-DPOp833l.js +1 -0
  31. package/dist/client/assets/blade-D4QpJJKB.js +1 -0
  32. package/dist/client/assets/bricolage-grotesque-latin-ext-wght-normal-CcLUaPy7.woff2 +0 -0
  33. package/dist/client/assets/bricolage-grotesque-latin-wght-normal-DLoelf7F.woff2 +0 -0
  34. package/dist/client/assets/bricolage-grotesque-vietnamese-wght-normal-BUzh504Q.woff2 +0 -0
  35. package/dist/client/assets/bsl-BO_Y6i37.js +1 -0
  36. package/dist/client/assets/c-BIGW1oBm.js +1 -0
  37. package/dist/client/assets/c3-eo99z4R2.js +1 -0
  38. package/dist/client/assets/cadence-Bv_4Rxtq.js +1 -0
  39. package/dist/client/assets/cairo-KRGpt6FW.js +1 -0
  40. package/dist/client/assets/catppuccin-frappe-DFWUc33u.js +1 -0
  41. package/dist/client/assets/catppuccin-latte-C9dUb6Cb.js +1 -0
  42. package/dist/client/assets/catppuccin-macchiato-DQyhUUbL.js +1 -0
  43. package/dist/client/assets/catppuccin-mocha-D87Tk5Gz.js +1 -0
  44. package/dist/client/assets/clarity-D53aC0YG.js +1 -0
  45. package/dist/client/assets/clojure-P80f7IUj.js +1 -0
  46. package/dist/client/assets/cmake-D1j8_8rp.js +1 -0
  47. package/dist/client/assets/cobol-nwyudZeR.js +1 -0
  48. package/dist/client/assets/codeowners-Bp6g37R7.js +1 -0
  49. package/dist/client/assets/codeql-DsOJ9woJ.js +1 -0
  50. package/dist/client/assets/coffee-Ch7k5sss.js +1 -0
  51. package/dist/client/assets/common-lisp-Cg-RD9OK.js +1 -0
  52. package/dist/client/assets/coq-DkFqJrB1.js +1 -0
  53. package/dist/client/assets/cpp-CofmeUqb.js +1 -0
  54. package/dist/client/assets/crystal-tKQVLTB8.js +1 -0
  55. package/dist/client/assets/csharp-COcwbKMJ.js +1 -0
  56. package/dist/client/assets/css-DPfMkruS.js +1 -0
  57. package/dist/client/assets/csv-fuZLfV_i.js +1 -0
  58. package/dist/client/assets/cue-D82EKSYY.js +1 -0
  59. package/dist/client/assets/cypher-COkxafJQ.js +1 -0
  60. package/dist/client/assets/d-85-TOEBH.js +1 -0
  61. package/dist/client/assets/dark-plus-C3mMm8J8.js +1 -0
  62. package/dist/client/assets/dart-CF10PKvl.js +1 -0
  63. package/dist/client/assets/dax-CEL-wOlO.js +1 -0
  64. package/dist/client/assets/desktop-BmXAJ9_W.js +1 -0
  65. package/dist/client/assets/diff-D97Zzqfu.js +1 -0
  66. package/dist/client/assets/docker-BcOcwvcX.js +1 -0
  67. package/dist/client/assets/dotenv-Da5cRb03.js +1 -0
  68. package/dist/client/assets/dracula-BzJJZx-M.js +1 -0
  69. package/dist/client/assets/dracula-soft-BXkSAIEj.js +1 -0
  70. package/dist/client/assets/dream-maker-BtqSS_iP.js +1 -0
  71. package/dist/client/assets/edge-BkV0erSs.js +1 -0
  72. package/dist/client/assets/elixir-CDX3lj18.js +1 -0
  73. package/dist/client/assets/elm-DbKCFpqz.js +1 -0
  74. package/dist/client/assets/emacs-lisp-C9XAeP06.js +1 -0
  75. package/dist/client/assets/erb-B12qg9BL.js +1 -0
  76. package/dist/client/assets/erlang-DsQrWhSR.js +1 -0
  77. package/dist/client/assets/everforest-dark-BgDCqdQA.js +1 -0
  78. package/dist/client/assets/everforest-light-C8M2exoo.js +1 -0
  79. package/dist/client/assets/fennel-BYunw83y.js +1 -0
  80. package/dist/client/assets/fish-BvzEVeQv.js +1 -0
  81. package/dist/client/assets/fluent-C4IJs8-o.js +1 -0
  82. package/dist/client/assets/fortran-fixed-form-CkoXwp7k.js +1 -0
  83. package/dist/client/assets/fortran-free-form-BxgE0vQu.js +1 -0
  84. package/dist/client/assets/fsharp-CXgrBDvD.js +1 -0
  85. package/dist/client/assets/gdresource-BOOCDP_w.js +1 -0
  86. package/dist/client/assets/gdscript-C5YyOfLZ.js +1 -0
  87. package/dist/client/assets/gdshader-DkwncUOv.js +1 -0
  88. package/dist/client/assets/genie-D0YGMca9.js +1 -0
  89. package/dist/client/assets/gherkin-DyxjwDmM.js +1 -0
  90. package/dist/client/assets/git-commit-F4YmCXRG.js +1 -0
  91. package/dist/client/assets/git-rebase-r7XF79zn.js +1 -0
  92. package/dist/client/assets/github-dark-DHJKELXO.js +1 -0
  93. package/dist/client/assets/github-dark-default-Cuk6v7N8.js +1 -0
  94. package/dist/client/assets/github-dark-dimmed-DH5Ifo-i.js +1 -0
  95. package/dist/client/assets/github-dark-high-contrast-E3gJ1_iC.js +1 -0
  96. package/dist/client/assets/github-light-DAi9KRSo.js +1 -0
  97. package/dist/client/assets/github-light-default-D7oLnXFd.js +1 -0
  98. package/dist/client/assets/github-light-high-contrast-BfjtVDDH.js +1 -0
  99. package/dist/client/assets/gleam-BspZqrRM.js +1 -0
  100. package/dist/client/assets/glimmer-js-Rg0-pVw9.js +1 -0
  101. package/dist/client/assets/glimmer-ts-U6CK756n.js +1 -0
  102. package/dist/client/assets/glsl-DplSGwfg.js +1 -0
  103. package/dist/client/assets/gn-n2N0HUVH.js +1 -0
  104. package/dist/client/assets/gnuplot-DdkO51Og.js +1 -0
  105. package/dist/client/assets/go-CxLEBnE3.js +1 -0
  106. package/dist/client/assets/graphql-ChdNCCLP.js +1 -0
  107. package/dist/client/assets/groovy-gcz8RCvz.js +1 -0
  108. package/dist/client/assets/gruvbox-dark-hard-CFHQjOhq.js +1 -0
  109. package/dist/client/assets/gruvbox-dark-medium-GsRaNv29.js +1 -0
  110. package/dist/client/assets/gruvbox-dark-soft-CVdnzihN.js +1 -0
  111. package/dist/client/assets/gruvbox-light-hard-CH1njM8p.js +1 -0
  112. package/dist/client/assets/gruvbox-light-medium-DRw_LuNl.js +1 -0
  113. package/dist/client/assets/gruvbox-light-soft-hJgmCMqR.js +1 -0
  114. package/dist/client/assets/hack-CaT9iCJl.js +1 -0
  115. package/dist/client/assets/haml-B8DHNrY2.js +1 -0
  116. package/dist/client/assets/handlebars-BL8al0AC.js +1 -0
  117. package/dist/client/assets/haskell-Df6bDoY_.js +1 -0
  118. package/dist/client/assets/haxe-CzTSHFRz.js +1 -0
  119. package/dist/client/assets/hcl-BWvSN4gD.js +1 -0
  120. package/dist/client/assets/hjson-D5-asLiD.js +1 -0
  121. package/dist/client/assets/hlsl-D3lLCCz7.js +1 -0
  122. package/dist/client/assets/horizon-BUw7H-hv.js +1 -0
  123. package/dist/client/assets/horizon-bright-Cn-bp-IR.js +1 -0
  124. package/dist/client/assets/houston-DnULxvSX.js +1 -0
  125. package/dist/client/assets/html-GMplVEZG.js +1 -0
  126. package/dist/client/assets/html-derivative-BFtXZ54Q.js +1 -0
  127. package/dist/client/assets/http-jrhK8wxY.js +1 -0
  128. package/dist/client/assets/hurl-irOxFIW8.js +1 -0
  129. package/dist/client/assets/hxml-Bvhsp5Yf.js +1 -0
  130. package/dist/client/assets/hy-DFXneXwc.js +1 -0
  131. package/dist/client/assets/imba-DGztddWO.js +1 -0
  132. package/dist/client/assets/index-Do7324M0.css +32 -0
  133. package/dist/client/assets/index-ktE9DLCD.js +2620 -0
  134. package/dist/client/assets/ini-BEwlwnbL.js +1 -0
  135. package/dist/client/assets/java-CylS5w8V.js +1 -0
  136. package/dist/client/assets/javascript-wDzz0qaB.js +1 -0
  137. package/dist/client/assets/jinja-4LBKfQ-Z.js +1 -0
  138. package/dist/client/assets/jison-wvAkD_A8.js +1 -0
  139. package/dist/client/assets/json-Cp-IABpG.js +1 -0
  140. package/dist/client/assets/json5-C9tS-k6U.js +1 -0
  141. package/dist/client/assets/jsonc-Des-eS-w.js +1 -0
  142. package/dist/client/assets/jsonl-DcaNXYhu.js +1 -0
  143. package/dist/client/assets/jsonnet-DFQXde-d.js +1 -0
  144. package/dist/client/assets/jssm-C2t-YnRu.js +1 -0
  145. package/dist/client/assets/jsx-g9-lgVsj.js +1 -0
  146. package/dist/client/assets/julia-CxzCAyBv.js +1 -0
  147. package/dist/client/assets/just-Cw27pwNe.js +1 -0
  148. package/dist/client/assets/kanagawa-dragon-CkXjmgJE.js +1 -0
  149. package/dist/client/assets/kanagawa-lotus-CfQXZHmo.js +1 -0
  150. package/dist/client/assets/kanagawa-wave-DWedfzmr.js +1 -0
  151. package/dist/client/assets/kdl-DV7GczEv.js +1 -0
  152. package/dist/client/assets/kotlin-BdnUsdx6.js +1 -0
  153. package/dist/client/assets/kusto-DZf3V79B.js +1 -0
  154. package/dist/client/assets/laserwave-DUszq2jm.js +1 -0
  155. package/dist/client/assets/latex-CWtU0Tv5.js +1 -0
  156. package/dist/client/assets/lean-BZvkOJ9d.js +1 -0
  157. package/dist/client/assets/less-B1dDrJ26.js +1 -0
  158. package/dist/client/assets/light-plus-B7mTdjB0.js +1 -0
  159. package/dist/client/assets/liquid-DYVedYrR.js +1 -0
  160. package/dist/client/assets/llvm-DjAJT7YJ.js +1 -0
  161. package/dist/client/assets/log-2UxHyX5q.js +1 -0
  162. package/dist/client/assets/logo-BtOb2qkB.js +1 -0
  163. package/dist/client/assets/lua-BaeVxFsk.js +1 -0
  164. package/dist/client/assets/luau-C-HG3fhB.js +1 -0
  165. package/dist/client/assets/make-CHLpvVh8.js +1 -0
  166. package/dist/client/assets/markdown-Cvjx9yec.js +1 -0
  167. package/dist/client/assets/marko-CnJfTvn9.js +1 -0
  168. package/dist/client/assets/material-theme-D5KoaKCx.js +1 -0
  169. package/dist/client/assets/material-theme-darker-BfHTSMKl.js +1 -0
  170. package/dist/client/assets/material-theme-lighter-B0m2ddpp.js +1 -0
  171. package/dist/client/assets/material-theme-ocean-CyktbL80.js +1 -0
  172. package/dist/client/assets/material-theme-palenight-Csfq5Kiy.js +1 -0
  173. package/dist/client/assets/matlab-D7o27uSR.js +1 -0
  174. package/dist/client/assets/mdc-BMNejdWA.js +1 -0
  175. package/dist/client/assets/mdx-Cmh6b_Ma.js +1 -0
  176. package/dist/client/assets/mermaid-mWjccvbQ.js +1 -0
  177. package/dist/client/assets/min-dark-CafNBF8u.js +1 -0
  178. package/dist/client/assets/min-light-CTRr51gU.js +1 -0
  179. package/dist/client/assets/mipsasm-CKIfxQSi.js +1 -0
  180. package/dist/client/assets/mojo-rZm6bMo-.js +1 -0
  181. package/dist/client/assets/monokai-D4h5O-jR.js +1 -0
  182. package/dist/client/assets/moonbit-_H4v1dQx.js +1 -0
  183. package/dist/client/assets/move-IF9eRakj.js +1 -0
  184. package/dist/client/assets/narrat-DRg8JJMk.js +1 -0
  185. package/dist/client/assets/nextflow-Zz6hmt5N.js +1 -0
  186. package/dist/client/assets/nextflow-groovy-BeH2EWoN.js +1 -0
  187. package/dist/client/assets/nginx-BpAMiNFr.js +1 -0
  188. package/dist/client/assets/night-owl-C39BiMTA.js +1 -0
  189. package/dist/client/assets/night-owl-light-CMTm3GFP.js +1 -0
  190. package/dist/client/assets/nim-CVrawwO9.js +1 -0
  191. package/dist/client/assets/nix-CwoSXNpI.js +1 -0
  192. package/dist/client/assets/nord-Ddv68eIx.js +1 -0
  193. package/dist/client/assets/nushell-Cz2AlsmD.js +1 -0
  194. package/dist/client/assets/objective-c-DXmwc3jG.js +1 -0
  195. package/dist/client/assets/objective-cpp-CLxacb5B.js +1 -0
  196. package/dist/client/assets/ocaml-C0hk2d4L.js +1 -0
  197. package/dist/client/assets/odin-BBf5iR-q.js +1 -0
  198. package/dist/client/assets/one-dark-pro-DVMEJ2y_.js +1 -0
  199. package/dist/client/assets/one-light-C3Wv6jpd.js +1 -0
  200. package/dist/client/assets/openscad-C4EeE6gA.js +1 -0
  201. package/dist/client/assets/pascal-D93ZcfNL.js +1 -0
  202. package/dist/client/assets/perl-C0TMdlhV.js +1 -0
  203. package/dist/client/assets/php-Dhbhpdrm.js +1 -0
  204. package/dist/client/assets/pierre-dark-DF2SEV7i.js +1 -0
  205. package/dist/client/assets/pierre-light-DOlZxES8.js +1 -0
  206. package/dist/client/assets/pkl-u5AG7uiY.js +1 -0
  207. package/dist/client/assets/plastic-3e1v2bzS.js +1 -0
  208. package/dist/client/assets/plsql-ChMvpjG-.js +1 -0
  209. package/dist/client/assets/po-BTJTHyun.js +1 -0
  210. package/dist/client/assets/poimandres-CS3Unz2-.js +1 -0
  211. package/dist/client/assets/polar-C0HS_06l.js +1 -0
  212. package/dist/client/assets/postcss-CXtECtnM.js +1 -0
  213. package/dist/client/assets/powerquery-CEu0bR-o.js +1 -0
  214. package/dist/client/assets/powershell-Dpen1YoG.js +1 -0
  215. package/dist/client/assets/prisma-Dd19v3D-.js +1 -0
  216. package/dist/client/assets/prolog-CbFg5uaA.js +1 -0
  217. package/dist/client/assets/proto-C7zT0LnQ.js +1 -0
  218. package/dist/client/assets/pug-CGlum2m_.js +1 -0
  219. package/dist/client/assets/puppet-BMWR74SV.js +1 -0
  220. package/dist/client/assets/purescript-CklMAg4u.js +1 -0
  221. package/dist/client/assets/python-B6aJPvgy.js +1 -0
  222. package/dist/client/assets/qml-3beO22l8.js +1 -0
  223. package/dist/client/assets/qmldir-C8lEn-DE.js +1 -0
  224. package/dist/client/assets/qss-IeuSbFQv.js +1 -0
  225. package/dist/client/assets/r-Dspwwk_N.js +1 -0
  226. package/dist/client/assets/racket-BqYA7rlc.js +1 -0
  227. package/dist/client/assets/raku-DXvB9xmW.js +1 -0
  228. package/dist/client/assets/razor-Uh8Bk_45.js +1 -0
  229. package/dist/client/assets/red-bN70gL4F.js +1 -0
  230. package/dist/client/assets/reg-C-SQnVFl.js +1 -0
  231. package/dist/client/assets/regexp-CDVJQ6XC.js +1 -0
  232. package/dist/client/assets/rel-C3B-1QV4.js +1 -0
  233. package/dist/client/assets/riscv-BM1_JUlF.js +1 -0
  234. package/dist/client/assets/ron-D8l8udqQ.js +1 -0
  235. package/dist/client/assets/rose-pine-dawn-DHQR4-dF.js +1 -0
  236. package/dist/client/assets/rose-pine-moon-D4_iv3hh.js +1 -0
  237. package/dist/client/assets/rose-pine-qdsjHGoJ.js +1 -0
  238. package/dist/client/assets/rosmsg-BJDFO7_C.js +1 -0
  239. package/dist/client/assets/rst-BrH8l1NY.js +1 -0
  240. package/dist/client/assets/ruby-Dw2BHqvy.js +1 -0
  241. package/dist/client/assets/rust-B1yitclQ.js +1 -0
  242. package/dist/client/assets/sas-cz2c8ADy.js +1 -0
  243. package/dist/client/assets/sass-Cj5Yp3dK.js +1 -0
  244. package/dist/client/assets/scala-C151Ov-r.js +1 -0
  245. package/dist/client/assets/scheme-C98Dy4si.js +1 -0
  246. package/dist/client/assets/scss-OYdSNvt2.js +1 -0
  247. package/dist/client/assets/sdbl-DVxCFoDh.js +1 -0
  248. package/dist/client/assets/shaderlab-Dg9Lc6iA.js +1 -0
  249. package/dist/client/assets/shellscript-Yzrsuije.js +1 -0
  250. package/dist/client/assets/shellsession-BADoaaVG.js +1 -0
  251. package/dist/client/assets/slack-dark-BthQWCQV.js +1 -0
  252. package/dist/client/assets/slack-ochin-DqwNpetd.js +1 -0
  253. package/dist/client/assets/smalltalk-BERRCDM3.js +1 -0
  254. package/dist/client/assets/snazzy-light-Bw305WKR.js +1 -0
  255. package/dist/client/assets/solarized-dark-DXbdFlpD.js +1 -0
  256. package/dist/client/assets/solarized-light-L9t79GZl.js +1 -0
  257. package/dist/client/assets/solidity-rGO070M0.js +1 -0
  258. package/dist/client/assets/soy-Brmx7dQM.js +1 -0
  259. package/dist/client/assets/sparql-rVzFXLq3.js +1 -0
  260. package/dist/client/assets/splunk-BtCnVYZw.js +1 -0
  261. package/dist/client/assets/sql-BLtJtn59.js +1 -0
  262. package/dist/client/assets/ssh-config-_ykCGR6B.js +1 -0
  263. package/dist/client/assets/stata-BH5u7GGu.js +1 -0
  264. package/dist/client/assets/stylus-BEDo0Tqx.js +1 -0
  265. package/dist/client/assets/surrealql-Bq5Q-fJD.js +1 -0
  266. package/dist/client/assets/svelte-C_ipcX3V.js +1 -0
  267. package/dist/client/assets/swift-D82vCrfD.js +1 -0
  268. package/dist/client/assets/synthwave-84-CbfX1IO0.js +1 -0
  269. package/dist/client/assets/system-verilog-CnnmHF94.js +1 -0
  270. package/dist/client/assets/systemd-4A_iFExJ.js +1 -0
  271. package/dist/client/assets/talonscript-CkByrt1z.js +1 -0
  272. package/dist/client/assets/tasl-QIJgUcNo.js +1 -0
  273. package/dist/client/assets/tcl-dwOrl1Do.js +1 -0
  274. package/dist/client/assets/templ-P3uqSqPl.js +1 -0
  275. package/dist/client/assets/terraform-BETggiCN.js +1 -0
  276. package/dist/client/assets/tex-idrVyKtj.js +1 -0
  277. package/dist/client/assets/tokyo-night-hegEt444.js +1 -0
  278. package/dist/client/assets/toml-vGWfd6FD.js +1 -0
  279. package/dist/client/assets/ts-tags-zn1MmPIZ.js +1 -0
  280. package/dist/client/assets/tsv-B_m7g4N7.js +1 -0
  281. package/dist/client/assets/tsx-COt5Ahok.js +1 -0
  282. package/dist/client/assets/turtle-BsS91CYL.js +1 -0
  283. package/dist/client/assets/twig-DNn4PbVi.js +1 -0
  284. package/dist/client/assets/typescript-BPQ3VLAy.js +1 -0
  285. package/dist/client/assets/typespec-BGHnOYBU.js +1 -0
  286. package/dist/client/assets/typst-DHCkPAjA.js +1 -0
  287. package/dist/client/assets/v-BcVCzyr7.js +1 -0
  288. package/dist/client/assets/vala-CsfeWuGM.js +1 -0
  289. package/dist/client/assets/vb-D17OF-Vu.js +1 -0
  290. package/dist/client/assets/verilog-BQ8w6xss.js +1 -0
  291. package/dist/client/assets/vesper-DU1UobuO.js +1 -0
  292. package/dist/client/assets/vhdl-CeAyd5Ju.js +1 -0
  293. package/dist/client/assets/viml-CJc9bBzg.js +1 -0
  294. package/dist/client/assets/vitesse-black-Bkuqu6BP.js +1 -0
  295. package/dist/client/assets/vitesse-dark-D0r3Knsf.js +1 -0
  296. package/dist/client/assets/vitesse-light-CVO1_9PV.js +1 -0
  297. package/dist/client/assets/vue-DN_0RTcg.js +1 -0
  298. package/dist/client/assets/vue-html-AaS7Mt5G.js +1 -0
  299. package/dist/client/assets/vue-vine-CQOfvN7w.js +1 -0
  300. package/dist/client/assets/vyper-CDx5xZoG.js +1 -0
  301. package/dist/client/assets/wasm-CG6Dc4jp.js +1 -0
  302. package/dist/client/assets/wasm-MzD3tlZU.js +1 -0
  303. package/dist/client/assets/wenyan-BV7otONQ.js +1 -0
  304. package/dist/client/assets/wgsl-Dx-B1_4e.js +1 -0
  305. package/dist/client/assets/wikitext-BhOHFoWU.js +1 -0
  306. package/dist/client/assets/wit-5i3qLPDT.js +1 -0
  307. package/dist/client/assets/wolfram-lXgVvXCa.js +1 -0
  308. package/dist/client/assets/xml-sdJ4AIDG.js +1 -0
  309. package/dist/client/assets/xsl-CtQFsRM5.js +1 -0
  310. package/dist/client/assets/yaml-Buea-lGh.js +1 -0
  311. package/dist/client/assets/zenscript-DVFEvuxE.js +1 -0
  312. package/dist/client/assets/zig-VOosw3JB.js +1 -0
  313. package/dist/client/chat-sounds/Blow.mp3 +0 -0
  314. package/dist/client/chat-sounds/Bottle.mp3 +0 -0
  315. package/dist/client/chat-sounds/Frog.mp3 +0 -0
  316. package/dist/client/chat-sounds/Funk.mp3 +0 -0
  317. package/dist/client/chat-sounds/Glass.mp3 +0 -0
  318. package/dist/client/chat-sounds/Ping.mp3 +0 -0
  319. package/dist/client/chat-sounds/Pop.mp3 +0 -0
  320. package/dist/client/chat-sounds/Purr.mp3 +0 -0
  321. package/dist/client/chat-sounds/Tink.mp3 +0 -0
  322. package/dist/client/editor-icons/cursor.png +0 -0
  323. package/dist/client/editor-icons/custom.png +0 -0
  324. package/dist/client/editor-icons/default-app.png +0 -0
  325. package/dist/client/editor-icons/finder.png +0 -0
  326. package/dist/client/editor-icons/preview.png +0 -0
  327. package/dist/client/editor-icons/terminal.png +0 -0
  328. package/dist/client/editor-icons/windsurf.png +0 -0
  329. package/dist/client/editor-icons/xcode.png +0 -0
  330. package/dist/client/favicon.png +0 -0
  331. package/dist/client/fonts/body-medium.woff2 +0 -0
  332. package/dist/client/fonts/body-regular-italic.woff2 +0 -0
  333. package/dist/client/fonts/body-regular.woff2 +0 -0
  334. package/dist/client/fonts/body-semibold.woff2 +0 -0
  335. package/dist/client/icon-192.png +0 -0
  336. package/dist/client/icon-512.png +0 -0
  337. package/dist/client/icon-maskable-512.png +0 -0
  338. package/dist/client/icon.svg +4 -0
  339. package/dist/client/index.html +34 -0
  340. package/dist/client/manifest.webmanifest +46 -0
  341. package/dist/client/screenshot-light.png +0 -0
  342. package/dist/client/screenshot.png +0 -0
  343. package/dist/export-viewer/assets/bricolage-grotesque-latin-ext-wght-normal-CcLUaPy7.woff2 +0 -0
  344. package/dist/export-viewer/assets/bricolage-grotesque-latin-wght-normal-DLoelf7F.woff2 +0 -0
  345. package/dist/export-viewer/assets/bricolage-grotesque-vietnamese-wght-normal-BUzh504Q.woff2 +0 -0
  346. package/dist/export-viewer/assets/index-D1qUumZR.js +410 -0
  347. package/dist/export-viewer/assets/index-gG2nMW51.css +1 -0
  348. package/dist/export-viewer/editor-icons/cursor.png +0 -0
  349. package/dist/export-viewer/editor-icons/custom.png +0 -0
  350. package/dist/export-viewer/editor-icons/default-app.png +0 -0
  351. package/dist/export-viewer/editor-icons/finder.png +0 -0
  352. package/dist/export-viewer/editor-icons/preview.png +0 -0
  353. package/dist/export-viewer/editor-icons/terminal.png +0 -0
  354. package/dist/export-viewer/editor-icons/windsurf.png +0 -0
  355. package/dist/export-viewer/editor-icons/xcode.png +0 -0
  356. package/dist/export-viewer/fonts/body-medium.woff2 +0 -0
  357. package/dist/export-viewer/fonts/body-regular-italic.woff2 +0 -0
  358. package/dist/export-viewer/fonts/body-regular.woff2 +0 -0
  359. package/dist/export-viewer/fonts/body-semibold.woff2 +0 -0
  360. package/dist/export-viewer/index.html +14 -0
  361. package/package.json +99 -0
  362. package/src/server/__fixtures__/claude-session-empty.jsonl +0 -0
  363. package/src/server/__fixtures__/claude-session-malformed.jsonl +3 -0
  364. package/src/server/__fixtures__/claude-session-valid.jsonl +6 -0
  365. package/src/server/agent.test.ts +2369 -0
  366. package/src/server/agent.ts +1927 -0
  367. package/src/server/analytics.test.ts +313 -0
  368. package/src/server/analytics.ts +131 -0
  369. package/src/server/app-settings.test.ts +233 -0
  370. package/src/server/app-settings.ts +548 -0
  371. package/src/server/auth.test.ts +329 -0
  372. package/src/server/auth.ts +204 -0
  373. package/src/server/auto-continue/e2e.test.ts +215 -0
  374. package/src/server/auto-continue/events.test.ts +30 -0
  375. package/src/server/auto-continue/events.ts +35 -0
  376. package/src/server/auto-continue/limit-detector.test.ts +153 -0
  377. package/src/server/auto-continue/limit-detector.ts +159 -0
  378. package/src/server/auto-continue/read-model.test.ts +109 -0
  379. package/src/server/auto-continue/read-model.ts +83 -0
  380. package/src/server/auto-continue/schedule-manager.test.ts +155 -0
  381. package/src/server/auto-continue/schedule-manager.ts +116 -0
  382. package/src/server/claude-session-importer.test.ts +214 -0
  383. package/src/server/claude-session-importer.ts +187 -0
  384. package/src/server/claude-session-mapper.test.ts +88 -0
  385. package/src/server/claude-session-mapper.ts +106 -0
  386. package/src/server/claude-session-parser.test.ts +38 -0
  387. package/src/server/claude-session-parser.ts +67 -0
  388. package/src/server/claude-session-scanner.test.ts +49 -0
  389. package/src/server/claude-session-scanner.ts +24 -0
  390. package/src/server/claude-session-types.ts +61 -0
  391. package/src/server/cli-runtime.test.ts +523 -0
  392. package/src/server/cli-runtime.ts +405 -0
  393. package/src/server/cli-supervisor.ts +102 -0
  394. package/src/server/cli.ts +64 -0
  395. package/src/server/cloudflare-tunnel/agent-integration.test.ts +76 -0
  396. package/src/server/cloudflare-tunnel/agent-integration.ts +55 -0
  397. package/src/server/cloudflare-tunnel/detector.test.ts +72 -0
  398. package/src/server/cloudflare-tunnel/detector.ts +44 -0
  399. package/src/server/cloudflare-tunnel/e2e.test.ts +194 -0
  400. package/src/server/cloudflare-tunnel/events.test.ts +43 -0
  401. package/src/server/cloudflare-tunnel/events.ts +31 -0
  402. package/src/server/cloudflare-tunnel/gateway.ts +143 -0
  403. package/src/server/cloudflare-tunnel/lifecycle.test.ts +48 -0
  404. package/src/server/cloudflare-tunnel/lifecycle.ts +62 -0
  405. package/src/server/cloudflare-tunnel/read-model.test.ts +69 -0
  406. package/src/server/cloudflare-tunnel/read-model.ts +80 -0
  407. package/src/server/cloudflare-tunnel/tunnel-manager.test.ts +116 -0
  408. package/src/server/cloudflare-tunnel/tunnel-manager.ts +165 -0
  409. package/src/server/codex-app-server-protocol.ts +487 -0
  410. package/src/server/codex-app-server.test.ts +1816 -0
  411. package/src/server/codex-app-server.ts +1475 -0
  412. package/src/server/diff-store.test.ts +737 -0
  413. package/src/server/diff-store.ts +2199 -0
  414. package/src/server/discovery.test.ts +211 -0
  415. package/src/server/discovery.ts +301 -0
  416. package/src/server/event-store.test.ts +797 -0
  417. package/src/server/event-store.ts +1421 -0
  418. package/src/server/events.ts +217 -0
  419. package/src/server/external-open.test.ts +112 -0
  420. package/src/server/external-open.ts +345 -0
  421. package/src/server/generate-commit-message.test.ts +79 -0
  422. package/src/server/generate-commit-message.ts +126 -0
  423. package/src/server/generate-title.ts +76 -0
  424. package/src/server/harness-types.ts +19 -0
  425. package/src/server/keybindings.test.ts +144 -0
  426. package/src/server/keybindings.ts +178 -0
  427. package/src/server/llm-provider.test.ts +134 -0
  428. package/src/server/llm-provider.ts +207 -0
  429. package/src/server/machine-name.ts +22 -0
  430. package/src/server/paths-route.test.ts +64 -0
  431. package/src/server/paths.ts +35 -0
  432. package/src/server/process-utils.test.ts +12 -0
  433. package/src/server/process-utils.ts +47 -0
  434. package/src/server/project-paths.test.ts +95 -0
  435. package/src/server/project-paths.ts +191 -0
  436. package/src/server/provider-catalog.test.ts +69 -0
  437. package/src/server/provider-catalog.ts +87 -0
  438. package/src/server/quick-response.test.ts +440 -0
  439. package/src/server/quick-response.ts +300 -0
  440. package/src/server/read-models.test.ts +509 -0
  441. package/src/server/read-models.ts +230 -0
  442. package/src/server/restart.test.ts +27 -0
  443. package/src/server/restart.ts +30 -0
  444. package/src/server/server.ts +616 -0
  445. package/src/server/share.test.ts +180 -0
  446. package/src/server/share.ts +150 -0
  447. package/src/server/standalone-export.test.ts +224 -0
  448. package/src/server/standalone-export.ts +419 -0
  449. package/src/server/terminal-manager.test.ts +315 -0
  450. package/src/server/terminal-manager.ts +350 -0
  451. package/src/server/test-helpers/async-event-queue.ts +52 -0
  452. package/src/server/test-helpers/wait-for.ts +14 -0
  453. package/src/server/title-generation.live.test.ts +44 -0
  454. package/src/server/update-manager.test.ts +158 -0
  455. package/src/server/update-manager.ts +222 -0
  456. package/src/server/update-strategy.test.ts +237 -0
  457. package/src/server/update-strategy.ts +241 -0
  458. package/src/server/uploads.test.ts +292 -0
  459. package/src/server/uploads.ts +131 -0
  460. package/src/server/ws-router.test.ts +2292 -0
  461. package/src/server/ws-router.ts +1465 -0
  462. package/src/shared/analytics.ts +30 -0
  463. package/src/shared/branding.test.ts +31 -0
  464. package/src/shared/branding.ts +77 -0
  465. package/src/shared/dev-ports.test.ts +113 -0
  466. package/src/shared/dev-ports.ts +134 -0
  467. package/src/shared/ports.ts +2 -0
  468. package/src/shared/protocol.ts +257 -0
  469. package/src/shared/share.ts +27 -0
  470. package/src/shared/tools.test.ts +164 -0
  471. package/src/shared/tools.ts +327 -0
  472. package/src/shared/types.test.ts +25 -0
  473. package/src/shared/types.ts +1088 -0
@@ -0,0 +1,2292 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { mkdtemp, rm } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import path from "node:path"
5
+ import { CLOUDFLARE_TUNNEL_DEFAULTS, PROTOCOL_VERSION } from "../shared/types"
6
+ import type { AppSettingsSnapshot, KeybindingsSnapshot, LlmProviderSnapshot, UpdateSnapshot } from "../shared/types"
7
+ import { createEmptyState } from "./events"
8
+ import { createWsRouter } from "./ws-router"
9
+
10
+ function withSidebarGroupDefaults(group: {
11
+ groupKey: string
12
+ localPath: string
13
+ chats: Array<{
14
+ _id: string
15
+ _creationTime: number
16
+ chatId: string
17
+ title: string
18
+ status: "idle" | "starting" | "running" | "waiting_for_user" | "failed"
19
+ unread: boolean
20
+ localPath: string
21
+ provider: "claude" | "codex" | null
22
+ lastMessageAt?: number
23
+ canFork?: boolean
24
+ hasAutomation: boolean
25
+ }>
26
+ }) {
27
+ return {
28
+ ...group,
29
+ previewChats: group.chats,
30
+ olderChats: [],
31
+ defaultCollapsed: true,
32
+ }
33
+ }
34
+
35
+ class FakeWebSocket {
36
+ readonly sent: unknown[] = []
37
+ readonly data = {
38
+ subscriptions: new Map(),
39
+ protectedDraftChatIds: new Set<string>(),
40
+ }
41
+
42
+ send(message: string) {
43
+ this.sent.push(JSON.parse(message))
44
+ }
45
+ }
46
+
47
+ const DEFAULT_KEYBINDINGS_SNAPSHOT: KeybindingsSnapshot = {
48
+ bindings: {
49
+ toggleEmbeddedTerminal: ["cmd+j", "ctrl+`"],
50
+ toggleRightSidebar: ["ctrl+b"],
51
+ openInFinder: ["cmd+alt+f"],
52
+ openInEditor: ["cmd+shift+o"],
53
+ addSplitTerminal: ["cmd+shift+j"],
54
+ jumpToSidebarChat: ["cmd+alt"],
55
+ createChatInCurrentProject: ["cmd+alt+n"],
56
+ openAddProject: ["cmd+alt+o"],
57
+ },
58
+ warning: null,
59
+ filePathDisplay: "~/.kanna/keybindings.json",
60
+ }
61
+
62
+ const DEFAULT_APP_SETTINGS_SNAPSHOT: AppSettingsSnapshot = {
63
+ analyticsEnabled: true,
64
+ cloudflareTunnel: CLOUDFLARE_TUNNEL_DEFAULTS,
65
+ browserSettingsMigrated: false,
66
+ theme: "system",
67
+ chatSoundPreference: "always",
68
+ chatSoundId: "funk",
69
+ terminal: {
70
+ scrollbackLines: 1_000,
71
+ minColumnWidth: 450,
72
+ },
73
+ editor: {
74
+ preset: "cursor",
75
+ commandTemplate: "cursor {path}",
76
+ },
77
+ defaultProvider: "last_used",
78
+ providerDefaults: {
79
+ claude: {
80
+ model: "claude-opus-4-7",
81
+ modelOptions: {
82
+ reasoningEffort: "high",
83
+ contextWindow: "200k",
84
+ },
85
+ planMode: false,
86
+ },
87
+ codex: {
88
+ model: "gpt-5.5",
89
+ modelOptions: {
90
+ reasoningEffort: "high",
91
+ fastMode: false,
92
+ },
93
+ planMode: false,
94
+ },
95
+ },
96
+ warning: null,
97
+ filePathDisplay: "~/.kanna/data/settings.json",
98
+ }
99
+
100
+ const DEFAULT_UPDATE_SNAPSHOT: UpdateSnapshot = {
101
+ currentVersion: "0.12.0",
102
+ latestVersion: null,
103
+ status: "idle",
104
+ updateAvailable: false,
105
+ lastCheckedAt: null,
106
+ error: null,
107
+ installAction: "restart",
108
+ reloadRequestedAt: null,
109
+ }
110
+
111
+ const DEFAULT_LLM_PROVIDER_SNAPSHOT: LlmProviderSnapshot = {
112
+ provider: "openai",
113
+ apiKey: "",
114
+ model: "",
115
+ baseUrl: "",
116
+ resolvedBaseUrl: "https://api.openai.com/v1",
117
+ enabled: false,
118
+ warning: null,
119
+ filePathDisplay: "~/.kanna/llm-provider.json",
120
+ }
121
+
122
+ describe("ws-router", () => {
123
+ test("acks system.ping without broadcasting snapshots", async () => {
124
+ const router = createWsRouter({
125
+ store: { state: createEmptyState() } as never,
126
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set(), getSlashCommandsLoadingChatIds: () => new Set(), ensureSlashCommandsLoaded: async () => {} } as never,
127
+ terminals: {
128
+ getSnapshot: () => null,
129
+ onEvent: () => () => {},
130
+ } as never,
131
+ keybindings: {
132
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
133
+ onChange: () => () => {},
134
+ } as never,
135
+ refreshDiscovery: async () => [],
136
+ getDiscoveredProjects: () => [],
137
+ machineDisplayName: "Local Machine",
138
+ updateManager: null,
139
+ })
140
+ const ws = new FakeWebSocket()
141
+ router.handleOpen(ws as never)
142
+
143
+ ws.data.subscriptions.set("sub-1", { type: "sidebar" })
144
+ await router.handleMessage(
145
+ ws as never,
146
+ JSON.stringify({
147
+ v: 1,
148
+ type: "command",
149
+ id: "ping-1",
150
+ command: { type: "system.ping" },
151
+ })
152
+ )
153
+
154
+ expect(ws.sent).toEqual([
155
+ {
156
+ v: PROTOCOL_VERSION,
157
+ type: "ack",
158
+ id: "ping-1",
159
+ },
160
+ ])
161
+ })
162
+
163
+ test("reads and writes llm provider settings via commands", async () => {
164
+ const writes: Array<Pick<LlmProviderSnapshot, "provider" | "apiKey" | "model" | "baseUrl">> = []
165
+ const router = createWsRouter({
166
+ store: { state: createEmptyState() } as never,
167
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set(), getSlashCommandsLoadingChatIds: () => new Set(), ensureSlashCommandsLoaded: async () => {} } as never,
168
+ terminals: {
169
+ getSnapshot: () => null,
170
+ onEvent: () => () => {},
171
+ } as never,
172
+ keybindings: {
173
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
174
+ onChange: () => () => {},
175
+ } as never,
176
+ llmProvider: {
177
+ read: async () => DEFAULT_LLM_PROVIDER_SNAPSHOT,
178
+ write: async (value) => {
179
+ writes.push(value)
180
+ return {
181
+ ...DEFAULT_LLM_PROVIDER_SNAPSHOT,
182
+ ...value,
183
+ resolvedBaseUrl: value.provider === "custom" ? value.baseUrl : "https://api.openai.com/v1",
184
+ enabled: Boolean(value.apiKey && value.model),
185
+ }
186
+ },
187
+ validate: async () => ({
188
+ ok: true,
189
+ error: null,
190
+ }),
191
+ },
192
+ refreshDiscovery: async () => [],
193
+ getDiscoveredProjects: () => [],
194
+ machineDisplayName: "Local Machine",
195
+ updateManager: null,
196
+ })
197
+ const ws = new FakeWebSocket()
198
+ router.handleOpen(ws as never)
199
+
200
+ await router.handleMessage(
201
+ ws as never,
202
+ JSON.stringify({
203
+ v: 1,
204
+ type: "command",
205
+ id: "llm-read-1",
206
+ command: { type: "settings.readLlmProvider" },
207
+ })
208
+ )
209
+
210
+ await router.handleMessage(
211
+ ws as never,
212
+ JSON.stringify({
213
+ v: 1,
214
+ type: "command",
215
+ id: "llm-write-1",
216
+ command: {
217
+ type: "settings.writeLlmProvider",
218
+ provider: "custom",
219
+ apiKey: "test-key",
220
+ model: "gpt-test",
221
+ baseUrl: "https://example.com/v1",
222
+ },
223
+ })
224
+ )
225
+
226
+ expect(ws.sent).toEqual([
227
+ {
228
+ v: PROTOCOL_VERSION,
229
+ type: "ack",
230
+ id: "llm-read-1",
231
+ result: DEFAULT_LLM_PROVIDER_SNAPSHOT,
232
+ },
233
+ {
234
+ v: PROTOCOL_VERSION,
235
+ type: "ack",
236
+ id: "llm-write-1",
237
+ result: {
238
+ ...DEFAULT_LLM_PROVIDER_SNAPSHOT,
239
+ provider: "custom",
240
+ apiKey: "test-key",
241
+ model: "gpt-test",
242
+ baseUrl: "https://example.com/v1",
243
+ resolvedBaseUrl: "https://example.com/v1",
244
+ enabled: true,
245
+ },
246
+ },
247
+ ])
248
+ expect(writes).toEqual([{
249
+ provider: "custom",
250
+ apiKey: "test-key",
251
+ model: "gpt-test",
252
+ baseUrl: "https://example.com/v1",
253
+ }])
254
+ })
255
+
256
+ test("reads and writes app settings via commands", async () => {
257
+ const writes: Array<{ analyticsEnabled: boolean }> = []
258
+ let analyticsEnabled = DEFAULT_APP_SETTINGS_SNAPSHOT.analyticsEnabled
259
+ const router = createWsRouter({
260
+ store: { state: createEmptyState() } as never,
261
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
262
+ terminals: {
263
+ getSnapshot: () => null,
264
+ onEvent: () => () => {},
265
+ } as never,
266
+ keybindings: {
267
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
268
+ onChange: () => () => {},
269
+ } as never,
270
+ appSettings: {
271
+ getSnapshot: () => ({
272
+ ...DEFAULT_APP_SETTINGS_SNAPSHOT,
273
+ analyticsEnabled,
274
+ }),
275
+ write: async (value) => {
276
+ writes.push(value)
277
+ analyticsEnabled = value.analyticsEnabled
278
+ return {
279
+ ...DEFAULT_APP_SETTINGS_SNAPSHOT,
280
+ analyticsEnabled: value.analyticsEnabled,
281
+ }
282
+ },
283
+ setCloudflareTunnel: async (_patch) => ({ ...DEFAULT_APP_SETTINGS_SNAPSHOT }),
284
+ },
285
+ refreshDiscovery: async () => [],
286
+ getDiscoveredProjects: () => [],
287
+ machineDisplayName: "Local Machine",
288
+ updateManager: null,
289
+ })
290
+ const ws = new FakeWebSocket()
291
+ router.handleOpen(ws as never)
292
+
293
+ await router.handleMessage(
294
+ ws as never,
295
+ JSON.stringify({
296
+ v: 1,
297
+ type: "command",
298
+ id: "settings-read-1",
299
+ command: { type: "settings.readAppSettings" },
300
+ })
301
+ )
302
+
303
+ await router.handleMessage(
304
+ ws as never,
305
+ JSON.stringify({
306
+ v: 1,
307
+ type: "command",
308
+ id: "settings-write-1",
309
+ command: {
310
+ type: "settings.writeAppSettings",
311
+ analyticsEnabled: false,
312
+ },
313
+ })
314
+ )
315
+
316
+ expect(ws.sent).toEqual([
317
+ {
318
+ v: PROTOCOL_VERSION,
319
+ type: "ack",
320
+ id: "settings-read-1",
321
+ result: DEFAULT_APP_SETTINGS_SNAPSHOT,
322
+ },
323
+ {
324
+ v: PROTOCOL_VERSION,
325
+ type: "ack",
326
+ id: "settings-write-1",
327
+ result: {
328
+ ...DEFAULT_APP_SETTINGS_SNAPSHOT,
329
+ analyticsEnabled: false,
330
+ },
331
+ },
332
+ ])
333
+ expect(writes).toEqual([{ analyticsEnabled: false }])
334
+ })
335
+
336
+ test("subscribes to app settings and writes patches through the router", async () => {
337
+ let snapshot: AppSettingsSnapshot = DEFAULT_APP_SETTINGS_SNAPSHOT
338
+ let listener: ((nextSnapshot: AppSettingsSnapshot) => void) | null = null
339
+ const router = createWsRouter({
340
+ store: { state: createEmptyState() } as never,
341
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
342
+ terminals: {
343
+ getSnapshot: () => null,
344
+ onEvent: () => () => {},
345
+ } as never,
346
+ keybindings: {
347
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
348
+ onChange: () => () => {},
349
+ } as never,
350
+ appSettings: {
351
+ getSnapshot: () => snapshot,
352
+ write: async (value) => {
353
+ snapshot = { ...snapshot, analyticsEnabled: value.analyticsEnabled }
354
+ return snapshot
355
+ },
356
+ writePatch: async (patch) => {
357
+ snapshot = {
358
+ ...snapshot,
359
+ analyticsEnabled: patch.analyticsEnabled ?? snapshot.analyticsEnabled,
360
+ browserSettingsMigrated: patch.browserSettingsMigrated ?? snapshot.browserSettingsMigrated,
361
+ theme: patch.theme ?? snapshot.theme,
362
+ chatSoundPreference: patch.chatSoundPreference ?? snapshot.chatSoundPreference,
363
+ chatSoundId: patch.chatSoundId ?? snapshot.chatSoundId,
364
+ defaultProvider: patch.defaultProvider ?? snapshot.defaultProvider,
365
+ terminal: { ...snapshot.terminal, ...patch.terminal },
366
+ editor: { ...snapshot.editor, ...patch.editor },
367
+ }
368
+ listener?.(snapshot)
369
+ return snapshot
370
+ },
371
+ onChange: (nextListener) => {
372
+ listener = nextListener
373
+ return () => {
374
+ listener = null
375
+ }
376
+ },
377
+ },
378
+ refreshDiscovery: async () => [],
379
+ getDiscoveredProjects: () => [],
380
+ machineDisplayName: "Local Machine",
381
+ updateManager: null,
382
+ })
383
+ const ws = new FakeWebSocket()
384
+ router.handleOpen(ws as never)
385
+
386
+ await router.handleMessage(
387
+ ws as never,
388
+ JSON.stringify({
389
+ v: 1,
390
+ type: "subscribe",
391
+ id: "app-settings-sub-1",
392
+ topic: { type: "app-settings" },
393
+ })
394
+ )
395
+
396
+ await router.handleMessage(
397
+ ws as never,
398
+ JSON.stringify({
399
+ v: 1,
400
+ type: "command",
401
+ id: "settings-patch-1",
402
+ command: {
403
+ type: "settings.writeAppSettingsPatch",
404
+ patch: {
405
+ theme: "dark",
406
+ terminal: { scrollbackLines: 2_000 },
407
+ },
408
+ },
409
+ })
410
+ )
411
+
412
+ expect(ws.sent).toEqual([
413
+ {
414
+ v: PROTOCOL_VERSION,
415
+ type: "snapshot",
416
+ id: "app-settings-sub-1",
417
+ snapshot: {
418
+ type: "app-settings",
419
+ data: DEFAULT_APP_SETTINGS_SNAPSHOT,
420
+ },
421
+ },
422
+ {
423
+ v: PROTOCOL_VERSION,
424
+ type: "snapshot",
425
+ id: "app-settings-sub-1",
426
+ snapshot: {
427
+ type: "app-settings",
428
+ data: {
429
+ ...DEFAULT_APP_SETTINGS_SNAPSHOT,
430
+ theme: "dark",
431
+ terminal: {
432
+ ...DEFAULT_APP_SETTINGS_SNAPSHOT.terminal,
433
+ scrollbackLines: 2_000,
434
+ },
435
+ },
436
+ },
437
+ },
438
+ {
439
+ v: PROTOCOL_VERSION,
440
+ type: "ack",
441
+ id: "settings-patch-1",
442
+ result: {
443
+ ...DEFAULT_APP_SETTINGS_SNAPSHOT,
444
+ theme: "dark",
445
+ terminal: {
446
+ ...DEFAULT_APP_SETTINGS_SNAPSHOT.terminal,
447
+ scrollbackLines: 2_000,
448
+ },
449
+ },
450
+ },
451
+ ])
452
+ })
453
+
454
+ test("tracks analytics preference transitions in the correct order", async () => {
455
+ const analyticsEvents: string[] = []
456
+ let analyticsEnabled = true
457
+ const router = createWsRouter({
458
+ store: { state: createEmptyState() } as never,
459
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
460
+ terminals: {
461
+ getSnapshot: () => null,
462
+ onEvent: () => () => {},
463
+ } as never,
464
+ keybindings: {
465
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
466
+ onChange: () => () => {},
467
+ } as never,
468
+ appSettings: {
469
+ getSnapshot: () => ({
470
+ ...DEFAULT_APP_SETTINGS_SNAPSHOT,
471
+ analyticsEnabled,
472
+ }),
473
+ write: async (value) => {
474
+ analyticsEnabled = value.analyticsEnabled
475
+ return {
476
+ ...DEFAULT_APP_SETTINGS_SNAPSHOT,
477
+ analyticsEnabled: value.analyticsEnabled,
478
+ }
479
+ },
480
+ setCloudflareTunnel: async (_patch) => ({ ...DEFAULT_APP_SETTINGS_SNAPSHOT }),
481
+ },
482
+ analytics: {
483
+ track: (eventName: string) => {
484
+ analyticsEvents.push(eventName)
485
+ },
486
+ trackLaunch: () => {},
487
+ },
488
+ refreshDiscovery: async () => [],
489
+ getDiscoveredProjects: () => [],
490
+ machineDisplayName: "Local Machine",
491
+ updateManager: null,
492
+ })
493
+ const ws = new FakeWebSocket()
494
+
495
+ await router.handleMessage(
496
+ ws as never,
497
+ JSON.stringify({
498
+ v: 1,
499
+ type: "command",
500
+ id: "settings-disable-1",
501
+ command: {
502
+ type: "settings.writeAppSettings",
503
+ analyticsEnabled: false,
504
+ },
505
+ })
506
+ )
507
+
508
+ await router.handleMessage(
509
+ ws as never,
510
+ JSON.stringify({
511
+ v: 1,
512
+ type: "command",
513
+ id: "settings-enable-1",
514
+ command: {
515
+ type: "settings.writeAppSettings",
516
+ analyticsEnabled: true,
517
+ },
518
+ })
519
+ )
520
+
521
+ await router.handleMessage(
522
+ ws as never,
523
+ JSON.stringify({
524
+ v: 1,
525
+ type: "command",
526
+ id: "settings-enable-2",
527
+ command: {
528
+ type: "settings.writeAppSettings",
529
+ analyticsEnabled: true,
530
+ },
531
+ })
532
+ )
533
+
534
+ expect(analyticsEvents).toEqual([
535
+ "analytics_disabled",
536
+ "analytics_enabled",
537
+ ])
538
+ })
539
+
540
+ test("tracks project lifecycle analytics", async () => {
541
+ const analyticsEvents: string[] = []
542
+ const state = createEmptyState()
543
+ const projectPath = await mkdtemp(path.join(tmpdir(), "kanna-router-project-"))
544
+
545
+ try {
546
+ const router = createWsRouter({
547
+ store: {
548
+ state,
549
+ openProject: async (localPath: string, title?: string) => {
550
+ const project = {
551
+ id: "project-1",
552
+ localPath,
553
+ title: title ?? "Project",
554
+ createdAt: Date.now(),
555
+ updatedAt: Date.now(),
556
+ deletedAt: null,
557
+ }
558
+ state.projectsById.set(project.id, project as never)
559
+ state.projectIdsByPath.set(localPath, project.id)
560
+ return project
561
+ },
562
+ getProject: () => ({
563
+ id: "project-1",
564
+ localPath: projectPath,
565
+ }),
566
+ listChatsByProject: () => [{ id: "chat-1" }, { id: "chat-2" }],
567
+ removeProject: async () => {},
568
+ } as never,
569
+ agent: {
570
+ cancel: async () => {},
571
+ closeChat: async () => {},
572
+ getActiveStatuses: () => new Map(),
573
+ getDrainingChatIds: () => new Set(),
574
+ } as never,
575
+ analytics: {
576
+ track: (eventName: string) => {
577
+ analyticsEvents.push(eventName)
578
+ },
579
+ trackLaunch: () => {},
580
+ },
581
+ terminals: {
582
+ closeByCwd: () => {},
583
+ getSnapshot: () => null,
584
+ onEvent: () => () => {},
585
+ } as never,
586
+ keybindings: {
587
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
588
+ onChange: () => () => {},
589
+ } as never,
590
+ refreshDiscovery: async () => [],
591
+ getDiscoveredProjects: () => [],
592
+ machineDisplayName: "Local Machine",
593
+ updateManager: null,
594
+ })
595
+ const ws = new FakeWebSocket()
596
+
597
+ await router.handleMessage(
598
+ ws as never,
599
+ JSON.stringify({
600
+ v: 1,
601
+ type: "command",
602
+ id: "project-create-1",
603
+ command: { type: "project.create", localPath: projectPath, title: "Project" },
604
+ })
605
+ )
606
+
607
+ await router.handleMessage(
608
+ ws as never,
609
+ JSON.stringify({
610
+ v: 1,
611
+ type: "command",
612
+ id: "project-remove-1",
613
+ command: { type: "project.remove", projectId: "project-1" },
614
+ })
615
+ )
616
+
617
+ expect(analyticsEvents).toEqual([
618
+ "project_opened",
619
+ "project_created",
620
+ "project_removed",
621
+ ])
622
+ } finally {
623
+ await rm(projectPath, { recursive: true, force: true })
624
+ }
625
+ })
626
+
627
+ test("acks terminal.input without rebroadcasting terminal snapshots", async () => {
628
+ const router = createWsRouter({
629
+ store: { state: createEmptyState() } as never,
630
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set(), getSlashCommandsLoadingChatIds: () => new Set(), ensureSlashCommandsLoaded: async () => {} } as never,
631
+ terminals: {
632
+ getSnapshot: () => null,
633
+ onEvent: () => () => {},
634
+ write: () => {},
635
+ } as never,
636
+ keybindings: {
637
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
638
+ onChange: () => () => {},
639
+ } as never,
640
+ refreshDiscovery: async () => [],
641
+ getDiscoveredProjects: () => [],
642
+ machineDisplayName: "Local Machine",
643
+ updateManager: null,
644
+ })
645
+ const ws = new FakeWebSocket()
646
+
647
+ ws.data.subscriptions.set("sub-terminal", { type: "terminal", terminalId: "terminal-1" })
648
+ await router.handleMessage(
649
+ ws as never,
650
+ JSON.stringify({
651
+ v: 1,
652
+ type: "command",
653
+ id: "terminal-input-1",
654
+ command: {
655
+ type: "terminal.input",
656
+ terminalId: "terminal-1",
657
+ data: "ls\r",
658
+ },
659
+ })
660
+ )
661
+
662
+ expect(ws.sent).toEqual([
663
+ {
664
+ v: PROTOCOL_VERSION,
665
+ type: "ack",
666
+ id: "terminal-input-1",
667
+ },
668
+ ])
669
+ })
670
+
671
+ test("subscribes and unsubscribes chat topics", async () => {
672
+ const router = createWsRouter({
673
+ store: { state: createEmptyState() } as never,
674
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set(), getSlashCommandsLoadingChatIds: () => new Set(), ensureSlashCommandsLoaded: async () => {} } as never,
675
+ terminals: {
676
+ getSnapshot: () => null,
677
+ onEvent: () => () => {},
678
+ } as never,
679
+ keybindings: {
680
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
681
+ onChange: () => () => {},
682
+ } as never,
683
+ refreshDiscovery: async () => [],
684
+ getDiscoveredProjects: () => [],
685
+ machineDisplayName: "Local Machine",
686
+ updateManager: null,
687
+ })
688
+ const ws = new FakeWebSocket()
689
+ router.handleOpen(ws as never)
690
+
691
+ await router.handleMessage(
692
+ ws as never,
693
+ JSON.stringify({
694
+ v: 1,
695
+ type: "subscribe",
696
+ id: "chat-sub-1",
697
+ topic: { type: "chat", chatId: "chat-1" },
698
+ })
699
+ )
700
+
701
+ expect(ws.sent[0]).toEqual({
702
+ v: PROTOCOL_VERSION,
703
+ type: "snapshot",
704
+ id: "chat-sub-1",
705
+ snapshot: {
706
+ type: "chat",
707
+ data: null,
708
+ },
709
+ })
710
+
711
+ await router.handleMessage(
712
+ ws as never,
713
+ JSON.stringify({
714
+ v: 1,
715
+ type: "unsubscribe",
716
+ id: "chat-sub-1",
717
+ })
718
+ )
719
+
720
+ expect(ws.sent[1]).toEqual({
721
+ v: PROTOCOL_VERSION,
722
+ type: "ack",
723
+ id: "chat-sub-1",
724
+ })
725
+ })
726
+
727
+ test("reuses one sidebar derivation across sockets in the same broadcast pass", async () => {
728
+ const state = createEmptyState()
729
+ state.projectsById.set("project-1", {
730
+ id: "project-1",
731
+ localPath: "/tmp/project",
732
+ title: "Project",
733
+ createdAt: 1,
734
+ updatedAt: 1,
735
+ })
736
+
737
+ let activeStatusCalls = 0
738
+ const router = createWsRouter({
739
+ store: { state } as never,
740
+ agent: {
741
+ getActiveStatuses: () => {
742
+ activeStatusCalls += 1
743
+ return new Map()
744
+ },
745
+ getDrainingChatIds: () => new Set(),
746
+ getSlashCommandsLoadingChatIds: () => new Set(),
747
+ ensureSlashCommandsLoaded: async () => {},
748
+ } as never,
749
+ terminals: {
750
+ getSnapshot: () => null,
751
+ onEvent: () => () => {},
752
+ } as never,
753
+ keybindings: {
754
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
755
+ onChange: () => () => {},
756
+ } as never,
757
+ refreshDiscovery: async () => [],
758
+ getDiscoveredProjects: () => [],
759
+ machineDisplayName: "Local Machine",
760
+ updateManager: null,
761
+ })
762
+
763
+ const wsA = new FakeWebSocket()
764
+ const wsB = new FakeWebSocket()
765
+ router.handleOpen(wsA as never)
766
+ router.handleOpen(wsB as never)
767
+ wsA.data.subscriptions.set("sidebar-a", { type: "sidebar" })
768
+ wsB.data.subscriptions.set("sidebar-b", { type: "sidebar" })
769
+
770
+ await router.broadcastSnapshots()
771
+
772
+ expect(activeStatusCalls).toBe(1)
773
+ expect(wsA.sent).toHaveLength(1)
774
+ expect(wsB.sent).toHaveLength(1)
775
+ })
776
+
777
+ test("subscribes to project git snapshots independently from chat snapshots", async () => {
778
+ const state = createEmptyState()
779
+ state.projectsById.set("project-1", {
780
+ id: "project-1",
781
+ localPath: "/tmp/project",
782
+ title: "Project",
783
+ createdAt: 1,
784
+ updatedAt: 1,
785
+ })
786
+
787
+ const router = createWsRouter({
788
+ store: {
789
+ state,
790
+ getProject: () => state.projectsById.get("project-1") ?? null,
791
+ } as never,
792
+ diffStore: {
793
+ getProjectSnapshot: () => ({
794
+ status: "ready",
795
+ branchName: "main",
796
+ files: [],
797
+ branchHistory: { entries: [] },
798
+ }),
799
+ refreshSnapshot: async () => false,
800
+ listBranches: async () => ({ recent: [], local: [], remote: [], pullRequests: [], pullRequestsStatus: "unavailable" }),
801
+ previewMergeBranch: async () => ({ currentBranchName: "main", targetBranchName: "feature/test", targetDisplayName: "feature/test", status: "mergeable", commitCount: 1, hasConflicts: false, message: "ready" }),
802
+ mergeBranch: async () => ({ ok: true, branchName: "main", snapshotChanged: false }),
803
+ syncBranch: async () => ({ ok: true, action: "fetch", snapshotChanged: false }),
804
+ checkoutBranch: async () => ({ ok: true, snapshotChanged: false }),
805
+ createBranch: async () => ({ ok: true, branchName: "main", snapshotChanged: false }),
806
+ generateCommitMessage: async () => ({ subject: "", body: "", usedFallback: true, failureMessage: null }),
807
+ commitFiles: async () => ({ ok: true, mode: "commit_only", pushed: false, snapshotChanged: false }),
808
+ discardFile: async () => ({ snapshotChanged: false }),
809
+ ignoreFile: async () => ({ snapshotChanged: false }),
810
+ readPatch: async () => ({ patch: "" }),
811
+ } as never,
812
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set(), getSlashCommandsLoadingChatIds: () => new Set(), ensureSlashCommandsLoaded: async () => {} } as never,
813
+ terminals: {
814
+ getSnapshot: () => null,
815
+ onEvent: () => () => {},
816
+ } as never,
817
+ keybindings: {
818
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
819
+ onChange: () => () => {},
820
+ } as never,
821
+ refreshDiscovery: async () => [],
822
+ getDiscoveredProjects: () => [],
823
+ machineDisplayName: "Local Machine",
824
+ updateManager: null,
825
+ })
826
+ const ws = new FakeWebSocket()
827
+ router.handleOpen(ws as never)
828
+
829
+ await router.handleMessage(
830
+ ws as never,
831
+ JSON.stringify({
832
+ v: 1,
833
+ type: "subscribe",
834
+ id: "project-git-sub-1",
835
+ topic: { type: "project-git", projectId: "project-1" },
836
+ })
837
+ )
838
+
839
+ expect(ws.sent[0]).toEqual({
840
+ v: PROTOCOL_VERSION,
841
+ type: "snapshot",
842
+ id: "project-git-sub-1",
843
+ snapshot: {
844
+ type: "project-git",
845
+ data: {
846
+ status: "ready",
847
+ branchName: "main",
848
+ files: [],
849
+ branchHistory: { entries: [] },
850
+ },
851
+ },
852
+ })
853
+ })
854
+
855
+ test("reads diff patches through the project-scoped command", async () => {
856
+ const state = createEmptyState()
857
+ state.projectsById.set("project-1", {
858
+ id: "project-1",
859
+ localPath: "/tmp/project",
860
+ title: "Project",
861
+ createdAt: 1,
862
+ updatedAt: 1,
863
+ })
864
+
865
+ const router = createWsRouter({
866
+ store: {
867
+ state,
868
+ getProject: (projectId: string) => state.projectsById.get(projectId) ?? null,
869
+ } as never,
870
+ diffStore: {
871
+ getProjectSnapshot: () => null,
872
+ refreshSnapshot: async () => false,
873
+ listBranches: async () => ({ recent: [], local: [], remote: [], pullRequests: [], pullRequestsStatus: "unavailable" }),
874
+ previewMergeBranch: async () => ({ currentBranchName: "main", targetBranchName: "feature/test", targetDisplayName: "feature/test", status: "mergeable", commitCount: 1, hasConflicts: false, message: "ready" }),
875
+ mergeBranch: async () => ({ ok: true, branchName: "main", snapshotChanged: false }),
876
+ syncBranch: async () => ({ ok: true, action: "fetch", snapshotChanged: false }),
877
+ checkoutBranch: async () => ({ ok: true, snapshotChanged: false }),
878
+ createBranch: async () => ({ ok: true, branchName: "main", snapshotChanged: false }),
879
+ generateCommitMessage: async () => ({ subject: "", body: "", usedFallback: true, failureMessage: null }),
880
+ commitFiles: async () => ({ ok: true, mode: "commit_only", pushed: false, snapshotChanged: false }),
881
+ discardFile: async () => ({ snapshotChanged: false }),
882
+ ignoreFile: async () => ({ snapshotChanged: false }),
883
+ readPatch: async () => ({ patch: "diff --git a/app.txt b/app.txt" }),
884
+ } as never,
885
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set(), getSlashCommandsLoadingChatIds: () => new Set(), ensureSlashCommandsLoaded: async () => {} } as never,
886
+ terminals: {
887
+ getSnapshot: () => null,
888
+ onEvent: () => () => {},
889
+ } as never,
890
+ keybindings: {
891
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
892
+ onChange: () => () => {},
893
+ } as never,
894
+ refreshDiscovery: async () => [],
895
+ getDiscoveredProjects: () => [],
896
+ machineDisplayName: "Local Machine",
897
+ updateManager: null,
898
+ })
899
+ const ws = new FakeWebSocket()
900
+ router.handleOpen(ws as never)
901
+
902
+ await router.handleMessage(
903
+ ws as never,
904
+ JSON.stringify({
905
+ v: 1,
906
+ type: "command",
907
+ id: "read-patch-1",
908
+ command: {
909
+ type: "project.readDiffPatch",
910
+ projectId: "project-1",
911
+ path: "app.txt",
912
+ },
913
+ })
914
+ )
915
+
916
+ expect(ws.sent[0]).toEqual({
917
+ v: PROTOCOL_VERSION,
918
+ type: "ack",
919
+ id: "read-patch-1",
920
+ result: { patch: "diff --git a/app.txt b/app.txt" },
921
+ })
922
+ })
923
+
924
+ test("routes merge preview and merge commands through the diff store", async () => {
925
+ const state = createEmptyState()
926
+ state.projectsById.set("project-1", {
927
+ id: "project-1",
928
+ localPath: "/tmp/project",
929
+ title: "Project",
930
+ createdAt: 1,
931
+ updatedAt: 1,
932
+ })
933
+ state.chatsById.set("chat-1", {
934
+ id: "chat-1",
935
+ projectId: "project-1",
936
+ title: "Chat",
937
+ createdAt: 1,
938
+ updatedAt: 1,
939
+ unread: false,
940
+ provider: null,
941
+ planMode: false,
942
+ sessionToken: null,
943
+ sourceHash: null,
944
+ lastTurnOutcome: null,
945
+ })
946
+
947
+ const router = createWsRouter({
948
+ store: {
949
+ state,
950
+ getProject: (projectId: string) => state.projectsById.get(projectId) ?? null,
951
+ getChat: (chatId: string) => state.chatsById.get(chatId) ?? null,
952
+ } as never,
953
+ diffStore: {
954
+ getProjectSnapshot: () => ({ status: "ready", branchName: "main", files: [], branchHistory: { entries: [] } }),
955
+ refreshSnapshot: async () => false,
956
+ listBranches: async () => ({ recent: [], local: [], remote: [], pullRequests: [], pullRequestsStatus: "unavailable" }),
957
+ previewMergeBranch: async () => ({ currentBranchName: "main", targetBranchName: "feature/test", targetDisplayName: "feature/test", status: "mergeable", commitCount: 2, hasConflicts: false, message: "2 commits from feature/test will merge into main." }),
958
+ mergeBranch: async () => ({ ok: true, branchName: "main", snapshotChanged: true }),
959
+ syncBranch: async () => ({ ok: true, action: "fetch", snapshotChanged: false }),
960
+ checkoutBranch: async () => ({ ok: true, snapshotChanged: false }),
961
+ createBranch: async () => ({ ok: true, branchName: "main", snapshotChanged: false }),
962
+ generateCommitMessage: async () => ({ subject: "", body: "", usedFallback: true, failureMessage: null }),
963
+ commitFiles: async () => ({ ok: true, mode: "commit_only", pushed: false, snapshotChanged: false }),
964
+ discardFile: async () => ({ snapshotChanged: false }),
965
+ ignoreFile: async () => ({ snapshotChanged: false }),
966
+ readPatch: async () => ({ patch: "" }),
967
+ } as never,
968
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set(), getSlashCommandsLoadingChatIds: () => new Set(), ensureSlashCommandsLoaded: async () => {} } as never,
969
+ terminals: {
970
+ getSnapshot: () => null,
971
+ onEvent: () => () => {},
972
+ } as never,
973
+ keybindings: {
974
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
975
+ onChange: () => () => {},
976
+ } as never,
977
+ refreshDiscovery: async () => [],
978
+ getDiscoveredProjects: () => [],
979
+ machineDisplayName: "Local Machine",
980
+ updateManager: null,
981
+ })
982
+ const ws = new FakeWebSocket()
983
+ router.handleOpen(ws as never)
984
+
985
+ await router.handleMessage(
986
+ ws as never,
987
+ JSON.stringify({
988
+ v: 1,
989
+ type: "command",
990
+ id: "preview-merge-1",
991
+ command: {
992
+ type: "chat.previewMergeBranch",
993
+ chatId: "chat-1",
994
+ branch: { kind: "local", name: "feature/test" },
995
+ },
996
+ })
997
+ )
998
+
999
+ await router.handleMessage(
1000
+ ws as never,
1001
+ JSON.stringify({
1002
+ v: 1,
1003
+ type: "command",
1004
+ id: "merge-1",
1005
+ command: {
1006
+ type: "chat.mergeBranch",
1007
+ chatId: "chat-1",
1008
+ branch: { kind: "local", name: "feature/test" },
1009
+ },
1010
+ })
1011
+ )
1012
+
1013
+ expect(ws.sent[0]).toEqual({
1014
+ v: PROTOCOL_VERSION,
1015
+ type: "ack",
1016
+ id: "preview-merge-1",
1017
+ result: {
1018
+ currentBranchName: "main",
1019
+ targetBranchName: "feature/test",
1020
+ targetDisplayName: "feature/test",
1021
+ status: "mergeable",
1022
+ commitCount: 2,
1023
+ hasConflicts: false,
1024
+ message: "2 commits from feature/test will merge into main.",
1025
+ },
1026
+ })
1027
+ expect(ws.sent[1]).toEqual({
1028
+ v: PROTOCOL_VERSION,
1029
+ type: "ack",
1030
+ id: "merge-1",
1031
+ result: {
1032
+ ok: true,
1033
+ branchName: "main",
1034
+ snapshotChanged: true,
1035
+ },
1036
+ })
1037
+ })
1038
+
1039
+ test("loads older chat history pages", async () => {
1040
+ const state = createEmptyState()
1041
+ state.projectsById.set("project-1", {
1042
+ id: "project-1",
1043
+ localPath: "/tmp/project",
1044
+ title: "Project",
1045
+ createdAt: 1,
1046
+ updatedAt: 1,
1047
+ })
1048
+ state.projectIdsByPath.set("/tmp/project", "project-1")
1049
+ state.chatsById.set("chat-1", {
1050
+ id: "chat-1",
1051
+ projectId: "project-1",
1052
+ title: "Chat",
1053
+ createdAt: 1,
1054
+ updatedAt: 1,
1055
+ unread: false,
1056
+ provider: null,
1057
+ planMode: false,
1058
+ sessionToken: null,
1059
+ sourceHash: null,
1060
+ lastTurnOutcome: null,
1061
+ })
1062
+
1063
+ const router = createWsRouter({
1064
+ store: {
1065
+ state,
1066
+ getMessagesPageBefore: () => ({
1067
+ messages: [{
1068
+ _id: "msg-1",
1069
+ kind: "assistant_text",
1070
+ createdAt: 1,
1071
+ text: "older message",
1072
+ }],
1073
+ hasOlder: false,
1074
+ olderCursor: null,
1075
+ }),
1076
+ getChat: () => state.chatsById.get("chat-1") ?? null,
1077
+ } as never,
1078
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set(), getSlashCommandsLoadingChatIds: () => new Set(), ensureSlashCommandsLoaded: async () => {} } as never,
1079
+ terminals: {
1080
+ getSnapshot: () => null,
1081
+ onEvent: () => () => {},
1082
+ } as never,
1083
+ keybindings: {
1084
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
1085
+ onChange: () => () => {},
1086
+ } as never,
1087
+ refreshDiscovery: async () => [],
1088
+ getDiscoveredProjects: () => [],
1089
+ machineDisplayName: "Local Machine",
1090
+ updateManager: null,
1091
+ })
1092
+ const ws = new FakeWebSocket()
1093
+
1094
+ await router.handleMessage(
1095
+ ws as never,
1096
+ JSON.stringify({
1097
+ v: 1,
1098
+ type: "command",
1099
+ id: "history-1",
1100
+ command: {
1101
+ type: "chat.loadHistory",
1102
+ chatId: "chat-1",
1103
+ beforeCursor: "idx:100",
1104
+ limit: 100,
1105
+ },
1106
+ })
1107
+ )
1108
+
1109
+ expect(ws.sent[0]).toEqual({
1110
+ v: PROTOCOL_VERSION,
1111
+ type: "ack",
1112
+ id: "history-1",
1113
+ result: {
1114
+ messages: [{
1115
+ _id: "msg-1",
1116
+ kind: "assistant_text",
1117
+ createdAt: 1,
1118
+ text: "older message",
1119
+ }],
1120
+ hasOlder: false,
1121
+ olderCursor: null,
1122
+ },
1123
+ })
1124
+ })
1125
+
1126
+ test("marks chats read and rebroadcasts sidebar snapshots", async () => {
1127
+ const state = createEmptyState()
1128
+ state.projectsById.set("project-1", {
1129
+ id: "project-1",
1130
+ localPath: "/tmp/project",
1131
+ title: "Project",
1132
+ createdAt: 1,
1133
+ updatedAt: 1,
1134
+ })
1135
+ state.projectIdsByPath.set("/tmp/project", "project-1")
1136
+ state.chatsById.set("chat-1", {
1137
+ id: "chat-1",
1138
+ projectId: "project-1",
1139
+ title: "Chat",
1140
+ createdAt: 1,
1141
+ updatedAt: 1,
1142
+ unread: true,
1143
+ provider: null,
1144
+ planMode: false,
1145
+ sessionToken: null,
1146
+ sourceHash: null,
1147
+ lastTurnOutcome: null,
1148
+ })
1149
+
1150
+ const store = {
1151
+ state,
1152
+ getTunnelEvents: (_chatId: string) => [] as never[],
1153
+ async setChatReadState(chatId: string, unread: boolean) {
1154
+ const chat = state.chatsById.get(chatId)
1155
+ if (!chat) throw new Error("Chat not found")
1156
+ chat.unread = unread
1157
+ },
1158
+ }
1159
+
1160
+ const router = createWsRouter({
1161
+ store: store as never,
1162
+ agent: {
1163
+ getActiveStatuses: () => new Map(),
1164
+ getDrainingChatIds: () => new Set(),
1165
+ } as never,
1166
+ terminals: {
1167
+ getSnapshot: () => null,
1168
+ onEvent: () => () => {},
1169
+ } as never,
1170
+ keybindings: {
1171
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
1172
+ onChange: () => () => {},
1173
+ } as never,
1174
+ refreshDiscovery: async () => [],
1175
+ getDiscoveredProjects: () => [],
1176
+ machineDisplayName: "Local Machine",
1177
+ updateManager: null,
1178
+ })
1179
+ const wsA = new FakeWebSocket()
1180
+ const wsB = new FakeWebSocket()
1181
+
1182
+ router.handleOpen(wsA as never)
1183
+ router.handleOpen(wsB as never)
1184
+
1185
+ await router.handleMessage(
1186
+ wsA as never,
1187
+ JSON.stringify({
1188
+ v: 1,
1189
+ type: "subscribe",
1190
+ id: "sidebar-a",
1191
+ topic: { type: "sidebar" },
1192
+ })
1193
+ )
1194
+ await router.handleMessage(
1195
+ wsB as never,
1196
+ JSON.stringify({
1197
+ v: 1,
1198
+ type: "subscribe",
1199
+ id: "sidebar-b",
1200
+ topic: { type: "sidebar" },
1201
+ })
1202
+ )
1203
+
1204
+ await router.handleMessage(
1205
+ wsA as never,
1206
+ JSON.stringify({
1207
+ v: 1,
1208
+ type: "command",
1209
+ id: "mark-read-1",
1210
+ command: { type: "chat.markRead", chatId: "chat-1" },
1211
+ })
1212
+ )
1213
+
1214
+ expect(wsA.sent.at(-2)).toEqual({
1215
+ v: PROTOCOL_VERSION,
1216
+ type: "ack",
1217
+ id: "mark-read-1",
1218
+ })
1219
+ expect(wsA.sent.at(-1)).toEqual({
1220
+ v: PROTOCOL_VERSION,
1221
+ type: "snapshot",
1222
+ id: "sidebar-a",
1223
+ snapshot: {
1224
+ type: "sidebar",
1225
+ data: {
1226
+ projectGroups: [withSidebarGroupDefaults({
1227
+ groupKey: "project-1",
1228
+ localPath: "/tmp/project",
1229
+ chats: [{
1230
+ _id: "chat-1",
1231
+ _creationTime: 1,
1232
+ chatId: "chat-1",
1233
+ title: "Chat",
1234
+ status: "idle",
1235
+ unread: false,
1236
+ localPath: "/tmp/project",
1237
+ provider: null,
1238
+ hasAutomation: false,
1239
+ }],
1240
+ })],
1241
+ },
1242
+ },
1243
+ })
1244
+ expect(wsB.sent.at(-1)).toEqual({
1245
+ v: PROTOCOL_VERSION,
1246
+ type: "snapshot",
1247
+ id: "sidebar-b",
1248
+ snapshot: {
1249
+ type: "sidebar",
1250
+ data: {
1251
+ projectGroups: [withSidebarGroupDefaults({
1252
+ groupKey: "project-1",
1253
+ localPath: "/tmp/project",
1254
+ chats: [{
1255
+ _id: "chat-1",
1256
+ _creationTime: 1,
1257
+ chatId: "chat-1",
1258
+ title: "Chat",
1259
+ status: "idle",
1260
+ unread: false,
1261
+ localPath: "/tmp/project",
1262
+ provider: null,
1263
+ hasAutomation: false,
1264
+ }],
1265
+ })],
1266
+ },
1267
+ },
1268
+ })
1269
+ })
1270
+
1271
+ test("reorders sidebar project groups on the server and rebroadcasts the snapshot", async () => {
1272
+ const state = createEmptyState()
1273
+ state.projectsById.set("project-1", {
1274
+ id: "project-1",
1275
+ localPath: "/tmp/project-1",
1276
+ title: "Project 1",
1277
+ createdAt: 1,
1278
+ updatedAt: 1,
1279
+ })
1280
+ state.projectsById.set("project-2", {
1281
+ id: "project-2",
1282
+ localPath: "/tmp/project-2",
1283
+ title: "Project 2",
1284
+ createdAt: 2,
1285
+ updatedAt: 2,
1286
+ })
1287
+
1288
+ const setSidebarProjectOrderCalls: string[][] = []
1289
+ let sidebarProjectOrder: string[] = []
1290
+ const router = createWsRouter({
1291
+ store: {
1292
+ state,
1293
+ getSidebarProjectOrder() {
1294
+ return [...sidebarProjectOrder]
1295
+ },
1296
+ async setSidebarProjectOrder(projectIds: string[]) {
1297
+ setSidebarProjectOrderCalls.push(projectIds)
1298
+ sidebarProjectOrder = [...projectIds]
1299
+ },
1300
+ } as never,
1301
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set(), getSlashCommandsLoadingChatIds: () => new Set(), ensureSlashCommandsLoaded: async () => {} } as never,
1302
+ terminals: {
1303
+ getSnapshot: () => null,
1304
+ onEvent: () => () => {},
1305
+ } as never,
1306
+ keybindings: {
1307
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
1308
+ onChange: () => () => {},
1309
+ } as never,
1310
+ refreshDiscovery: async () => [],
1311
+ getDiscoveredProjects: () => [],
1312
+ machineDisplayName: "Local Machine",
1313
+ updateManager: null,
1314
+ })
1315
+ const ws = new FakeWebSocket()
1316
+ router.handleOpen(ws as never)
1317
+
1318
+ await router.handleMessage(
1319
+ ws as never,
1320
+ JSON.stringify({
1321
+ v: 1,
1322
+ type: "subscribe",
1323
+ id: "sidebar-sub-1",
1324
+ topic: { type: "sidebar" },
1325
+ })
1326
+ )
1327
+
1328
+ await router.handleMessage(
1329
+ ws as never,
1330
+ JSON.stringify({
1331
+ v: 1,
1332
+ type: "command",
1333
+ id: "sidebar-reorder-1",
1334
+ command: { type: "sidebar.reorderProjectGroups", projectIds: ["project-1", "project-2"] },
1335
+ })
1336
+ )
1337
+
1338
+ expect(setSidebarProjectOrderCalls).toEqual([["project-1", "project-2"]])
1339
+ expect(ws.sent.at(-2)).toEqual({
1340
+ v: PROTOCOL_VERSION,
1341
+ type: "ack",
1342
+ id: "sidebar-reorder-1",
1343
+ })
1344
+ expect(ws.sent.at(-1)).toEqual({
1345
+ v: PROTOCOL_VERSION,
1346
+ type: "snapshot",
1347
+ id: "sidebar-sub-1",
1348
+ snapshot: {
1349
+ type: "sidebar",
1350
+ data: {
1351
+ projectGroups: [
1352
+ withSidebarGroupDefaults({
1353
+ groupKey: "project-1",
1354
+ localPath: "/tmp/project-1",
1355
+ chats: [],
1356
+ }),
1357
+ withSidebarGroupDefaults({
1358
+ groupKey: "project-2",
1359
+ localPath: "/tmp/project-2",
1360
+ chats: [],
1361
+ }),
1362
+ ],
1363
+ },
1364
+ },
1365
+ })
1366
+ })
1367
+
1368
+ test("forks a chat through the agent and rebroadcasts the sidebar snapshot", async () => {
1369
+ const state = createEmptyState()
1370
+ state.projectsById.set("project-1", {
1371
+ id: "project-1",
1372
+ localPath: "/tmp/project",
1373
+ title: "Project",
1374
+ createdAt: 1,
1375
+ updatedAt: 1,
1376
+ })
1377
+ state.chatsById.set("chat-1", {
1378
+ id: "chat-1",
1379
+ projectId: "project-1",
1380
+ title: "Chat",
1381
+ createdAt: 1,
1382
+ updatedAt: 1,
1383
+ unread: false,
1384
+ provider: "claude",
1385
+ planMode: false,
1386
+ sessionToken: "session-1",
1387
+ sourceHash: null,
1388
+ pendingForkSessionToken: null,
1389
+ lastTurnOutcome: null,
1390
+ })
1391
+
1392
+ const forkChatCalls: string[] = []
1393
+ const router = createWsRouter({
1394
+ store: { state } as never,
1395
+ agent: {
1396
+ getActiveStatuses: () => new Map(),
1397
+ getDrainingChatIds: () => new Set(),
1398
+ forkChat: async (chatId: string) => {
1399
+ forkChatCalls.push(chatId)
1400
+ state.chatsById.set("chat-fork-1", {
1401
+ id: "chat-fork-1",
1402
+ projectId: "project-1",
1403
+ title: "Fork: Chat",
1404
+ createdAt: 2,
1405
+ updatedAt: 2,
1406
+ unread: false,
1407
+ provider: "claude",
1408
+ planMode: false,
1409
+ sessionToken: null,
1410
+ sourceHash: null,
1411
+ pendingForkSessionToken: "session-1",
1412
+ lastTurnOutcome: null,
1413
+ })
1414
+ return { chatId: "chat-fork-1" }
1415
+ },
1416
+ } as never,
1417
+ terminals: {
1418
+ getSnapshot: () => null,
1419
+ onEvent: () => () => {},
1420
+ } as never,
1421
+ keybindings: {
1422
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
1423
+ onChange: () => () => {},
1424
+ } as never,
1425
+ refreshDiscovery: async () => [],
1426
+ getDiscoveredProjects: () => [],
1427
+ machineDisplayName: "Local Machine",
1428
+ updateManager: null,
1429
+ })
1430
+ const ws = new FakeWebSocket()
1431
+ router.handleOpen(ws as never)
1432
+
1433
+ await router.handleMessage(
1434
+ ws as never,
1435
+ JSON.stringify({
1436
+ v: 1,
1437
+ type: "subscribe",
1438
+ id: "sidebar-sub-1",
1439
+ topic: { type: "sidebar" },
1440
+ })
1441
+ )
1442
+
1443
+ await router.handleMessage(
1444
+ ws as never,
1445
+ JSON.stringify({
1446
+ v: 1,
1447
+ type: "command",
1448
+ id: "fork-1",
1449
+ command: { type: "chat.fork", chatId: "chat-1" },
1450
+ })
1451
+ )
1452
+
1453
+ expect(forkChatCalls).toEqual(["chat-1"])
1454
+ expect(ws.sent.at(-2)).toEqual({
1455
+ v: PROTOCOL_VERSION,
1456
+ type: "ack",
1457
+ id: "fork-1",
1458
+ result: { chatId: "chat-fork-1" },
1459
+ })
1460
+ expect(ws.sent.at(-1)).toEqual({
1461
+ v: PROTOCOL_VERSION,
1462
+ type: "snapshot",
1463
+ id: "sidebar-sub-1",
1464
+ snapshot: {
1465
+ type: "sidebar",
1466
+ data: {
1467
+ projectGroups: [withSidebarGroupDefaults({
1468
+ groupKey: "project-1",
1469
+ localPath: "/tmp/project",
1470
+ chats: [{
1471
+ _id: "chat-fork-1",
1472
+ _creationTime: 2,
1473
+ chatId: "chat-fork-1",
1474
+ title: "Fork: Chat",
1475
+ status: "idle",
1476
+ unread: false,
1477
+ localPath: "/tmp/project",
1478
+ provider: "claude",
1479
+ canFork: true,
1480
+ hasAutomation: false,
1481
+ }, {
1482
+ _id: "chat-1",
1483
+ _creationTime: 1,
1484
+ chatId: "chat-1",
1485
+ title: "Chat",
1486
+ status: "idle",
1487
+ unread: false,
1488
+ localPath: "/tmp/project",
1489
+ provider: "claude",
1490
+ canFork: true,
1491
+ hasAutomation: false,
1492
+ }],
1493
+ })],
1494
+ },
1495
+ },
1496
+ })
1497
+ })
1498
+
1499
+ test("prunes stale empty chats during explicit maintenance runs", async () => {
1500
+ const state = createEmptyState()
1501
+ state.projectsById.set("project-1", {
1502
+ id: "project-1",
1503
+ localPath: "/tmp/project",
1504
+ title: "Project",
1505
+ createdAt: 1,
1506
+ updatedAt: 1,
1507
+ })
1508
+ state.projectIdsByPath.set("/tmp/project", "project-1")
1509
+ state.chatsById.set("chat-stale", {
1510
+ id: "chat-stale",
1511
+ projectId: "project-1",
1512
+ title: "New Chat",
1513
+ createdAt: 1,
1514
+ updatedAt: 1,
1515
+ unread: false,
1516
+ provider: null,
1517
+ planMode: false,
1518
+ sessionToken: null,
1519
+ sourceHash: null,
1520
+ lastTurnOutcome: null,
1521
+ })
1522
+
1523
+ let pruneCalls = 0
1524
+ const router = createWsRouter({
1525
+ store: {
1526
+ state,
1527
+ async pruneStaleEmptyChats() {
1528
+ pruneCalls += 1
1529
+ state.chatsById.delete("chat-stale")
1530
+ return ["chat-stale"]
1531
+ },
1532
+ } as never,
1533
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set(), getSlashCommandsLoadingChatIds: () => new Set(), ensureSlashCommandsLoaded: async () => {} } as never,
1534
+ terminals: {
1535
+ getSnapshot: () => null,
1536
+ onEvent: () => () => {},
1537
+ } as never,
1538
+ keybindings: {
1539
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
1540
+ onChange: () => () => {},
1541
+ } as never,
1542
+ refreshDiscovery: async () => [],
1543
+ getDiscoveredProjects: () => [],
1544
+ machineDisplayName: "Local Machine",
1545
+ updateManager: null,
1546
+ })
1547
+ const ws = new FakeWebSocket()
1548
+
1549
+ await router.pruneStaleEmptyChats()
1550
+ await router.handleMessage(
1551
+ ws as never,
1552
+ JSON.stringify({
1553
+ v: 1,
1554
+ type: "subscribe",
1555
+ id: "sidebar-sub-1",
1556
+ topic: { type: "sidebar" },
1557
+ })
1558
+ )
1559
+
1560
+ expect(pruneCalls).toBe(1)
1561
+ expect(ws.sent[0]).toEqual({
1562
+ v: PROTOCOL_VERSION,
1563
+ type: "snapshot",
1564
+ id: "sidebar-sub-1",
1565
+ snapshot: {
1566
+ type: "sidebar",
1567
+ data: {
1568
+ projectGroups: [{
1569
+ ...withSidebarGroupDefaults({
1570
+ groupKey: "project-1",
1571
+ localPath: "/tmp/project",
1572
+ chats: [],
1573
+ }),
1574
+ }],
1575
+ },
1576
+ },
1577
+ })
1578
+ })
1579
+
1580
+ test("protects draft-bearing chats during explicit maintenance runs", async () => {
1581
+ const state = createEmptyState()
1582
+ state.projectsById.set("project-1", {
1583
+ id: "project-1",
1584
+ localPath: "/tmp/project",
1585
+ title: "Project",
1586
+ createdAt: 1,
1587
+ updatedAt: 1,
1588
+ })
1589
+ state.projectIdsByPath.set("/tmp/project", "project-1")
1590
+ state.chatsById.set("chat-stale", {
1591
+ id: "chat-stale",
1592
+ projectId: "project-1",
1593
+ title: "New Chat",
1594
+ createdAt: 1,
1595
+ updatedAt: 1,
1596
+ unread: false,
1597
+ provider: null,
1598
+ planMode: false,
1599
+ sessionToken: null,
1600
+ sourceHash: null,
1601
+ lastTurnOutcome: null,
1602
+ })
1603
+
1604
+ let capturedProtectedChatIds: string[] = []
1605
+ const router = createWsRouter({
1606
+ store: {
1607
+ state,
1608
+ async pruneStaleEmptyChats(args?: { protectedChatIds?: Iterable<string> }) {
1609
+ capturedProtectedChatIds = [...(args?.protectedChatIds ?? [])]
1610
+ return []
1611
+ },
1612
+ } as never,
1613
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set(), getSlashCommandsLoadingChatIds: () => new Set(), ensureSlashCommandsLoaded: async () => {} } as never,
1614
+ terminals: {
1615
+ getSnapshot: () => null,
1616
+ onEvent: () => () => {},
1617
+ } as never,
1618
+ keybindings: {
1619
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
1620
+ onChange: () => () => {},
1621
+ } as never,
1622
+ refreshDiscovery: async () => [],
1623
+ getDiscoveredProjects: () => [],
1624
+ machineDisplayName: "Local Machine",
1625
+ updateManager: null,
1626
+ })
1627
+ const ws = new FakeWebSocket()
1628
+ router.handleOpen(ws as never)
1629
+
1630
+ await router.handleMessage(
1631
+ ws as never,
1632
+ JSON.stringify({
1633
+ v: 1,
1634
+ type: "command",
1635
+ id: "draft-protection-1",
1636
+ command: {
1637
+ type: "chat.setDraftProtection",
1638
+ chatIds: ["chat-stale"],
1639
+ },
1640
+ })
1641
+ )
1642
+
1643
+ await router.pruneStaleEmptyChats()
1644
+ await router.handleMessage(
1645
+ ws as never,
1646
+ JSON.stringify({
1647
+ v: 1,
1648
+ type: "subscribe",
1649
+ id: "sidebar-sub-1",
1650
+ topic: { type: "sidebar" },
1651
+ })
1652
+ )
1653
+
1654
+ expect(capturedProtectedChatIds).toEqual(["chat-stale"])
1655
+ expect(ws.sent[0]).toEqual({
1656
+ v: PROTOCOL_VERSION,
1657
+ type: "ack",
1658
+ id: "draft-protection-1",
1659
+ })
1660
+ })
1661
+
1662
+ test("broadcasts background title-generation errors to connected clients", () => {
1663
+ let reportBackgroundError: ((message: string) => void) | null | undefined
1664
+ const router = createWsRouter({
1665
+ store: { state: createEmptyState() } as never,
1666
+ agent: {
1667
+ getActiveStatuses: () => new Map(),
1668
+ setBackgroundErrorReporter: (reporter: ((message: string) => void) | null) => {
1669
+ reportBackgroundError = reporter
1670
+ },
1671
+ } as never,
1672
+ terminals: {
1673
+ getSnapshot: () => null,
1674
+ onEvent: () => () => {},
1675
+ } as never,
1676
+ keybindings: {
1677
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
1678
+ onChange: () => () => {},
1679
+ } as never,
1680
+ refreshDiscovery: async () => [],
1681
+ getDiscoveredProjects: () => [],
1682
+ machineDisplayName: "Local Machine",
1683
+ updateManager: null,
1684
+ })
1685
+ const ws = new FakeWebSocket()
1686
+ router.handleOpen(ws as never)
1687
+
1688
+ reportBackgroundError?.("[title-generation] chat chat-1 failed")
1689
+
1690
+ expect(ws.sent).toEqual([
1691
+ {
1692
+ v: PROTOCOL_VERSION,
1693
+ type: "error",
1694
+ message: "[title-generation] chat chat-1 failed",
1695
+ },
1696
+ ])
1697
+ })
1698
+
1699
+ test("subscribes to keybindings snapshots and writes keybindings through the router", async () => {
1700
+ const initialSnapshot: KeybindingsSnapshot = DEFAULT_KEYBINDINGS_SNAPSHOT
1701
+ const keybindings = {
1702
+ snapshot: initialSnapshot,
1703
+ getSnapshot() {
1704
+ return this.snapshot
1705
+ },
1706
+ onChange: () => () => {},
1707
+ async write(bindings: KeybindingsSnapshot["bindings"]) {
1708
+ this.snapshot = { bindings, warning: null, filePathDisplay: "~/.kanna/keybindings.json" }
1709
+ return this.snapshot
1710
+ },
1711
+ }
1712
+
1713
+ const router = createWsRouter({
1714
+ store: { state: createEmptyState() } as never,
1715
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set(), getSlashCommandsLoadingChatIds: () => new Set(), ensureSlashCommandsLoaded: async () => {} } as never,
1716
+ terminals: {
1717
+ getSnapshot: () => null,
1718
+ onEvent: () => () => {},
1719
+ } as never,
1720
+ keybindings: keybindings as never,
1721
+ refreshDiscovery: async () => [],
1722
+ getDiscoveredProjects: () => [],
1723
+ machineDisplayName: "Local Machine",
1724
+ updateManager: null,
1725
+ })
1726
+ const ws = new FakeWebSocket()
1727
+
1728
+ await router.handleMessage(
1729
+ ws as never,
1730
+ JSON.stringify({
1731
+ v: 1,
1732
+ type: "subscribe",
1733
+ id: "keybindings-sub-1",
1734
+ topic: { type: "keybindings" },
1735
+ })
1736
+ )
1737
+
1738
+ expect(ws.sent[0]).toEqual({
1739
+ v: PROTOCOL_VERSION,
1740
+ type: "snapshot",
1741
+ id: "keybindings-sub-1",
1742
+ snapshot: {
1743
+ type: "keybindings",
1744
+ data: keybindings.snapshot,
1745
+ },
1746
+ })
1747
+
1748
+ await router.handleMessage(
1749
+ ws as never,
1750
+ JSON.stringify({
1751
+ v: 1,
1752
+ type: "command",
1753
+ id: "keybindings-write-1",
1754
+ command: {
1755
+ type: "settings.writeKeybindings",
1756
+ bindings: {
1757
+ toggleEmbeddedTerminal: ["cmd+k"],
1758
+ toggleRightSidebar: ["ctrl+shift+b"],
1759
+ openInFinder: ["cmd+shift+g"],
1760
+ openInEditor: ["cmd+shift+p"],
1761
+ addSplitTerminal: ["cmd+alt+j"],
1762
+ jumpToSidebarChat: ["cmd+alt"],
1763
+ createChatInCurrentProject: ["cmd+alt+n"],
1764
+ openAddProject: ["cmd+alt+o"],
1765
+ },
1766
+ },
1767
+ })
1768
+ )
1769
+
1770
+ await Promise.resolve()
1771
+ expect(ws.sent[1]).toEqual({
1772
+ v: PROTOCOL_VERSION,
1773
+ type: "ack",
1774
+ id: "keybindings-write-1",
1775
+ result: {
1776
+ bindings: {
1777
+ toggleEmbeddedTerminal: ["cmd+k"],
1778
+ toggleRightSidebar: ["ctrl+shift+b"],
1779
+ openInFinder: ["cmd+shift+g"],
1780
+ openInEditor: ["cmd+shift+p"],
1781
+ addSplitTerminal: ["cmd+alt+j"],
1782
+ jumpToSidebarChat: ["cmd+alt"],
1783
+ createChatInCurrentProject: ["cmd+alt+n"],
1784
+ openAddProject: ["cmd+alt+o"],
1785
+ },
1786
+ warning: null,
1787
+ filePathDisplay: "~/.kanna/keybindings.json",
1788
+ },
1789
+ })
1790
+ })
1791
+
1792
+ test("subscribes to update snapshots and handles update.check commands", async () => {
1793
+ const updateManager = {
1794
+ snapshot: { ...DEFAULT_UPDATE_SNAPSHOT },
1795
+ getSnapshot() {
1796
+ return this.snapshot
1797
+ },
1798
+ onChange: () => () => {},
1799
+ async checkForUpdates({ force }: { force?: boolean }) {
1800
+ this.snapshot = {
1801
+ ...this.snapshot,
1802
+ latestVersion: force ? "0.13.0" : "0.12.1",
1803
+ status: "available",
1804
+ updateAvailable: true,
1805
+ lastCheckedAt: 123,
1806
+ }
1807
+ return this.snapshot
1808
+ },
1809
+ async installUpdate() {
1810
+ return {
1811
+ ok: false,
1812
+ action: "restart",
1813
+ errorCode: "version_not_live_yet",
1814
+ userTitle: "Update not live yet",
1815
+ userMessage: "This update is still propagating. Try again in a few minutes.",
1816
+ }
1817
+ },
1818
+ }
1819
+
1820
+ const router = createWsRouter({
1821
+ store: { state: createEmptyState() } as never,
1822
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set(), getSlashCommandsLoadingChatIds: () => new Set(), ensureSlashCommandsLoaded: async () => {} } as never,
1823
+ terminals: {
1824
+ getSnapshot: () => null,
1825
+ onEvent: () => () => {},
1826
+ } as never,
1827
+ keybindings: {
1828
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
1829
+ onChange: () => () => {},
1830
+ } as never,
1831
+ refreshDiscovery: async () => [],
1832
+ getDiscoveredProjects: () => [],
1833
+ machineDisplayName: "Local Machine",
1834
+ updateManager: updateManager as never,
1835
+ })
1836
+ const ws = new FakeWebSocket()
1837
+
1838
+ await router.handleMessage(
1839
+ ws as never,
1840
+ JSON.stringify({
1841
+ v: 1,
1842
+ type: "subscribe",
1843
+ id: "update-sub-1",
1844
+ topic: { type: "update" },
1845
+ })
1846
+ )
1847
+
1848
+ expect(ws.sent[0]).toEqual({
1849
+ v: PROTOCOL_VERSION,
1850
+ type: "snapshot",
1851
+ id: "update-sub-1",
1852
+ snapshot: {
1853
+ type: "update",
1854
+ data: DEFAULT_UPDATE_SNAPSHOT,
1855
+ },
1856
+ })
1857
+
1858
+ await router.handleMessage(
1859
+ ws as never,
1860
+ JSON.stringify({
1861
+ v: 1,
1862
+ type: "command",
1863
+ id: "update-check-1",
1864
+ command: {
1865
+ type: "update.check",
1866
+ force: true,
1867
+ },
1868
+ })
1869
+ )
1870
+
1871
+ await Promise.resolve()
1872
+ expect(ws.sent[1]).toEqual({
1873
+ v: PROTOCOL_VERSION,
1874
+ type: "ack",
1875
+ id: "update-check-1",
1876
+ result: {
1877
+ currentVersion: "0.12.0",
1878
+ latestVersion: "0.13.0",
1879
+ status: "available",
1880
+ updateAvailable: true,
1881
+ lastCheckedAt: 123,
1882
+ error: null,
1883
+ installAction: "restart",
1884
+ reloadRequestedAt: null,
1885
+ },
1886
+ })
1887
+
1888
+ await router.handleMessage(
1889
+ ws as never,
1890
+ JSON.stringify({
1891
+ v: 1,
1892
+ type: "command",
1893
+ id: "update-install-1",
1894
+ command: {
1895
+ type: "update.install",
1896
+ },
1897
+ })
1898
+ )
1899
+
1900
+ await Promise.resolve()
1901
+ expect(ws.sent[2]).toEqual({
1902
+ v: PROTOCOL_VERSION,
1903
+ type: "ack",
1904
+ id: "update-install-1",
1905
+ result: {
1906
+ ok: false,
1907
+ action: "restart",
1908
+ errorCode: "version_not_live_yet",
1909
+ userTitle: "Update not live yet",
1910
+ userMessage: "This update is still propagating. Try again in a few minutes.",
1911
+ },
1912
+ })
1913
+ })
1914
+
1915
+ test("routes discard diff file commands through the diff store and rebroadcasts chat snapshots", async () => {
1916
+ const state = createEmptyState()
1917
+ state.projectsById.set("project-1", {
1918
+ id: "project-1",
1919
+ localPath: "/tmp/project",
1920
+ title: "Project",
1921
+ createdAt: 1,
1922
+ updatedAt: 1,
1923
+ })
1924
+ state.projectIdsByPath.set("/tmp/project", "project-1")
1925
+ state.chatsById.set("chat-1", {
1926
+ id: "chat-1",
1927
+ projectId: "project-1",
1928
+ title: "Chat",
1929
+ createdAt: 1,
1930
+ updatedAt: 1,
1931
+ unread: false,
1932
+ provider: null,
1933
+ planMode: false,
1934
+ sessionToken: null,
1935
+ sourceHash: null,
1936
+ lastTurnOutcome: null,
1937
+ })
1938
+
1939
+ const discardCalls: Array<{ projectId: string; projectPath: string; path: string }> = []
1940
+ const diffStore = {
1941
+ getProjectSnapshot: () => ({ status: "ready" as const, files: [], defaultBranchName: "main", originRepoSlug: "acme/repo", aheadCount: 0, behindCount: 0, lastFetchedAt: undefined }),
1942
+ refreshSnapshot: async () => false,
1943
+ syncBranch: async () => ({ ok: true as const, action: "fetch" as const, snapshotChanged: false }),
1944
+ generateCommitMessage: async () => ({ subject: "", body: "" }),
1945
+ commitFiles: async () => ({ ok: true as const, mode: "commit_only" as const, pushed: false, snapshotChanged: false }),
1946
+ discardFile: async (args: { projectId: string; projectPath: string; path: string }) => {
1947
+ discardCalls.push(args)
1948
+ return { snapshotChanged: true }
1949
+ },
1950
+ ignoreFile: async () => ({ snapshotChanged: false }),
1951
+ }
1952
+
1953
+ const router = createWsRouter({
1954
+ store: {
1955
+ state,
1956
+ getChat: (chatId: string) => state.chatsById.get(chatId) ?? null,
1957
+ getProject: (projectId: string) => state.projectsById.get(projectId) ?? null,
1958
+ getRecentChatHistory: () => ({ entries: [], hasOlder: false, olderCursor: null }),
1959
+ getTunnelEvents: (_chatId: string) => [] as never[],
1960
+ } as never,
1961
+ diffStore: diffStore as never,
1962
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set(), getSlashCommandsLoadingChatIds: () => new Set(), ensureSlashCommandsLoaded: async () => {} } as never,
1963
+ terminals: {
1964
+ getSnapshot: () => null,
1965
+ onEvent: () => () => {},
1966
+ } as never,
1967
+ keybindings: {
1968
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
1969
+ onChange: () => () => {},
1970
+ } as never,
1971
+ refreshDiscovery: async () => [],
1972
+ getDiscoveredProjects: () => [],
1973
+ machineDisplayName: "Local Machine",
1974
+ updateManager: null,
1975
+ })
1976
+ const ws = new FakeWebSocket()
1977
+
1978
+ router.handleOpen(ws as never)
1979
+ router.handleMessage(
1980
+ ws as never,
1981
+ JSON.stringify({
1982
+ v: 1,
1983
+ type: "subscribe",
1984
+ id: "chat-sub",
1985
+ topic: { type: "chat", chatId: "chat-1" },
1986
+ })
1987
+ )
1988
+
1989
+ await router.handleMessage(
1990
+ ws as never,
1991
+ JSON.stringify({
1992
+ v: 1,
1993
+ type: "command",
1994
+ id: "discard-1",
1995
+ command: {
1996
+ type: "chat.discardDiffFile",
1997
+ chatId: "chat-1",
1998
+ path: "app.txt",
1999
+ },
2000
+ })
2001
+ )
2002
+
2003
+ expect(discardCalls).toEqual([{
2004
+ projectId: "project-1",
2005
+ projectPath: "/tmp/project",
2006
+ path: "app.txt",
2007
+ }])
2008
+ expect(ws.sent).toContainEqual({
2009
+ v: PROTOCOL_VERSION,
2010
+ type: "ack",
2011
+ id: "discard-1",
2012
+ result: { snapshotChanged: true },
2013
+ })
2014
+ })
2015
+
2016
+ test("routes ignore diff file commands through the diff store", async () => {
2017
+ const state = createEmptyState()
2018
+ state.projectsById.set("project-1", {
2019
+ id: "project-1",
2020
+ localPath: "/tmp/project",
2021
+ title: "Project",
2022
+ createdAt: 1,
2023
+ updatedAt: 1,
2024
+ })
2025
+ state.projectIdsByPath.set("/tmp/project", "project-1")
2026
+ state.chatsById.set("chat-1", {
2027
+ id: "chat-1",
2028
+ projectId: "project-1",
2029
+ title: "Chat",
2030
+ createdAt: 1,
2031
+ updatedAt: 1,
2032
+ unread: false,
2033
+ provider: null,
2034
+ planMode: false,
2035
+ sessionToken: null,
2036
+ sourceHash: null,
2037
+ lastTurnOutcome: null,
2038
+ })
2039
+
2040
+ const ignoreCalls: Array<{ projectId: string; projectPath: string; path: string }> = []
2041
+ const router = createWsRouter({
2042
+ store: {
2043
+ state,
2044
+ getChat: (chatId: string) => state.chatsById.get(chatId) ?? null,
2045
+ getProject: (projectId: string) => state.projectsById.get(projectId) ?? null,
2046
+ } as never,
2047
+ diffStore: {
2048
+ getProjectSnapshot: () => ({ status: "ready" as const, files: [], defaultBranchName: "main", originRepoSlug: "acme/repo", aheadCount: 0, behindCount: 0, lastFetchedAt: undefined }),
2049
+ refreshSnapshot: async () => false,
2050
+ syncBranch: async () => ({ ok: true as const, action: "fetch" as const, snapshotChanged: false }),
2051
+ generateCommitMessage: async () => ({ subject: "", body: "" }),
2052
+ commitFiles: async () => ({ ok: true as const, mode: "commit_only" as const, pushed: false, snapshotChanged: false }),
2053
+ discardFile: async () => ({ snapshotChanged: false }),
2054
+ ignoreFile: async (args: { projectId: string; projectPath: string; path: string }) => {
2055
+ ignoreCalls.push(args)
2056
+ return { snapshotChanged: false }
2057
+ },
2058
+ } as never,
2059
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set(), getSlashCommandsLoadingChatIds: () => new Set(), ensureSlashCommandsLoaded: async () => {} } as never,
2060
+ terminals: {
2061
+ getSnapshot: () => null,
2062
+ onEvent: () => () => {},
2063
+ } as never,
2064
+ keybindings: {
2065
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
2066
+ onChange: () => () => {},
2067
+ } as never,
2068
+ refreshDiscovery: async () => [],
2069
+ getDiscoveredProjects: () => [],
2070
+ machineDisplayName: "Local Machine",
2071
+ updateManager: null,
2072
+ })
2073
+ const ws = new FakeWebSocket()
2074
+
2075
+ await router.handleMessage(
2076
+ ws as never,
2077
+ JSON.stringify({
2078
+ v: 1,
2079
+ type: "command",
2080
+ id: "ignore-1",
2081
+ command: {
2082
+ type: "chat.ignoreDiffFile",
2083
+ chatId: "chat-1",
2084
+ path: "scratch.log",
2085
+ },
2086
+ })
2087
+ )
2088
+
2089
+ expect(ignoreCalls).toEqual([{
2090
+ projectId: "project-1",
2091
+ projectPath: "/tmp/project",
2092
+ path: "scratch.log",
2093
+ }])
2094
+ expect(ws.sent).toContainEqual({
2095
+ v: PROTOCOL_VERSION,
2096
+ type: "ack",
2097
+ id: "ignore-1",
2098
+ result: { snapshotChanged: false },
2099
+ })
2100
+ })
2101
+
2102
+ // ── autoContinue WS command tests ────────────────────────────────────────
2103
+
2104
+ function makeAutoContinueAgent(overrides: Record<string, unknown> = {}) {
2105
+ const appendedEvents: unknown[] = []
2106
+ return {
2107
+ appendedEvents,
2108
+ agent: {
2109
+ getActiveStatuses: () => new Map(),
2110
+ getDrainingChatIds: () => new Set(),
2111
+ getSlashCommandsLoadingChatIds: () => new Set(),
2112
+ ensureSlashCommandsLoaded: async () => {},
2113
+ acceptAutoContinue: async (_chatId: string, _scheduleId: string, _scheduledAt: number) => {},
2114
+ rescheduleAutoContinue: async (_chatId: string, _scheduleId: string, _scheduledAt: number) => {},
2115
+ cancelAutoContinue: async (_chatId: string, _scheduleId: string, _reason: string) => {},
2116
+ listLiveSchedules: (_chatId: string): string[] => [],
2117
+ cancel: async () => {},
2118
+ closeChat: async () => {},
2119
+ ...overrides,
2120
+ },
2121
+ }
2122
+ }
2123
+
2124
+ function makeAutoContinueStore(state: ReturnType<typeof createEmptyState>) {
2125
+ return {
2126
+ state,
2127
+ deleteChat: async () => {},
2128
+ }
2129
+ }
2130
+
2131
+ test("autoContinue.accept routes to agent.acceptAutoContinue and acks", async () => {
2132
+ const acceptCalls: Array<{ chatId: string; scheduleId: string; scheduledAt: number }> = []
2133
+ const { agent } = makeAutoContinueAgent({
2134
+ acceptAutoContinue: async (chatId: string, scheduleId: string, scheduledAt: number) => {
2135
+ acceptCalls.push({ chatId, scheduleId, scheduledAt })
2136
+ },
2137
+ })
2138
+
2139
+ const router = createWsRouter({
2140
+ store: makeAutoContinueStore(createEmptyState()) as never,
2141
+ agent: agent as never,
2142
+ terminals: { getSnapshot: () => null, onEvent: () => () => {} } as never,
2143
+ keybindings: { getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT, onChange: () => () => {} } as never,
2144
+ refreshDiscovery: async () => [],
2145
+ getDiscoveredProjects: () => [],
2146
+ machineDisplayName: "Local Machine",
2147
+ updateManager: null,
2148
+ })
2149
+ const ws = new FakeWebSocket()
2150
+
2151
+ await router.handleMessage(
2152
+ ws as never,
2153
+ JSON.stringify({
2154
+ v: 1,
2155
+ type: "command",
2156
+ id: "accept-1",
2157
+ command: {
2158
+ type: "autoContinue.accept",
2159
+ chatId: "chat-1",
2160
+ scheduleId: "sched-1",
2161
+ scheduledAt: Date.now() + 60_000,
2162
+ },
2163
+ })
2164
+ )
2165
+
2166
+ expect(acceptCalls).toHaveLength(1)
2167
+ expect(acceptCalls[0]!.chatId).toBe("chat-1")
2168
+ expect(acceptCalls[0]!.scheduleId).toBe("sched-1")
2169
+ expect(ws.sent).toContainEqual({ v: PROTOCOL_VERSION, type: "ack", id: "accept-1" })
2170
+ })
2171
+
2172
+ test("autoContinue.reschedule routes to agent.rescheduleAutoContinue and acks", async () => {
2173
+ const rescheduleCalls: Array<{ chatId: string; scheduleId: string; scheduledAt: number }> = []
2174
+ const { agent } = makeAutoContinueAgent({
2175
+ rescheduleAutoContinue: async (chatId: string, scheduleId: string, scheduledAt: number) => {
2176
+ rescheduleCalls.push({ chatId, scheduleId, scheduledAt })
2177
+ },
2178
+ })
2179
+
2180
+ const router = createWsRouter({
2181
+ store: makeAutoContinueStore(createEmptyState()) as never,
2182
+ agent: agent as never,
2183
+ terminals: { getSnapshot: () => null, onEvent: () => () => {} } as never,
2184
+ keybindings: { getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT, onChange: () => () => {} } as never,
2185
+ refreshDiscovery: async () => [],
2186
+ getDiscoveredProjects: () => [],
2187
+ machineDisplayName: "Local Machine",
2188
+ updateManager: null,
2189
+ })
2190
+ const ws = new FakeWebSocket()
2191
+
2192
+ await router.handleMessage(
2193
+ ws as never,
2194
+ JSON.stringify({
2195
+ v: 1,
2196
+ type: "command",
2197
+ id: "reschedule-1",
2198
+ command: {
2199
+ type: "autoContinue.reschedule",
2200
+ chatId: "chat-1",
2201
+ scheduleId: "sched-1",
2202
+ scheduledAt: Date.now() + 120_000,
2203
+ },
2204
+ })
2205
+ )
2206
+
2207
+ expect(rescheduleCalls).toHaveLength(1)
2208
+ expect(rescheduleCalls[0]!.chatId).toBe("chat-1")
2209
+ expect(ws.sent).toContainEqual({ v: PROTOCOL_VERSION, type: "ack", id: "reschedule-1" })
2210
+ })
2211
+
2212
+ test("autoContinue.cancel routes to agent.cancelAutoContinue and acks", async () => {
2213
+ const cancelCalls: Array<{ chatId: string; scheduleId: string; reason: string }> = []
2214
+ const { agent } = makeAutoContinueAgent({
2215
+ cancelAutoContinue: async (chatId: string, scheduleId: string, reason: string) => {
2216
+ cancelCalls.push({ chatId, scheduleId, reason })
2217
+ },
2218
+ })
2219
+
2220
+ const router = createWsRouter({
2221
+ store: makeAutoContinueStore(createEmptyState()) as never,
2222
+ agent: agent as never,
2223
+ terminals: { getSnapshot: () => null, onEvent: () => () => {} } as never,
2224
+ keybindings: { getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT, onChange: () => () => {} } as never,
2225
+ refreshDiscovery: async () => [],
2226
+ getDiscoveredProjects: () => [],
2227
+ machineDisplayName: "Local Machine",
2228
+ updateManager: null,
2229
+ })
2230
+ const ws = new FakeWebSocket()
2231
+
2232
+ await router.handleMessage(
2233
+ ws as never,
2234
+ JSON.stringify({
2235
+ v: 1,
2236
+ type: "command",
2237
+ id: "cancel-1",
2238
+ command: {
2239
+ type: "autoContinue.cancel",
2240
+ chatId: "chat-1",
2241
+ scheduleId: "sched-1",
2242
+ },
2243
+ })
2244
+ )
2245
+
2246
+ expect(cancelCalls).toHaveLength(1)
2247
+ expect(cancelCalls[0]!.reason).toBe("user")
2248
+ expect(ws.sent).toContainEqual({ v: PROTOCOL_VERSION, type: "ack", id: "cancel-1" })
2249
+ })
2250
+
2251
+ test("chat.delete cancels live schedules before closing chat", async () => {
2252
+ const cancelledScheduleIds: string[] = []
2253
+ const callOrder: string[] = []
2254
+
2255
+ const { agent } = makeAutoContinueAgent({
2256
+ listLiveSchedules: (_chatId: string) => ["sched-live"],
2257
+ cancelAutoContinue: async (_chatId: string, scheduleId: string, _reason: string) => {
2258
+ cancelledScheduleIds.push(scheduleId)
2259
+ callOrder.push("cancelAutoContinue")
2260
+ },
2261
+ closeChat: async () => {
2262
+ callOrder.push("closeChat")
2263
+ },
2264
+ })
2265
+
2266
+ const router = createWsRouter({
2267
+ store: makeAutoContinueStore(createEmptyState()) as never,
2268
+ agent: agent as never,
2269
+ terminals: { getSnapshot: () => null, onEvent: () => () => {} } as never,
2270
+ keybindings: { getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT, onChange: () => () => {} } as never,
2271
+ refreshDiscovery: async () => [],
2272
+ getDiscoveredProjects: () => [],
2273
+ machineDisplayName: "Local Machine",
2274
+ updateManager: null,
2275
+ })
2276
+ const ws = new FakeWebSocket()
2277
+
2278
+ await router.handleMessage(
2279
+ ws as never,
2280
+ JSON.stringify({
2281
+ v: 1,
2282
+ type: "command",
2283
+ id: "delete-1",
2284
+ command: { type: "chat.delete", chatId: "chat-1" },
2285
+ })
2286
+ )
2287
+
2288
+ expect(cancelledScheduleIds).toEqual(["sched-live"])
2289
+ expect(callOrder.indexOf("cancelAutoContinue")).toBeLessThan(callOrder.indexOf("closeChat"))
2290
+ expect(ws.sent).toContainEqual({ v: PROTOCOL_VERSION, type: "ack", id: "delete-1" })
2291
+ })
2292
+ })