Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 67 additions & 11 deletions app/src/features/conversations/Conversations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ import {
} from '../../features/conversations/components/ThreadGoalChip';
import { ThreadTodoStrip } from '../../features/conversations/components/ThreadTodoStrip';
import { ToolTimelineBlock } from '../../features/conversations/components/ToolTimelineBlock';
import { InterruptedAnswer } from '../../features/conversations/components/InterruptedAnswer';
import { PastTurnInsights } from '../../features/conversations/components/PastTurnInsights';
import {
evaluateComposerSend,
getComposerBlockedSendFeedback,
Expand Down Expand Up @@ -106,6 +108,7 @@ import {
hydrateThreadUsage,
markSubagentCancelled,
markThreadSendPending,
type ProcessingTranscriptItem,
type QueuedFollowup,
registerParallelRequest,
setTaskBoardForThread,
Expand Down Expand Up @@ -239,6 +242,16 @@ const EMPTY_QUEUED_FOLLOWUPS: Record<string, QueuedFollowup[]> = {};
// Stable empty reference for the per-thread past-turn timelines map, so the
// derived value keeps the same identity when the slice field is absent.
const EMPTY_TURN_TIMELINES: Record<string, ToolTimelineEntry[]> = {};
// Sibling stable empty for the per-thread past-turn processing transcripts map
// (restore-fidelity fix 1).
const EMPTY_TURN_TRANSCRIPTS: Record<string, ProcessingTranscriptItem[]> = {};
// Stable empty transcript for a past turn that has tool rows but no persisted
// reasoning/narration trail (legacy snapshot), so `PastTurnInsights` falls back
// to the tool-only view without allocating a fresh array each render.
const EMPTY_TRANSCRIPT: ProcessingTranscriptItem[] = [];
// Stable empty tool-row list for a transcript-only past turn (agent thought /
// narrated but ran no tools).
const EMPTY_TRANSCRIPT_ENTRIES: ToolTimelineEntry[] = [];

export function isComposerInteractionBlocked(args: {
/** Whether the *currently selected* thread has an in-flight inference turn. */
Expand Down Expand Up @@ -398,6 +411,12 @@ const Conversations = ({
const uiLocale = useAppSelector(state => state.locale?.current ?? 'en');
const toolTimelineByThread = useAppSelector(state => state.chatRuntime.toolTimelineByThread);
const turnTimelinesByThread = useAppSelector(state => state.chatRuntime.turnTimelinesByThread);
const turnTranscriptsByThread = useAppSelector(
state => state.chatRuntime.turnTranscriptsByThread
);
const interruptedAssistantByThread = useAppSelector(
state => state.chatRuntime.interruptedAssistantByThread
);
const processingByThread = useAppSelector(state => state.chatRuntime.processingByThread);
const taskBoardByThread = useAppSelector(state => state.chatRuntime.taskBoardByThread);
const inferenceStatusByThread = useAppSelector(
Expand Down Expand Up @@ -1834,21 +1853,33 @@ const Conversations = ({
const selectedThreadTurnTimelines = selectedThreadId
? (turnTimelinesByThread[selectedThreadId] ?? EMPTY_TURN_TIMELINES)
: EMPTY_TURN_TIMELINES;
// Sibling map: each past turn's persisted reasoning/narration trail, so a
// reopened turn replays its thoughts, not just its tool cards (fix 1).
const selectedThreadTurnTranscripts = selectedThreadId
? (turnTranscriptsByThread[selectedThreadId] ?? EMPTY_TURN_TRANSCRIPTS)
: EMPTY_TURN_TRANSCRIPTS;
const pastTurnAnchors = useMemo(() => {
const anchors: Record<string, ToolTimelineEntry[]> = {};
const anchors: Record<
string,
{ entries: ToolTimelineEntry[]; transcript: ProcessingTranscriptItem[] }
> = {};
const seen = new Set<string>();
for (const msg of timelineMessages) {
if (msg.sender !== 'agent') continue;
const requestId = msg.extraMetadata?.requestId;
if (typeof requestId !== 'string' || seen.has(requestId)) continue;
const entries = selectedThreadTurnTimelines[requestId];
if (entries && entries.length > 0) {
anchors[msg.id] = entries;
const entries = selectedThreadTurnTimelines[requestId] ?? EMPTY_TRANSCRIPT_ENTRIES;
const transcript = selectedThreadTurnTranscripts[requestId] ?? EMPTY_TRANSCRIPT;
// Anchor the turn when it has EITHER tool rows OR a reasoning/narration
// trail — a tool-less turn (agent only thought/narrated) must still
// render its restored thoughts above its answer (fix 1).
if (entries.length > 0 || transcript.length > 0) {
anchors[msg.id] = { entries, transcript };
seen.add(requestId);
}
}
return anchors;
}, [timelineMessages, selectedThreadTurnTimelines]);
}, [timelineMessages, selectedThreadTurnTimelines, selectedThreadTurnTranscripts]);
const activeSubagentTimelineEntry = selectedThreadToolTimeline.find(
entry => entry.status === 'running' && entry.name.startsWith('subagent:')
);
Expand All @@ -1861,6 +1892,12 @@ const Conversations = ({
const selectedStreamingAssistant = selectedThreadId
? (streamingAssistantByThread[selectedThreadId] ?? null)
: null;
// The partial reply an interrupted turn left behind (restore-fidelity fix 2):
// surfaced as a settled, marked-interrupted bubble on restore so a turn that
// crashed mid-answer keeps its visible work instead of rendering blank.
const selectedInterruptedAssistant = selectedThreadId
? (interruptedAssistantByThread[selectedThreadId] ?? null)
: null;
// Live streams for concurrent parallel (forked) turns on the selected thread,
// rendered as separate interleaved branch bubbles.
const selectedParallelStreams = selectedThreadId
Expand Down Expand Up @@ -1928,7 +1965,10 @@ const Conversations = ({
isSending ||
selectedThreadToolTimeline.length > 0 ||
selectedThreadProcessing.length > 0 ||
Boolean(selectedStreamingAssistant);
Boolean(selectedStreamingAssistant) ||
// An interrupted turn's restored partial answer must surface too, even
// before the durable message history loads (restore-fidelity fix 2).
Boolean(selectedInterruptedAssistant);

// Anchor the "Agentic task insights" panel right after the latest turn's user
// message — processing happens *before* the answer, so it reads above the
Expand Down Expand Up @@ -2259,14 +2299,20 @@ const Conversations = ({
// what keeps the marker text out of both the rendered bubble and
// the copy-to-clipboard action.
const parsedContent = parseMessageImages(msg.content ?? '');
const pastTurnEntries = pastTurnAnchors[msg.id];
const pastTurn = pastTurnAnchors[msg.id];
return (
<Fragment key={msg.id}>
{/* Past-turn process trail (Phase 5): each older settled turn's
tool timeline, collapsed, above the answer it produced. */}
{pastTurnEntries ? (
{/* Past-turn process trail (Phase 5 + restore-fidelity fix 1):
each older settled turn's interleaved reasoning/narration +
tool steps (and restored sub-agent transcripts), collapsed,
above the answer it produced. Falls back to tool-cards-only
for legacy snapshots with no persisted transcript. */}
{pastTurn ? (
<div data-testid="past-turn-insights">
<ToolTimelineBlock entries={pastTurnEntries} />
<PastTurnInsights
entries={pastTurn.entries}
transcript={pastTurn.transcript}
/>
</div>
) : null}
<div>
Expand Down Expand Up @@ -2661,6 +2707,16 @@ const Conversations = ({
</div>
</div>
)}
{/* Interrupted turn's partial answer (restore-fidelity fix 2):
a settled, marked-interrupted bubble surfaced on restore. Only
when NOT streaming live (the buffer is cleared by any live turn
in the slice; this guard is belt-and-braces). */}
{!isSending && selectedInterruptedAssistant ? (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear interrupted partials when starting a new turn

When a restored interrupted thread is used again, this only hides the old partial while isSending is true; the live socket path clears processing/streaming on inference_start/chat_done but never clears the new interruptedAssistantByThread entry. After the replacement turn completes, isSending becomes false and the stale interrupted bubble can reappear below the new answer until some later snapshot hydration happens to clear it, so the buffer should be cleared when a fresh live turn starts (or when that turn settles).

Useful? React with 👍 / 👎.

<InterruptedAnswer
content={selectedInterruptedAssistant.content}
thinking={selectedInterruptedAssistant.thinking}
/>
) : null}
{/* Parallel (forked) branch streams — concurrent turns on this
thread, each its own labeled bubble so they don't collide with
the primary stream above. */}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { describe, expect, it } from 'vitest';

import { store } from '../../../store';
import { InterruptedAnswer } from './InterruptedAnswer';

function renderInStore(ui: React.ReactNode) {
return render(<Provider store={store}>{ui}</Provider>);
}

describe('InterruptedAnswer', () => {
it('surfaces the partial reply with an interrupted marker', () => {
renderInStore(
<InterruptedAnswer content="Here is the partial answer" thinking="was reasoning about it" />
);

const block = screen.getByTestId('interrupted-answer');
expect(block.textContent).toContain('Here is the partial answer');
// The reasoning it had streamed is kept in a collapsed block.
expect(block.textContent).toContain('was reasoning about it');
// Marked interrupted rather than presented as a finished answer.
expect(screen.getByTestId('interrupted-answer-marker')).toBeTruthy();
});

it('renders nothing when neither content nor thinking has any text', () => {
const { container } = renderInStore(<InterruptedAnswer content=" " thinking="" />);
expect(container.querySelector('[data-testid="interrupted-answer"]')).toBeNull();
});
});
59 changes: 59 additions & 0 deletions app/src/features/conversations/components/InterruptedAnswer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useT } from '../../../lib/i18n/I18nContext';
import { BubbleMarkdown } from './AgentMessageBubble';

/**
* The partial assistant reply left behind by an INTERRUPTED turn — the core
* process that was streaming it exited before `chat_done`, so no final message
* was ever committed to the durable thread log (restore-fidelity fix 2).
*
* Unlike the live streaming preview, this is a SETTLED buffer: it renders as a
* static agent bubble (no pulsing cursor, full Markdown like a finished answer)
* carrying an "Interrupted" marker, plus the hidden reasoning it had streamed in
* a collapsed block. It is deliberately NOT written into the durable message
* list — it is a restore-time surfacing of what the agent had produced, so the
* user sees the partial work instead of a blank turn.
*/
export function InterruptedAnswer({
content,
thinking,
}: {
content: string;
thinking: string;
}) {
const { t } = useT();
const trimmedContent = content.trim();
const trimmedThinking = thinking.trim();
// Nothing persisted to show — render nothing rather than an empty marked bubble.
if (!trimmedContent && !trimmedThinking) return null;

return (
<div className="flex justify-start" data-testid="interrupted-answer">
<div className="relative w-fit max-w-[75%] space-y-1">
{trimmedThinking ? (
<details className="mb-0.5 rounded-lg bg-surface-subtle px-3 py-1.5 text-xs text-content-secondary dark:bg-surface-muted">
<summary className="flex cursor-pointer items-center gap-1.5 select-none">
<span aria-hidden className="text-[10px] leading-none">
💭
</span>
<span>{t('chat.thinking')}</span>
</summary>
<pre className="mt-1.5 font-sans text-[11px] break-words whitespace-pre-wrap text-content-muted">
{trimmedThinking}
</pre>
</details>
) : null}
<div className="rounded-2xl rounded-bl-md border-l-2 border-amber-400/70 bg-surface-strong/80 px-3 py-2 text-content dark:bg-surface-muted">
<div
className="mb-1 flex items-center gap-1.5 text-[10px] font-medium tracking-wide text-amber-600 uppercase dark:text-amber-300"
data-testid="interrupted-answer-marker">
<span aria-hidden className="text-[11px] leading-none">
</span>
<span>{t('intelligence.agentWork.status.interrupted')}</span>
</div>
{trimmedContent ? <BubbleMarkdown content={trimmedContent} /> : null}
</div>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { describe, expect, it } from 'vitest';

import { store } from '../../../store';
import type { ProcessingTranscriptItem, ToolTimelineEntry } from '../../../store/chatRuntimeSlice';
import { PastTurnInsights } from './PastTurnInsights';

function renderInStore(ui: React.ReactNode) {
return render(<Provider store={store}>{ui}</Provider>);
}

describe('PastTurnInsights', () => {
it('replays a restored turn as interleaved thoughts + tool rows, not just tool cards', () => {
// A reopened past turn carries both its reasoning trail and its tools.
const entries: ToolTimelineEntry[] = [
{ id: 'c1', name: 'read_file', round: 0, seq: 0, status: 'success' },
];
const transcript: ProcessingTranscriptItem[] = [
{ kind: 'thinking', round: 0, seq: 1, text: 'planning the search' },
{ kind: 'toolCall', round: 0, seq: 2, callId: 'c1' },
];

renderInStore(<PastTurnInsights entries={entries} transcript={transcript} />);

// The hidden reasoning replays (fix 1) — a restored turn is no longer
// tool-cards-only.
expect(screen.getByTestId('processing-thinking').textContent).toContain('planning the search');
// And its tool step still renders in the interleaved view.
expect(screen.getAllByTestId('processing-tool-row').length).toBeGreaterThan(0);
});

it('renders restored sub-agent transcripts beneath the trail', () => {
const entries: ToolTimelineEntry[] = [
{
id: 'subagent:task-y',
name: 'subagent:researcher',
round: 0,
seq: 0,
status: 'success',
subagent: {
taskId: 'task-y',
agentId: 'researcher',
toolCalls: [],
transcript: [{ kind: 'thinking', iteration: 1, text: 'child reasoning trail' }],
},
},
];
const transcript: ProcessingTranscriptItem[] = [
{ kind: 'narration', round: 0, seq: 0, text: 'delegating to a researcher' },
];

renderInStore(<PastTurnInsights entries={entries} transcript={transcript} />);

const subagents = screen.getByTestId('past-turn-subagents');
expect(subagents.textContent).toContain('child reasoning trail');
});

it('falls back to the tool-only timeline for a legacy turn with no transcript', () => {
const entries: ToolTimelineEntry[] = [
{ id: 'c1', name: 'read_file', round: 0, seq: 0, status: 'success' },
];

renderInStore(<PastTurnInsights entries={entries} transcript={[]} />);

// The interleaved transcript view is absent; the tool-only block renders.
expect(screen.queryByTestId('processing-transcript')).toBeNull();
expect(screen.getByTestId('agent-task-insights')).toBeTruthy();
});
});
60 changes: 60 additions & 0 deletions app/src/features/conversations/components/PastTurnInsights.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { ProcessingTranscriptItem, ToolTimelineEntry } from '../../../store/chatRuntimeSlice';
import { formatTimelineEntry } from '../../../utils/toolTimelineFormatting';
import { ProcessingTranscriptView } from './ProcessingTranscriptView';
import { SubagentActivityBlock, ToolTimelineBlock } from './ToolTimelineBlock';

/**
* The collapsed process trail rendered above a PAST (settled) turn's answer on a
* reopened thread — restore-fidelity fix 1.
*
* A restored turn must show the *same* interleaved thoughts + tools view a live
* turn does, not just its tool cards: the persisted `transcript`
* (narration/thinking/tool pointers, hydrated into
* `turnTranscriptsByThread`) drives {@link ProcessingTranscriptView}, so the
* reasoning and narration replay inline exactly where they streamed. Restored
* sub-agent transcripts (fix 4) render beneath as their own activity blocks,
* mirroring the whole-run {@link AgentProcessSourcePanel} body.
*
* Legacy turns persisted before the transcript field existed have no
* `transcript` — they fall back to the tool-only {@link ToolTimelineBlock}, so
* older threads keep rendering unchanged.
*/
export function PastTurnInsights({
entries,
transcript,
}: {
entries: ToolTimelineEntry[];
transcript: ProcessingTranscriptItem[];
}) {
// No reasoning/narration trail persisted (legacy snapshot): render the
// tool-only timeline, which already nests each sub-agent's activity inline.
if (transcript.length === 0) {
return <ToolTimelineBlock entries={entries} />;
}

const subagentEntries = entries.filter(entry => entry.subagent);

return (
<div className="space-y-3">
{/* Interleaved narration + hidden reasoning + grouped tool steps. */}
<ProcessingTranscriptView transcript={transcript} entries={entries} />

{/* Sub-agents — each delegated agent's restored transcript (thoughts +
tool rows), so a reopened turn keeps its sub-agent reasoning, not just
a flat tool row. `ProcessingTranscriptView` shows the spawn as a tool
row but does not nest the child's activity, so surface it here. */}
{subagentEntries.length > 0 ? (
<div className="space-y-3" data-testid="past-turn-subagents">
{subagentEntries.map(entry => (
<div key={entry.id}>
<p className="text-[12px] font-medium text-content-secondary">
{formatTimelineEntry(entry).title}
</p>
<SubagentActivityBlock subagent={entry.subagent!} />
</div>
))}
</div>
) : null}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,6 @@ export function ConversationTimeline({
/>
);
break;
case 'reasoning':
// Reasoning is rendered inside the process/streaming affordances today;
// no standalone element yet.
break;
default:
break;
}
Expand Down
Loading
Loading