ompchamber 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/README.md +9 -6
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/static/css/index.5291f58223.css +2 -0
  4. package/dist/client/static/js/async/{1k.544adbf77d.js → 1k.5974df3b87.js} +2 -2
  5. package/dist/client/static/js/async/3j.357c80ff38.js +2 -0
  6. package/dist/client/static/js/async/{3y.22075aa079.js → 3y.ededeebde8.js} +5 -5
  7. package/dist/client/static/js/async/51.95bcae98af.js +2 -0
  8. package/dist/client/static/js/async/6.f034c9b528.js +1 -0
  9. package/dist/client/static/js/async/a.e05da304c8.js +10 -0
  10. package/dist/client/static/js/async/ar.1e14da5588.js +1 -0
  11. package/dist/client/static/js/async/art.62139ed479.js +2 -0
  12. package/dist/client/static/js/async/ct.b74928f7a3.js +1 -0
  13. package/dist/client/static/js/async/fw.6a51998a8e.js +6 -0
  14. package/dist/client/static/js/async/g.7a4bbd4798.js +2 -0
  15. package/dist/client/static/js/async/g.7a4bbd4798.js.LICENSE.txt +11 -0
  16. package/dist/client/static/js/async/{k.e878dd350f.js → k.7466330d1a.js} +2 -2
  17. package/dist/client/static/js/async/katex-vendor.de6a87c308.js +1 -0
  18. package/dist/client/static/js/async/kc.bbdb35b771.js +1 -0
  19. package/dist/client/static/js/async/l6.c56640c205.js +1 -0
  20. package/dist/client/static/js/async/{m.3fc017a055.js → m.4b29d34f93.js} +3 -3
  21. package/dist/client/static/js/async/{og.ae7acf03ac.js → og.634fba99ac.js} +2 -2
  22. package/dist/client/static/js/async/q.7fb4d2f374.js +21 -0
  23. package/dist/client/static/js/async/rd.cb6bf495bb.js +2 -0
  24. package/dist/client/static/js/async/rf.d626afc367.js +2 -0
  25. package/dist/client/static/js/async/s.413c2a4683.js +2 -0
  26. package/dist/client/static/js/async/s.413c2a4683.js.LICENSE.txt +6 -0
  27. package/dist/client/static/js/async/sy.d16be843a7.js +94 -0
  28. package/dist/client/static/js/async/t.cb3fcf8615.js +1 -0
  29. package/dist/client/static/js/async/ud.3065bfee31.js +2 -0
  30. package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
  31. package/dist/client/static/js/async/uf.46ad6209fa.js.LICENSE.txt +6 -0
  32. package/dist/client/static/js/async/v.7d28d3c241.js +34 -0
  33. package/dist/client/static/js/async/v0.394ed37c6b.js +2 -0
  34. package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +6 -0
  35. package/dist/client/static/js/async/vt.42416b707d.js +1 -0
  36. package/dist/client/static/js/async/vy.bbe114c4e0.js +2 -0
  37. package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +6 -0
  38. package/dist/client/static/js/async/xw.ae67ba06fd.js +31 -0
  39. package/dist/client/static/js/async/xw.ae67ba06fd.js.LICENSE.txt +4 -0
  40. package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
  41. package/dist/client/static/js/async/yv.2fa22f009a.js +1 -0
  42. package/dist/client/static/js/index.bf1b1fe988.js +33 -0
  43. package/dist/client/static/js/index.bf1b1fe988.js.LICENSE.txt +4 -0
  44. package/dist/client/static/js/{markdown-vendor.9da142c67b.js → markdown-vendor.71e43278b3.js} +1 -1
  45. package/dist/client/static/js/uy.5f791144ce.js +139 -0
  46. package/package.json +2 -1
  47. package/src/client/App.tsx +22 -5
  48. package/src/client/components/common/MarkdownRenderer.tsx +36 -0
  49. package/src/client/components/layout/desktop-layout/index.tsx +1 -1
  50. package/src/client/components/layout/session-sidebar/Modals.tsx +1 -1
  51. package/src/client/components/mobile/LayoutWrapper.tsx +1 -1
  52. package/src/client/components/settings/LazyModal.tsx +46 -0
  53. package/src/client/components/settings/Modal.tsx +1 -1
  54. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +43 -29
  55. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +15 -69
  56. package/src/client/components/workspace/chat-timeline/MessageList.tsx +47 -66
  57. package/src/client/components/workspace/chat-timeline/RunFooter.tsx +60 -0
  58. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +37 -21
  59. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +22 -4
  60. package/src/client/components/workspace/chat-timeline/index.tsx +3 -6
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +15 -19
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/EnvelopeHeader.tsx +46 -0
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/FallbackOutput.tsx +18 -68
  64. package/src/client/components/workspace/file-explorer/TreeItem.tsx +8 -2
  65. package/src/client/hooks/chat/omp/index.ts +9 -10
  66. package/src/client/hooks/chat/omp/revalidation-throttle.ts +6 -5
  67. package/src/client/hooks/chat/timeline/actions.ts +1 -2
  68. package/src/client/hooks/chat/timeline/index.ts +5 -0
  69. package/src/client/hooks/chat/timeline/send.ts +1 -2
  70. package/src/client/hooks/chat/timeline/session-load.ts +3 -4
  71. package/src/client/hooks/ui/syntax-ready.ts +16 -3
  72. package/src/client/main.tsx +2 -5
  73. package/src/client/stubs/elkjs.ts +37 -0
  74. package/src/client/tailwind.css +10 -0
  75. package/src/server/lib/fs/git-ignore.ts +49 -0
  76. package/src/server/lib/omp/rpc/constants.ts +10 -0
  77. package/src/server/lib/omp/rpc/lifecycle.test.ts +138 -0
  78. package/src/server/lib/omp/rpc/manager.ts +32 -4
  79. package/src/server/lib/omp/rpc/session-commands.ts +32 -9
  80. package/src/server/lib/omp/rpc/session-registry.ts +3 -2
  81. package/src/server/lib/omp/rpc/subagent-liveness.test.ts +62 -0
  82. package/src/server/lib/omp/rpc/subagent-liveness.ts +100 -0
  83. package/src/server/lib/omp/session/messages.test.ts +112 -0
  84. package/src/server/lib/omp/session/messages.ts +6 -5
  85. package/src/server/routes/agent/command.ts +34 -16
  86. package/src/server/routes/fs/read.ts +15 -7
  87. package/src/shared/lib/chat/notice-row.test.ts +89 -0
  88. package/src/shared/lib/chat/notice-row.ts +78 -0
  89. package/src/shared/lib/chat/omp/abort.ts +41 -0
  90. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +154 -0
  91. package/src/shared/lib/chat/timeline/omp-callbacks.ts +36 -7
  92. package/src/shared/lib/chat/timeline/run-footer.test.ts +84 -0
  93. package/src/shared/lib/chat/timeline/run-footer.ts +96 -0
  94. package/src/shared/lib/chat/tool-output.test.ts +91 -0
  95. package/src/shared/lib/chat/tool-output.ts +54 -0
  96. package/src/shared/lib/chat/xml-envelope.ts +158 -0
  97. package/src/shared/lib/code/highlighter-lazy.ts +106 -0
  98. package/src/shared/lib/code/highlighter.ts +31 -52
  99. package/src/shared/lib/code/syntax-highlight.test.ts +36 -1
  100. package/src/shared/lib/code/syntax-highlight.ts +17 -3
  101. package/src/shared/lib/format/time.ts +19 -0
  102. package/src/shared/lib/markdown/fira-code-fonts.css +51 -0
  103. package/src/shared/lib/markdown/katex-fonts.css +20 -0
  104. package/src/shared/lib/markdown/katex.ts +101 -0
  105. package/src/shared/lib/markdown/marked.ts +23 -14
  106. package/src/shared/lib/markdown/math.test.ts +109 -0
  107. package/src/shared/lib/markdown/mermaid.ts +6 -0
  108. package/src/shared/lib/markdown/sanitize.ts +48 -2
  109. package/src/shared/lib/omp/session/mapper.ts +11 -3
  110. package/src/shared/lib/omp/session/messages-map.ts +12 -12
  111. package/src/shared/types/chat.ts +3 -1
  112. package/src/shared/types/fs.ts +3 -0
  113. package/dist/client/static/css/g.f36aa429fa.css +0 -1
  114. package/dist/client/static/css/index.a0f07455bd.css +0 -2
  115. package/dist/client/static/css/katex-vendor.ca3a0600e8.css +0 -1
  116. package/dist/client/static/font/KaTeX_AMS-Regular.1608a09b4a.woff +0 -0
  117. package/dist/client/static/font/KaTeX_AMS-Regular.4aafdb685c.ttf +0 -0
  118. package/dist/client/static/font/KaTeX_Caligraphic-Bold.b67709187b.woff +0 -0
  119. package/dist/client/static/font/KaTeX_Caligraphic-Bold.cce5b8ecea.ttf +0 -0
  120. package/dist/client/static/font/KaTeX_Caligraphic-Regular.07ef19e7b0.ttf +0 -0
  121. package/dist/client/static/font/KaTeX_Caligraphic-Regular.dad44a7fc6.woff +0 -0
  122. package/dist/client/static/font/KaTeX_Fraktur-Bold.9f256b8593.woff +0 -0
  123. package/dist/client/static/font/KaTeX_Fraktur-Bold.b18f59e1d1.ttf +0 -0
  124. package/dist/client/static/font/KaTeX_Fraktur-Regular.7c1871215e.woff +0 -0
  125. package/dist/client/static/font/KaTeX_Fraktur-Regular.ed38e79f57.ttf +0 -0
  126. package/dist/client/static/font/KaTeX_Main-Bold.b74a1a8b2c.ttf +0 -0
  127. package/dist/client/static/font/KaTeX_Main-Bold.d181c4650d.woff +0 -0
  128. package/dist/client/static/font/KaTeX_Main-BoldItalic.70d8b0a530.ttf +0 -0
  129. package/dist/client/static/font/KaTeX_Main-BoldItalic.e3f82f9d27.woff +0 -0
  130. package/dist/client/static/font/KaTeX_Main-Italic.47373d1e51.ttf +0 -0
  131. package/dist/client/static/font/KaTeX_Main-Italic.9024d815ba.woff +0 -0
  132. package/dist/client/static/font/KaTeX_Main-Regular.7f51fe0340.woff +0 -0
  133. package/dist/client/static/font/KaTeX_Main-Regular.b7f8fe9b5f.ttf +0 -0
  134. package/dist/client/static/font/KaTeX_Math-BoldItalic.a879cf8383.ttf +0 -0
  135. package/dist/client/static/font/KaTeX_Math-BoldItalic.f1035d8d5d.woff +0 -0
  136. package/dist/client/static/font/KaTeX_Math-Italic.5295ba483a.woff +0 -0
  137. package/dist/client/static/font/KaTeX_Math-Italic.939bc64440.ttf +0 -0
  138. package/dist/client/static/font/KaTeX_SansSerif-Bold.94e1e8dc5f.ttf +0 -0
  139. package/dist/client/static/font/KaTeX_SansSerif-Bold.bf59d231c3.woff +0 -0
  140. package/dist/client/static/font/KaTeX_SansSerif-Italic.7c9bc82b17.woff +0 -0
  141. package/dist/client/static/font/KaTeX_SansSerif-Italic.b4c20c84d8.ttf +0 -0
  142. package/dist/client/static/font/KaTeX_SansSerif-Regular.740484788f.woff +0 -0
  143. package/dist/client/static/font/KaTeX_SansSerif-Regular.d4d7ba4804.ttf +0 -0
  144. package/dist/client/static/font/KaTeX_Script-Regular.0750571032.woff +0 -0
  145. package/dist/client/static/font/KaTeX_Script-Regular.fe9cbbe1a0.ttf +0 -0
  146. package/dist/client/static/font/KaTeX_Size1-Regular.e1e279cbdd.woff +0 -0
  147. package/dist/client/static/font/KaTeX_Size1-Regular.fabc004aab.ttf +0 -0
  148. package/dist/client/static/font/KaTeX_Size2-Regular.577270225e.woff +0 -0
  149. package/dist/client/static/font/KaTeX_Size2-Regular.d6b476ecd3.ttf +0 -0
  150. package/dist/client/static/font/KaTeX_Size3-Regular.9acaf01c64.woff +0 -0
  151. package/dist/client/static/font/KaTeX_Size3-Regular.a144ef5840.ttf +0 -0
  152. package/dist/client/static/font/KaTeX_Size4-Regular.7a996c9da2.woff +0 -0
  153. package/dist/client/static/font/KaTeX_Size4-Regular.fbccdabe0a.ttf +0 -0
  154. package/dist/client/static/font/KaTeX_Typewriter-Regular.6258592bdc.woff +0 -0
  155. package/dist/client/static/font/KaTeX_Typewriter-Regular.d97aaf4a1e.ttf +0 -0
  156. package/dist/client/static/font/fira-code-cyrillic-400-normal.5971438a4a.woff2 +0 -0
  157. package/dist/client/static/font/fira-code-cyrillic-400-normal.9bb7bfea9c.woff +0 -0
  158. package/dist/client/static/font/fira-code-cyrillic-500-normal.5486265b18.woff2 +0 -0
  159. package/dist/client/static/font/fira-code-cyrillic-500-normal.89f92bfb31.woff +0 -0
  160. package/dist/client/static/font/fira-code-cyrillic-600-normal.2805b497e5.woff +0 -0
  161. package/dist/client/static/font/fira-code-cyrillic-600-normal.86e0df22a3.woff2 +0 -0
  162. package/dist/client/static/font/fira-code-cyrillic-700-normal.505a88f22a.woff +0 -0
  163. package/dist/client/static/font/fira-code-cyrillic-700-normal.76f5e8d2e2.woff2 +0 -0
  164. package/dist/client/static/font/fira-code-cyrillic-ext-400-normal.3c4b15e57d.woff2 +0 -0
  165. package/dist/client/static/font/fira-code-cyrillic-ext-400-normal.61323debaa.woff +0 -0
  166. package/dist/client/static/font/fira-code-cyrillic-ext-500-normal.0f34210f2a.woff2 +0 -0
  167. package/dist/client/static/font/fira-code-cyrillic-ext-500-normal.2497cfa1aa.woff +0 -0
  168. package/dist/client/static/font/fira-code-cyrillic-ext-600-normal.40fce94af9.woff2 +0 -0
  169. package/dist/client/static/font/fira-code-cyrillic-ext-600-normal.6c8774b829.woff +0 -0
  170. package/dist/client/static/font/fira-code-cyrillic-ext-700-normal.4b742eb865.woff +0 -0
  171. package/dist/client/static/font/fira-code-cyrillic-ext-700-normal.e75eb4fccc.woff2 +0 -0
  172. package/dist/client/static/font/fira-code-greek-400-normal.bb900468b6.woff2 +0 -0
  173. package/dist/client/static/font/fira-code-greek-400-normal.be76e699ba.woff +0 -0
  174. package/dist/client/static/font/fira-code-greek-500-normal.176c9fb001.woff2 +0 -0
  175. package/dist/client/static/font/fira-code-greek-500-normal.d2c596136b.woff +0 -0
  176. package/dist/client/static/font/fira-code-greek-600-normal.490c3deed7.woff +0 -0
  177. package/dist/client/static/font/fira-code-greek-600-normal.7df14376d9.woff2 +0 -0
  178. package/dist/client/static/font/fira-code-greek-700-normal.c82b6072fc.woff2 +0 -0
  179. package/dist/client/static/font/fira-code-greek-700-normal.d5f2b2917b.woff +0 -0
  180. package/dist/client/static/font/fira-code-greek-ext-400-normal.4849a79781.woff +0 -0
  181. package/dist/client/static/font/fira-code-greek-ext-400-normal.7f5bfc4538.woff2 +0 -0
  182. package/dist/client/static/font/fira-code-greek-ext-500-normal.aaccca4075.woff2 +0 -0
  183. package/dist/client/static/font/fira-code-greek-ext-500-normal.e383181256.woff +0 -0
  184. package/dist/client/static/font/fira-code-greek-ext-600-normal.78cb4bff8e.woff +0 -0
  185. package/dist/client/static/font/fira-code-greek-ext-600-normal.d6c93d54f9.woff2 +0 -0
  186. package/dist/client/static/font/fira-code-greek-ext-700-normal.57ec7c92d9.woff +0 -0
  187. package/dist/client/static/font/fira-code-greek-ext-700-normal.8fafdae5e8.woff2 +0 -0
  188. package/dist/client/static/font/fira-code-latin-400-normal.973295532b.woff +0 -0
  189. package/dist/client/static/font/fira-code-latin-500-normal.7ae2be8fb8.woff +0 -0
  190. package/dist/client/static/font/fira-code-latin-600-normal.1dbbe5bc27.woff +0 -0
  191. package/dist/client/static/font/fira-code-latin-700-normal.497a70148b.woff +0 -0
  192. package/dist/client/static/font/fira-code-latin-ext-400-normal.9dfae29aaf.woff +0 -0
  193. package/dist/client/static/font/fira-code-latin-ext-400-normal.e534ae46e2.woff2 +0 -0
  194. package/dist/client/static/font/fira-code-latin-ext-500-normal.24c7b781b0.woff +0 -0
  195. package/dist/client/static/font/fira-code-latin-ext-500-normal.aad44ab072.woff2 +0 -0
  196. package/dist/client/static/font/fira-code-latin-ext-600-normal.7bed0f2f7e.woff +0 -0
  197. package/dist/client/static/font/fira-code-latin-ext-600-normal.c509a7c9f1.woff2 +0 -0
  198. package/dist/client/static/font/fira-code-latin-ext-700-normal.6c66c8b0a9.woff2 +0 -0
  199. package/dist/client/static/font/fira-code-latin-ext-700-normal.88f64e2822.woff +0 -0
  200. package/dist/client/static/font/fira-code-symbols2-400-normal.97d3f4b17c.woff +0 -0
  201. package/dist/client/static/font/fira-code-symbols2-500-normal.b06f1a06e4.woff +0 -0
  202. package/dist/client/static/font/fira-code-symbols2-600-normal.304b653b93.woff +0 -0
  203. package/dist/client/static/font/fira-code-symbols2-700-normal.d8ce0ad586.woff +0 -0
  204. package/dist/client/static/js/async/3j.a2fcea99be.js +0 -2
  205. package/dist/client/static/js/async/51.1938cec320.js +0 -2
  206. package/dist/client/static/js/async/6.7929428505.js +0 -1
  207. package/dist/client/static/js/async/69.a18a10882f.js +0 -1
  208. package/dist/client/static/js/async/a.bce4542392.js +0 -1
  209. package/dist/client/static/js/async/ar.9dc8b1992a.js +0 -2
  210. package/dist/client/static/js/async/bi.cbfe8eaa8e.js +0 -1
  211. package/dist/client/static/js/async/ct.38d9da8a5a.js +0 -1
  212. package/dist/client/static/js/async/fw.fb1a08b92d.js +0 -6
  213. package/dist/client/static/js/async/l6.45f3b62e15.js +0 -1
  214. package/dist/client/static/js/async/q.0b6fb9bf8d.js +0 -21
  215. package/dist/client/static/js/async/rd.b77011ab7f.js +0 -2
  216. package/dist/client/static/js/async/rf.cfbde7f744.js +0 -2
  217. package/dist/client/static/js/async/s.c7d521a8ae.js +0 -2
  218. package/dist/client/static/js/async/sy.7345abcd67.js +0 -94
  219. package/dist/client/static/js/async/t.0dcb01dfde.js +0 -1
  220. package/dist/client/static/js/async/v.5c7a4b087b.js +0 -1
  221. package/dist/client/static/js/async/vu.311f1d202c.js +0 -34
  222. package/dist/client/static/js/async/yg.9bd4e09ecb.js +0 -149
  223. package/dist/client/static/js/async/yv.593a3f7d54.js +0 -1
  224. package/dist/client/static/js/g.124fba0826.js +0 -139
  225. package/dist/client/static/js/index.907fd4670e.js +0 -68
  226. package/dist/client/static/js/katex-vendor.95e48cf65a.js +0 -1
  227. package/src/shared/lib/chat/order.ts +0 -40
  228. /package/dist/client/static/js/async/{1k.544adbf77d.js.LICENSE.txt → 1k.5974df3b87.js.LICENSE.txt} +0 -0
  229. /package/dist/client/static/js/async/{3j.a2fcea99be.js.LICENSE.txt → 3j.357c80ff38.js.LICENSE.txt} +0 -0
  230. /package/dist/client/static/js/async/{3y.22075aa079.js.LICENSE.txt → 3y.ededeebde8.js.LICENSE.txt} +0 -0
  231. /package/dist/client/static/js/async/{51.1938cec320.js.LICENSE.txt → 51.95bcae98af.js.LICENSE.txt} +0 -0
  232. /package/dist/client/static/js/async/{ar.9dc8b1992a.js.LICENSE.txt → a.e05da304c8.js.LICENSE.txt} +0 -0
  233. /package/dist/client/static/js/async/{fw.fb1a08b92d.js.LICENSE.txt → art.62139ed479.js.LICENSE.txt} +0 -0
  234. /package/dist/client/static/js/async/{m.3fc017a055.js.LICENSE.txt → fw.6a51998a8e.js.LICENSE.txt} +0 -0
  235. /package/dist/client/static/js/async/{q.0b6fb9bf8d.js.LICENSE.txt → m.4b29d34f93.js.LICENSE.txt} +0 -0
  236. /package/dist/client/static/js/async/{rf.cfbde7f744.js.LICENSE.txt → q.7fb4d2f374.js.LICENSE.txt} +0 -0
  237. /package/dist/client/static/js/async/{rd.b77011ab7f.js.LICENSE.txt → rd.cb6bf495bb.js.LICENSE.txt} +0 -0
  238. /package/dist/client/static/js/async/{s.c7d521a8ae.js.LICENSE.txt → rf.d626afc367.js.LICENSE.txt} +0 -0
  239. /package/dist/client/static/js/{index.907fd4670e.js.LICENSE.txt → async/ud.3065bfee31.js.LICENSE.txt} +0 -0
  240. /package/dist/client/static/js/{g.124fba0826.js.LICENSE.txt → uy.5f791144ce.js.LICENSE.txt} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ompchamber",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Developer web console and diagnostic chamber for AI Oh-My-Pi — Elysia + Preact on Bun.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -69,6 +69,7 @@
