feat: add navigable JSON summaries for tool output

Tool JSON output now starts with a compact navigable summary view.
Expandable tool output includes quick open-file and diff actions for changed files.
Reasoning headers strip stray HTML comments, and navigation tools stay compact.
This commit is contained in:
Bohdan Triapitsyn
2026-07-10 14:51:33 +03:00
parent 6c2e657511
commit b3fa19fe3e
23 changed files with 389 additions and 144 deletions
@@ -40,6 +40,8 @@ const EXPANDED_CONTENT_TRANSITION = { duration: 0.2, ease: 'easeOut' as const };
/** Strip common markdown syntax so the header preview reads as plain text. */
const stripMarkdown = (text: string): string =>
text
// Empty HTML comments are frequently appended by model tool wrappers.
.replace(/<!--\s*-->/g, '')
// Fenced code blocks → keep inner text on one line
.replace(/```[\w]*\n?([\s\S]*?)```/g, (_, inner: string) => inner.trim())
// Inline code
@@ -476,84 +478,4 @@ const ReasoningPart = React.memo(({
);
});
type MergedReasoningPartProps = {
parts: Part[];
onContentChange?: (reason?: ContentChangeReason) => void;
messageId: string;
streamPhase?: StreamPhase;
};
/**
* Renders ALL reasoning parts for a message as a single collapsible block,
* merging their text and spanning their combined time range.
* This matches the VSCode Copilot pattern of showing one "Thought" block per turn.
*/
export const MergedReasoningPart = React.memo(({
parts,
onContentChange,
messageId,
streamPhase,
}: MergedReasoningPartProps) => {
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
const mergedText = React.useMemo(() => {
return parts
.map((part) => {
const p = part as PartWithText;
return cleanReasoningText(p.text || p.content || '');
})
.filter((t) => t.length > 0)
.join('\n\n');
}, [parts]);
const mergedTime = React.useMemo(() => {
let earliestStart: number | undefined;
let latestEnd: number | undefined;
for (const part of parts) {
const time = (part as PartWithText).time;
if (typeof time?.start === 'number' && Number.isFinite(time.start)) {
if (earliestStart === undefined || time.start < earliestStart) {
earliestStart = time.start;
}
}
if (typeof time?.end === 'number' && Number.isFinite(time.end)) {
if (latestEnd === undefined || time.end > latestEnd) {
latestEnd = time.end;
}
}
}
return earliestStart !== undefined ? { start: earliestStart, end: latestEnd } : undefined;
}, [parts]);
const canBeStreaming = streamPhase === undefined || streamPhase !== 'completed';
const isStreaming = chatRenderMode === 'live' && canBeStreaming && parts.some(
(part) => typeof (part as PartWithText).time?.end !== 'number',
);
const throttledMergedText = useStreamingTextThrottle({
text: mergedText,
isStreaming,
identityKey: `${messageId}:reasoning-merged`,
});
const blockId = parts[0]?.id ?? `${messageId}-reasoning-merged`;
if (!throttledMergedText.trim()) {
return null;
}
return (
<ReasoningTimelineBlock
text={throttledMergedText}
variant="thinking"
onContentChange={onContentChange}
blockId={blockId}
time={mergedTime}
isStreaming={isStreaming}
/>
);
});
export default ReasoningPart;