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
119 changes: 99 additions & 20 deletions packages/agents-server-ui/src/components/AgentResponse.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Check, Copy, GitFork } from 'lucide-react'
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
Expand All @@ -26,7 +27,7 @@ import { ToolCallView } from './ToolCallView'
import { TimeText } from './TimeText'
import { ThinkingIndicator } from './ThinkingIndicator'
import { ElapsedTime } from './ElapsedTime'
import { ReasoningSection, type ReasoningEntry } from './ReasoningSection'
import { ReasoningBlock, type ReasoningEntry } from './ReasoningSection'
import { TokenUsage } from './TokenUsage'

import { formatElapsedDuration, toMillis } from '../lib/formatTime'
Expand Down Expand Up @@ -305,6 +306,42 @@ function compareLiveRunItems(
return runItemKey(left).localeCompare(runItemKey(right))
}

/**
* One renderable element of a live run — either a text/tool-call item
* or a reasoning block — tagged with its stream order so the two
* streams can be interleaved at the positions they were emitted
* (think → write → call tool → think → write …).
*/
type LiveRenderEntry =
| {
kind: `item`
key: string
order: string | number
item: EntityTimelineRunItem
}
| {
kind: `reasoning`
key: string
order: string | number
reasoning: ReasoningEntry
}

function compareLiveRenderEntries(
left: LiveRenderEntry,
right: LiveRenderEntry
): number {
const orderCompare = compareTimelineOrderValues(left.order, right.order)
if (orderCompare !== 0) return orderCompare
if (left.kind === `item` && right.kind === `item`) {
return compareLiveRunItems(left.item, right.item)
}
// At equal order, reasoning precedes output — the model thinks,
// then writes. Mostly matters for legacy rows that predate
// `_timeline_order` and all coalesce to the same sentinel.
if (left.kind !== right.kind) return left.kind === `reasoning` ? -1 : 1
return left.key.localeCompare(right.key)
}

