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
@@ -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
-
@@ -53,6 +53,9 @@ export function FileTreeItem({
53
53
  const gitStatusInfo = gitChange ? getGitStatusInfo(gitChange.status, gitChange.staged) : null;
54
54
  const folderStatus = isFolder ? (gitFolderMap?.get(normalizedPath) || gitFolderMap?.get(file.path)) : null;
55
55
  const hasGitStatus = Boolean(gitStatusInfo);
56
+ // Git-ignored entries (local .gitignore or the global excludes file) are still
57
+ // listed but rendered faded, the way an editor dims untracked noise.
58
+ const isIgnored = file.ignored === true;
56
59
 
57
60
  const isExpanded = expandedPaths ? expandedPaths.has(file.path) : isOpen;
58
61
  const actualIsOpen = file.forceExpanded !== undefined ? file.forceExpanded : isExpanded;
@@ -194,8 +197,11 @@ export function FileTreeItem({
194
197
  <span className="w-3 flex-shrink-0"></span>
195
198
  )}
196
199
 
197
- <FileIcon name={file.name} isFolder={isFolder} isOpen={actualIsOpen} size={12} className="flex-shrink-0" />
198
- <span className={`truncate min-w-0 flex-1 ${gitStatusInfo ? gitStatusInfo.colorClass : folderStatus ? folderStatus.colorClass : ''}`}>
200
+ <FileIcon name={file.name} isFolder={isFolder} isOpen={actualIsOpen} size={12} className={`flex-shrink-0${isIgnored ? ' opacity-40' : ''}`} />
201
+ <span
202
+ className={`truncate min-w-0 flex-1 ${gitStatusInfo ? gitStatusInfo.colorClass : folderStatus ? folderStatus.colorClass : isIgnored ? 'text-ink/40' : ''}`}
203
+ title={isIgnored ? `${file.path} — git-ignored` : undefined}
204
+ >
199
205
  {file.name}
200
206
  </span>
201
207
 
@@ -3,6 +3,7 @@ import type { ChatMessageData, OmpAgentCallbacks, OmpAgentHandle, OmpAgentState,
3
3
  import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
4
4
  import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
5
5
  import { type ToolResultRecord, useOmpAgentStream } from '@/client/hooks/chat/omp/stream';
6
+ import { stopAgentSession } from '@/shared/lib/chat/omp/abort';
6
7
 
7
8
  /**
8
9
  * Live omp agent bridge for the chamber chat (real mode, MOCK=false). Mirrors
@@ -72,12 +73,14 @@ export function useOmpAgent(sessionId: string | null, callbacks: OmpAgentCallbac
72
73
  .then(res => (res.ok ? res.json() : null))
73
74
  .then((data: {
74
75
  running?: boolean;
76
+ /** Set when the server answered from local flags, not a get_state RPC. */
77
+ busy?: boolean;
75
78
  state?: { isStreaming?: boolean; isPromptRunning?: boolean };
76
79
  pendingUiRequests?: ExtensionUiDialogRequest[];
77
80
  } | null) => {
78
81
  if (cancelled || !data?.running) return;
79
82
  const probe = data.state;
80
- if (probe && (probe.isStreaming || probe.isPromptRunning)) {
83
+ if (data.busy || probe?.isStreaming || probe?.isPromptRunning) {
81
84
  connect(sessionId);
82
85
  callbacksRef.current.onResumeStream?.();
83
86
  }
@@ -132,6 +135,8 @@ export function useOmpAgent(sessionId: string | null, callbacks: OmpAgentCallbac
132
135
  });
133
136
  const body = (await res.json().catch(() => ({}))) as { success?: boolean; error?: string };
134
137
  if (!res.ok || body.error) {
138
+ // `session_busy`: the ack timed out behind a still-running turn, so the
139
+ // prompt may already be accepted — never resend it automatically.
135
140
  setState((prev) => ({ ...prev, isGenerating: false, error: body.error ?? `HTTP ${res.status}` }));
136
141
  return false;
137
142
  }
@@ -209,15 +214,9 @@ export function useOmpAgent(sessionId: string | null, callbacks: OmpAgentCallbac
209
214
  // A user stop supersedes an in-flight interrupt-and-reply: clearing the
210
215
  // guard lets the aborted turn's agent_end reach onAgentEnd (no stuck spinner).
211
216
  interruptPendingRef.current = false;
212
- try {
213
- await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
214
- method: 'POST',
215
- headers: { 'Content-Type': 'application/json' },
216
- body: JSON.stringify({ type: 'abort' }),
217
- });
218
- } catch {
219
- // Abort is best-effort; the event stream surfaces the terminal state.
220
- }
217
+ // Self-escalating: a child that does not stop within the helper's grace
218
+ // period is reset instead, so a wedged session cannot hang the button.
219
+ await stopAgentSession(sid);
221
220
  setState((prev) => ({ ...prev, isGenerating: false }));
222
221
  }, []);
