mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-23 13:08:36 +02:00
feat: render per-turn thinking tiles in chat pane
Replace the single aggregate thinking tile with per-turn thinking groups. Instead of collecting all thinking messages into one flat array and rendering a single ThinkingProcess before the last assistant message, process session.messages in chronological order to produce interleaved display items that naturally group consecutive thinking messages by turn. Each turn now shows its own collapsible thinking tile placed inline before its assistant response, with correct per-turn isActive state and turnStartedAt from the matching run. Also fixes a latent index-mismatch bug in getAssistantMessagePhase where the visible-messages index was compared against the full session.messages index, potentially hiding the Final badge when thinking messages existed. Switched to ID-based comparison. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -29,7 +29,7 @@ import {
|
||||
} from '@shared/domain/models';
|
||||
import { type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern';
|
||||
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
||||
import { resolveSessionToolingSelection, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session';
|
||||
import { resolveSessionToolingSelection, type ChatMessageRecord, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session';
|
||||
import {
|
||||
countApprovedToolsInGroups,
|
||||
groupApprovalToolsByProvider,
|
||||
@@ -41,6 +41,10 @@ import {
|
||||
|
||||
/* ── ChatPane ──────────────────────────────────────────────── */
|
||||
|
||||
type DisplayItem =
|
||||
| { type: 'message'; message: ChatMessageRecord }
|
||||
| { type: 'thinking-group'; messages: ChatMessageRecord[]; turnStartedAt?: string };
|
||||
|
||||
interface ChatPaneProps {
|
||||
project: ProjectRecord;
|
||||
pattern: PatternDefinition;
|
||||
@@ -122,28 +126,51 @@ export function ChatPane({
|
||||
const composerRef = useRef<MarkdownComposerHandle>(null);
|
||||
|
||||
const isSessionBusy = session.status === 'running';
|
||||
const { visibleMessages, thinkingMessages } = useMemo(() => {
|
||||
const visible: typeof session.messages = [];
|
||||
const thinking: typeof session.messages = [];
|
||||
|
||||
const displayItems = useMemo(() => {
|
||||
const runsByTrigger = new Map(session.runs.map((r) => [r.triggerMessageId, r]));
|
||||
const items: DisplayItem[] = [];
|
||||
let pendingThinking: ChatMessageRecord[] = [];
|
||||
let lastUserMessageId: string | undefined;
|
||||
|
||||
for (const message of session.messages) {
|
||||
if (message.messageKind === 'thinking') {
|
||||
thinking.push(message);
|
||||
pendingThinking.push(message);
|
||||
} else {
|
||||
visible.push(message);
|
||||
if (pendingThinking.length > 0) {
|
||||
const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined;
|
||||
items.push({ type: 'thinking-group', messages: pendingThinking, turnStartedAt: run?.startedAt });
|
||||
pendingThinking = [];
|
||||
}
|
||||
items.push({ type: 'message', message });
|
||||
if (message.role === 'user') {
|
||||
lastUserMessageId = message.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { visibleMessages: visible, thinkingMessages: thinking };
|
||||
}, [session.messages]);
|
||||
const lastAssistantIndex = useMemo(() => {
|
||||
for (let i = visibleMessages.length - 1; i >= 0; i--) {
|
||||
if (visibleMessages[i].role === 'assistant') return i;
|
||||
|
||||
if (pendingThinking.length > 0) {
|
||||
const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined;
|
||||
items.push({ type: 'thinking-group', messages: pendingThinking, turnStartedAt: run?.startedAt });
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [session.messages, session.runs]);
|
||||
|
||||
const lastThinkingGroupIndex = useMemo(() => {
|
||||
for (let i = displayItems.length - 1; i >= 0; i--) {
|
||||
if (displayItems[i].type === 'thinking-group') return i;
|
||||
}
|
||||
return -1;
|
||||
}, [visibleMessages]);
|
||||
const turnStartedAt = useMemo(() => {
|
||||
if (session.runs.length === 0) return undefined;
|
||||
return session.runs[0].startedAt;
|
||||
}, [session.runs]);
|
||||
}, [displayItems]);
|
||||
|
||||
const lastAssistantId = useMemo(() => {
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
const m = session.messages[i];
|
||||
if (m.role === 'assistant' && m.messageKind !== 'thinking') return m.id;
|
||||
}
|
||||
return undefined;
|
||||
}, [session.messages]);
|
||||
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;
|
||||
@@ -398,11 +425,25 @@ export function ChatPane({
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{visibleMessages.map((message, index) => {
|
||||
{displayItems.map((item, itemIndex) => {
|
||||
if (item.type === 'thinking-group') {
|
||||
const isLastThinkingGroup = itemIndex === lastThinkingGroupIndex;
|
||||
return (
|
||||
<div key={`thinking-${item.messages[0].id}`} className="py-2">
|
||||
<ThinkingProcess
|
||||
messages={item.messages}
|
||||
isActive={isSessionBusy && isLastThinkingGroup}
|
||||
turnStartedAt={item.turnStartedAt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const message = item.message;
|
||||
const isUser = message.role === 'user';
|
||||
const isEditing = editingMessageId === message.id;
|
||||
const isLastAssistant = index === lastAssistantIndex;
|
||||
const phase = getAssistantMessagePhase(session, message, index);
|
||||
const isLastAssistant = message.id === lastAssistantId;
|
||||
const phase = getAssistantMessagePhase(session, message);
|
||||
const assistantContainerClass =
|
||||
phase === 'thinking'
|
||||
? 'border-[var(--color-accent-sky)]/20 bg-[var(--color-accent-sky)]/5'
|
||||
@@ -416,19 +457,9 @@ export function ChatPane({
|
||||
const phaseLabel =
|
||||
phase === 'thinking' ? 'Thinking' : phase === 'final' ? 'Final' : undefined;
|
||||
const showActions = !isSessionBusy && !message.pending;
|
||||
const showThinkingBefore = isLastAssistant && thinkingMessages.length > 0;
|
||||
|
||||
return (
|
||||
<div key={message.id}>
|
||||
{showThinkingBefore && (
|
||||
<div className="py-2">
|
||||
<ThinkingProcess
|
||||
messages={thinkingMessages}
|
||||
isActive={isSessionBusy}
|
||||
turnStartedAt={turnStartedAt}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="message-enter group py-3" data-message-id={message.id}>
|
||||
<div className="flex gap-3">
|
||||
<div
|
||||
@@ -523,15 +554,6 @@ export function ChatPane({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{thinkingMessages.length > 0 && lastAssistantIndex < 0 && (
|
||||
<div className="py-2">
|
||||
<ThinkingProcess
|
||||
messages={thinkingMessages}
|
||||
isActive={isSessionBusy}
|
||||
turnStartedAt={turnStartedAt}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{activeSubagents && activeSubagents.length > 0 && (
|
||||
<div className="px-6 py-1">
|
||||
|
||||
@@ -5,7 +5,6 @@ export type AssistantMessagePhase = 'default' | 'thinking' | 'final';
|
||||
export function getAssistantMessagePhase(
|
||||
session: SessionRecord,
|
||||
message: ChatMessageRecord,
|
||||
index: number,
|
||||
): AssistantMessagePhase {
|
||||
if (message.role !== 'assistant') {
|
||||
return 'default';
|
||||
@@ -23,17 +22,17 @@ export function getAssistantMessagePhase(
|
||||
return 'default';
|
||||
}
|
||||
|
||||
const lastCompletedAssistantIndex = findLastCompletedAssistantIndex(session.messages);
|
||||
return index === lastCompletedAssistantIndex ? 'final' : 'default';
|
||||
const lastId = findLastCompletedAssistantId(session.messages);
|
||||
return message.id === lastId ? 'final' : 'default';
|
||||
}
|
||||
|
||||
function findLastCompletedAssistantIndex(messages: ChatMessageRecord[]): number {
|
||||
function findLastCompletedAssistantId(messages: ChatMessageRecord[]): string | undefined {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (message.role === 'assistant' && !message.pending && message.messageKind !== 'thinking') {
|
||||
return index;
|
||||
return message.id;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('assistant message phase', () => {
|
||||
},
|
||||
], 'running');
|
||||
|
||||
expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('thinking');
|
||||
expect(getAssistantMessagePhase(session, session.messages[0])).toBe('thinking');
|
||||
});
|
||||
|
||||
test('marks the last completed assistant message as final when the session is idle', () => {
|
||||
@@ -54,8 +54,8 @@ describe('assistant message phase', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default');
|
||||
expect(getAssistantMessagePhase(session, session.messages[1], 1)).toBe('final');
|
||||
expect(getAssistantMessagePhase(session, session.messages[0])).toBe('default');
|
||||
expect(getAssistantMessagePhase(session, session.messages[1])).toBe('final');
|
||||
});
|
||||
|
||||
test('does not mark completed assistant messages as final while the session is still running', () => {
|
||||
@@ -69,7 +69,7 @@ describe('assistant message phase', () => {
|
||||
},
|
||||
], 'running');
|
||||
|
||||
expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default');
|
||||
expect(getAssistantMessagePhase(session, session.messages[0])).toBe('default');
|
||||
});
|
||||
|
||||
test('ignores non-assistant messages', () => {
|
||||
@@ -83,7 +83,7 @@ describe('assistant message phase', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default');
|
||||
expect(getAssistantMessagePhase(session, session.messages[0])).toBe('default');
|
||||
});
|
||||
|
||||
test('returns default for thinking-kind messages regardless of other state', () => {
|
||||
@@ -105,8 +105,8 @@ describe('assistant message phase', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default');
|
||||
expect(getAssistantMessagePhase(session, session.messages[1], 1)).toBe('final');
|
||||
expect(getAssistantMessagePhase(session, session.messages[0])).toBe('default');
|
||||
expect(getAssistantMessagePhase(session, session.messages[1])).toBe('final');
|
||||
});
|
||||
|
||||
test('skips thinking messages when determining the last completed assistant', () => {
|
||||
@@ -136,8 +136,8 @@ describe('assistant message phase', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
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');
|
||||
expect(getAssistantMessagePhase(session, session.messages[0])).toBe('default');
|
||||
expect(getAssistantMessagePhase(session, session.messages[1])).toBe('default');
|
||||
expect(getAssistantMessagePhase(session, session.messages[2])).toBe('final');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user