function liveRunItemsToContentItems(
items: Array<EntityTimelineRunItem>
): Array<EntityTimelineContentItem> {
Expand Down Expand Up @@ -422,26 +459,29 @@ export const AgentResponseLive = memo(function AgentResponseLive({
body?: { content?: string }
summary_title?: string
encrypted?: string
order?: unknown
order?: string | number
}>
)
.slice()
// The live query already orders by `_timeline_order` then key,
// but TanStack's projection isn't guaranteed stable across
// re-mounts — sort by `key` here as a cheap deterministic
// tiebreaker so the section doesn't visibly reflow between
// renders if two rows share an order.
.sort((a, b) => a.key.localeCompare(b.key))
.map<ReasoningEntry>((row) => ({
key: row.key,
order: row.order ?? `~`,
status: row.status,
summary_title: row.summary_title,
encrypted: row.encrypted,
// The projection in `entity-timeline.ts` wraps content under
// `body` (inside a caseWhen) to force include materialization.
// See the comment there.
content: row.body?.content ?? ``,
})),
}))
// Drop rows with nothing to show. The bridge opens a reasoning
// row on `thinking_start` even when no delta ever arrives —
// some providers (e.g. OpenAI codex models) report that the
// model reasoned but never expose the tokens — and an empty
// "Thought" block is pure noise. Encrypted rows stay: they're
// Anthropic redacted thinking, rendered as a placeholder. A
// row that is still streaming appears as soon as its first
// delta lands.
.filter((entry) => entry.content.trim().length > 0 || entry.encrypted),
[reasoningRows]
)
// Token totals are aggregated in the query layer
Expand All @@ -465,6 +505,39 @@ export const AgentResponseLive = memo(function AgentResponseLive({
() => [...items].sort(compareLiveRunItems),
[items]
)
// Interleave reasoning blocks with the run's items by stream order
// so each block renders where the model emitted it — before the
// step's text / tool calls, not lumped above the whole response.
const renderEntries = useMemo<Array<LiveRenderEntry>>(
() =>
[
...sortedItems.map<LiveRenderEntry>((item) => ({
kind: `item`,
key: item.$key,
order: item.text?.order ?? item.toolCall?.order ?? `~`,
item,
})),
...reasoningEntries.map<LiveRenderEntry>((reasoning) => ({
kind: `reasoning`,
key: reasoning.key,
order: reasoning.order,
reasoning,
})),
].sort(compareLiveRenderEntries),
[sortedItems, reasoningEntries]
)
// Expand/collapse state for settled reasoning blocks, keyed by row
// key. Owned here rather than inside `ReasoningBlock` so the user's
// choice survives the block being unmounted and remounted — e.g.
// when the reasoning row briefly disappears from the live query
// while another part of the run updates, or when a virtualizer
// measurement pass replaces the subtree.
const [expandedReasoning, setExpandedReasoning] = useState<
Record<string, boolean>
>({})
const toggleReasoning = useCallback((key: string) => {
setExpandedReasoning((prev) => ({ ...prev, [key]: !prev[key] }))
}, [])
const contentItems = useMemo(
() => liveRunItemsToContentItems(sortedItems),
[sortedItems]
Expand Down Expand Up @@ -539,21 +612,27 @@ export const AgentResponseLive = memo(function AgentResponseLive({

return (
<Stack direction="column" gap={2} className={styles.root}>
{/* Reasoning sits above the answer because providers stream it
first — the model "thinks" then "writes". Collapses on
settle so old turns don't drown out the actual response. */}
<ReasoningSection
entries={reasoningEntries}
isStreaming={isStreaming}
timestamp={timestamp}
/>
{sortedItems.map((item, i) => {
{renderEntries.map((entry) => {
if (entry.kind === `reasoning`) {
return (
<ReasoningBlock
key={entry.key}
entry={entry.reasoning}
isStreaming={isStreaming}
timestamp={timestamp}
expanded={Boolean(expandedReasoning[entry.key])}
onToggle={toggleReasoning}
/>
)
}

const item = entry.item
if (item.text) {
return (
<LiveTextItem
key={item.$key}
item={item.text}
isStreaming={isStreaming && i === sortedItems.length - 1}
isStreaming={isStreaming && item === lastItem}
renderWidth={renderWidth}
/>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
/* Reasoning sits above the agent's visible answer. We want it to read
* as secondary content — never compete with the response — but stay
* legible enough that a curious user can skim it.
/* Reasoning blocks interleave with the agent's text / tool-call items
* at the stream position they were emitted. We want them to read as
* secondary content — never compete with the response — but stay
* legible enough that a curious user can skim them.
*
* Visual hierarchy:
* live → faded markdown body, animated "Thinking" heading
* settled → single muted line, click-to-expand
* redacted → single muted line, no expand
*
* Top/bottom padding matches the agent-response root so the layout
* doesn't shift when the reasoning section disappears post-collapse. */

.root {
margin-inline: auto;
width: max(0px, calc(100% - 24px));
}
* redacted → single muted line, no expand */

.live {
border-left: 2px solid var(--ds-border-2);
Expand Down
57 changes: 15 additions & 42 deletions packages/agents-server-ui/src/components/ReasoningSection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Streamdown } from 'streamdown'
import {
streamdownComponents,
Expand All @@ -20,6 +20,10 @@ import styles from './ReasoningSection.module.css'
*/
export type ReasoningEntry = {
key: string
// Stream position of the reasoning row — same `_timeline_order`
// space as the run's text / tool-call items, so the parent can
// interleave reasoning blocks at the position they were emitted.
order: string | number
content: string
status: `streaming` | `completed`
summary_title?: string
Expand All @@ -46,48 +50,17 @@ export type ReasoningEntry = {
* the model gets it back on the next turn.
*
* Multiple reasoning rows per run are possible — typically one per LLM
* step in a tool-using turn — so we render each independently with its
* own collapse state, in order.
* step in a tool-using turn — so the parent renders one block per row,
* interleaved with the run's text / tool-call items by stream order.
*
* Expand/collapse state is controlled by the parent (keyed by
* `entry.key`) rather than owned here, so the user's choice survives
* this block being unmounted and remounted — e.g. when the reasoning
* row briefly disappears from the live query while another part of
* the run updates, or when a virtualizer measurement pass replaces
* the subtree.
*/
export function ReasoningSection({
entries,
isStreaming,
timestamp,
}: {
entries: Array<ReasoningEntry>
isStreaming: boolean
timestamp?: number | null
}): React.ReactElement | null {
// Owned here rather than inside `ReasoningEntryView` so the user's
// expand/collapse choice survives the entry view being unmounted and
// remounted — e.g. when the reasoning row briefly disappears from
// the live query while another part of the run updates, or when a
// virtualizer measurement pass replaces the subtree.
const [expandedByKey, setExpandedByKey] = useState<Record<string, boolean>>(
{}
)
const toggleExpanded = useCallback((key: string) => {
setExpandedByKey((prev) => ({ ...prev, [key]: !prev[key] }))
}, [])

if (entries.length === 0) return null
return (
<Stack direction="column" gap={2} className={styles.root}>
{entries.map((entry) => (
<ReasoningEntryView
key={entry.key}
entry={entry}
isStreaming={isStreaming}
timestamp={timestamp}
expanded={Boolean(expandedByKey[entry.key])}
onToggle={toggleExpanded}
/>
))}
</Stack>
)
}

function ReasoningEntryView({
export function ReasoningBlock({
entry,
isStreaming,
timestamp,
Expand Down
Loading