feat: label pending and final agent messages

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-22 09:46:13 +01:00
co-authored by Copilot
parent 16229b8b0a
commit 2f90a19736
3 changed files with 154 additions and 4 deletions
+32 -4
View File
@@ -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) {
) : (
<div className="mx-auto max-w-3xl px-6 py-4">
<div className="space-y-1">
{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 (
<div className="group py-3" key={message.id}>
<div className="flex gap-3">
@@ -111,10 +126,23 @@ export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) {
{isUser ? <User className="size-3.5" /> : <Bot className="size-3.5" />}
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 text-[12px] font-medium text-zinc-400">
{message.authorName}
<div className="mb-1 flex items-center gap-2 text-[12px] font-medium text-zinc-400">
<span>{message.authorName}</span>
{!isUser && phaseLabel && (
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] ${assistantBadgeClass}`}
>
{phaseLabel}
</span>
)}
</div>
<div className="text-[14px] leading-relaxed text-zinc-200">
<div
className={
isUser
? 'text-[14px] leading-relaxed text-zinc-200'
: `rounded-xl border px-4 py-3 text-[14px] leading-relaxed text-zinc-200 ${assistantContainerClass}`
}
>
<MarkdownContent content={message.content} />
{message.pending && message.content && (
<span className="mt-1 inline-block h-4 w-[2px] animate-pulse rounded-sm bg-zinc-400" />
+35
View File
@@ -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;
}
+87
View File
@@ -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');
});
});