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'); }); });