69
69
  "@tailwindcss/postcss": "^4.1.14",
70
70
  "@types/bun": "^1.4.2",
71
71
  "@types/node": "^22.14.0",
72
+ "happy-dom": "^20.14.5",
72
73
  "tailwindcss": "^4.1.14",
73
74
  "typescript": "~5.8.2"
74
75
  }
@@ -4,12 +4,27 @@
4
4
  */
5
5
 
6
6
  import { useEffect, useRef, useState } from 'preact/hooks';
7
+ import { Suspense, lazy } from 'preact/compat';
7
8
  import { useSearchParams } from '@/client/lib/router/search-params';
8
9
  import { DesktopLayout } from '@/client/components/layout/desktop-layout/index';
9
- import { MobileLayoutWrapper } from '@/client/components/mobile/LayoutWrapper';
10
10
  import { SessionStateProvider } from '@/client/components/common/session-state-provider';
11
11
  import { SidebarDataProvider } from '@/client/hooks/chat/omp/session-list';
12
12
 
13
+ /**
14
+ * The mobile and desktop layouts are mutually exclusive screen trees — exactly
15
+ * one is mounted per load, and `initialIsMobile` is known before the first
16
+ * render because the server injects it. Keeping the mobile tree out of the
17
+ * initial bundle is therefore pure win for the desktop (the common case for a
18
+ * developer console): its subtree moves to this async chunk and is fetched only
19
+ * when a phone boots, or when the user switches layout by hand at runtime.
20
+ *
21
+ * `fallback={null}` is safe rather than a blank flash: `body` already paints
22
+ * `--theme-canvas`, so the pre-hydration frame and the fallback look identical.
23
+ */
24
+ const MobileLayoutWrapper = lazy(() =>
25
+ import('@/client/components/mobile/LayoutWrapper').then((m) => ({ default: m.MobileLayoutWrapper }))
26
+ );
27
+
13
28
  export interface AppProps {
14
29
  /** Server-detected mobile verdict, so the first paint needs no UA check. */
15
30
  initialIsMobile?: boolean;
@@ -72,10 +87,12 @@ export function App({ initialIsMobile = false, appSettings = {} }: AppProps) {
72
87
  return (
73
88
  <SessionStateProvider sessionId={sessionId}>
74
89
  <SidebarDataProvider>
75
- <MobileLayoutWrapper
76
- onDesktopToggle={handleSwitchToDesktop}
77
- appSettings={appSettings}
78
- />
90
+ <Suspense fallback={null}>
91
+ <MobileLayoutWrapper
92
+ onDesktopToggle={handleSwitchToDesktop}
93
+ appSettings={appSettings}
94
+ />
95
+ </Suspense>
79
96
  </SidebarDataProvider>
80
97
  </SessionStateProvider>
81
98
  );
@@ -16,6 +16,7 @@
16
16
  import type { TargetedMouseEvent } from 'preact';
17
17
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'preact/hooks';
18
18
  import { renderMarkdown } from '@/shared/lib/markdown/marked';
19
+ import { hydrateMathBlocks, MATH_PENDING_CLASS, preloadKatex } from '@/shared/lib/markdown/katex';
19
20
  import { sanitizeHtml } from '@/shared/lib/markdown/sanitize';
20
21
  import { hydrateMermaidBlocks } from '@/shared/lib/markdown/mermaid';
21
22
  import { copyToClipboard } from '@/client/hooks/ui/clipboard';
@@ -36,10 +37,45 @@ export function MarkdownRenderer({ content, className = '' }: MarkdownRendererPr
36
37
  const html = useMemo(() => {
37
38
  if (!content) return '';
38
39
  const rendered = renderMarkdown(content);
40
+ // If the message contains math, start fetching KaTeX during parse rather
41
+ // than waiting for the post-mount hydration effect — the request overlaps
42
+ // sanitization and the rest of the render instead of following it.
43
+ if (rendered.includes(MATH_PENDING_CLASS)) preloadKatex();
39
44
  return sanitizeHtml(rendered);
40
45
  }, [content, syntaxReady]);
41
46
 
42
47
  const hasMermaid = html.includes('mermaid-block');
48
+ const hasMath = html.includes(MATH_PENDING_CLASS);
49
+
50
+ // KaTeX is ~522 kB of JS fetched only when a message contains math; the
51
+ // placeholders emitted by `marked.ts` are swapped for real markup here.
52
+ useEffect(() => {
53
+ if (!hasMath || typeof window === 'undefined') return;
54
+ const container = containerRef.current;
55
+ if (!container) return;
56
+
57
+ let cancelled = false;
58
+ const hydrate = async () => {
59
+ if (!container.isConnected) return;
60
+ await hydrateMathBlocks(container);
61
+ if (cancelled) return;
62
+ };
63
+ void hydrate();
64
+ // Same reason as the mermaid observer: a timeline re-render can replace the
65
+ // container's innerHTML with an identical string without this effect
66
+ // re-running, stranding new placeholders. hydrate() only writes inside the
67
+ // placeholder elements, so it never re-triggers this observer.
68
+ const observer = new MutationObserver(() => {
69
+ const current = containerRef.current;
70
+ if (current?.isConnected) void hydrateMathBlocks(current);
71
+ });
72
+ observer.observe(container, { childList: true });
73
+
74
+ return () => {
75
+ cancelled = true;
76
+ observer.disconnect();
77
+ };
78
+ }, [hasMath, html]);
43
79
 
44
80
  useIsomorphicLayoutEffect(() => {
45
81
  if (!hasMermaid || typeof window === 'undefined') return;
@@ -4,7 +4,7 @@ import { useSearchParams } from '@/client/lib/router/search-params';
4
4
  import { Group, Panel, type PanelImperativeHandle } from '@/client/components/layout/desktop-layout/resizer';
5
5
  import { SessionSidebar } from '@/client/components/layout/session-sidebar/index';
6
6
  import { type RightPanelType } from '@/shared/lib/workspace/right-panels';
7
- import { SettingsModal } from '@/client/components/settings/Modal';
7
+ import { SettingsModal } from '@/client/components/settings/LazyModal';
8
8
  import { PanelLeft } from 'lucide-preact';
9
9
  import type { SettingsCategoryId } from '@/shared/types';
10
10
  import { activeProjectForSession } from '@/shared/lib/workspace/active-project';
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useState } from 'preact/hooks';
2
2
  import { Clock, Columns, FolderOpen, Home, Maximize2, Plus, Send, Settings2, Terminal, X } from 'lucide-preact';
3
- import { SettingsModal } from '@/client/components/settings/Modal';
3
+ import { SettingsModal } from '@/client/components/settings/LazyModal';
4
4
  import { FolderPicker } from '@/client/components/common/FolderPicker';
5
5
 
6
6
  export { SettingsModal };
@@ -7,7 +7,7 @@ import { MobileSessionSidebar } from '@/client/components/mobile/SessionSidebar'
7
7
  import { MobileRightSidebar } from '@/client/components/mobile/RightSidebar';
8
8
  import { MobileFullEditor } from '@/client/components/mobile/mobile-right-sidebar/FullEditor';
9
9
  import { MobileFullDiff } from '@/client/components/mobile/mobile-right-sidebar/FullDiff';
10
- import { SettingsModal } from '@/client/components/settings/Modal';
10
+ import { SettingsModal } from '@/client/components/settings/LazyModal';
11
11
  import { activeProjectForSession } from '@/shared/lib/workspace/active-project';
12
12
  import { spawnCwdForNewSession, triggerSessionPrewarm } from '@/shared/lib/omp/session/prewarm';
13
13
  import { useSidebarData } from '@/client/hooks/chat/omp/session-list';
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Lazy boundary for the settings modal.
8
+ *
9
+ * The settings tree is 68 modules plus the ~160 kB `omp-schema.json`, and it is
10
+ * reached only by a click — yet `Modal.tsx` was imported statically by the
11
+ * desktop layout, the mobile layout, and the sidebar, so every page load paid
12
+ * for all of it. Importing through here moves the subtree to its own async
13
+ * chunk and takes ~84 kB gzip off the initial payload.
14
+ *
15
+ * The `isOpen` gate is load-bearing, not cosmetic: `lazy()` starts fetching as
16
+ * soon as the component renders, even when the wrapped component would render
17
+ * `null` internally. Without the gate the chunk would be requested at boot and
18
+ * nothing would be saved.
19
+ *
20
+ * All three call sites already own the open state (each layout runs its own
21
+ * `omp:open-settings` listener), so gating preserves the existing contract. The
22
+ * unmount is safe for settings values too: `mergeChamberSettings` re-reads the
23
+ * in-memory snapshot in `lib/settings/client`, which every write updates, not
24
+ * the stale bootstrap payload.
25
+ *
26
+ * `fallback={null}` matches the other lazy dialog boundary
27
+ * (`tool-renderers/ask-dialog/Lazy.tsx`): the modal is a full-screen overlay
28
+ * that appears on user intent, so a brief absence reads as the click landing
29
+ * rather than as a broken surface.
30
+ */
31
+
32
+ import { Suspense, lazy } from 'preact/compat';
33
+ import type { SettingsModalProps } from '@/client/components/settings/Modal';
34
+
35
+ const SettingsModalImpl = lazy(() =>
36
+ import('@/client/components/settings/Modal').then((m) => ({ default: m.SettingsModal }))
37
+ );
38
+
39
+ export function SettingsModal({ isOpen, ...rest }: SettingsModalProps) {
40
+ if (!isOpen) return null;
41
+ return (
42
+ <Suspense fallback={null}>
43
+ <SettingsModalImpl isOpen={isOpen} {...rest} />
44
+ </Suspense>
45
+ );
46
+ }
@@ -21,7 +21,7 @@ import { TokenUsageSettings } from '@/client/components/settings/categories/Toke
21
21
  import { UsageSettings } from '@/client/components/settings/categories/UsageSettings';
22
22
  import { NotificationSettings } from '@/client/components/settings/categories/NotificationSettings';
23
23
 
24
- interface SettingsModalProps {
24
+ export interface SettingsModalProps {
25
25
  isOpen: boolean;
26
26
  onClose: () => void;
27
27
  initialCategory?: SettingsCategoryId;
@@ -1,11 +1,14 @@
1
1
  import type { SetStateAction } from 'preact/compat';
2
+ import { Fragment } from 'preact';
2
3
  import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
3
4
  import { Check, ChevronDown, Folder } from 'lucide-preact';
4
5
  import type { Attachment, ChatMessageData } from '@/shared/types';
5
6
  import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
6
7
  import { ChatInput } from '@/client/components/workspace/chat-timeline/chat-input/index';
7
8
  import { ChatMessageItem } from '@/client/components/workspace/chat-timeline/MessageItem';
9
+ import { RunFooter } from '@/client/components/workspace/chat-timeline/RunFooter';
8
10
  import { GeneratingIndicator } from '@/client/components/workspace/chat-timeline/GeneratingIndicator';
11
+ import { resolveRunFooters, streamingRowIndex } from '@/shared/lib/chat/timeline/run-footer';
9
12
  import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
10
13
 
11
14
  interface EmptyWorkspacePromptProps {
@@ -22,8 +25,9 @@ interface EmptyWorkspacePromptProps {
22
25
  appSettings?: Record<string, any>;
23
26
  /** Messages of the pending session: rendered above the input so the
24
27
  * optimistic user bubble shows immediately on send (before the omp spawn
25
- * completes and the real session timeline takes over). */
26
- localMessages?: ChatMessageData[];
28
+ * completes and the real session timeline takes over). Rendered in array
29
+ * order — no display-side reordering. */
30
+ messages?: ChatMessageData[];
27
31
  provider?: string;
28
32
  providerNames?: Record<string, string>;
29
33
  modelName?: string;
@@ -52,7 +56,7 @@ export function EmptyWorkspacePrompt({
52
56
  onSend,
53
57
  isGenerating,
54
58
  appSettings = {},
55
- localMessages = [],
59
+ messages = [],
56
60
  provider,
57
61
  providerNames,
58
62
  modelName,
@@ -77,11 +81,21 @@ export function EmptyWorkspacePrompt({
77
81
  return folders?.find(f => f.id === selectedFolderId);
78
82
  }, [folders, selectedFolderId]);
79
83
 
84
+ // Run footers are boundary rows: emitted after the run's last row, so a
85
+ // trailing notice never separates the footer from the next user message.
86
+ const { streamingIdx, footers } = useMemo(
87
+ () => ({
88
+ streamingIdx: streamingRowIndex(messages),
89
+ footers: resolveRunFooters(messages, isGenerating),
90
+ }),
91
+ [messages, isGenerating],
92
+ );
93
+
80
94
  useEffect(() => {
81
95
  if (timelineRef.current) {
82
96
  timelineRef.current.scrollTop = timelineRef.current.scrollHeight;
83
97
  }
84
- }, [localMessages.length]);
98
+ }, [messages.length]);
85
99
 
86
100
  return (
87
101
  <div className={`flex flex-col h-full bg-canvas ${variant === 'mobile' ? 'px-3 py-4' : 'p-8'} ${className}`}
@@ -89,13 +103,13 @@ export function EmptyWorkspacePrompt({
89
103
  >
90
104
  <div
91
105
  className={`mx-auto w-full max-w-[970px] flex flex-col min-h-0 space-y-3 ${
92
- localMessages.length > 0 ? 'flex-1' : 'my-auto justify-center'
106
+ messages.length > 0 ? 'flex-1' : 'my-auto justify-center'
93
107
  }`}
94
108
  >
95
109
  {/* Workspace Selection Seamless Dropdown (No border, transparent
96
110
  background). Hidden once a chat has been sent — the session now has
97
111
  a target workspace, so the composer is the only focus. */}
98
- {localMessages.length === 0 && (
112
+ {messages.length === 0 && (
99
113
  <div className="relative shrink-0" ref={workspaceRef}>
100
114
  <button
101
115
  type="button"
@@ -147,36 +161,36 @@ export function EmptyWorkspacePrompt({
147
161
  on send, then the real session (UUID) takes over the ChatTimeline.
148
162
  Only shown when there are messages so the empty composer stays
149
163
  centered next to the workspace picker. */}
150
- {localMessages.length > 0 && (
164
+ {messages.length > 0 && (
151
165
  <div
152
166
  ref={timelineRef}
153
167
  className="flex-1 min-h-0 overflow-y-auto scroll-smooth overscroll-contain"
154
168
  >
155
169
  <div className="mx-auto w-full max-w-[970px]">
156
- {localMessages.map((msg, idx) => {
157
- const prev = localMessages[idx - 1];
158
- let lastAiIdx = localMessages.length - 1;
159
- while (lastAiIdx >= 0 && localMessages[lastAiIdx].notice) lastAiIdx--;
160
- const isLoading = isGenerating && idx === lastAiIdx && msg.role === 'ai';
170
+ {messages.map((msg, idx) => {
171
+ const prev = messages[idx - 1];
172
+ const isLoading = isGenerating && idx === streamingIdx && msg.role === 'ai';
161
173
  const isAiFragment = msg.role !== 'user' && prev && prev.role !== 'user';
162
- // Notice rows are transparent for footer purposes — the last
163
- // real AI message of a run still owns the footer (mirror
164
- // ChatTimeline) and the notice itself never gets one.
165
- const nextReal = localMessages.slice(idx + 1).find(m => !m.notice);
166
- const isLastAi = msg.role !== 'user' && !msg.notice && (!nextReal || nextReal.role === 'user');
174
+ const footer = footers[idx];
167
175
  return (
168
- <ChatMessageItem
169
- key={msg.id}
170
- msg={msg}
171
- provider={provider}
172
- providerNames={providerNames}
173
- modelName={modelName}
174
- modelNames={modelNames}
175
- thinkingLevel={sessionThinkingLevel ?? undefined}
176
- isStreaming={isLoading}
177
- footerVisible={isLastAi}
178
- className={isAiFragment ? 'mt-1' : 'mt-8'}
179
- />
176
+ <Fragment key={msg.id}>
177
+ <ChatMessageItem
178
+ msg={msg}
179
+ isStreaming={isLoading}
180
+ className={isAiFragment ? 'mt-1' : 'mt-8'}
181
+ />
182
+ {footer && (
183
+ <RunFooter
184
+ msg={footer.msg}
185
+ provider={provider}
186
+ providerNames={providerNames}
187
+ modelName={modelName}
188
+ modelNames={modelNames}
189
+ thinkingLevel={sessionThinkingLevel ?? undefined}
190
+ durationMs={footer.durationMs}
191
+ />
192
+ )}
193
+ </Fragment>
180
194
  );
181
195
  })}
182
196
  </div>
@@ -8,85 +8,49 @@ import { ToolCallingSection } from '@/client/components/workspace/chat-timeline/
8
8
  import { SystemNotice } from '@/client/components/workspace/chat-timeline/SystemNotice';
9
9
  import { MarkdownRenderer } from '@/client/components/common/MarkdownRenderer';
10
10
  import { AttachmentChips } from '@/client/components/workspace/chat-timeline/AttachmentChips';
11
- import { AiMessageFooter } from '@/client/components/workspace/chat-timeline/AiMessageFooter';
12
11
  import { CopyButton } from '@/client/components/common/CopyButton';
13
12
  import { capitalizeFirstLetter } from '@/shared/lib/chat/capitalize';
14
- import { formatClock, parseTodayLabel } from '@/shared/lib/format/time';
13
+ import { isNoticeRow, messageAnswerText } from '@/shared/lib/chat/notice-row';
14
+ import { formatMessageStamp } from '@/shared/lib/format/time';
15
15
 
16
16
  interface ChatMessageItemProps {
17
17
  msg: ChatMessageData | any;
18
- provider?: string;
19
- providerNames?: Record<string, string>;
20
- modelName?: string;
21
- /** id → display-name map from the model catalog; resolves per-message ids. */
22
- modelNames?: Record<string, string>;
23
- /** Session thinking level shown in the AI footer. */
24
- thinkingLevel?: string;
25
18
  isStreaming?: boolean;
26
- onRetry?: (msgId: string) => void;
27
19
  onUndo?: (msgId: string, content?: string) => void;
28
20
  onNewChat?: (content: string) => void;
29
- /** Render the AI metadata/toolbar footer. Only the last AI message of a
30
- * response run should show it so multi-part JSONL responses do not repeat
31
- * the footer per message. */
32
- footerVisible?: boolean;
33
- /** Mobile uses a compact provider/model footer with a bottom-sheet menu. */
34
- isMobile?: boolean;
35
21
 
36
22
  /** Extra classes on the root wrapper (e.g. spacing between messages). */
37
23
  className?: string;
38
- /** Elapsed ms of the whole AI response run — shown as ⏳ duration. */
39
- durationMs?: number | null;
40
24
  /** Whether the message immediately preceding this one was also an assistant message. */
41
25
  isPrevAssistant?: boolean;
42
26
  }
43
27
 
44
28
  export const ChatMessageItem = memo(function ChatMessageItem({
45
29
  msg,
46
- provider,
47
- providerNames,
48
- modelName,
49
- modelNames,
50
- thinkingLevel,
51
30
  isStreaming = false,
52
- onRetry,
53
- onUndo,
31
+ onUndo,
54
32
  onNewChat,
55
- footerVisible = true,
56
- isMobile = false,
57
33
  className = '',
58
- durationMs = null,
59
34
  isPrevAssistant = false,
60
35
  }: ChatMessageItemProps) {
61
36
  const isUser = msg.role === 'user';
62
37
 
38
+ /** A notice row renders as a card. When omp diverted the turn's answer into
39
+ * `notice` instead, the row is an answer (it kept the turn's own metadata)
40
+ * and the notice text is the response — see chat/notice-row.ts. */
41
+ const isNotice = isNoticeRow(msg);
42
+ const effectiveContent = isNotice ? msg.content : messageAnswerText(msg);
43
+
63
44
  /** Stable string references for MarkdownRenderer so its internal
64
45
  * useMemo([content]) holds across parent re-renders (streaming frames
65
46
  * replace the timeline array identity every frame). */
66
- const userContent = useMemo(() => (typeof msg.content === 'string' ? msg.content.trim() : msg.content), [msg.content]);
67
- const assistantContent = useMemo(() => (typeof msg.content === 'string' ? capitalizeFirstLetter(msg.content) : msg.content), [msg.content]);
47
+ const userContent = useMemo(() => (typeof effectiveContent === 'string' ? effectiveContent.trim() : effectiveContent), [effectiveContent]);
48
+ const assistantContent = useMemo(() => (typeof effectiveContent === 'string' ? capitalizeFirstLetter(effectiveContent) : effectiveContent), [effectiveContent]);
68
49
 
69
50
  /** Content is not worth rendering when it is empty or only punctuation
70
51
  * placeholders ("." / "..." etc.) — chunked assistant turns often carry a
71
52
  * lone dot while the real payload lives in tool calls / thinking. */
72
- const hasRenderableContent = typeof msg.content === 'string' && /[A-Za-z0-9]/.test(msg.content);
73
-
74
- const formatFooterDate = (value?: string) => {
75
- const raw = value || msg.timestamp || msg.date;
76
- if (!raw) return '';
77
- if (raw.startsWith('Today,')) {
78
- return `Today, ${raw.slice('Today,'.length).trim()}`;
79
- }
80
- // Bare "10:30 AM" timestamp (live path) — resolve against today.
81
- if (/^\d{1,2}:\d{2}\s*(AM|PM)$/i.test(raw)) {
82
- const parsedMs = parseTodayLabel(`Today, ${raw}`);
83
- if (parsedMs !== null) return `Today, ${formatClock(parsedMs)}`;
84
- }
85
- const date = new Date(raw);
86
- if (Number.isNaN(date.getTime())) return '';
87
- const day = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
88
- return `${day}, ${formatClock(date)}`;
89
- };
53
+ const hasRenderableContent = typeof effectiveContent === 'string' && /[A-Za-z0-9]/.test(effectiveContent);
90
54
 
91
55
  const handleNewChat = (e?: TargetedMouseEvent<HTMLElement>) => {
92
56
  if (e) e.preventDefault();
@@ -138,7 +102,7 @@ export const ChatMessageItem = memo(function ChatMessageItem({
138
102
  ) : (
139
103
  <User size={11} className="text-ink/70 shrink-0" />
140
104
  )}
141
- {formatFooterDate() && <span>{formatFooterDate()}</span>}
105
+ {formatMessageStamp(msg) && <span>{formatMessageStamp(msg)}</span>}
142
106
  </div>
143
107
 
144
108
  <div className="flex items-center space-x-1">
@@ -197,8 +161,8 @@ export const ChatMessageItem = memo(function ChatMessageItem({
197
161
  {/* Main AI Response Container */}
198
162
  <div className="w-full space-y-2.5 font-sans leading-relaxed">
199
163
 
200
- {/* System Notice Alert */}
201
- {msg.notice && <SystemNotice notice={msg.notice} />}
164
+ {/* System Notice Alert (a notice carrying the answer renders as content below) */}
165
+ {isNotice && <SystemNotice notice={msg.notice} />}
202
166
 
203
167
  {/* Thinking / Reasoning Accordion */}
204
168
  {thinkingData && (
@@ -298,24 +262,6 @@ export const ChatMessageItem = memo(function ChatMessageItem({
298
262
  </div>
299
263
  )}
300
264
  </div>
301
-
302
- {/* Bottom AI Metadata & Actions Toolbar (Only shown once completed) */}
303
- {!isStreaming && footerVisible && (
304
- <AiMessageFooter
305
- provider={msg.provider || provider}
306
- providerNames={providerNames}
307
- currentModel={(msg.model ? (modelNames?.[msg.model] ?? msg.model) : '') || modelName || ''}
308
- thinkingLevel={msg.thinkingLevel ?? thinkingLevel}
309
- dateStr={formatFooterDate()}
310
- durationMs={durationMs ?? msg.durationMs}
311
- usage={msg.usage}
312
- content={msg.content}
313
- msgId={msg.id}
314
- onRetry={onRetry}
315
- onNewChat={onNewChat}
316
- isMobile={isMobile}
317
- />
318
- )}
319
265
  </div>
320
266
  );
321
267
  });
@@ -1,8 +1,11 @@
1
+ import { Fragment } from 'preact';
1
2
  import { useMemo } from 'preact/hooks';
2
3
  import { memo } from 'preact/compat';
3
4
 
4
5
  import { ChatMessageItem } from '@/client/components/workspace/chat-timeline/MessageItem';
5
- import { responseRunDurationMs } from '@/shared/lib/chat/duration';
6
+ import { RunFooter } from '@/client/components/workspace/chat-timeline/RunFooter';
7
+ import { isNoticeRow } from '@/shared/lib/chat/notice-row';
8
+ import { previousNonNoticeIndex, resolveRunFooters, streamingRowIndex } from '@/shared/lib/chat/timeline/run-footer';
6
9
  import type { ChatMessageData } from '@/shared/types';
7
10
 
8
11
  export interface MessageListProps {
@@ -22,14 +25,15 @@ export interface MessageListProps {
22
25
 
23
26
  /**
24
27
  * Shared timeline body: the ordered message rows plus the per-run footer
25
- * (model + duration) ownership rules. Desktop and mobile render the exact same
28
+ * (model + duration) placement rules. Desktop and mobile render the exact same
26
29
  * rows — only the surrounding scroll container differs.
27
30
  *
28
- * Footer ownership: notice rows are transparent for footer purposes, so the
29
- * last real AI message of a run still owns the footer even when a notice row
30
- * follows it, and the notice itself never gets one.
31
+ * Footer placement: the footer is the run's own boundary row, so it renders
32
+ * after every row the run owns — including notice rows omp wrote at its tail —
33
+ * and therefore immediately before the next user message. A notice-only
34
+ * stretch never gets one.
31
35
  */
32
- // Per-row lookups (next/prev non-notice neighbour, run durations) are built in
36
+ // Per-row lookups (previous non-notice neighbour, run footers) are built in
33
37
  // one O(n) pass and memoized on `messages`, so a streaming frame renders in
34
38
  // O(n) instead of re-scanning the array for every row. `memo` then skips the
35
39
  // rows entirely when the props are referentially unchanged.
@@ -46,75 +50,52 @@ export const MessageList = memo(function MessageList({
46
50
  onNewChat,
47
51
  isMobile = false,
48
52
  }: MessageListProps) {
49
- const { lastAiIdx, nextNonNotice, prevNonNoticeIdx, durations } = useMemo(() => {
50
- const count = messages.length;
51
-
52
- // The streaming AI message is the last non-notice row: notice rows sit above
53
- // the turn, so a plain "last item" check would mark the notice as streaming
54
- // and render the footer early.
55
- let lastAi = count - 1;
56
- while (lastAi >= 0 && messages[lastAi].notice) lastAi--;
57
-
58
- // nextNonNotice[idx]: nearest non-notice row strictly AFTER idx (or null).
59
- const nextNonNotice: (ChatMessageData | null)[] = new Array(count);
60
- let next: ChatMessageData | null = null;
61
- for (let i = count - 1; i >= 0; i--) {
62
- nextNonNotice[i] = next;
63
- if (!messages[i].notice) next = messages[i];
64
- }
65
-
66
- // prevNonNoticeIdx[idx]: nearest non-notice row index strictly BEFORE idx.
67
- const prevNonNoticeIdx: number[] = new Array(count);
68
- let prevIdx = -1;
69
- for (let i = 0; i < count; i++) {
70
- prevNonNoticeIdx[i] = prevIdx;
71
- if (!messages[i].notice) prevIdx = i;
72
- }
73
-
74
- // Footer-owning rows resolve their run duration once here (each is O(n) in
75
- // the worst case, so doing it inline would be O(n²) overall).
76
- const durations: (number | null)[] = new Array(count);
77
- for (let i = 0; i < count; i++) {
78
- const msg = messages[i];
79
- const nextReal = nextNonNotice[i];
80
- const ownsFooter = msg.role !== 'user' && !msg.notice && (!nextReal || nextReal.role === 'user');
81
- durations[i] = ownsFooter ? responseRunDurationMs(messages, i) : null;
82
- }
83
-
84
- return { lastAiIdx: lastAi, nextNonNotice, prevNonNoticeIdx, durations };
85
- }, [messages]);
53
+ const { streamingIdx, footers, prevNonNoticeIdx } = useMemo(() => {
54
+ return {
55
+ // The streaming AI row is the last non-notice row (see streamingRowIndex).
56
+ streamingIdx: streamingRowIndex(messages),
57
+ // Runs resolve their footer + run duration once here, keyed by the row
58
+ // the footer renders after.
59
+ footers: resolveRunFooters(messages, isGenerating),
60
+ prevNonNoticeIdx: previousNonNoticeIndex(messages),
61
+ };
62
+ }, [messages, isGenerating]);
86
63
 
87
64
  return (
88
65
  <>
89
66
  {messages.map((msg, idx) => {
90
67
  const prev = messages[idx - 1];
91
- const isLoading = isGenerating && idx === lastAiIdx && msg.role === 'ai';
92
- const nextReal = nextNonNotice[idx];
93
- const isLastAi = msg.role !== 'user' && !msg.notice && (!nextReal || nextReal.role === 'user');
94
- const isPrevNotice = Boolean(prev?.notice);
95
- const isAiFragment = msg.role !== 'user' && prev && prev.role !== 'user' && !isPrevNotice;
68
+ const isLoading = isGenerating && idx === streamingIdx && msg.role === 'ai';
69
+ const isAiFragment = msg.role !== 'user' && prev && prev.role !== 'user' && !isNoticeRow(prev);
96
70
  const prevRealIdx = prevNonNoticeIdx[idx];
97
71
  const prevReal = prevRealIdx >= 0 ? messages[prevRealIdx] : null;
98
72
  const isPrevAssistant = Boolean(msg.role !== 'user' && prevReal && prevReal.role !== 'user');
73
+ const footer = footers[idx];
99
74
  return (
100
- <ChatMessageItem
101
- key={msg.id}
102
- msg={msg}
103
- provider={provider}
104
- providerNames={providerNames}
105
- modelName={modelName}
106
- modelNames={modelNames}
107
- thinkingLevel={thinkingLevel}
108
- isStreaming={isLoading}
109
- onUndo={onUndo}
110
- onRetry={onRetry}
111
- onNewChat={onNewChat}
112
- footerVisible={isLastAi}
113
- durationMs={durations[idx]}
114
- isPrevAssistant={isPrevAssistant}
115
- isMobile={isMobile}
116
- className={msg.notice ? 'mt-3 mb-1' : isAiFragment ? 'mt-1' : 'mt-3'}
117
- />
75
+ <Fragment key={msg.id}>
76
+ <ChatMessageItem
77
+ msg={msg}
78
+ isStreaming={isLoading}
79
+ onUndo={onUndo}
80
+ onNewChat={onNewChat}
81
+ isPrevAssistant={isPrevAssistant}
82
+ className={isNoticeRow(msg) ? 'mt-3 mb-1' : isAiFragment ? 'mt-1' : 'mt-3'}
83
+ />
84
+ {footer && (
85
+ <RunFooter
86
+ msg={footer.msg}
87
+ provider={provider}
88
+ providerNames={providerNames}
89
+ modelName={modelName}
90
+ modelNames={modelNames}
91
+ thinkingLevel={thinkingLevel}
92
+ durationMs={footer.durationMs}
93
+ onRetry={onRetry}
94
+ onNewChat={onNewChat}
95
+ isMobile={isMobile}
96
+ />
97
+ )}
98
+ </Fragment>
118
99
  );
119
100
  })}
120
101
  </>