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;