ompchamber 0.3.0 → 0.5.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 (256) hide show
  1. package/README.md +17 -10
  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/ct.b74928f7a3.js +1 -0
  12. package/dist/client/static/js/async/dc.6dc4b0a04b.js +2 -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.0ac93ee878.js +33 -0
  43. package/dist/client/static/js/index.0ac93ee878.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 -2
  47. package/src/cli/lib/args.js +1 -0
  48. package/src/cli/lib/commands/serve.js +7 -0
  49. package/src/client/App.tsx +22 -5
  50. package/src/client/components/common/MarkdownRenderer.tsx +36 -0
  51. package/src/client/components/layout/desktop-layout/index.tsx +1 -1
  52. package/src/client/components/layout/session-sidebar/Modals.tsx +1 -1
  53. package/src/client/components/mobile/LayoutWrapper.tsx +1 -1
  54. package/src/client/components/settings/LazyModal.tsx +46 -0
  55. package/src/client/components/settings/Modal.tsx +1 -1
  56. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +43 -29
  57. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +15 -69
  58. package/src/client/components/workspace/chat-timeline/MessageList.tsx +47 -66
  59. package/src/client/components/workspace/chat-timeline/RunFooter.tsx +60 -0
  60. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +37 -21
  61. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +22 -4
  62. package/src/client/components/workspace/chat-timeline/index.tsx +3 -6
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +15 -19
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/EnvelopeHeader.tsx +46 -0
  65. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/FallbackOutput.tsx +18 -68
  66. package/src/client/components/workspace/file-explorer/TreeItem.tsx +16 -4
  67. package/src/client/components/workspace/file-explorer/index.tsx +6 -0
  68. package/src/client/data/models/catalog.ts +8 -0
  69. package/src/client/data/samples/dialogue-session.ts +1 -1
  70. package/src/client/data/samples/sample-tools/other.ts +1 -1
  71. package/src/client/hooks/chat/omp/index.ts +9 -10
  72. package/src/client/hooks/chat/omp/revalidation-throttle.ts +6 -5
  73. package/src/client/hooks/chat/timeline/actions.ts +1 -2
  74. package/src/client/hooks/chat/timeline/index.ts +5 -0
  75. package/src/client/hooks/chat/timeline/send.ts +2 -3
  76. package/src/client/hooks/chat/timeline/session-load.ts +3 -4
  77. package/src/client/hooks/ui/syntax-ready.ts +16 -3
  78. package/src/client/main.tsx +2 -5
  79. package/src/client/stubs/elkjs.ts +37 -0
  80. package/src/client/tailwind.css +10 -0
  81. package/src/server/index.ts +53 -0
  82. package/src/server/lib/fs/git-ignore.ts +49 -0
  83. package/src/server/lib/models/provider-registry.server.ts +44 -20
  84. package/src/server/lib/omp/core/cli.ts +3 -2
  85. package/src/server/lib/omp/core/startup.ts +64 -0
  86. package/src/server/lib/omp/rpc/constants.ts +10 -0
  87. package/src/server/lib/omp/rpc/lifecycle.test.ts +138 -0
  88. package/src/server/lib/omp/rpc/manager.ts +32 -4
  89. package/src/server/lib/omp/rpc/process.ts +1 -1
  90. package/src/server/lib/omp/rpc/session-commands.ts +32 -9
  91. package/src/server/lib/omp/rpc/session-registry.ts +3 -2
  92. package/src/server/lib/omp/rpc/subagent-liveness.test.ts +62 -0
  93. package/src/server/lib/omp/rpc/subagent-liveness.ts +100 -0
  94. package/src/server/lib/omp/session/messages.test.ts +112 -0
  95. package/src/server/lib/omp/session/messages.ts +6 -5
  96. package/src/server/mock.server.ts +6 -12
  97. package/src/server/routes/agent/command.ts +34 -16
  98. package/src/server/routes/chat/stream.ts +13 -30
  99. package/src/server/routes/fs/read.ts +15 -7
  100. package/src/server/routes/models/root.ts +5 -1
  101. package/src/shared/lib/chat/notice-row.test.ts +89 -0
  102. package/src/shared/lib/chat/notice-row.ts +78 -0
  103. package/src/shared/lib/chat/omp/abort.ts +41 -0
  104. package/src/shared/lib/chat/stream-service.ts +0 -76
  105. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +154 -0
  106. package/src/shared/lib/chat/timeline/omp-callbacks.ts +36 -7
  107. package/src/shared/lib/chat/timeline/run-footer.test.ts +84 -0
  108. package/src/shared/lib/chat/timeline/run-footer.ts +96 -0
  109. package/src/shared/lib/chat/tool-output.test.ts +91 -0
  110. package/src/shared/lib/chat/tool-output.ts +54 -0
  111. package/src/shared/lib/chat/xml-envelope.ts +158 -0
  112. package/src/shared/lib/code/highlighter-lazy.ts +106 -0
  113. package/src/shared/lib/code/highlighter.ts +31 -52
  114. package/src/shared/lib/code/syntax-highlight.test.ts +36 -1
  115. package/src/shared/lib/code/syntax-highlight.ts +17 -3
  116. package/src/shared/lib/format/time.ts +19 -0
  117. package/src/shared/lib/fs/paths.ts +19 -0
  118. package/src/shared/lib/markdown/fira-code-fonts.css +51 -0
  119. package/src/shared/lib/markdown/katex-fonts.css +20 -0
  120. package/src/shared/lib/markdown/katex.ts +101 -0
  121. package/src/shared/lib/markdown/marked.ts +23 -14
  122. package/src/shared/lib/markdown/math.test.ts +109 -0
  123. package/src/shared/lib/markdown/mermaid.ts +6 -0
  124. package/src/shared/lib/markdown/sanitize.ts +48 -2
  125. package/src/shared/lib/omp/session/mapper.ts +11 -3
  126. package/src/shared/lib/omp/session/messages-map.ts +12 -12
  127. package/src/shared/types/chat.ts +3 -1
  128. package/src/shared/types/fs.ts +3 -0
  129. package/dist/client/static/css/g.f36aa429fa.css +0 -1
  130. package/dist/client/static/css/index.a0f07455bd.css +0 -2
  131. package/dist/client/static/css/katex-vendor.ca3a0600e8.css +0 -1
  132. package/dist/client/static/font/KaTeX_AMS-Regular.1608a09b4a.woff +0 -0
  133. package/dist/client/static/font/KaTeX_AMS-Regular.4aafdb685c.ttf +0 -0
  134. package/dist/client/static/font/KaTeX_Caligraphic-Bold.b67709187b.woff +0 -0
  135. package/dist/client/static/font/KaTeX_Caligraphic-Bold.cce5b8ecea.ttf +0 -0
  136. package/dist/client/static/font/KaTeX_Caligraphic-Regular.07ef19e7b0.ttf +0 -0
  137. package/dist/client/static/font/KaTeX_Caligraphic-Regular.dad44a7fc6.woff +0 -0
  138. package/dist/client/static/font/KaTeX_Fraktur-Bold.9f256b8593.woff +0 -0
  139. package/dist/client/static/font/KaTeX_Fraktur-Bold.b18f59e1d1.ttf +0 -0
  140. package/dist/client/static/font/KaTeX_Fraktur-Regular.7c1871215e.woff +0 -0
  141. package/dist/client/static/font/KaTeX_Fraktur-Regular.ed38e79f57.ttf +0 -0
  142. package/dist/client/static/font/KaTeX_Main-Bold.b74a1a8b2c.ttf +0 -0
  143. package/dist/client/static/font/KaTeX_Main-Bold.d181c4650d.woff +0 -0
  144. package/dist/client/static/font/KaTeX_Main-BoldItalic.70d8b0a530.ttf +0 -0
  145. package/dist/client/static/font/KaTeX_Main-BoldItalic.e3f82f9d27.woff +0 -0
  146. package/dist/client/static/font/KaTeX_Main-Italic.47373d1e51.ttf +0 -0
  147. package/dist/client/static/font/KaTeX_Main-Italic.9024d815ba.woff +0 -0
  148. package/dist/client/static/font/KaTeX_Main-Regular.7f51fe0340.woff +0 -0
  149. package/dist/client/static/font/KaTeX_Main-Regular.b7f8fe9b5f.ttf +0 -0
  150. package/dist/client/static/font/KaTeX_Math-BoldItalic.a879cf8383.ttf +0 -0
  151. package/dist/client/static/font/KaTeX_Math-BoldItalic.f1035d8d5d.woff +0 -0
  152. package/dist/client/static/font/KaTeX_Math-Italic.5295ba483a.woff +0 -0
  153. package/dist/client/static/font/KaTeX_Math-Italic.939bc64440.ttf +0 -0
  154. package/dist/client/static/font/KaTeX_SansSerif-Bold.94e1e8dc5f.ttf +0 -0
  155. package/dist/client/static/font/KaTeX_SansSerif-Bold.bf59d231c3.woff +0 -0
  156. package/dist/client/static/font/KaTeX_SansSerif-Italic.7c9bc82b17.woff +0 -0
  157. package/dist/client/static/font/KaTeX_SansSerif-Italic.b4c20c84d8.ttf +0 -0
  158. package/dist/client/static/font/KaTeX_SansSerif-Regular.740484788f.woff +0 -0
  159. package/dist/client/static/font/KaTeX_SansSerif-Regular.d4d7ba4804.ttf +0 -0
  160. package/dist/client/static/font/KaTeX_Script-Regular.0750571032.woff +0 -0
  161. package/dist/client/static/font/KaTeX_Script-Regular.fe9cbbe1a0.ttf +0 -0
  162. package/dist/client/static/font/KaTeX_Size1-Regular.e1e279cbdd.woff +0 -0
  163. package/dist/client/static/font/KaTeX_Size1-Regular.fabc004aab.ttf +0 -0
  164. package/dist/client/static/font/KaTeX_Size2-Regular.577270225e.woff +0 -0
  165. package/dist/client/static/font/KaTeX_Size2-Regular.d6b476ecd3.ttf +0 -0
  166. package/dist/client/static/font/KaTeX_Size3-Regular.9acaf01c64.woff +0 -0
  167. package/dist/client/static/font/KaTeX_Size3-Regular.a144ef5840.ttf +0 -0
  168. package/dist/client/static/font/KaTeX_Size4-Regular.7a996c9da2.woff +0 -0
  169. package/dist/client/static/font/KaTeX_Size4-Regular.fbccdabe0a.ttf +0 -0
  170. package/dist/client/static/font/KaTeX_Typewriter-Regular.6258592bdc.woff +0 -0
  171. package/dist/client/static/font/KaTeX_Typewriter-Regular.d97aaf4a1e.ttf +0 -0
  172. package/dist/client/static/font/fira-code-cyrillic-400-normal.5971438a4a.woff2 +0 -0
  173. package/dist/client/static/font/fira-code-cyrillic-400-normal.9bb7bfea9c.woff +0 -0
  174. package/dist/client/static/font/fira-code-cyrillic-500-normal.5486265b18.woff2 +0 -0
  175. package/dist/client/static/font/fira-code-cyrillic-500-normal.89f92bfb31.woff +0 -0
  176. package/dist/client/static/font/fira-code-cyrillic-600-normal.2805b497e5.woff +0 -0
  177. package/dist/client/static/font/fira-code-cyrillic-600-normal.86e0df22a3.woff2 +0 -0
  178. package/dist/client/static/font/fira-code-cyrillic-700-normal.505a88f22a.woff +0 -0
  179. package/dist/client/static/font/fira-code-cyrillic-700-normal.76f5e8d2e2.woff2 +0 -0
  180. package/dist/client/static/font/fira-code-cyrillic-ext-400-normal.3c4b15e57d.woff2 +0 -0
  181. package/dist/client/static/font/fira-code-cyrillic-ext-400-normal.61323debaa.woff +0 -0
  182. package/dist/client/static/font/fira-code-cyrillic-ext-500-normal.0f34210f2a.woff2 +0 -0
  183. package/dist/client/static/font/fira-code-cyrillic-ext-500-normal.2497cfa1aa.woff +0 -0
  184. package/dist/client/static/font/fira-code-cyrillic-ext-600-normal.40fce94af9.woff2 +0 -0
  185. package/dist/client/static/font/fira-code-cyrillic-ext-600-normal.6c8774b829.woff +0 -0
  186. package/dist/client/static/font/fira-code-cyrillic-ext-700-normal.4b742eb865.woff +0 -0
  187. package/dist/client/static/font/fira-code-cyrillic-ext-700-normal.e75eb4fccc.woff2 +0 -0
  188. package/dist/client/static/font/fira-code-greek-400-normal.bb900468b6.woff2 +0 -0
  189. package/dist/client/static/font/fira-code-greek-400-normal.be76e699ba.woff +0 -0
  190. package/dist/client/static/font/fira-code-greek-500-normal.176c9fb001.woff2 +0 -0
  191. package/dist/client/static/font/fira-code-greek-500-normal.d2c596136b.woff +0 -0
  192. package/dist/client/static/font/fira-code-greek-600-normal.490c3deed7.woff +0 -0
  193. package/dist/client/static/font/fira-code-greek-600-normal.7df14376d9.woff2 +0 -0
  194. package/dist/client/static/font/fira-code-greek-700-normal.c82b6072fc.woff2 +0 -0
  195. package/dist/client/static/font/fira-code-greek-700-normal.d5f2b2917b.woff +0 -0
  196. package/dist/client/static/font/fira-code-greek-ext-400-normal.4849a79781.woff +0 -0
  197. package/dist/client/static/font/fira-code-greek-ext-400-normal.7f5bfc4538.woff2 +0 -0
  198. package/dist/client/static/font/fira-code-greek-ext-500-normal.aaccca4075.woff2 +0 -0
  199. package/dist/client/static/font/fira-code-greek-ext-500-normal.e383181256.woff +0 -0
  200. package/dist/client/static/font/fira-code-greek-ext-600-normal.78cb4bff8e.woff +0 -0
  201. package/dist/client/static/font/fira-code-greek-ext-600-normal.d6c93d54f9.woff2 +0 -0
  202. package/dist/client/static/font/fira-code-greek-ext-700-normal.57ec7c92d9.woff +0 -0
  203. package/dist/client/static/font/fira-code-greek-ext-700-normal.8fafdae5e8.woff2 +0 -0
  204. package/dist/client/static/font/fira-code-latin-400-normal.973295532b.woff +0 -0
  205. package/dist/client/static/font/fira-code-latin-500-normal.7ae2be8fb8.woff +0 -0
  206. package/dist/client/static/font/fira-code-latin-600-normal.1dbbe5bc27.woff +0 -0
  207. package/dist/client/static/font/fira-code-latin-700-normal.497a70148b.woff +0 -0
  208. package/dist/client/static/font/fira-code-latin-ext-400-normal.9dfae29aaf.woff +0 -0
  209. package/dist/client/static/font/fira-code-latin-ext-400-normal.e534ae46e2.woff2 +0 -0
  210. package/dist/client/static/font/fira-code-latin-ext-500-normal.24c7b781b0.woff +0 -0
  211. package/dist/client/static/font/fira-code-latin-ext-500-normal.aad44ab072.woff2 +0 -0
  212. package/dist/client/static/font/fira-code-latin-ext-600-normal.7bed0f2f7e.woff +0 -0
  213. package/dist/client/static/font/fira-code-latin-ext-600-normal.c509a7c9f1.woff2 +0 -0
  214. package/dist/client/static/font/fira-code-latin-ext-700-normal.6c66c8b0a9.woff2 +0 -0
  215. package/dist/client/static/font/fira-code-latin-ext-700-normal.88f64e2822.woff +0 -0
  216. package/dist/client/static/font/fira-code-symbols2-400-normal.97d3f4b17c.woff +0 -0
  217. package/dist/client/static/font/fira-code-symbols2-500-normal.b06f1a06e4.woff +0 -0
  218. package/dist/client/static/font/fira-code-symbols2-600-normal.304b653b93.woff +0 -0
  219. package/dist/client/static/font/fira-code-symbols2-700-normal.d8ce0ad586.woff +0 -0
  220. package/dist/client/static/js/async/3j.a2fcea99be.js +0 -2
  221. package/dist/client/static/js/async/51.1938cec320.js +0 -2
  222. package/dist/client/static/js/async/6.7929428505.js +0 -1
  223. package/dist/client/static/js/async/69.a18a10882f.js +0 -1
  224. package/dist/client/static/js/async/a.bce4542392.js +0 -1
  225. package/dist/client/static/js/async/ar.9dc8b1992a.js +0 -2
  226. package/dist/client/static/js/async/bi.cbfe8eaa8e.js +0 -1
  227. package/dist/client/static/js/async/ct.38d9da8a5a.js +0 -1
  228. package/dist/client/static/js/async/fw.fb1a08b92d.js +0 -6
  229. package/dist/client/static/js/async/l6.45f3b62e15.js +0 -1
  230. package/dist/client/static/js/async/q.0b6fb9bf8d.js +0 -21
  231. package/dist/client/static/js/async/rd.b77011ab7f.js +0 -2
  232. package/dist/client/static/js/async/rf.cfbde7f744.js +0 -2
  233. package/dist/client/static/js/async/s.c7d521a8ae.js +0 -2
  234. package/dist/client/static/js/async/sy.7345abcd67.js +0 -94
  235. package/dist/client/static/js/async/t.0dcb01dfde.js +0 -1
  236. package/dist/client/static/js/async/v.5c7a4b087b.js +0 -1
  237. package/dist/client/static/js/async/vu.311f1d202c.js +0 -34
  238. package/dist/client/static/js/async/yg.9bd4e09ecb.js +0 -149
  239. package/dist/client/static/js/async/yv.593a3f7d54.js +0 -1
  240. package/dist/client/static/js/g.124fba0826.js +0 -139
  241. package/dist/client/static/js/index.907fd4670e.js +0 -68
  242. package/dist/client/static/js/katex-vendor.95e48cf65a.js +0 -1
  243. package/src/shared/lib/chat/order.ts +0 -40
  244. /package/dist/client/static/js/async/{1k.544adbf77d.js.LICENSE.txt → 1k.5974df3b87.js.LICENSE.txt} +0 -0
  245. /package/dist/client/static/js/async/{3j.a2fcea99be.js.LICENSE.txt → 3j.357c80ff38.js.LICENSE.txt} +0 -0
  246. /package/dist/client/static/js/async/{3y.22075aa079.js.LICENSE.txt → 3y.ededeebde8.js.LICENSE.txt} +0 -0
  247. /package/dist/client/static/js/async/{51.1938cec320.js.LICENSE.txt → 51.95bcae98af.js.LICENSE.txt} +0 -0
  248. /package/dist/client/static/js/async/{ar.9dc8b1992a.js.LICENSE.txt → a.e05da304c8.js.LICENSE.txt} +0 -0
  249. /package/dist/client/static/js/async/{fw.fb1a08b92d.js.LICENSE.txt → dc.6dc4b0a04b.js.LICENSE.txt} +0 -0
  250. /package/dist/client/static/js/async/{m.3fc017a055.js.LICENSE.txt → fw.6a51998a8e.js.LICENSE.txt} +0 -0
  251. /package/dist/client/static/js/async/{q.0b6fb9bf8d.js.LICENSE.txt → m.4b29d34f93.js.LICENSE.txt} +0 -0
  252. /package/dist/client/static/js/async/{rf.cfbde7f744.js.LICENSE.txt → q.7fb4d2f374.js.LICENSE.txt} +0 -0
  253. /package/dist/client/static/js/async/{rd.b77011ab7f.js.LICENSE.txt → rd.cb6bf495bb.js.LICENSE.txt} +0 -0
  254. /package/dist/client/static/js/async/{s.c7d521a8ae.js.LICENSE.txt → rf.d626afc367.js.LICENSE.txt} +0 -0
  255. /package/dist/client/static/js/{index.907fd4670e.js.LICENSE.txt → async/ud.3065bfee31.js.LICENSE.txt} +0 -0
  256. /package/dist/client/static/js/{g.124fba0826.js.LICENSE.txt → uy.5f791144ce.js.LICENSE.txt} +0 -0
