feat: render thinking process UI for intermediate agent messages

Wire message-reclassified sidecar events through the main process to
the renderer, where reclassified messages are filtered out of the main
transcript and collected into a collapsible ThinkingProcess component.

Main process changes:
- Route message-reclassified via dedicated onMessageReclassified callback
- Add applyMessageReclassified handler that sets messageKind and emits
  the session event for the renderer
- Forward assistant-intent and reasoning-delta as turn-scoped events

Renderer changes:
- Split session.messages into visibleMessages and thinkingMessages
- Render ThinkingProcess above the last assistant message
- ThinkingProcess auto-expands during active turns, collapses on finish
- Update messagePhase to skip thinking-kind messages for final detection

Tests:
- 3 new sessionWorkspace tests for reclassification apply/dedup/ignore
- 2 new messagePhase tests for thinking-kind handling
- Updated runTurnPending test fixtures for new callback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-30 16:16:37 +01:00
co-authored by Copilot
parent 56de8b7bd6
commit deb5c96d58
11 changed files with 464 additions and 90 deletions
+29
View File
@@ -10,6 +10,7 @@ import type {
ExitPlanModeRequestedEvent, ExitPlanModeRequestedEvent,
MessageMode, MessageMode,
McpOauthRequiredEvent, McpOauthRequiredEvent,
MessageReclassifiedEvent,
RunTurnCustomAgentConfig, RunTurnCustomAgentConfig,
RunTurnToolingConfig, RunTurnToolingConfig,
SidecarCapabilities, SidecarCapabilities,
@@ -1364,6 +1365,9 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
async (event) => { async (event) => {
await this.handleExitPlanModeRequested(workspace, session.id, event); await this.handleExitPlanModeRequested(workspace, session.id, event);
}, },
async (event) => {
await this.applyMessageReclassified(workspace, session.id, event);
},
async (event) => { async (event) => {
await this.handleTurnScopedEvent(workspace, session.id, event); await this.handleTurnScopedEvent(workspace, session.id, event);
}, },
@@ -1715,6 +1719,31 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
} }
} }
private async applyMessageReclassified(
workspace: WorkspaceState,
sessionId: string,
event: MessageReclassifiedEvent,
): Promise<void> {
const session = this.requireSession(workspace, sessionId);
const message = session.messages.find((m) => m.id === event.messageId);
if (!message || message.messageKind === 'thinking') {
return;
}
message.messageKind = 'thinking';
const occurredAt = nowIso();
session.updatedAt = occurredAt;
await this.workspaceRepository.save(workspace);
this.emitSessionEvent({
sessionId,
kind: 'message-reclassified',
occurredAt,
messageId: event.messageId,
messageKind: 'thinking',
});
}
private async applyAgentActivity( private async applyAgentActivity(
workspace: WorkspaceState, workspace: WorkspaceState,
sessionId: string, sessionId: string,
+7 -1
View File
@@ -3,6 +3,7 @@ import type {
ApprovalRequestedEvent, ApprovalRequestedEvent,
ExitPlanModeRequestedEvent, ExitPlanModeRequestedEvent,
McpOauthRequiredEvent, McpOauthRequiredEvent,
MessageReclassifiedEvent,
TurnDeltaEvent, TurnDeltaEvent,
UserInputRequestedEvent, UserInputRequestedEvent,
SubagentEvent, SubagentEvent,
@@ -12,6 +13,8 @@ import type {
SessionCompactionEvent, SessionCompactionEvent,
PendingMessagesModifiedEvent, PendingMessagesModifiedEvent,
AssistantUsageEvent, AssistantUsageEvent,
AssistantIntentEvent,
ReasoningDeltaEvent,
} from '@shared/contracts/sidecar'; } from '@shared/contracts/sidecar';
import type { ChatMessageRecord } from '@shared/domain/session'; import type { ChatMessageRecord } from '@shared/domain/session';
@@ -22,7 +25,9 @@ export type TurnScopedEvent =
| SessionUsageEvent | SessionUsageEvent
| SessionCompactionEvent | SessionCompactionEvent
| PendingMessagesModifiedEvent | PendingMessagesModifiedEvent
| AssistantUsageEvent; | AssistantUsageEvent
| AssistantIntentEvent
| ReasoningDeltaEvent;
export interface RunTurnPendingCommand { export interface RunTurnPendingCommand {
kind: 'run-turn'; kind: 'run-turn';
@@ -34,6 +39,7 @@ export interface RunTurnPendingCommand {
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>; onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>;
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>; onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>;
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>; onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>;
onMessageReclassified: (event: MessageReclassifiedEvent) => void | Promise<void>;
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>; onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>;
errored: boolean; errored: boolean;
} }
+12 -1
View File
@@ -9,6 +9,7 @@ import type {
SidecarCapabilities, SidecarCapabilities,
SidecarEvent, SidecarEvent,
TurnDeltaEvent, TurnDeltaEvent,
MessageReclassifiedEvent,
UserInputRequestedEvent, UserInputRequestedEvent,
McpOauthRequiredEvent, McpOauthRequiredEvent,
ExitPlanModeRequestedEvent, ExitPlanModeRequestedEvent,
@@ -134,9 +135,10 @@ export class SidecarClient {
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>, onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>,
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>, onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>,
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>, onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
onMessageReclassified: (event: MessageReclassifiedEvent) => void | Promise<void>,
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>, onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>,
): Promise<ChatMessageRecord[]> { ): Promise<ChatMessageRecord[]> {
return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, onTurnScopedEvent); return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, onMessageReclassified, onTurnScopedEvent);
} }
async resolveUserInput(userInputId: string, answer: string, wasFreeform: boolean): Promise<void> { async resolveUserInput(userInputId: string, answer: string, wasFreeform: boolean): Promise<void> {
@@ -286,6 +288,7 @@ export class SidecarClient {
onUserInput?: (event: UserInputRequestedEvent) => void | Promise<void>, onUserInput?: (event: UserInputRequestedEvent) => void | Promise<void>,
onMcpOAuthRequired?: (event: McpOauthRequiredEvent) => void | Promise<void>, onMcpOAuthRequired?: (event: McpOauthRequiredEvent) => void | Promise<void>,
onExitPlanMode?: (event: ExitPlanModeRequestedEvent) => void | Promise<void>, onExitPlanMode?: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
onMessageReclassified?: (event: MessageReclassifiedEvent) => void | Promise<void>,
onTurnScopedEvent?: (event: TurnScopedEvent) => void | Promise<void>, onTurnScopedEvent?: (event: TurnScopedEvent) => void | Promise<void>,
): Promise<TResult> { ): Promise<TResult> {
const state = await this.ensureProcess(); const state = await this.ensureProcess();
@@ -303,6 +306,7 @@ export class SidecarClient {
onUserInput: onUserInput ?? (() => undefined), onUserInput: onUserInput ?? (() => undefined),
onMcpOAuthRequired: onMcpOAuthRequired ?? (() => undefined), onMcpOAuthRequired: onMcpOAuthRequired ?? (() => undefined),
onExitPlanMode: onExitPlanMode ?? (() => undefined), onExitPlanMode: onExitPlanMode ?? (() => undefined),
onMessageReclassified: onMessageReclassified ?? (() => undefined),
onTurnScopedEvent: onTurnScopedEvent ?? (() => undefined), onTurnScopedEvent: onTurnScopedEvent ?? (() => undefined),
errored: false, errored: false,
}); });
@@ -439,6 +443,11 @@ export class SidecarClient {
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onExitPlanMode(event)); this.invokeRunTurnHandler(event.requestId, pending, () => pending.onExitPlanMode(event));
} }
return; return;
case 'message-reclassified':
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onMessageReclassified(event));
}
return;
case 'subagent-event': case 'subagent-event':
case 'skill-invoked': case 'skill-invoked':
case 'hook-lifecycle': case 'hook-lifecycle':
@@ -446,6 +455,8 @@ export class SidecarClient {
case 'session-compaction': case 'session-compaction':
case 'pending-messages-modified': case 'pending-messages-modified':
case 'assistant-usage': case 'assistant-usage':
case 'assistant-intent':
case 'reasoning-delta':
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) { if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onTurnScopedEvent(event)); this.invokeRunTurnHandler(event.requestId, pending, () => pending.onTurnScopedEvent(event));
} }
+124 -86
View File
@@ -12,6 +12,7 @@ import { UserInputBanner } from '@renderer/components/chat/UserInputBanner';
import { InlineApprovalPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills'; import { InlineApprovalPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills';
import { InlinePromptPill } from '@renderer/components/chat/InlinePromptPill'; import { InlinePromptPill } from '@renderer/components/chat/InlinePromptPill';
import { ThinkingDots } from '@renderer/components/chat/ThinkingDots'; import { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
import { ThinkingProcess } from '@renderer/components/chat/ThinkingProcess';
import { SubagentActivityList } from '@renderer/components/chat/SubagentActivityCard'; import { SubagentActivityList } from '@renderer/components/chat/SubagentActivityCard';
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase'; import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
import type { ApprovalDecision } from '@shared/domain/approval'; import type { ApprovalDecision } from '@shared/domain/approval';
@@ -115,12 +116,28 @@ export function ChatPane({
const composerRef = useRef<MarkdownComposerHandle>(null); const composerRef = useRef<MarkdownComposerHandle>(null);
const isSessionBusy = session.status === 'running'; const isSessionBusy = session.status === 'running';
const { visibleMessages, thinkingMessages } = useMemo(() => {
const visible: typeof session.messages = [];
const thinking: typeof session.messages = [];
for (const message of session.messages) {
if (message.messageKind === 'thinking') {
thinking.push(message);
} else {
visible.push(message);
}
}
return { visibleMessages: visible, thinkingMessages: thinking };
}, [session.messages]);
const lastAssistantIndex = useMemo(() => { const lastAssistantIndex = useMemo(() => {
for (let i = session.messages.length - 1; i >= 0; i--) { for (let i = visibleMessages.length - 1; i >= 0; i--) {
if (session.messages[i].role === 'assistant') return i; if (visibleMessages[i].role === 'assistant') return i;
} }
return -1; return -1;
}, [session.messages]); }, [visibleMessages]);
const turnStartedAt = useMemo(() => {
if (session.runs.length === 0) return undefined;
return session.runs[0].startedAt;
}, [session.runs]);
const pendingApproval = session.pendingApproval?.status === 'pending' ? session.pendingApproval : undefined; const pendingApproval = session.pendingApproval?.status === 'pending' ? session.pendingApproval : undefined;
const queuedApprovals = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending'); const queuedApprovals = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending');
const totalPendingCount = (pendingApproval ? 1 : 0) + queuedApprovals.length; const totalPendingCount = (pendingApproval ? 1 : 0) + queuedApprovals.length;
@@ -358,7 +375,7 @@ export function ChatPane({
/> />
)} )}
<div className="space-y-1"> <div className="space-y-1">
{session.messages.map((message, index) => { {visibleMessages.map((message, index) => {
const isUser = message.role === 'user'; const isUser = message.role === 'user';
const isEditing = editingMessageId === message.id; const isEditing = editingMessageId === message.id;
const isLastAssistant = index === lastAssistantIndex; const isLastAssistant = index === lastAssistantIndex;
@@ -376,101 +393,122 @@ export function ChatPane({
const phaseLabel = const phaseLabel =
phase === 'thinking' ? 'Thinking' : phase === 'final' ? 'Final' : undefined; phase === 'thinking' ? 'Thinking' : phase === 'final' ? 'Final' : undefined;
const showActions = !isSessionBusy && !message.pending; const showActions = !isSessionBusy && !message.pending;
const showThinkingBefore = isLastAssistant && thinkingMessages.length > 0;
return ( return (
<div className="message-enter group py-3" data-message-id={message.id} key={message.id}> <div key={message.id}>
<div className="flex gap-3"> {showThinkingBefore && (
<div <div className="py-2">
className={`mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full ${ <ThinkingProcess
isUser ? 'brand-gradient-bg text-white' : 'bg-[var(--color-surface-2)] text-[var(--color-text-secondary)]' messages={thinkingMessages}
}`} isActive={isSessionBusy}
> turnStartedAt={turnStartedAt}
{isUser ? <User className="size-3.5" /> : <Bot className="size-3.5" />} />
</div> </div>
<div className="min-w-0 flex-1"> )}
<div className="mb-1 flex items-center gap-2 text-[12px] font-medium text-[var(--color-text-secondary)]"> <div className="message-enter group py-3" data-message-id={message.id}>
<span>{message.authorName}</span> <div className="flex gap-3">
{message.isPinned && ( <div
<Bookmark className="size-3 fill-[var(--color-accent-sky)] text-[var(--color-accent-sky)]" /> className={`mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full ${
)} isUser ? 'brand-gradient-bg text-white' : 'bg-[var(--color-surface-2)] text-[var(--color-text-secondary)]'
{!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}`} {isUser ? <User className="size-3.5" /> : <Bot className="size-3.5" />}
>
{phaseLabel}
</span>
)}
{showActions && (
<div className="ml-auto">
<MessageActions
message={message}
isLastAssistant={isLastAssistant}
onCopy={() => handleCopyMessage(message.content)}
onPin={() => onPinMessage?.(message.id, !message.isPinned)}
onBranch={() => onBranchFromMessage?.(message.id)}
onRegenerate={onRegenerateMessage ? () => onRegenerateMessage(message.id) : undefined}
onEdit={onEditAndResendMessage && isUser ? () => setEditingMessageId(message.id) : undefined}
/>
</div>
)}
</div> </div>
<div className="min-w-0 flex-1">
{/* Edit mode */} <div className="mb-1 flex items-center gap-2 text-[12px] font-medium text-[var(--color-text-secondary)]">
{isEditing ? ( <span>{message.authorName}</span>
<MessageEditComposer {message.isPinned && (
initialContent={message.content} <Bookmark className="size-3 fill-[var(--color-accent-sky)] text-[var(--color-accent-sky)]" />
onSave={(content) => handleEditSave(message.id, content)}
onCancel={() => setEditingMessageId(undefined)}
/>
) : (
<div
className={
isUser
? 'text-[14px] leading-relaxed text-[var(--color-text-primary)]'
: `rounded-xl border px-4 py-3 text-[14px] leading-relaxed text-[var(--color-text-primary)] ${assistantContainerClass}`
}
>
{/* Attachment thumbnails */}
{isUser && message.attachments && message.attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{message.attachments.map((att, attIdx) =>
isImageAttachment(att) ? (
<img
key={attIdx}
alt={getAttachmentDisplayName(att)}
className="max-h-48 max-w-xs rounded-lg border border-[var(--color-border)] object-cover"
src={`data:${att.mimeType};base64,${att.data}`}
/>
) : (
<div
key={attIdx}
className="flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-2 py-1 text-[11px] text-[var(--color-text-secondary)]"
>
<Paperclip className="size-3" />
{getAttachmentDisplayName(att)}
</div>
),
)}
</div>
)} )}
{!isUser && message.pending ? ( {!isUser && phaseLabel && (
<div className="whitespace-pre-wrap break-words text-[14px] leading-relaxed text-[var(--color-text-primary)]"> <span
{message.content} className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] ${assistantBadgeClass}`}
</div> >
) : ( {phaseLabel}
<MarkdownContent content={message.content} /> </span>
)} )}
{message.pending && message.content && ( {showActions && (
<span className="mt-1 inline-block h-4 w-[2px] animate-pulse rounded-sm bg-[var(--color-accent)]" /> <div className="ml-auto">
<MessageActions
message={message}
isLastAssistant={isLastAssistant}
onCopy={() => handleCopyMessage(message.content)}
onPin={() => onPinMessage?.(message.id, !message.isPinned)}
onBranch={() => onBranchFromMessage?.(message.id)}
onRegenerate={onRegenerateMessage ? () => onRegenerateMessage(message.id) : undefined}
onEdit={onEditAndResendMessage && isUser ? () => setEditingMessageId(message.id) : undefined}
/>
</div>
)} )}
</div> </div>
)}
{message.pending && !message.content && <ThinkingDots />} {/* Edit mode */}
{isEditing ? (
<MessageEditComposer
initialContent={message.content}
onSave={(content) => handleEditSave(message.id, content)}
onCancel={() => setEditingMessageId(undefined)}
/>
) : (
<div
className={
isUser
? 'text-[14px] leading-relaxed text-[var(--color-text-primary)]'
: `rounded-xl border px-4 py-3 text-[14px] leading-relaxed text-[var(--color-text-primary)] ${assistantContainerClass}`
}
>
{/* Attachment thumbnails */}
{isUser && message.attachments && message.attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{message.attachments.map((att, attIdx) =>
isImageAttachment(att) ? (
<img
key={attIdx}
alt={getAttachmentDisplayName(att)}
className="max-h-48 max-w-xs rounded-lg border border-[var(--color-border)] object-cover"
src={`data:${att.mimeType};base64,${att.data}`}
/>
) : (
<div
key={attIdx}
className="flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-2 py-1 text-[11px] text-[var(--color-text-secondary)]"
>
<Paperclip className="size-3" />
{getAttachmentDisplayName(att)}
</div>
),
)}
</div>
)}
{!isUser && message.pending ? (
<div className="whitespace-pre-wrap break-words text-[14px] leading-relaxed text-[var(--color-text-primary)]">
{message.content}
</div>
) : (
<MarkdownContent content={message.content} />
)}
{message.pending && message.content && (
<span className="mt-1 inline-block h-4 w-[2px] animate-pulse rounded-sm bg-[var(--color-accent)]" />
)}
</div>
)}
{message.pending && !message.content && <ThinkingDots />}
</div>
</div> </div>
</div> </div>
</div> </div>
); );
})} })}
{thinkingMessages.length > 0 && lastAssistantIndex < 0 && (
<div className="py-2">
<ThinkingProcess
messages={thinkingMessages}
isActive={isSessionBusy}
turnStartedAt={turnStartedAt}
/>
</div>
)}
</div> </div>
{activeSubagents && activeSubagents.length > 0 && ( {activeSubagents && activeSubagents.length > 0 && (
<div className="px-6 py-1"> <div className="px-6 py-1">
@@ -0,0 +1,120 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Brain, ChevronDown, ChevronRight } from 'lucide-react';
import type { ChatMessageRecord } from '@shared/domain/session';
interface ThinkingProcessProps {
messages: ChatMessageRecord[];
isActive: boolean;
turnStartedAt?: string;
}
export function ThinkingProcess({ messages, isActive, turnStartedAt }: ThinkingProcessProps) {
const [expanded, setExpanded] = useState(false);
const wasActiveRef = useRef(isActive);
// Auto-expand when the turn is active and thinking messages appear.
// Auto-collapse once the turn finishes.
useEffect(() => {
if (isActive && messages.length > 0) {
setExpanded(true);
} else if (wasActiveRef.current && !isActive) {
setExpanded(false);
}
wasActiveRef.current = isActive;
}, [isActive, messages.length]);
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
const elapsed = useMemo(() => {
if (!turnStartedAt || messages.length === 0) return undefined;
const start = new Date(turnStartedAt).getTime();
const lastMessage = messages[messages.length - 1];
const end = isActive ? Date.now() : new Date(lastMessage.createdAt).getTime();
const seconds = Math.max(0, Math.round((end - start) / 1000));
if (seconds < 2) return undefined;
return seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`;
}, [turnStartedAt, messages, isActive]);
if (messages.length === 0) {
return null;
}
const stepCount = messages.length;
const summaryParts: string[] = [];
if (elapsed) summaryParts.push(`${elapsed}`);
summaryParts.push(`${stepCount} ${stepCount === 1 ? 'step' : 'steps'}`);
return (
<div className="thinking-process-enter mb-2 overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-1)]/60">
<button
type="button"
onClick={toggle}
onKeyDown={(e) => { if (e.key === ' ') { e.preventDefault(); toggle(); } }}
aria-expanded={expanded}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-2)]/50"
>
<Brain className="size-3.5 shrink-0 text-[var(--color-accent-purple)]" />
{isActive ? (
<span className="flex items-center gap-1.5">
<span className="text-[var(--color-text-secondary)]">Thinking</span>
<ThinkingPulse />
</span>
) : (
<span className="text-[var(--color-text-secondary)]">
Thought for {summaryParts.join(' · ')}
</span>
)}
<span className="ml-auto shrink-0">
{expanded
? <ChevronDown className="size-3 text-[var(--color-text-muted)]" />
: <ChevronRight className="size-3 text-[var(--color-text-muted)]" />}
</span>
</button>
{expanded && (
<div className="border-t border-[var(--color-border)]/30 px-3 py-2">
<div className="space-y-1.5">
{messages.map((message) => (
<ThinkingStep key={message.id} message={message} />
))}
</div>
</div>
)}
</div>
);
}
function ThinkingStep({ message }: { message: ChatMessageRecord }) {
const preview = useMemo(() => truncatePreview(message.content, 180), [message.content]);
return (
<div className="flex gap-2 text-[12px] leading-relaxed">
<span className="mt-0.5 shrink-0 text-[var(--color-text-muted)]"></span>
<div className="min-w-0">
{message.authorName && (
<span className="mr-1.5 font-medium text-[var(--color-text-secondary)]">
{message.authorName}
</span>
)}
<span className="text-[var(--color-text-muted)]">{preview}</span>
</div>
</div>
);
}
function ThinkingPulse() {
return (
<span className="inline-flex items-center gap-0.5">
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent-purple)]" />
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent-purple)]" />
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent-purple)]" />
</span>
);
}
function truncatePreview(text: string, maxLength: number): string {
const firstLine = text.split('\n')[0] ?? '';
const cleaned = firstLine.trim();
if (cleaned.length <= maxLength) return cleaned;
return `${cleaned.slice(0, maxLength)}`;
}
+5 -1
View File
@@ -11,6 +11,10 @@ export function getAssistantMessagePhase(
return 'default'; return 'default';
} }
if (message.messageKind === 'thinking') {
return 'default';
}
if (message.pending) { if (message.pending) {
return 'thinking'; return 'thinking';
} }
@@ -26,7 +30,7 @@ export function getAssistantMessagePhase(
function findLastCompletedAssistantIndex(messages: ChatMessageRecord[]): number { function findLastCompletedAssistantIndex(messages: ChatMessageRecord[]): number {
for (let index = messages.length - 1; index >= 0; index -= 1) { for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index]; const message = messages[index];
if (message.role === 'assistant' && !message.pending) { if (message.role === 'assistant' && !message.pending && message.messageKind !== 'thinking') {
return index; return index;
} }
} }
+26
View File
@@ -41,6 +41,8 @@ function applySessionEvent(session: SessionRecord, event: SessionEventRecord): S
return applyMessageDeltaEvent(session, event); return applyMessageDeltaEvent(session, event);
case 'message-complete': case 'message-complete':
return applyMessageCompleteEvent(session, event); return applyMessageCompleteEvent(session, event);
case 'message-reclassified':
return applyMessageReclassifiedEvent(session, event);
case 'run-updated': case 'run-updated':
return applyRunUpdatedEvent(session, event); return applyRunUpdatedEvent(session, event);
default: default:
@@ -172,6 +174,30 @@ function applyMessageCompleteEvent(session: SessionRecord, event: SessionEventRe
}; };
} }
function applyMessageReclassifiedEvent(session: SessionRecord, event: SessionEventRecord): SessionRecord {
if (!event.messageId || !event.messageKind) {
return session;
}
const messageIndex = session.messages.findIndex((message) => message.id === event.messageId);
if (messageIndex < 0) {
return session;
}
const existing = session.messages[messageIndex];
if (existing.messageKind === event.messageKind) {
return session;
}
const nextMessages = session.messages.slice();
nextMessages[messageIndex] = { ...existing, messageKind: event.messageKind };
return {
...session,
messages: nextMessages,
updatedAt: event.occurredAt,
};
}
function applyRunUpdatedEvent(session: SessionRecord, event: SessionEventRecord): SessionRecord { function applyRunUpdatedEvent(session: SessionRecord, event: SessionEventRecord): SessionRecord {
if (!event.run) { if (!event.run) {
return session; return session;
+15 -1
View File
@@ -626,6 +626,19 @@ body {
animation: banner-slide-in 0.25s cubic-bezier(0.16, 1, 0.3, 1); animation: banner-slide-in 0.25s cubic-bezier(0.16, 1, 0.3, 1);
} }
/* ── Thinking process section ────────────────────────────────── */
@keyframes thinking-process-in {
from {
opacity: 0;
transform: translateY(-4px);
}
}
.thinking-process-enter {
animation: thinking-process-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
}
/* ── Respect reduced motion ──────────────────────────────────── */ /* ── Respect reduced motion ──────────────────────────────────── */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -637,7 +650,8 @@ body {
.message-enter, .message-enter,
.msg-actions-enter, .msg-actions-enter,
.session-item-enter, .session-item-enter,
.banner-slide-enter { .banner-slide-enter,
.thinking-process-enter {
animation: none; animation: none;
} }
} }
+2
View File
@@ -19,6 +19,7 @@ describe('run turn pending helpers', () => {
onUserInput: () => undefined, onUserInput: () => undefined,
onExitPlanMode: () => undefined, onExitPlanMode: () => undefined,
onMcpOAuthRequired: () => undefined, onMcpOAuthRequired: () => undefined,
onMessageReclassified: () => undefined,
onTurnScopedEvent: () => undefined, onTurnScopedEvent: () => undefined,
errored: false, errored: false,
}; };
@@ -45,6 +46,7 @@ describe('run turn pending helpers', () => {
onUserInput: () => undefined, onUserInput: () => undefined,
onExitPlanMode: () => undefined, onExitPlanMode: () => undefined,
onMcpOAuthRequired: () => undefined, onMcpOAuthRequired: () => undefined,
onMessageReclassified: () => undefined,
onTurnScopedEvent: () => undefined, onTurnScopedEvent: () => undefined,
errored: false, errored: false,
}; };
+55
View File
@@ -85,4 +85,59 @@ describe('assistant message phase', () => {
expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default'); expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default');
}); });
test('returns default for thinking-kind messages regardless of other state', () => {
const session = createSession([
{
id: 'msg-1',
role: 'assistant',
authorName: 'Primary Agent',
content: 'Let me search...',
createdAt: '2026-03-23T00:00:00.000Z',
messageKind: 'thinking',
},
{
id: 'msg-2',
role: 'assistant',
authorName: 'Primary Agent',
content: 'Here is the result.',
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('skips thinking messages when determining the last completed assistant', () => {
const session = createSession([
{
id: 'msg-1',
role: 'assistant',
authorName: 'Primary Agent',
content: 'Let me search...',
createdAt: '2026-03-23T00:00:00.000Z',
messageKind: 'thinking',
},
{
id: 'msg-2',
role: 'assistant',
authorName: 'Primary Agent',
content: 'More searching...',
createdAt: '2026-03-23T00:00:01.000Z',
messageKind: 'thinking',
},
{
id: 'msg-3',
role: 'assistant',
authorName: 'Primary Agent',
content: 'Final answer.',
createdAt: '2026-03-23T00:00:02.000Z',
},
]);
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');
});
}); });
+69
View File
@@ -281,4 +281,73 @@ describe('session workspace helpers', () => {
} satisfies SessionEventRecord), } satisfies SessionEventRecord),
).toBe(workspace); ).toBe(workspace);
}); });
test('reclassifies a message as thinking when message-reclassified event arrives', () => {
const workspace = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:01.000Z',
messageId: 'assistant-1',
authorName: 'Primary Agent',
contentDelta: 'Let me search...',
content: 'Let me search...',
} satisfies SessionEventRecord);
const reclassified = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'message-reclassified',
occurredAt: '2026-03-23T00:00:02.000Z',
messageId: 'assistant-1',
messageKind: 'thinking',
} satisfies SessionEventRecord);
expect(reclassified?.sessions[0].messages[0]).toMatchObject({
id: 'assistant-1',
messageKind: 'thinking',
});
});
test('ignores message-reclassified for an already reclassified message', () => {
let workspace = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:01.000Z',
messageId: 'assistant-1',
authorName: 'Primary Agent',
contentDelta: 'Let me search...',
content: 'Let me search...',
} satisfies SessionEventRecord);
workspace = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'message-reclassified',
occurredAt: '2026-03-23T00:00:02.000Z',
messageId: 'assistant-1',
messageKind: 'thinking',
} satisfies SessionEventRecord);
const duplicate = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'message-reclassified',
occurredAt: '2026-03-23T00:00:03.000Z',
messageId: 'assistant-1',
messageKind: 'thinking',
} satisfies SessionEventRecord);
// Should return the same reference (no change)
expect(duplicate).toBe(workspace);
});
test('ignores message-reclassified for unknown message ids', () => {
const workspace = createWorkspace();
const result = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'message-reclassified',
occurredAt: '2026-03-23T00:00:01.000Z',
messageId: 'nonexistent',
messageKind: 'thinking',
} satisfies SessionEventRecord);
expect(result).toBe(workspace);
});
}); });