chat_detail.html 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <pstm-uri>/chats/{chat_id}</pstm-uri>
  5. <pstm-template>main</pstm-template>
  6. <script>
  7. // Live chat (goal 27): a turn runs in the BACKGROUND - the send
  8. // returns immediately and every append (tool call, assistant
  9. // reply, parked question, suggestion) bumps the global pulse.
  10. // This listener re-runs the sealed refresh action per bump, the
  11. // exact pattern the dashboard uses, so the transcript streams in
  12. // without a reload. All submissions ride statum.act (the JSON
  13. // transport), never a urlencoded form: Astralis mangles '+' in
  14. // form bodies and free text is full of them.
  15. if (window.statum) {
  16. (function () {
  17. 'use strict';
  18. function chatState() {
  19. return statum.read('chat') || {};
  20. }
  21. statum.addStateListener('pulse', function () {
  22. var chat = chatState();
  23. if (chat.refresh) {
  24. // The chat id is sealed in the action (GET): no
  25. // parameters travel the query string, so nothing
  26. // can be lost in transport and misparsed to 0.
  27. statum.act(chat.refresh, {}).catch(function () { });
  28. }
  29. });
  30. // Keep the transcript pinned to its newest row after the
  31. // initial render and every refresh.
  32. if (statum.afterEvaluate) {
  33. statum.afterEvaluate(function () {
  34. var thread = document.getElementById('chat-thread');
  35. if (thread) {
  36. thread.scrollTop = thread.scrollHeight;
  37. }
  38. });
  39. }
  40. function trimmed(text) {
  41. return String(text || '').replace(/^\s+|\s+$/g, '');
  42. }
  43. window.sendAdvisorMessage = function () {
  44. var chat = chatState();
  45. if (!chat.send || chat.status === 'running') return;
  46. var box = document.getElementById('chat-composer');
  47. if (!box) return;
  48. var text = trimmed(box.value);
  49. if (!text) return;
  50. box.value = '';
  51. statum.act(chat.send, { message: text }).catch(function () { });
  52. };
  53. // The question card's "Other" box: the form carries its
  54. // own input, so no state lookups are needed.
  55. window.submitAdvisorOther = function (form) {
  56. var chat = chatState();
  57. if (!chat.send || !form || !form.elements.other) return;
  58. var text = trimmed(form.elements.other.value);
  59. if (!text) return;
  60. form.elements.other.value = '';
  61. statum.act(chat.send, { answer: text }).catch(function () { });
  62. };
  63. // Option buttons and suggestion decisions go through one
  64. // delegated click handler: the cards carry their row ids
  65. // as data attributes (hydrated from state), the buttons
  66. // carry their payload as text or a data-decision, and the
  67. // handler re-reads the CURRENT state row - so a refresh
  68. // mid-click can never act on a stale sealed action.
  69. function suggestionRow(id) {
  70. var chat = chatState();
  71. var rows = chat.suggestions || [];
  72. for (var index = 0; index < rows.length; index++) {
  73. if (String(rows[index].id) === String(id)) return rows[index];
  74. }
  75. return null;
  76. }
  77. document.addEventListener('click', function (event) {
  78. if (!event.target.closest) return;
  79. var option = event.target.closest('.chat-options button');
  80. if (option) {
  81. event.preventDefault();
  82. var chat = chatState();
  83. var answer = trimmed(option.textContent);
  84. if (chat.send && answer) {
  85. statum.act(chat.send, { answer: answer }).catch(function () { });
  86. }
  87. return;
  88. }
  89. var decision = event.target.closest('[data-decision]');
  90. if (decision) {
  91. event.preventDefault();
  92. var card = decision.closest('[data-suggestion-id]');
  93. if (!card) return;
  94. var row = suggestionRow(card.getAttribute('data-suggestion-id'));
  95. if (row && row.decide) {
  96. statum.act(row.decide, {
  97. decision: decision.getAttribute('data-decision')
  98. }).catch(function () { });
  99. }
  100. }
  101. });
  102. // A transcript row's wrapper class: the kind picks the
  103. // lane (user right, assistant left, tool/system wide).
  104. window.chatMessageClass = function (message) {
  105. if (!message || !message.kind) return 'chat-row';
  106. return 'chat-row chat-row-' + message.kind;
  107. };
  108. })();
  109. }
  110. </script>
  111. </head>
  112. <body>
  113. <section>
  114. <p stm-if="chat == null">Loading…</p>
  115. <div stm-if="chat != null && chat.id == 0">
  116. <h2>Chat not found</h2>
  117. <p class="muted">That chat does not exist, or belongs to another user -
  118. chats are private. <a href="/chats">Back to your chats.</a></p>
  119. </div>
  120. <div stm-if="chat != null && chat.id > 0" class="chat-page">
  121. <div class="chat-head">
  122. <h2 stm-text="chat.title">title</h2>
  123. <p><span stm-attribute.class="'badge badge-chat-' + chat.status"
  124. stm-text="chat.status_display">status</span>
  125. <span class="muted">advisor chat</span>
  126. <form class="inline">
  127. <button type="button" class="chat-delete" stm-action="chat.delete"
  128. stm-confirm="Delete this chat? Its messages and suggestions go with it.">Delete chat</button>
  129. </form></p>
  130. <p class="notice" stm-if="!chat.advisor_configured">The advisor is not
  131. configured - set <code>ADVISOR_LLM_BASE_URL</code> (or
  132. <code>SADDLE_LLM_BASE_URL</code>) to enable it. Your messages wait
  133. until then.</p>
  134. </div>
  135. <div class="chat-thread" id="chat-thread">
  136. <!-- The transcript, oldest first. Tool rows render as
  137. collapsed details; a question row carries option
  138. buttons until answered; errors and system
  139. notices render in their own muted styles. -->
  140. <div stm-for-message-in="chat.messages" stm-key="message.id"
  141. stm-attribute.class="window.chatMessageClass(message)">
  142. <div stm-if="message.kind == 'tool'" class="chat-tool">
  143. <details>
  144. <summary><span class="badge badge-tool">tool</span>
  145. <code stm-text="message.tool_name">name</code></summary>
  146. <p class="muted">arguments</p>
  147. <pre stm-text="message.arguments">args</pre>
  148. <p class="muted">result</p>
  149. <pre stm-text="message.result">result</pre>
  150. </details>
  151. </div>
  152. <div stm-if="message.kind == 'user'" class="chat-bubble chat-user">
  153. <div class="md" stm-markdown="message.content">message</div>
  154. <p class="muted" stm-text="message.when_display">when</p>
  155. </div>
  156. <!-- A tool-calling assistant row renders NO bubble
  157. of its own when it said nothing worth reading
  158. (empty or the bare "@tool call" marker) — the
  159. tool row's details expander right below IS its
  160. render. Meaningful prose alongside a call keeps
  161. its bubble. -->
  162. <div stm-if="message.kind == 'assistant' && !message.is_question && !message.bubble_hidden"
  163. class="chat-bubble chat-assistant">
  164. <div class="md" stm-markdown="message.content">message</div>
  165. <p class="muted" stm-text="message.when_display">when</p>
  166. </div>
  167. <div stm-if="message.kind == 'assistant' && message.is_question"
  168. class="chat-question">
  169. <div class="chat-bubble chat-assistant">
  170. <div class="md" stm-markdown="message.content">question</div>
  171. </div>
  172. <div stm-if="!message.answered && chat.status == 'awaiting_answer'"
  173. class="card chat-question-card"
  174. stm-attribute.data-message-id="message.id">
  175. <p stm-if="message.options.length > 0" class="muted">Choose an
  176. answer, or type your own below.</p>
  177. <div class="chat-options" stm-if="message.options.length > 0">
  178. <button type="button" stm-for-option-in="message.options"
  179. stm-key="option" stm-text="option">option</button>
  180. </div>
  181. <form class="inline"
  182. onsubmit="window.submitAdvisorOther(this); return false;">
  183. <input type="text" name="other"
  184. placeholder="Or type your own answer">
  185. <button type="submit">Answer</button>
  186. </form>
  187. </div>
  188. <p stm-if="message.answered" class="muted">You answered:
  189. <strong stm-text="message.answer">answer</strong></p>
  190. </div>
  191. <div stm-if="message.kind == 'error'" class="chat-error">
  192. <p stm-text="message.content">error</p>
  193. </div>
  194. <div stm-if="message.kind == 'system'" class="chat-system">
  195. <p class="muted" stm-text="message.content">system</p>
  196. </div>
  197. </div>
  198. <div stm-if="chat.status == 'running'" class="chat-thinking">
  199. <p class="muted">thinking…</p>
  200. </div>
  201. <p stm-if="chat.messages.length == 0" class="muted">Nothing yet - say
  202. something below.</p>
  203. </div>
  204. <div class="cards" stm-if="chat.suggestions.length > 0">
  205. <section stm-for-suggestion-in="chat.suggestions"
  206. stm-key="suggestion.id"
  207. stm-attribute.data-suggestion-id="suggestion.id"
  208. class="card chat-suggestion">
  209. <p><span stm-attribute.class="'badge badge-kind-' + suggestion.kind"
  210. stm-text="suggestion.kind">task</span>
  211. <span stm-attribute.class="'badge badge-suggestion-' + suggestion.status"
  212. stm-text="suggestion.status">pending</span>
  213. <span class="muted" stm-text="suggestion.created_display">when</span></p>
  214. <p><strong stm-text="suggestion.title">title</strong></p>
  215. <div class="md" stm-markdown="suggestion.body">body</div>
  216. <p class="muted" stm-if="suggestion.kind == 'task'">
  217. mode <span stm-text="suggestion.mode.length > 0 ? suggestion.mode : 'implement'">mode</span>
  218. - priority <span stm-text="suggestion.priority">5</span>
  219. <span stm-if="suggestion.project_id > 0"> - project
  220. <span stm-text="suggestion.project_id">id</span></span></p>
  221. <p class="muted" stm-if="suggestion.kind == 'goal'">
  222. <span stm-if="suggestion.branch_name.length > 0">branch
  223. <code stm-text="suggestion.branch_name">branch</code> - </span>
  224. <span stm-if="suggestion.project_id > 0">project
  225. <span stm-text="suggestion.project_id">id</span> - </span>
  226. created as an unqueued draft</p>
  227. <div stm-if="suggestion.status == 'pending'" class="chat-suggestion-actions">
  228. <button type="button" class="primary" data-decision="approve">Approve</button>
  229. <button type="button" data-decision="approve_enqueue">Approve + enqueue</button>
  230. <button type="button" data-decision="decline">Decline</button>
  231. </div>
  232. <p stm-if="suggestion.status != 'pending'">
  233. <a stm-if="suggestion.task_id > 0"
  234. stm-attribute.href="'/tasks/' + suggestion.task_id">task</a>
  235. <a stm-if="suggestion.goal_id > 0"
  236. stm-attribute.href="'/goals/' + suggestion.goal_id">goal</a>
  237. </p>
  238. </section>
  239. </div>
  240. <div class="chat-composer-row">
  241. <textarea id="chat-composer" rows="3"
  242. stm-attribute.disabled="chat.status == 'running'"
  243. placeholder="Reply to the advisor..."></textarea>
  244. <button type="button" class="primary"
  245. stm-attribute.disabled="chat.status == 'running'"
  246. onclick="window.sendAdvisorMessage()">Send</button>
  247. </div>
  248. <p class="muted" stm-if="chat.status == 'awaiting_answer'">The advisor is
  249. waiting for your answer above.</p>
  250. </div>
  251. </section>
  252. </body>
  253. </html>