@@ -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
  </>
@@ -0,0 +1,60 @@
1
+ import type { ChatMessageData } from '@/shared/types';
2
+ import { AiMessageFooter } from '@/client/components/workspace/chat-timeline/AiMessageFooter';
3
+ import { messageAnswerText } from '@/shared/lib/chat/notice-row';
4
+ import { formatMessageStamp } from '@/shared/lib/format/time';
5
+
6
+ interface RunFooterProps {
7
+ /** The run's last real AI message (`RunFooterSlot.msg`). */
8
+ msg: ChatMessageData;
9
+ /** Session-level provider, used when the turn recorded none. */
10
+ provider?: string;
11
+ providerNames?: Record<string, string>;
12
+ modelName?: string;
13
+ /** id → display-name map from the model catalog. */
14
+ modelNames?: Record<string, string>;
15
+ /** Session thinking level, used when the turn recorded none. */
16
+ thinkingLevel?: string;
17
+ durationMs?: number | null;
18
+ onRetry?: (msgId: string) => void;
19
+ onNewChat?: (content: string) => void;
20
+ /** Mobile uses a compact provider/model footer with a bottom-sheet menu. */
21
+ isMobile?: boolean;
22
+ }
23
+
24
+ /**
25
+ * Boundary footer of an AI response run: the timeline renders it as the run's
26
+ * own row — after every row the run owns, immediately before the next user
27
+ * message — never inside the answer bubble. The slot's AI message supplies the
28
+ * turn's model/usage/content.
29
+ */
30
+ export function RunFooter({
31
+ msg,
32
+ provider,
33
+ providerNames,
34
+ modelName,
35
+ modelNames,
36
+ thinkingLevel,
37
+ durationMs,
38
+ onRetry,
39
+ onNewChat,
40
+ isMobile = false,
41
+ }: RunFooterProps) {
42
+ return (
43
+ <div className="mt-2">
44
+ <AiMessageFooter
45
+ provider={msg.provider || provider}
46
+ providerNames={providerNames}
47
+ currentModel={(msg.model ? (modelNames?.[msg.model] ?? msg.model) : '') || modelName || ''}
48
+ thinkingLevel={msg.thinkingLevel ?? thinkingLevel}
49
+ dateStr={formatMessageStamp(msg)}
50
+ durationMs={durationMs ?? msg.durationMs}
51
+ usage={msg.usage}
52
+ content={messageAnswerText(msg)}
53
+ msgId={msg.id}
54
+ onRetry={onRetry}
55
+ onNewChat={onNewChat}
56
+ isMobile={isMobile}
57
+ />
58
+ </div>
59
+ );
60
+ }
@@ -1,10 +1,14 @@
1
- import { useEffect, useRef } from 'preact/hooks';
1
+ import { Fragment } from 'preact';
2
+ import { useEffect, useMemo, useRef } from 'preact/hooks';
2
3
  import { ArrowLeft, Bot, Loader2, Lock } from 'lucide-preact';
