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,
MessageMode,
McpOauthRequiredEvent,
MessageReclassifiedEvent,
RunTurnCustomAgentConfig,
RunTurnToolingConfig,
SidecarCapabilities,
@@ -1364,6 +1365,9 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
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<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(
workspace: WorkspaceState,
sessionId: string,
+7 -1
View File
@@ -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<void>;
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>;
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>;
onMessageReclassified: (event: MessageReclassifiedEvent) => void | Promise<void>;
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>;
errored: boolean;
}
+12 -1
View File
@@ -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<void>,
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>,
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
onMessageReclassified: (event: MessageReclassifiedEvent) => void | Promise<void>,
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>,
): 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> {
@@ -286,6 +288,7 @@ export class SidecarClient {
onUserInput?: (event: UserInputRequestedEvent) => void | Promise<void>,
onMcpOAuthRequired?: (event: McpOauthRequiredEvent) => void | Promise<void>,
onExitPlanMode?: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
onMessageReclassified?: (event: MessageReclassifiedEvent) => void | Promise<void>,
onTurnScopedEvent?: (event: TurnScopedEvent) => void | Promise<void>,
): Promise<TResult> {
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));
}