diff --git a/client/js/app.js b/client/js/app.js index 4aa27bb..eb6fa79 100644 --- a/client/js/app.js +++ b/client/js/app.js @@ -1,7 +1,8 @@ import { api } from './api.js'; import { WsClient } from './ws.js'; import { appendMessage, appendStreamBubble, finalizeStreamBubble, - appendToolCall, appendTypingIndicator, removeTypingIndicator, + appendToolCall, appendThinkingCard, finalizeThinkingCard, + appendTypingIndicator, removeTypingIndicator, appendError, showEmptyState, scrollToBottom } from './chat.js'; import { renderProfiles, renderSessions, updateChatHeader } from './sidebar.js'; @@ -20,12 +21,13 @@ // ── State ───────────────────────────────────────────────────────────────────── -let profiles = []; -let sessions = []; -let currentId = null; -let streaming = false; -let currentBubble = null; -let pendingImages = []; // array of full data URLs (data:image/...;base64,...) +let profiles = []; +let sessions = []; +let currentId = null; +let streaming = false; +let currentBubble = null; +let currentThinking = null; // { card, pre } during thinking phase +let pendingImages = []; // array of full data URLs (data:image/...;base64,...) const ws = new WsClient(); @@ -175,12 +177,29 @@ switch (event.type) { case 'stream_start': streaming = true; - currentBubble = null; // bubble created lazily on first delta, so tool cards appear first + currentBubble = null; + currentThinking = null; + break; + + case 'thinking_delta': + if (!currentThinking) { + removeTypingIndicator(messagesEl); + currentThinking = appendThinkingCard(messagesEl); + } + currentThinking.pre.textContent += event.delta; + scrollToBottom(messagesEl); + break; + + case 'thinking_end': + if (currentThinking) { + finalizeThinkingCard(currentThinking.card); + currentThinking = null; + } break; case 'stream_delta': if (!currentBubble) { - removeTypingIndicator(messagesEl); // remove only when text actually starts + removeTypingIndicator(messagesEl); currentBubble = appendStreamBubble(messagesEl); } currentBubble.textContent += event.delta; @@ -207,7 +226,11 @@ function finishStream(finalContent) { streaming = false; - removeTypingIndicator(messagesEl); // safe to call even if already removed + removeTypingIndicator(messagesEl); + if (currentThinking) { + finalizeThinkingCard(currentThinking.card); + currentThinking = null; + } if (finalContent !== undefined) { if (!currentBubble) { currentBubble = appendStreamBubble(messagesEl); diff --git a/client/js/chat.js b/client/js/chat.js index a410076..74cfa09 100644 --- a/client/js/chat.js +++ b/client/js/chat.js @@ -163,6 +163,40 @@ el.appendChild(card); } +/** + * Create a thinking block (open by default, collapses on finalizeThinkingCard). + * Returns {card, pre} — caller appends thinking text to pre.textContent. + */ +export function appendThinkingCard(el) { + const card = document.createElement('div'); + card.className = 'thinking-card open'; + + const header = document.createElement('div'); + header.className = 'thinking-header'; + header.innerHTML = '💭Thinking…'; + + const body = document.createElement('div'); + body.className = 'thinking-body'; + + const pre = document.createElement('pre'); + pre.className = 'thinking-pre'; + body.appendChild(pre); + + header.addEventListener('click', () => card.classList.toggle('open')); + card.append(header, body); + el.appendChild(card); + return { card, pre }; +} + +/** + * Called on thinking_end: update label and collapse the card. + */ +export function finalizeThinkingCard(card) { + const label = card.querySelector('.thinking-label'); + if (label) label.textContent = 'Thought'; + card.classList.remove('open'); +} + export function appendTypingIndicator(el) { removeTypingIndicator(el); const div = document.createElement('div'); diff --git a/client/style.css b/client/style.css index 257299d..c0ce6a3 100644 --- a/client/style.css +++ b/client/style.css @@ -314,6 +314,56 @@ color: var(--text); } +/* ── Thinking card ───────────────────────────────────── */ + +.thinking-card { + align-self: flex-start; + max-width: 84%; + background: #111820; + border: 1px solid #1e3a5f; + border-radius: var(--radius); + font-size: 12px; + color: #6b9fd4; +} + +.thinking-header { + display: flex; + align-items: center; + gap: 7px; + padding: 8px 12px; + cursor: pointer; + user-select: none; + font-weight: 600; + border-radius: var(--radius); +} +.thinking-header:hover { background: rgba(255,255,255,0.03); } +.thinking-icon { font-size: 14px; } +.thinking-label { flex: 1; } +.thinking-card:not(.open) .thinking-header::after { content: '›'; font-size: 16px; opacity: 0.5; } +.thinking-card.open .thinking-header::after { content: '‹'; font-size: 16px; opacity: 0.5; } + +.thinking-body { + border-top: 1px solid #1e3a5f; + padding: 8px 12px; + display: none; +} +.thinking-card.open .thinking-body { + display: block; + animation: fadeSlide 0.18s ease; +} + +.thinking-pre { + margin: 0; + font-family: ui-monospace, monospace; + font-size: 11px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; + max-height: 320px; + overflow-y: auto; + color: #5a8ab0; +} + /* Typing indicator */ .typing { align-self: flex-start;