fix(chat): render daily-mode thinking and tool calls in true event order

The stream, the DB and the grouped event array all carry a truthful
interleaving — the backend closes the open thinking segment before every tool
call precisely so each ReAct round can stand on its own. The final render then
threw that away: two filter() calls merged every thinking fragment into one
block at the top and dropped all tool rows below it, so reasoning that happened
after a search read as if it happened before.

Replace the filters with an in-order walk that folds only ADJACENT thinking
fragments (task mode's DeepStepGroup algorithm), leaving each tool call where
it occurred; the rail connector now follows "is there a next row" regardless of
kind. Adjacent rounds join with a paragraph break — they are complete passages,
not per-frame chunks. A just-opened live segment with no text yet is skipped so
the connector never points at a row that renders nothing. In-place completion
updates and collapse/reopen were already order-safe; backend untouched.
This commit is contained in:
dolphin
2026-07-31 18:53:13 +08:00
parent 2901c03087
commit b608efd6b8
@@ -130,27 +130,37 @@ const DeepThinkingGroup: FC<DeepThinkingGroupProps> = memo(
setIsExpanded((prev) => !prev);
}, []);
// Pull thinking content (concat) for the inner ThinkingContent block.
const reasoning = useMemo(
() =>
events
.filter(
(e): e is Extract<AgentEvent, { type: "thinking" }> =>
e.type === "thinking",
)
.map((e) => e.content)
.join("\n\n"),
[events],
);
const toolCalls = useMemo(
() =>
events.filter(
(e): e is Extract<AgentEvent, { type: "tool_call" }> =>
e.type === "tool_call",
),
[events],
);
// Walk the events in arrival order, folding only ADJACENT thinking
// fragments into one passage. The wire and the DB already interleave
// thinking and tool calls truthfully (the backend closes the open
// thinking segment before every tool call), so the render must not
// regroup them: reasoning that happened after a search belongs below
// that search, not merged into the block above it. Mirrors task mode's
// DeepStepGroup.buildSegments.
const segments = useMemo(() => {
type Segment =
| { kind: "thinking"; key: string; content: string }
| { kind: "tool"; key: string; toolCall: Extract<AgentEvent, { type: "tool_call" }> };
const out: Segment[] = [];
events.forEach((ev, i) => {
if (ev.type === "thinking") {
// A just-opened live segment has no text yet — skip it so the
// connector chain never points at a row that renders nothing.
if (!ev.content) return;
const last = out[out.length - 1];
if (last?.kind === "thinking") {
// Adjacent rounds are distinct closed passages (unlike task
// mode's per-frame chunks), so a paragraph break is right.
last.content += `\n\n${ev.content}`;
} else {
out.push({ kind: "thinking", key: `think-${i}`, content: ev.content });
}
} else if (ev.type === "tool_call") {
out.push({ kind: "tool", key: ev.tool_call_id || `tc-${i}`, toolCall: ev });
}
});
return out;
}, [events]);
return (
<div className="flex w-full min-w-0 flex-col gap-3">
@@ -170,17 +180,23 @@ const DeepThinkingGroup: FC<DeepThinkingGroupProps> = memo(
style={{ gridTemplateRows: isExpanded ? "1fr" : "0fr" }}
>
<div className="overflow-hidden flex flex-col gap-2">
<ThinkingContent
reasoning={reasoning}
showConnector={!!reasoning && toolCalls.length > 0}
/>
{toolCalls.map((tc, i) => (
<ToolCallDisplay
key={tc.tool_call_id || `tc-${i}`}
toolCall={tc}
showConnector={i < toolCalls.length - 1}
/>
))}
{segments.map((seg, i) => {
// Connector runs to the next row, whatever kind it is.
const hasNext = i < segments.length - 1;
return seg.kind === "thinking" ? (
<ThinkingContent
key={seg.key}
reasoning={seg.content}
showConnector={hasNext}
/>
) : (
<ToolCallDisplay
key={seg.key}
toolCall={seg.toolCall}
showConnector={hasNext}
/>
);
})}
</div>
</div>
</div>