diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index c786017..61d85ea 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -10,6 +10,7 @@ import type { ExitPlanModeRequestedEvent, MessageMode, McpOauthRequiredEvent, + MessageReclassifiedEvent, RunTurnCustomAgentConfig, RunTurnToolingConfig, SidecarCapabilities, @@ -1364,6 +1365,9 @@ export class AryxAppService extends EventEmitter { async (event) => { await this.handleExitPlanModeRequested(workspace, session.id, event); }, + async (event) => { + await this.applyMessageReclassified(workspace, session.id, event); + }, async (event) => { await this.handleTurnScopedEvent(workspace, session.id, event); }, @@ -1715,6 +1719,31 @@ export class AryxAppService extends EventEmitter { } } + private async applyMessageReclassified( + workspace: WorkspaceState, + sessionId: string, + event: MessageReclassifiedEvent, + ): Promise { + 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( workspace: WorkspaceState, sessionId: string, diff --git a/src/main/sidecar/runTurnPending.ts b/src/main/sidecar/runTurnPending.ts index 2b2f4d5..dd0b613 100644 --- a/src/main/sidecar/runTurnPending.ts +++ b/src/main/sidecar/runTurnPending.ts @@ -3,6 +3,7 @@ import type { ApprovalRequestedEvent, ExitPlanModeRequestedEvent, McpOauthRequiredEvent, + MessageReclassifiedEvent, TurnDeltaEvent, UserInputRequestedEvent, SubagentEvent, @@ -12,6 +13,8 @@ import type { SessionCompactionEvent, PendingMessagesModifiedEvent, AssistantUsageEvent, + AssistantIntentEvent, + ReasoningDeltaEvent, } from '@shared/contracts/sidecar'; import type { ChatMessageRecord } from '@shared/domain/session'; @@ -22,7 +25,9 @@ export type TurnScopedEvent = | SessionUsageEvent | SessionCompactionEvent | PendingMessagesModifiedEvent - | AssistantUsageEvent; + | AssistantUsageEvent + | AssistantIntentEvent + | ReasoningDeltaEvent; export interface RunTurnPendingCommand { kind: 'run-turn'; @@ -34,6 +39,7 @@ export interface RunTurnPendingCommand { onUserInput: (event: UserInputRequestedEvent) => void | Promise; onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise; onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise; + onMessageReclassified: (event: MessageReclassifiedEvent) => void | Promise; onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise; errored: boolean; } diff --git a/src/main/sidecar/sidecarProcess.ts b/src/main/sidecar/sidecarProcess.ts index 96593e9..dfb8b9f 100644 --- a/src/main/sidecar/sidecarProcess.ts +++ b/src/main/sidecar/sidecarProcess.ts @@ -9,6 +9,7 @@ import type { SidecarCapabilities, SidecarEvent, TurnDeltaEvent, + MessageReclassifiedEvent, UserInputRequestedEvent, McpOauthRequiredEvent, ExitPlanModeRequestedEvent, @@ -134,9 +135,10 @@ export class SidecarClient { onUserInput: (event: UserInputRequestedEvent) => void | Promise, onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise, onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise, + onMessageReclassified: (event: MessageReclassifiedEvent) => void | Promise, onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise, ): Promise { - return this.dispatch(command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, onTurnScopedEvent); + return this.dispatch(command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, onMessageReclassified, onTurnScopedEvent); } async resolveUserInput(userInputId: string, answer: string, wasFreeform: boolean): Promise { @@ -286,6 +288,7 @@ export class SidecarClient { onUserInput?: (event: UserInputRequestedEvent) => void | Promise, onMcpOAuthRequired?: (event: McpOauthRequiredEvent) => void | Promise, onExitPlanMode?: (event: ExitPlanModeRequestedEvent) => void | Promise, + onMessageReclassified?: (event: MessageReclassifiedEvent) => void | Promise, onTurnScopedEvent?: (event: TurnScopedEvent) => void | Promise, ): Promise { const state = await this.ensureProcess(); @@ -303,6 +306,7 @@ export class SidecarClient { onUserInput: onUserInput ?? (() => undefined), onMcpOAuthRequired: onMcpOAuthRequired ?? (() => undefined), onExitPlanMode: onExitPlanMode ?? (() => undefined), + onMessageReclassified: onMessageReclassified ?? (() => undefined), onTurnScopedEvent: onTurnScopedEvent ?? (() => undefined), errored: false, }); @@ -439,6 +443,11 @@ export class SidecarClient { this.invokeRunTurnHandler(event.requestId, pending, () => pending.onExitPlanMode(event)); } return; + case 'message-reclassified': + if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) { + this.invokeRunTurnHandler(event.requestId, pending, () => pending.onMessageReclassified(event)); + } + return; case 'subagent-event': case 'skill-invoked': case 'hook-lifecycle': @@ -446,6 +455,8 @@ export class SidecarClient { case 'session-compaction': case 'pending-messages-modified': case 'assistant-usage': + case 'assistant-intent': + case 'reasoning-delta': if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) { this.invokeRunTurnHandler(event.requestId, pending, () => pending.onTurnScopedEvent(event)); } diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index 3395bc8..8ab2b68 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -12,6 +12,7 @@ import { UserInputBanner } from '@renderer/components/chat/UserInputBanner'; import { InlineApprovalPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills'; import { InlinePromptPill } from '@renderer/components/chat/InlinePromptPill'; import { ThinkingDots } from '@renderer/components/chat/ThinkingDots'; +import { ThinkingProcess } from '@renderer/components/chat/ThinkingProcess'; import { SubagentActivityList } from '@renderer/components/chat/SubagentActivityCard'; import { getAssistantMessagePhase } from '@renderer/lib/messagePhase'; import type { ApprovalDecision } from '@shared/domain/approval'; @@ -115,12 +116,28 @@ 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 = []; + 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(() => { - for (let i = session.messages.length - 1; i >= 0; i--) { - if (session.messages[i].role === 'assistant') return i; + for (let i = visibleMessages.length - 1; i >= 0; i--) { + if (visibleMessages[i].role === 'assistant') return i; } 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 queuedApprovals = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending'); const totalPendingCount = (pendingApproval ? 1 : 0) + queuedApprovals.length; @@ -358,7 +375,7 @@ export function ChatPane({ /> )}
- {session.messages.map((message, index) => { + {visibleMessages.map((message, index) => { const isUser = message.role === 'user'; const isEditing = editingMessageId === message.id; const isLastAssistant = index === lastAssistantIndex; @@ -376,101 +393,122 @@ export function ChatPane({ const phaseLabel = phase === 'thinking' ? 'Thinking' : phase === 'final' ? 'Final' : undefined; const showActions = !isSessionBusy && !message.pending; + const showThinkingBefore = isLastAssistant && thinkingMessages.length > 0; return ( -
-
-
- {isUser ? : } +
+ {showThinkingBefore && ( +
+
-
-
- {message.authorName} - {message.isPinned && ( - - )} - {!isUser && phaseLabel && ( - - {phaseLabel} - - )} - {showActions && ( -
- 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} - /> -
- )} + )} +
+
+
+ {isUser ? : }
- - {/* Edit mode */} - {isEditing ? ( - handleEditSave(message.id, content)} - onCancel={() => setEditingMessageId(undefined)} - /> - ) : ( -
- {/* Attachment thumbnails */} - {isUser && message.attachments && message.attachments.length > 0 && ( -
- {message.attachments.map((att, attIdx) => - isImageAttachment(att) ? ( - {getAttachmentDisplayName(att)} - ) : ( -
- - {getAttachmentDisplayName(att)} -
- ), - )} -
+
+
+ {message.authorName} + {message.isPinned && ( + )} - {!isUser && message.pending ? ( -
- {message.content} -
- ) : ( - + {!isUser && phaseLabel && ( + + {phaseLabel} + )} - {message.pending && message.content && ( - + {showActions && ( +
+ 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} + /> +
)}
- )} - {message.pending && !message.content && } + + {/* Edit mode */} + {isEditing ? ( + handleEditSave(message.id, content)} + onCancel={() => setEditingMessageId(undefined)} + /> + ) : ( +
+ {/* Attachment thumbnails */} + {isUser && message.attachments && message.attachments.length > 0 && ( +
+ {message.attachments.map((att, attIdx) => + isImageAttachment(att) ? ( + {getAttachmentDisplayName(att)} + ) : ( +
+ + {getAttachmentDisplayName(att)} +
+ ), + )} +
+ )} + {!isUser && message.pending ? ( +
+ {message.content} +
+ ) : ( + + )} + {message.pending && message.content && ( + + )} +
+ )} + {message.pending && !message.content && } +
); })} + {thinkingMessages.length > 0 && lastAssistantIndex < 0 && ( +
+ +
+ )}
{activeSubagents && activeSubagents.length > 0 && (
diff --git a/src/renderer/components/chat/ThinkingProcess.tsx b/src/renderer/components/chat/ThinkingProcess.tsx new file mode 100644 index 0000000..95ea1ab --- /dev/null +++ b/src/renderer/components/chat/ThinkingProcess.tsx @@ -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 ( +
+ + {expanded && ( +
+
+ {messages.map((message) => ( + + ))} +
+
+ )} +
+ ); +} + +function ThinkingStep({ message }: { message: ChatMessageRecord }) { + const preview = useMemo(() => truncatePreview(message.content, 180), [message.content]); + + return ( +
+ +
+ {message.authorName && ( + + {message.authorName} + + )} + {preview} +
+
+ ); +} + +function ThinkingPulse() { + return ( + + + + + + ); +} + +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)}…`; +} diff --git a/src/renderer/lib/messagePhase.ts b/src/renderer/lib/messagePhase.ts index eab5306..a85a95f 100644 --- a/src/renderer/lib/messagePhase.ts +++ b/src/renderer/lib/messagePhase.ts @@ -11,6 +11,10 @@ export function getAssistantMessagePhase( return 'default'; } + if (message.messageKind === 'thinking') { + return 'default'; + } + if (message.pending) { return 'thinking'; } @@ -26,7 +30,7 @@ export function getAssistantMessagePhase( 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) { + if (message.role === 'assistant' && !message.pending && message.messageKind !== 'thinking') { return index; } } diff --git a/src/renderer/lib/sessionWorkspace.ts b/src/renderer/lib/sessionWorkspace.ts index 57b3db1..80a313a 100644 --- a/src/renderer/lib/sessionWorkspace.ts +++ b/src/renderer/lib/sessionWorkspace.ts @@ -41,6 +41,8 @@ function applySessionEvent(session: SessionRecord, event: SessionEventRecord): S return applyMessageDeltaEvent(session, event); case 'message-complete': return applyMessageCompleteEvent(session, event); + case 'message-reclassified': + return applyMessageReclassifiedEvent(session, event); case 'run-updated': return applyRunUpdatedEvent(session, event); 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 { if (!event.run) { return session; diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 2bb3bae..0b0d58c 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -626,6 +626,19 @@ body { 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 ──────────────────────────────────── */ @media (prefers-reduced-motion: reduce) { @@ -637,7 +650,8 @@ body { .message-enter, .msg-actions-enter, .session-item-enter, - .banner-slide-enter { + .banner-slide-enter, + .thinking-process-enter { animation: none; } } diff --git a/tests/main/runTurnPending.test.ts b/tests/main/runTurnPending.test.ts index 9db7674..bfa7c45 100644 --- a/tests/main/runTurnPending.test.ts +++ b/tests/main/runTurnPending.test.ts @@ -19,6 +19,7 @@ describe('run turn pending helpers', () => { onUserInput: () => undefined, onExitPlanMode: () => undefined, onMcpOAuthRequired: () => undefined, + onMessageReclassified: () => undefined, onTurnScopedEvent: () => undefined, errored: false, }; @@ -45,6 +46,7 @@ describe('run turn pending helpers', () => { onUserInput: () => undefined, onExitPlanMode: () => undefined, onMcpOAuthRequired: () => undefined, + onMessageReclassified: () => undefined, onTurnScopedEvent: () => undefined, errored: false, }; diff --git a/tests/renderer/messagePhase.test.ts b/tests/renderer/messagePhase.test.ts index 535dd66..249b2a2 100644 --- a/tests/renderer/messagePhase.test.ts +++ b/tests/renderer/messagePhase.test.ts @@ -85,4 +85,59 @@ describe('assistant message phase', () => { 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'); + }); }); diff --git a/tests/renderer/sessionWorkspace.test.ts b/tests/renderer/sessionWorkspace.test.ts index c3e4239..3361ba7 100644 --- a/tests/renderer/sessionWorkspace.test.ts +++ b/tests/renderer/sessionWorkspace.test.ts @@ -281,4 +281,73 @@ describe('session workspace helpers', () => { } satisfies SessionEventRecord), ).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); + }); });