3
4
  import type { SubagentInfo } from '@/shared/types';
4
5
  import { SubagentStatusIcon } from '@/client/components/common/SubagentStatusIcon';
5
6
  import { ChatMessageItem } from '@/client/components/workspace/chat-timeline/MessageItem';
7
+ import { RunFooter } from '@/client/components/workspace/chat-timeline/RunFooter';
6
8
  import { useSubagentTranscript } from '@/client/hooks/chat/subagent';
7
9
  import { useModelNames } from '@/client/hooks/models/use-model-names';
10
+ import { isNoticeRow } from '@/shared/lib/chat/notice-row';
11
+ import { previousNonNoticeIndex, resolveRunFooters, streamingRowIndex } from '@/shared/lib/chat/timeline/run-footer';
8
12
  import { formatCompactTokens } from '@/shared/lib/format/number';
9
13
 
10
14
  interface SubagentViewProps {
@@ -43,6 +47,17 @@ export function SubagentView({ sessionId, subagent, onBack, provider, providerNa
43
47
  return () => cancelAnimationFrame(frame);
44
48
  }, [messages.length, isRunning]);
45
49
 
50
+ // Boundary footers: a run's footer renders after its last row, so trailing
51
+ // notice rows never separate it from the next user message.
52
+ const { streamingIdx, footers, prevNonNoticeIdx } = useMemo(
53
+ () => ({
54
+ streamingIdx: streamingRowIndex(messages),
55
+ footers: resolveRunFooters(messages, isRunning),
56
+ prevNonNoticeIdx: previousNonNoticeIndex(messages),
57
+ }),
58
+ [messages, isRunning],
59
+ );
60
+
46
61
  if (!subagent || !isActive) return null;
47
62
 
48
63
  const progress = status?.progress ?? subagent.progress;
@@ -104,30 +119,31 @@ export function SubagentView({ sessionId, subagent, onBack, provider, providerNa
104
119
  </div>
105
120
  ) : (
106
121
  messages.map((msg, idx) => {
107
- let lastAiIdx = messages.length - 1;
108
- while (lastAiIdx >= 0 && messages[lastAiIdx].notice) lastAiIdx--;
109
- const isLoading = isRunning && idx === lastAiIdx && msg.role !== 'user' && !msg.notice;
110
- const nextReal = messages.slice(idx + 1).find((m) => !m.notice);
111
- const isLastAi = msg.role !== 'user' && !msg.notice && (!nextReal || nextReal.role === 'user');
112
- let prevRealIdx = idx - 1;
113
- while (prevRealIdx >= 0 && messages[prevRealIdx].notice) prevRealIdx--;
122
+ const isLoading = isRunning && idx === streamingIdx && msg.role === 'ai';
123
+ const prevRealIdx = prevNonNoticeIdx[idx];
114
124
  const prevReal = prevRealIdx >= 0 ? messages[prevRealIdx] : null;
115
125
  const isPrevAssistant = Boolean(msg.role !== 'user' && prevReal && prevReal.role !== 'user');
126
+ const footer = footers[idx];
116
127
 
117
128
  return (
118
- <ChatMessageItem
119
- key={msg.id}
120
- msg={msg}
121
- provider={msg.provider || provider}
122
- providerNames={providerNames}
123
- modelName={msg.model || modelName}
124
- modelNames={modelNames}
125
- isStreaming={isLoading}
126
- footerVisible={isLastAi}
127
- durationMs={msg.durationMs ?? null}
128
- isPrevAssistant={isPrevAssistant}
129
- className={msg.notice ? 'mt-3 mb-1' : isPrevAssistant ? 'mt-1' : 'mt-3'}
130
- />
129
+ <Fragment key={msg.id}>
130
+ <ChatMessageItem
131
+ msg={msg}
132
+ isStreaming={isLoading}
133
+ isPrevAssistant={isPrevAssistant}
134
+ className={isNoticeRow(msg) ? 'mt-3 mb-1' : isPrevAssistant ? 'mt-1' : 'mt-3'}
135
+ />
136
+ {footer && (
137
+ <RunFooter
138
+ msg={footer.msg}
139
+ provider={provider}
140
+ providerNames={providerNames}
141
+ modelName={modelName}
142
+ modelNames={modelNames}
143
+ durationMs={footer.durationMs}
144
+ />
145
+ )}
146
+ </Fragment>
131
147
  );
132
148
  })
133
149
  )}
@@ -1,7 +1,9 @@
1
1
  import { useCallback, useMemo } from 'preact/hooks';
2
2
  import { memo } from 'preact/compat';
3
- import { Boxes, Brain, BrainCircuit, Camera, Check, Code2, Cpu, FileCode, FileText, GitPullRequest, Globe, HelpCircle, ListTodo, Search, Server, Shield, Terminal, Wrench } from 'lucide-preact';
3
+ import { Bell, Boxes, Brain, BrainCircuit, Camera, Check, Code2, Cpu, FileCode, FileText, GitPullRequest, Globe, HelpCircle, ListTodo, Search, Server, Shield, Terminal, Wrench } from 'lucide-preact';
4
4
  import type { ToolCallData } from '@/shared/types';
5
+ import { stripAnsiCodes } from '@/shared/lib/code/ansi';
6
+ import { isReminderTag, unwrapXmlEnvelope } from '@/shared/lib/chat/xml-envelope';
5
7
  import { CopyButton } from '@/client/components/common/CopyButton';
6
8
  import { ToolCardShell } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/ToolCardShell';
7
9
  import { DiffView } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView';
@@ -154,6 +156,13 @@ export const ToolCallCard = memo(function ToolCallCard({ tool, isOpen, onToggle,
154
156
  [hasPanel, commandOrInput]
155
157
  );
156
158
 
159
+ // A reminder envelope in the result is the runtime interrupting the call, not
160
+ // its outcome — the header flags it next to the status badge.
161
+ const isReminder = useMemo(() => {
162
+ const envelope = unwrapXmlEnvelope(stripAnsiCodes(outputText));
163
+ return Boolean(envelope && isReminderTag(envelope.tag));
164
+ }, [outputText]);
165
+
157
166
  // Clean title & subtitle extraction
158
167
  let displayTitle = '';
159
168
  let displaySubtitle: string | undefined;
@@ -226,9 +235,18 @@ export const ToolCallCard = memo(function ToolCallCard({ tool, isOpen, onToggle,
226
235
  ? targetFilePath
227
236
  : displaySubtitle || (tool.detail && !targetFilePath ? tool.detail : undefined);
228
237
 
229
- const meta = tool.duration || tool.time ? (
230
- <span className="font-mono text-[10px] text-ink/40">{tool.duration || tool.time}</span>
231
- ) : null;
238
+ const duration = tool.duration || tool.time;
239
+ const meta =
240
+ duration || isReminder ? (
241
+ <span className="flex items-center gap-2">
242
+ {duration ? <span className="font-mono text-[10px] text-ink/40">{duration}</span> : null}
243
+ {isReminder && (
244
+ <span className="inline-flex items-center gap-1 rounded-full bg-error/10 px-2 py-0.5 text-[10px] font-semibold text-error">
245
+ <Bell size={10} /> Reminder
246
+ </span>
247
+ )}
248
+ </span>
249
+ ) : null;
232
250
 
233
251
  return (
234
252
  <ToolCardShell
@@ -16,7 +16,6 @@ import { useModelNames } from '@/client/hooks/models/use-model-names';
16
16
  import { useProviderNames } from '@/client/hooks/models/use-provider-names';
17
17
  import { useToasts } from '@/client/hooks/ui/toasts';
18
18
  import { ToastStack } from '@/client/components/common/ToastStack';
19
- import { normalizeNoticePositions } from '@/shared/lib/chat/order';
20
19
  import { composerRootFor } from '@/shared/lib/workspace/active-project';
21
20
  import { useSidebarData } from '@/client/hooks/chat/omp/session-list';
22
21
 
@@ -125,8 +124,6 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
125
124
  ? (modelNames[sessionData.model.modelId] ?? sessionData.model.modelId)
126
125
  : sessionData?.model;
127
126
 
128
- const orderedMessages = useMemo(() => normalizeNoticePositions(localMessages), [localMessages]);
129
-
130
127
  // A pending "new-…" session has no messages yet, so the workspace picker
131
128
  // must stay available until the first chat is sent (which spawns the real
132
129
  // omp session). Never lock the user into a folder before sending.
@@ -137,7 +134,7 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
137
134
  // covers the whole chat timeline (body + composer) so a session switch shows
138
135
  // one coherent placeholder instead of a half-drawn view. Skipped while an
139
136
  // optimistic send owns the tail (fresh spawn adoption must keep its bubbles).
140
- const showFullSkeleton = !isPendingSession && sessionLoading && orderedMessages.length === 0;
137
+ const showFullSkeleton = !isPendingSession && sessionLoading && localMessages.length === 0;
141
138
 
142
139
  if (!sessionId || isPendingSession) {
143
140
  return (
@@ -154,7 +151,7 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
154
151
  onSend={handleSend}
155
152
  isGenerating={isGenerating}
156
153
  appSettings={appSettings}
157
- localMessages={localMessages}
154
+ messages={localMessages}
158
155
  provider={sessionProvider}
159
156
  providerNames={providerNames}
160
157
  modelName={sessionModelName}
@@ -201,7 +198,7 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
201
198
  hasMore={hasMore}
202
199
  loadOlderError={loadOlderError}
203
200
  loadOlder={loadOlder}
204
- messages={orderedMessages}
201
+ messages={localMessages}
205
202
  isGenerating={isGenerating}
206
203
  provider={sessionProvider}
207
204
  providerNames={providerNames}
@@ -4,9 +4,12 @@ import type { ToolCallData } from '@/shared/types';
4
4
  import { CopyButton } from '@/client/components/common/CopyButton';
5
5
  import { DiffView } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView';
6
6
  import { HashlinePatch } from '@/client/components/workspace/chat-timeline/tool-renderers/hashline-patch';
7
- import { getLanguageFromPath, highlightCode, isCodeLike } from '@/shared/lib/code/syntax-highlight';
7
+ import { MarkdownRenderer } from '@/client/components/common/MarkdownRenderer';
8
+ import { EnvelopeHeader } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/EnvelopeHeader';
9
+ import { getLanguageFromPath, highlightCode } from '@/shared/lib/code/syntax-highlight';
8
10
  import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
9
11
  import { MAX_OUTPUT_LINES, truncateTailLines } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/truncate';
12
+ import { outputMarkdown, readToolOutput } from '@/shared/lib/chat/tool-output';
10
13
  import { hashlinePatchFromArgs } from '@/shared/lib/omp/session/hashline-patch';
11
14
  import { formatBytes } from '@/shared/lib/format/number';
12
15
 
@@ -148,10 +151,11 @@ export function Edit({ tool }: { tool: ToolCallData }) {
148
151
  () => (newContent ? highlightCode(newContent.split('\n').slice(0, 80).join('\n'), lang) : ''),
149
152
  [newContent, lang, syntaxReady]
150
153
  );
151
- const truncatedOutput = useMemo(() => truncateTailLines(output, MAX_OUTPUT_LINES), [output]);
152
- const highlightedOutput = useMemo(
153
- () => (isCodeLike(output) ? highlightCode(truncatedOutput.text, lang) : ''),
154
- [output, truncatedOutput, lang, syntaxReady]
154
+ const outputText = useMemo(() => readToolOutput(output), [output]);
155
+ const truncatedOutput = useMemo(() => truncateTailLines(outputText.content, MAX_OUTPUT_LINES), [outputText]);
156
+ const outputBody = useMemo(
157
+ () => outputMarkdown(truncatedOutput.text, outputText.format),
158
+ [truncatedOutput, outputText.format]
155
159
  );
156
160
 
157
161
  return (
@@ -261,26 +265,18 @@ export function Edit({ tool }: { tool: ToolCallData }) {
261
265
  </div>
262
266
  )}
263
267
 
264
- {/* Execution Result Notification */}
268
+ {/* Execution Result Notification — envelope XML dilepas, isinya markdown */}
265
269
  {output && (
266
- <div className="rounded-lg border border-ink/8 bg-paper p-2.5 text-[11px] font-mono select-text">
270
+ <div className="rounded-lg border border-ink/8 bg-paper p-2.5 select-text">
267
271
  <div className="flex items-center gap-1.5 mb-1.5 text-[9.5px] font-semibold uppercase tracking-wider text-ink/40">
268
272
  <ArrowRight size={11} className="text-success" />
269
273
  <span>Execution Output</span>
270
274
  </div>
271
- {isCodeLike(output) ? (
272
- <>
273
- {truncatedOutput.skipped > 0 && (
274
- <div className="mb-1 font-mono text-[9.5px] text-ink/45">… {truncatedOutput.skipped} earlier lines hidden</div>
275
- )}
276
- <pre
277
- className="max-h-56 overflow-auto rounded bg-canvas/40 p-2 text-ink/85 whitespace-pre leading-relaxed scrollbar-overlay-container scrollbar-overlay-static"
278
- dangerouslySetInnerHTML={{ __html: highlightedOutput }}
279
- />
280
- </>
281
- ) : (
282
- <div className="text-ink/75 leading-relaxed whitespace-pre-wrap">{truncatedOutput.text}</div>
275
+ {outputText.envelope && <EnvelopeHeader envelope={outputText.envelope} />}
276
+ {truncatedOutput.skipped > 0 && (
277
+ <div className="mb-1 font-mono text-[9.5px] text-ink/45">… {truncatedOutput.skipped} earlier lines hidden</div>
283
278
  )}
279
+ <MarkdownRenderer content={outputBody} className="text-[11.5px] text-ink/85" />
284
280
  </div>
285
281
  )}
286
282
  </div>
@@ -0,0 +1,46 @@
1
+ import { AlertCircle, Bell, Boxes } from 'lucide-preact';
2
+ import { isReminderTag, type XmlEnvelope } from '@/shared/lib/chat/xml-envelope';
3
+ import { toTitleCase } from '@/shared/lib/chat/title-case';
4
+
5
+ /** Badge tone per wrapper family: a system reminder is advisory (warning), an
6
+ * error envelope is a failure, anything else is informational. */
7
+ function envelopeTone(tag: string) {
8
+ if (isReminderTag(tag)) {
9
+ return { icon: <Bell size={12} />, badge: 'bg-warning/10 text-warning' };
10
+ }
11
+ if (tag === 'error' || tag === 'failure') {
12
+ return { icon: <AlertCircle size={12} />, badge: 'bg-error/10 text-error' };
13
+ }
14
+ return { icon: <Boxes size={12} />, badge: 'bg-info/10 text-info' };
15
+ }
16
+
17
+ interface EnvelopeHeaderProps {
18
+ envelope: XmlEnvelope;
19
+ }
20
+
21
+ /** Identity row for a peeled XML envelope: which wrapper carried the output and
22
+ * the attributes it declared — for a system reminder those are the rule that
23
+ * fired and the file behind it, which is the reason the wrapper existed. */
24
+ export function EnvelopeHeader({ envelope }: EnvelopeHeaderProps) {
25
+ const tone = envelopeTone(envelope.tag);
26
+
27
+ return (
28
+ <div className="flex flex-wrap items-center gap-1.5">
29
+ <span className={`flex h-5 w-5 shrink-0 items-center justify-center rounded ${tone.badge}`}>
30
+ {tone.icon}
31
+ </span>
32
+ <span className="text-[9.5px] font-semibold uppercase tracking-[0.14em] text-ink/60">
33
+ {toTitleCase(envelope.tag)}
34
+ </span>
35
+ {Object.entries(envelope.attributes).map(([key, value]) => (
36
+ <span
37
+ key={key}
38
+ title={`${key}=${value}`}
39
+ className="max-w-[240px] truncate rounded bg-ink/5 px-1.5 py-0.5 font-mono text-[9.5px] text-ink/60"
40
+ >
41
+ {value ? `${key}=${value}` : key}
42
+ </span>
43
+ ))}
44
+ </div>
45
+ );
46
+ }
@@ -1,14 +1,10 @@
1
- import type { ReactNode } from 'preact/compat';
2
1
  import { useMemo } from 'preact/hooks';
3
2
  import { Code } from 'lucide-preact';
4
3
  import { MarkdownRenderer } from '@/client/components/common/MarkdownRenderer';
5
- import { sanitizeHtml } from '@/shared/lib/markdown/sanitize';
6
4
  import { CopyButton } from '@/client/components/common/CopyButton';
5
+ import { EnvelopeHeader } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/EnvelopeHeader';
7
6
  import { stripAnsiCodes } from '@/shared/lib/code/ansi';
8
- import { detectOutputFormat } from '@/shared/lib/chat/detect-format';
9
- import { highlightCode, tryParseJson } from '@/shared/lib/code/syntax-highlight';
10
- import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
11
- import { JsonCodeBlock } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock';
7
+ import { outputMarkdown, readToolOutput } from '@/shared/lib/chat/tool-output';
12
8
  import { MAX_OUTPUT_LINES, truncateTailLines } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/truncate';
13
9
 
14
10
  interface FallbackOutputProps {
@@ -16,64 +12,16 @@ interface FallbackOutputProps {
16
12
  text: string;
17
13
  }
18
14
 
19
- /** Render output fallback generik dengan deteksi format otomatis:
20
- * markdown → MarkdownRenderer; html → sanitize + inject; text → syntax highlight. */
15
+ /** Blok "Output" generik untuk panel tanpa renderer khusus. Output selalu
16
+ * dirender sebagai markdown: envelope XML yang membungkus seluruh output
17
+ * dilepas dulu, dan konten verbatim (log/JSON/HTML) dipagari fence supaya
18
+ * parser tidak meremasnya jadi satu paragraf. */
21
19
  export function FallbackOutput({ text }: FallbackOutputProps) {
22
20
  const stripped = useMemo(() => stripAnsiCodes(text), [text]);
23
- const format = useMemo(() => detectOutputFormat(stripped), [stripped]);
24
- const jsonResult = useMemo(() => (format === 'json' ? tryParseJson(stripped) : null), [format, stripped]);
25
- const displayLinesCount = useMemo(() => {
26
- if (jsonResult?.isValid && jsonResult.linesCount) return jsonResult.linesCount;
27
- return stripped.split(/\r?\n/).length;
28
- }, [jsonResult, stripped]);
29
-
30
- const display = useMemo(() => {
31
- const source = format === 'json' && jsonResult?.isValid && jsonResult.pretty ? jsonResult.pretty : stripped;
32
- return truncateTailLines(source, MAX_OUTPUT_LINES);
33
- }, [format, jsonResult, stripped]);
34
-
35
- const isPlainText = format !== 'markdown' && format !== 'json' && format !== 'html';
36
- const syntaxReady = useSyntaxReady();
37
- const highlightedText = useMemo(
38
- () => (isPlainText ? highlightCode(display.text, 'javascript') : ''),
39
- [isPlainText, display, syntaxReady]
40
- );
41
- const copyText = useMemo(
42
- () => (jsonResult?.isValid && jsonResult.pretty ? jsonResult.pretty : stripped),
43
- [jsonResult, stripped]
44
- );
45
-
46
- let body: ReactNode;
47
- if (format === 'markdown') {
48
- body = (
49
- <div className="prose-content max-h-72 overflow-auto rounded-lg border border-ink/8 bg-paper px-3 py-2.5 text-[12px] leading-relaxed text-ink/85 select-text">
50
- <MarkdownRenderer content={display.text} />
51
- </div>
52
- );
53
- } else if (format === 'json') {
54
- body = (
55
- <JsonCodeBlock
56
- jsonString={display.text}
57
- maxHeightClass="max-h-72"
58
- showHeader={false}
59
- />
60
- );
61
- } else if (format === 'html') {
62
- const sanitized = sanitizeHtml(display.text);
63
- body = (
64
- <div
65
- className="max-h-72 overflow-auto rounded-lg border border-ink/8 bg-paper px-3 py-2.5 text-[12px] leading-relaxed text-ink/85 select-text"
66
- dangerouslySetInnerHTML={{ __html: sanitized }}
67
- />
68
- );
69
- } else {
70
- body = (
71
- <pre
72
- className="max-h-72 overflow-auto rounded-lg border border-ink/8 bg-paper px-3 py-2.5 font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-words text-ink/80 select-text"
73
- dangerouslySetInnerHTML={{ __html: highlightedText }}
74
- />
75
- );
76
- }
21
+ const output = useMemo(() => readToolOutput(stripped), [stripped]);
22
+ const display = useMemo(() => truncateTailLines(output.content, MAX_OUTPUT_LINES), [output]);
23
+ const markdown = useMemo(() => outputMarkdown(display.text, output.format), [display, output.format]);
24
+ const lines = useMemo(() => output.content.split(/\r?\n/).length, [output.content]);
77
25
 
78
26
  return (
79
27
  <div className="space-y-1.5">
@@ -82,26 +30,28 @@ export function FallbackOutput({ text }: FallbackOutputProps) {
82
30
  <Code size={11} className="text-ink/40" />
83
31
  <span>Output</span>
84
32
  <span className="rounded bg-ink/5 px-1.5 py-0.2 font-mono text-[9px] text-ink/45">
85
- {format}
33
+ {output.format}
86
34
  </span>
87
- {displayLinesCount > 1 && (
35
+ {lines > 1 && (
88
36
  <span className="font-mono text-[9px] text-ink/35">
89
- {displayLinesCount} lines
37
+ {lines} lines
90
38
  </span>
91
39
  )}
92
40
  </div>
93
41
  <CopyButton
94
- text={copyText}
42
+ text={output.content}
95
43
  className="flex cursor-pointer items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] text-ink/45 transition-colors hover:bg-ink/5 hover:text-ink"
96
44
  onClick={(e) => e.stopPropagation()}
97
45
  label="Copy"
98
46
  />
99
47
  </div>
48
+ {output.envelope && <EnvelopeHeader envelope={output.envelope} />}
100
49
  {display.skipped > 0 && (
101
50
  <div className="font-mono text-[10px] text-ink/45">… {display.skipped} earlier lines hidden</div>
102
51
  )}
103
- {body}
52
+ <div className="max-h-72 overflow-auto rounded-lg border border-ink/8 bg-paper px-3 py-2.5 select-text">
53
+ <MarkdownRenderer content={markdown} className="text-[12px] text-ink/85" />
54
+ </div>
104
55
  </div>
105
56
  );
106
57
  }
107
-