| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271 |
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <pstm-uri>/chats/{chat_id}</pstm-uri>
- <pstm-template>main</pstm-template>
- <script>
- // Live chat (goal 27): a turn runs in the BACKGROUND - the send
- // returns immediately and every append (tool call, assistant
- // reply, parked question, suggestion) bumps the global pulse.
- // This listener re-runs the sealed refresh action per bump, the
- // exact pattern the dashboard uses, so the transcript streams in
- // without a reload. All submissions ride statum.act (the JSON
- // transport), never a urlencoded form: Astralis mangles '+' in
- // form bodies and free text is full of them.
- if (window.statum) {
- (function () {
- 'use strict';
- function chatState() {
- return statum.read('chat') || {};
- }
- statum.addStateListener('pulse', function () {
- var chat = chatState();
- if (chat.refresh) {
- // The chat id is sealed in the action (GET): no
- // parameters travel the query string, so nothing
- // can be lost in transport and misparsed to 0.
- statum.act(chat.refresh, {}).catch(function () { });
- }
- });
- // Keep the transcript pinned to its newest row after the
- // initial render and every refresh.
- if (statum.afterEvaluate) {
- statum.afterEvaluate(function () {
- var thread = document.getElementById('chat-thread');
- if (thread) {
- thread.scrollTop = thread.scrollHeight;
- }
- });
- }
- function trimmed(text) {
- return String(text || '').replace(/^\s+|\s+$/g, '');
- }
- window.sendAdvisorMessage = function () {
- var chat = chatState();
- if (!chat.send || chat.status === 'running') return;
- var box = document.getElementById('chat-composer');
- if (!box) return;
- var text = trimmed(box.value);
- if (!text) return;
- box.value = '';
- statum.act(chat.send, { message: text }).catch(function () { });
- };
- // The question card's "Other" box: the form carries its
- // own input, so no state lookups are needed.
- window.submitAdvisorOther = function (form) {
- var chat = chatState();
- if (!chat.send || !form || !form.elements.other) return;
- var text = trimmed(form.elements.other.value);
- if (!text) return;
- form.elements.other.value = '';
- statum.act(chat.send, { answer: text }).catch(function () { });
- };
- // Option buttons and suggestion decisions go through one
- // delegated click handler: the cards carry their row ids
- // as data attributes (hydrated from state), the buttons
- // carry their payload as text or a data-decision, and the
- // handler re-reads the CURRENT state row - so a refresh
- // mid-click can never act on a stale sealed action.
- function suggestionRow(id) {
- var chat = chatState();
- var rows = chat.suggestions || [];
- for (var index = 0; index < rows.length; index++) {
- if (String(rows[index].id) === String(id)) return rows[index];
- }
- return null;
- }
- document.addEventListener('click', function (event) {
- if (!event.target.closest) return;
- var option = event.target.closest('.chat-options button');
- if (option) {
- event.preventDefault();
- var chat = chatState();
- var answer = trimmed(option.textContent);
- if (chat.send && answer) {
- statum.act(chat.send, { answer: answer }).catch(function () { });
- }
- return;
- }
- var decision = event.target.closest('[data-decision]');
- if (decision) {
- event.preventDefault();
- var card = decision.closest('[data-suggestion-id]');
- if (!card) return;
- var row = suggestionRow(card.getAttribute('data-suggestion-id'));
- if (row && row.decide) {
- statum.act(row.decide, {
- decision: decision.getAttribute('data-decision')
- }).catch(function () { });
- }
- }
- });
- // A transcript row's wrapper class: the kind picks the
- // lane (user right, assistant left, tool/system wide).
- window.chatMessageClass = function (message) {
- if (!message || !message.kind) return 'chat-row';
- return 'chat-row chat-row-' + message.kind;
- };
- })();
- }
- </script>
- </head>
- <body>
- <section>
- <p stm-if="chat == null">Loading…</p>
- <div stm-if="chat != null && chat.id == 0">
- <h2>Chat not found</h2>
- <p class="muted">That chat does not exist, or belongs to another user -
- chats are private. <a href="/chats">Back to your chats.</a></p>
- </div>
- <div stm-if="chat != null && chat.id > 0" class="chat-page">
- <div class="chat-head">
- <h2 stm-text="chat.title">title</h2>
- <p><span stm-attribute.class="'badge badge-chat-' + chat.status"
- stm-text="chat.status_display">status</span>
- <span class="muted">advisor chat</span>
- <form class="inline">
- <button type="button" class="chat-delete" stm-action="chat.delete"
- stm-confirm="Delete this chat? Its messages and suggestions go with it.">Delete chat</button>
- </form></p>
- <p class="notice" stm-if="!chat.advisor_configured">The advisor is not
- configured - set <code>ADVISOR_LLM_BASE_URL</code> (or
- <code>SADDLE_LLM_BASE_URL</code>) to enable it. Your messages wait
- until then.</p>
- </div>
- <div class="chat-thread" id="chat-thread">
- <!-- The transcript, oldest first. Tool rows render as
- collapsed details; a question row carries option
- buttons until answered; errors and system
- notices render in their own muted styles. -->
- <div stm-for-message-in="chat.messages" stm-key="message.id"
- stm-attribute.class="window.chatMessageClass(message)">
- <div stm-if="message.kind == 'tool'" class="chat-tool">
- <details>
- <summary><span class="badge badge-tool">tool</span>
- <code stm-text="message.tool_name">name</code></summary>
- <p class="muted">arguments</p>
- <pre stm-text="message.arguments">args</pre>
- <p class="muted">result</p>
- <pre stm-text="message.result">result</pre>
- </details>
- </div>
- <div stm-if="message.kind == 'user'" class="chat-bubble chat-user">
- <div class="md" stm-markdown="message.content">message</div>
- <p class="muted" stm-text="message.when_display">when</p>
- </div>
- <!-- A tool-calling assistant row renders NO bubble
- of its own when it said nothing worth reading
- (empty or the bare "@tool call" marker) — the
- tool row's details expander right below IS its
- render. Meaningful prose alongside a call keeps
- its bubble. -->
- <div stm-if="message.kind == 'assistant' && !message.is_question && !message.bubble_hidden"
- class="chat-bubble chat-assistant">
- <div class="md" stm-markdown="message.content">message</div>
- <p class="muted" stm-text="message.when_display">when</p>
- </div>
- <div stm-if="message.kind == 'assistant' && message.is_question"
- class="chat-question">
- <div class="chat-bubble chat-assistant">
- <div class="md" stm-markdown="message.content">question</div>
- </div>
- <div stm-if="!message.answered && chat.status == 'awaiting_answer'"
- class="card chat-question-card"
- stm-attribute.data-message-id="message.id">
- <p stm-if="message.options.length > 0" class="muted">Choose an
- answer, or type your own below.</p>
- <div class="chat-options" stm-if="message.options.length > 0">
- <button type="button" stm-for-option-in="message.options"
- stm-key="option" stm-text="option">option</button>
- </div>
- <form class="inline"
- onsubmit="window.submitAdvisorOther(this); return false;">
- <input type="text" name="other"
- placeholder="Or type your own answer">
- <button type="submit">Answer</button>
- </form>
- </div>
- <p stm-if="message.answered" class="muted">You answered:
- <strong stm-text="message.answer">answer</strong></p>
- </div>
- <div stm-if="message.kind == 'error'" class="chat-error">
- <p stm-text="message.content">error</p>
- </div>
- <div stm-if="message.kind == 'system'" class="chat-system">
- <p class="muted" stm-text="message.content">system</p>
- </div>
- </div>
- <div stm-if="chat.status == 'running'" class="chat-thinking">
- <p class="muted">thinking…</p>
- </div>
- <p stm-if="chat.messages.length == 0" class="muted">Nothing yet - say
- something below.</p>
- </div>
- <div class="cards" stm-if="chat.suggestions.length > 0">
- <section stm-for-suggestion-in="chat.suggestions"
- stm-key="suggestion.id"
- stm-attribute.data-suggestion-id="suggestion.id"
- class="card chat-suggestion">
- <p><span stm-attribute.class="'badge badge-kind-' + suggestion.kind"
- stm-text="suggestion.kind">task</span>
- <span stm-attribute.class="'badge badge-suggestion-' + suggestion.status"
- stm-text="suggestion.status">pending</span>
- <span class="muted" stm-text="suggestion.created_display">when</span></p>
- <p><strong stm-text="suggestion.title">title</strong></p>
- <div class="md" stm-markdown="suggestion.body">body</div>
- <p class="muted" stm-if="suggestion.kind == 'task'">
- mode <span stm-text="suggestion.mode.length > 0 ? suggestion.mode : 'implement'">mode</span>
- - priority <span stm-text="suggestion.priority">5</span>
- <span stm-if="suggestion.project_id > 0"> - project
- <span stm-text="suggestion.project_id">id</span></span></p>
- <p class="muted" stm-if="suggestion.kind == 'goal'">
- <span stm-if="suggestion.branch_name.length > 0">branch
- <code stm-text="suggestion.branch_name">branch</code> - </span>
- <span stm-if="suggestion.project_id > 0">project
- <span stm-text="suggestion.project_id">id</span> - </span>
- created as an unqueued draft</p>
- <div stm-if="suggestion.status == 'pending'" class="chat-suggestion-actions">
- <button type="button" class="primary" data-decision="approve">Approve</button>
- <button type="button" data-decision="approve_enqueue">Approve + enqueue</button>
- <button type="button" data-decision="decline">Decline</button>
- </div>
- <p stm-if="suggestion.status != 'pending'">
- <a stm-if="suggestion.task_id > 0"
- stm-attribute.href="'/tasks/' + suggestion.task_id">task</a>
- <a stm-if="suggestion.goal_id > 0"
- stm-attribute.href="'/goals/' + suggestion.goal_id">goal</a>
- </p>
- </section>
- </div>
- <div class="chat-composer-row">
- <textarea id="chat-composer" rows="3"
- stm-attribute.disabled="chat.status == 'running'"
- placeholder="Reply to the advisor..."></textarea>
- <button type="button" class="primary"
- stm-attribute.disabled="chat.status == 'running'"
- onclick="window.sendAdvisorMessage()">Send</button>
- </div>
- <p class="muted" stm-if="chat.status == 'awaiting_answer'">The advisor is
- waiting for your answer above.</p>
- </div>
- </section>
- </body>
- </html>
|