From 2f90a197363f4a744e76b9477e0b5ad08de25962 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sun, 22 Mar 2026 09:46:13 +0100 Subject: [PATCH] feat: label pending and final agent messages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/renderer/components/ChatPane.tsx | 36 ++++++++++-- src/renderer/lib/messagePhase.ts | 35 +++++++++++ tests/renderer/messagePhase.test.ts | 87 ++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 src/renderer/lib/messagePhase.ts create mode 100644 tests/renderer/messagePhase.test.ts diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index bf639ea..ff12b39 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -2,6 +2,7 @@ import { type KeyboardEvent, useEffect, useRef, useState } from 'react'; import { AlertCircle, ArrowUp, Bot, Loader2, User } from 'lucide-react'; import { MarkdownContent } from '@renderer/components/MarkdownContent'; +import { getAssistantMessagePhase } from '@renderer/lib/messagePhase'; import type { PatternDefinition } from '@shared/domain/pattern'; import type { ProjectRecord } from '@shared/domain/project'; @@ -96,8 +97,22 @@ export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) { ) : (
- {session.messages.map((message) => { + {session.messages.map((message, index) => { const isUser = message.role === 'user'; + const phase = getAssistantMessagePhase(session, message, index); + const assistantContainerClass = + phase === 'thinking' + ? 'border-sky-500/20 bg-sky-500/5' + : phase === 'final' + ? 'border-emerald-500/20 bg-emerald-500/5' + : 'border-zinc-800 bg-zinc-900/40'; + const assistantBadgeClass = + phase === 'thinking' + ? 'border-sky-400/20 bg-sky-400/10 text-sky-300' + : 'border-emerald-400/20 bg-emerald-400/10 text-emerald-300'; + const phaseLabel = + phase === 'thinking' ? 'Thinking' : phase === 'final' ? 'Final' : undefined; + return (
@@ -111,10 +126,23 @@ export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) { {isUser ? : }
-
- {message.authorName} +
+ {message.authorName} + {!isUser && phaseLabel && ( + + {phaseLabel} + + )}
-
+
{message.pending && message.content && ( diff --git a/src/renderer/lib/messagePhase.ts b/src/renderer/lib/messagePhase.ts new file mode 100644 index 0000000..eab5306 --- /dev/null +++ b/src/renderer/lib/messagePhase.ts @@ -0,0 +1,35 @@ +import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session'; + +export type AssistantMessagePhase = 'default' | 'thinking' | 'final'; + +export function getAssistantMessagePhase( + session: SessionRecord, + message: ChatMessageRecord, + index: number, +): AssistantMessagePhase { + if (message.role !== 'assistant') { + return 'default'; + } + + if (message.pending) { + return 'thinking'; + } + + if (session.status === 'running') { + return 'default'; + } + + const lastCompletedAssistantIndex = findLastCompletedAssistantIndex(session.messages); + return index === lastCompletedAssistantIndex ? 'final' : 'default'; +} + +function findLastCompletedAssistantIndex(messages: ChatMessageRecord[]): number { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.role === 'assistant' && !message.pending) { + return index; + } + } + + return -1; +} diff --git a/tests/renderer/messagePhase.test.ts b/tests/renderer/messagePhase.test.ts new file mode 100644 index 0000000..90cdaae --- /dev/null +++ b/tests/renderer/messagePhase.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'bun:test'; + +import { getAssistantMessagePhase } from '@renderer/lib/messagePhase'; +import type { SessionRecord } from '@shared/domain/session'; + +function createSession( + messages: SessionRecord['messages'], + status: SessionRecord['status'] = 'idle', +): SessionRecord { + return { + id: 'session-1', + projectId: 'project-1', + patternId: 'pattern-1', + title: 'Test session', + createdAt: '2026-03-23T00:00:00.000Z', + updatedAt: '2026-03-23T00:00:00.000Z', + status, + messages, + }; +} + +describe('assistant message phase', () => { + test('marks pending assistant messages as thinking', () => { + const session = createSession([ + { + id: 'msg-1', + role: 'assistant', + authorName: 'Triage', + content: 'Draft', + createdAt: '2026-03-23T00:00:00.000Z', + pending: true, + }, + ], 'running'); + + expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('thinking'); + }); + + test('marks the last completed assistant message as final when the session is idle', () => { + const session = createSession([ + { + id: 'msg-1', + role: 'assistant', + authorName: 'Triage', + content: 'Earlier', + createdAt: '2026-03-23T00:00:00.000Z', + }, + { + id: 'msg-2', + role: 'assistant', + authorName: 'UX Specialist', + content: 'Final answer', + createdAt: '2026-03-23T00:00:01.000Z', + }, + ]); + + expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default'); + expect(getAssistantMessagePhase(session, session.messages[1], 1)).toBe('final'); + }); + + test('does not mark completed assistant messages as final while the session is still running', () => { + const session = createSession([ + { + id: 'msg-1', + role: 'assistant', + authorName: 'Triage', + content: 'In progress', + createdAt: '2026-03-23T00:00:00.000Z', + }, + ], 'running'); + + expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default'); + }); + + test('ignores non-assistant messages', () => { + const session = createSession([ + { + id: 'msg-1', + role: 'user', + authorName: 'You', + content: 'Hello', + createdAt: '2026-03-23T00:00:00.000Z', + }, + ]); + + expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default'); + }); +});