From 78949c5efd2b03b80faafb64bbc5992d1cfdd951 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Tue, 31 Mar 2026 16:53:28 +0100 Subject: [PATCH] feat: render per-turn thinking tiles in chat pane Replace the single aggregate thinking tile with per-turn thinking groups. Instead of collecting all thinking messages into one flat array and rendering a single ThinkingProcess before the last assistant message, process session.messages in chronological order to produce interleaved display items that naturally group consecutive thinking messages by turn. Each turn now shows its own collapsible thinking tile placed inline before its assistant response, with correct per-turn isActive state and turnStartedAt from the matching run. Also fixes a latent index-mismatch bug in getAssistantMessagePhase where the visible-messages index was compared against the full session.messages index, potentially hiding the Final badge when thinking messages existed. Switched to ID-based comparison. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/renderer/components/ChatPane.tsx | 98 +++++++++++++++++----------- src/renderer/lib/messagePhase.ts | 11 ++-- tests/renderer/messagePhase.test.ts | 20 +++--- 3 files changed, 75 insertions(+), 54 deletions(-) diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index ca8e2ca..5b562c1 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -29,7 +29,7 @@ import { } from '@shared/domain/models'; import { type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern'; import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project'; -import { resolveSessionToolingSelection, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session'; +import { resolveSessionToolingSelection, type ChatMessageRecord, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session'; import { countApprovedToolsInGroups, groupApprovalToolsByProvider, @@ -41,6 +41,10 @@ import { /* ── ChatPane ──────────────────────────────────────────────── */ +type DisplayItem = + | { type: 'message'; message: ChatMessageRecord } + | { type: 'thinking-group'; messages: ChatMessageRecord[]; turnStartedAt?: string }; + interface ChatPaneProps { project: ProjectRecord; pattern: PatternDefinition; @@ -122,28 +126,51 @@ export function ChatPane({ const composerRef = useRef(null); const isSessionBusy = session.status === 'running'; - const { visibleMessages, thinkingMessages } = useMemo(() => { - const visible: typeof session.messages = []; - const thinking: typeof session.messages = []; + + const displayItems = useMemo(() => { + const runsByTrigger = new Map(session.runs.map((r) => [r.triggerMessageId, r])); + const items: DisplayItem[] = []; + let pendingThinking: ChatMessageRecord[] = []; + let lastUserMessageId: string | undefined; + for (const message of session.messages) { if (message.messageKind === 'thinking') { - thinking.push(message); + pendingThinking.push(message); } else { - visible.push(message); + if (pendingThinking.length > 0) { + const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined; + items.push({ type: 'thinking-group', messages: pendingThinking, turnStartedAt: run?.startedAt }); + pendingThinking = []; + } + items.push({ type: 'message', message }); + if (message.role === 'user') { + lastUserMessageId = message.id; + } } } - return { visibleMessages: visible, thinkingMessages: thinking }; - }, [session.messages]); - const lastAssistantIndex = useMemo(() => { - for (let i = visibleMessages.length - 1; i >= 0; i--) { - if (visibleMessages[i].role === 'assistant') return i; + + if (pendingThinking.length > 0) { + const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined; + items.push({ type: 'thinking-group', messages: pendingThinking, turnStartedAt: run?.startedAt }); + } + + return items; + }, [session.messages, session.runs]); + + const lastThinkingGroupIndex = useMemo(() => { + for (let i = displayItems.length - 1; i >= 0; i--) { + if (displayItems[i].type === 'thinking-group') return i; } return -1; - }, [visibleMessages]); - const turnStartedAt = useMemo(() => { - if (session.runs.length === 0) return undefined; - return session.runs[0].startedAt; - }, [session.runs]); + }, [displayItems]); + + const lastAssistantId = useMemo(() => { + for (let i = session.messages.length - 1; i >= 0; i--) { + const m = session.messages[i]; + if (m.role === 'assistant' && m.messageKind !== 'thinking') return m.id; + } + return undefined; + }, [session.messages]); const pendingApproval = session.pendingApproval?.status === 'pending' ? session.pendingApproval : undefined; const queuedApprovals = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending'); const totalPendingCount = (pendingApproval ? 1 : 0) + queuedApprovals.length; @@ -398,11 +425,25 @@ export function ChatPane({ /> )}
- {visibleMessages.map((message, index) => { + {displayItems.map((item, itemIndex) => { + if (item.type === 'thinking-group') { + const isLastThinkingGroup = itemIndex === lastThinkingGroupIndex; + return ( +
+ +
+ ); + } + + const message = item.message; const isUser = message.role === 'user'; const isEditing = editingMessageId === message.id; - const isLastAssistant = index === lastAssistantIndex; - const phase = getAssistantMessagePhase(session, message, index); + const isLastAssistant = message.id === lastAssistantId; + const phase = getAssistantMessagePhase(session, message); const assistantContainerClass = phase === 'thinking' ? 'border-[var(--color-accent-sky)]/20 bg-[var(--color-accent-sky)]/5' @@ -416,19 +457,9 @@ export function ChatPane({ const phaseLabel = phase === 'thinking' ? 'Thinking' : phase === 'final' ? 'Final' : undefined; const showActions = !isSessionBusy && !message.pending; - const showThinkingBefore = isLastAssistant && thinkingMessages.length > 0; return (
- {showThinkingBefore && ( -
- -
- )}
); })} - {thinkingMessages.length > 0 && lastAssistantIndex < 0 && ( -
- -
- )}
{activeSubagents && activeSubagents.length > 0 && (
diff --git a/src/renderer/lib/messagePhase.ts b/src/renderer/lib/messagePhase.ts index a85a95f..dae74b9 100644 --- a/src/renderer/lib/messagePhase.ts +++ b/src/renderer/lib/messagePhase.ts @@ -5,7 +5,6 @@ export type AssistantMessagePhase = 'default' | 'thinking' | 'final'; export function getAssistantMessagePhase( session: SessionRecord, message: ChatMessageRecord, - index: number, ): AssistantMessagePhase { if (message.role !== 'assistant') { return 'default'; @@ -23,17 +22,17 @@ export function getAssistantMessagePhase( return 'default'; } - const lastCompletedAssistantIndex = findLastCompletedAssistantIndex(session.messages); - return index === lastCompletedAssistantIndex ? 'final' : 'default'; + const lastId = findLastCompletedAssistantId(session.messages); + return message.id === lastId ? 'final' : 'default'; } -function findLastCompletedAssistantIndex(messages: ChatMessageRecord[]): number { +function findLastCompletedAssistantId(messages: ChatMessageRecord[]): string | undefined { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; if (message.role === 'assistant' && !message.pending && message.messageKind !== 'thinking') { - return index; + return message.id; } } - return -1; + return undefined; } diff --git a/tests/renderer/messagePhase.test.ts b/tests/renderer/messagePhase.test.ts index 249b2a2..b9702ec 100644 --- a/tests/renderer/messagePhase.test.ts +++ b/tests/renderer/messagePhase.test.ts @@ -33,7 +33,7 @@ describe('assistant message phase', () => { }, ], 'running'); - expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('thinking'); + expect(getAssistantMessagePhase(session, session.messages[0])).toBe('thinking'); }); test('marks the last completed assistant message as final when the session is idle', () => { @@ -54,8 +54,8 @@ describe('assistant message phase', () => { }, ]); - expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default'); - expect(getAssistantMessagePhase(session, session.messages[1], 1)).toBe('final'); + expect(getAssistantMessagePhase(session, session.messages[0])).toBe('default'); + expect(getAssistantMessagePhase(session, session.messages[1])).toBe('final'); }); test('does not mark completed assistant messages as final while the session is still running', () => { @@ -69,7 +69,7 @@ describe('assistant message phase', () => { }, ], 'running'); - expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default'); + expect(getAssistantMessagePhase(session, session.messages[0])).toBe('default'); }); test('ignores non-assistant messages', () => { @@ -83,7 +83,7 @@ describe('assistant message phase', () => { }, ]); - expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default'); + expect(getAssistantMessagePhase(session, session.messages[0])).toBe('default'); }); test('returns default for thinking-kind messages regardless of other state', () => { @@ -105,8 +105,8 @@ describe('assistant message phase', () => { }, ]); - expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default'); - expect(getAssistantMessagePhase(session, session.messages[1], 1)).toBe('final'); + expect(getAssistantMessagePhase(session, session.messages[0])).toBe('default'); + expect(getAssistantMessagePhase(session, session.messages[1])).toBe('final'); }); test('skips thinking messages when determining the last completed assistant', () => { @@ -136,8 +136,8 @@ describe('assistant message phase', () => { }, ]); - expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default'); - expect(getAssistantMessagePhase(session, session.messages[1], 1)).toBe('default'); - expect(getAssistantMessagePhase(session, session.messages[2], 2)).toBe('final'); + expect(getAssistantMessagePhase(session, session.messages[0])).toBe('default'); + expect(getAssistantMessagePhase(session, session.messages[1])).toBe('default'); + expect(getAssistantMessagePhase(session, session.messages[2])).toBe('final'); }); });