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>
This commit is contained in:
David Kaya
2026-03-31 16:53:28 +01:00
co-authored by Copilot
parent fb3e80ec47
commit 78949c5efd
3 changed files with 75 additions and 54 deletions
+60 -38
View File
@@ -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<MarkdownComposerHandle>(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({
/>
)}
<div className="space-y-1">
{visibleMessages.map((message, index) => {
{displayItems.map((item, itemIndex) => {
if (item.type === 'thinking-group') {
const isLastThinkingGroup = itemIndex === lastThinkingGroupIndex;
return (
<div key={`thinking-${item.messages[0].id}`} className="py-2">
<ThinkingProcess
messages={item.messages}
isActive={isSessionBusy && isLastThinkingGroup}
turnStartedAt={item.turnStartedAt}
/>
</div>
);
}
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 (
<div key={message.id}>
{showThinkingBefore && (
<div className="py-2">
<ThinkingProcess
messages={thinkingMessages}
isActive={isSessionBusy}
turnStartedAt={turnStartedAt}
/>
</div>
)}
<div className="message-enter group py-3" data-message-id={message.id}>
<div className="flex gap-3">
<div
@@ -523,15 +554,6 @@ export function ChatPane({
</div>
);
})}
{thinkingMessages.length > 0 && lastAssistantIndex < 0 && (
<div className="py-2">
<ThinkingProcess
messages={thinkingMessages}
isActive={isSessionBusy}
turnStartedAt={turnStartedAt}
/>
</div>
)}
</div>
{activeSubagents && activeSubagents.length > 0 && (
<div className="px-6 py-1">
+5 -6
View File
@@ -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;
}
+10 -10
View File
@@ -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');
});
});