223
222
 
@@ -7,11 +7,12 @@
7
7
  * Throttled revalidation for the session sidebars.
8
8
  *
9
9
  * `omp:session-updated` arrives in bursts while a run is active (agent start,
10
- * JSONL write, spawn adoption, agent end) — and the chat timeline's metadata
11
- * retry loop can emit one every 250ms until the session file carries the user
12
- * turn. Each dispatch would otherwise trigger a full loader revalidation, and
13
- * with two sidebars mounted (desktop + mobile layouts keep theirs alive) a busy
14
- * run could revalidate several times per second.
10
+ * first completed assistant turn, JSONL write, spawn adoption, agent end) — and
11
+ * the chat timeline's metadata retry loop can emit one every 250ms until the
12
+ * session file carries the user turn. Each dispatch would otherwise trigger a
13
+ * full loader revalidation, and with two sidebars mounted (desktop + mobile
14
+ * layouts keep theirs alive) a busy run could revalidate several times per
15
+ * second.
15
16
  *
16
17
  * Implemented as a LEADING + TRAILING throttle, not a trailing debounce:
17
18
  *
@@ -16,7 +16,6 @@ import type { Dispatch, SetStateAction } from 'preact/compat';
16
16
  import type { Attachment, ChatMessageData, OmpAgentHandle, QueuedMessageModel } from '@/shared/types';
17
17
  import type { QueuedMessage } from '@/client/components/workspace/chat-timeline/QueueList';
18
18
  import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
19
- import { normalizeNoticePositions } from '@/shared/lib/chat/order';
20
19
 
21
20
  export interface ChatTimelineActionsDeps {
22
21
  inputValue: string;
@@ -211,7 +210,7 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
211
210
  if (!res.ok) return false;
212
211
  const next = await fetch(`/api/chat/${encodeURIComponent(sessionId)}`).then(r => r.json()).catch(() => null);
213
212
  const messages: ChatMessageData[] = next?.session?.messages ?? [];
214
- if (messages.length > 0) setLocalMessages(normalizeNoticePositions(messages));
213
+ if (messages.length > 0) setLocalMessages(messages);
215
214
  return true;
216
215
  }
217
216
 
@@ -99,6 +99,10 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
99
99
  // responding (agent_start = first chunk) — the omp JSONL now carries the
100
100
  // user turn, so the sidebar item + real title appear immediately.
101
101
  const metaRefreshedRef = useRef<string | null>(null);
102
+ // Per-run guard for the "first assistant answer landed" sidebar signal
103
+ // (omp-callbacks onMessageEnd): the sidebar refreshes once per run on the
104
+ // completed first assistant turn, not on every assistant segment.
105
+ const firstAssistantRef = useRef(false);
102
106
  // Optimistic user bubble awaiting omp's echo (reconciled by the callbacks).
103
107
  const optimisticUserIdRef = useRef<string | null>(null);
104
108
  // Raw composer text for steer/follow-up echoes (those paths have no bubble).
@@ -193,6 +197,7 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
193
197
  adoptedSessionIdRef,
194
198
  sessionIdRef,
195
199
  metaRefreshedRef,
200
+ firstAssistantRef,
196
201
  refreshSessionMeta,
197
202
  setLocalMessages,
198
203
  aiPlaceholderIdRef,
@@ -18,7 +18,6 @@ import { streamChatResponse } from '@/client/hooks/chat/stream';
18
18
  import { composeMessageWithTextAttachments, isTextAttachmentFile } from '@/shared/lib/chat/attachments';
19
19
  import { loadAgentNames } from '@/shared/lib/chat/composer/client';
20
20
  import { translateAgentMentions, translateFileMentions } from '@/shared/lib/chat/composer/translate';
21
- import { normalizeNoticePositions } from '@/shared/lib/chat/order';
22
21
  import { createMockStreamCallbacks } from '@/shared/lib/chat/timeline/stream-callbacks';
23
22
  import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
24
23
  import { formatClock } from '@/shared/lib/format/time';
@@ -213,7 +212,7 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
213
212
  };
214
213
 
215
214
  setLocalMessages(prev => {
216
- const next = normalizeNoticePositions([...prev, newUserMsg, initialAiMsg]);
215
+ const next = [...prev, newUserMsg, initialAiMsg];
217
216
  persistMessages(next.filter(m => m.id !== aiPlaceholderId));
218
217
  return next;
219
218
  });