diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index b7079018..67d220d7 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -4,7 +4,7 @@ import type { Part } from '@opencode-ai/sdk/v2'; import UserTextPart from './parts/UserTextPart'; import ToolPart from './parts/ToolPart'; import AssistantTextPart from './parts/AssistantTextPart'; -import ReasoningPart from './parts/ReasoningPart'; +import ReasoningPart, { MergedReasoningPart } from './parts/ReasoningPart'; import { MessageFilesDisplay } from '../FileAttachment'; import { TurnChangedFilesDropdown } from '../TurnChangedFilesDropdown'; import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2'; @@ -46,6 +46,7 @@ import { useSessions } from '@/sync/sync-context'; import { useI18n } from '@/lib/i18n'; import { extractLoopbackUrls } from '@/lib/url'; + const CONTAIN_LAYOUT_STYLE = { contain: 'layout' as const, transform: 'translateZ(0)' }; const MESSAGE_FOOTER_CONTAINER_STYLE = { containerType: 'inline-size' as const, containerName: 'message-footer' }; const INLINE_MESSAGE_ACTIONS_CLASS_NAME = 'mt-2 mb-1 flex items-center justify-start gap-1.5'; @@ -1015,6 +1016,8 @@ const AssistantMessageBody = React.memo(({ const [isPlanDialogOpen, setIsPlanDialogOpen] = React.useState(false); const [isSavingPlan, setIsSavingPlan] = React.useState(false); const chatRenderMode = useUIStore((state) => state.chatRenderMode); + const collapsibleThinkingBlocks = useUIStore((state) => state.collapsibleThinkingBlocks); + const groupReasoningBlocks = useUIStore((state) => state.groupReasoningBlocks); const showSplitAssistantMessageActions = useUIStore((state) => state.showSplitAssistantMessageActions); const isSortedRenderMode = chatRenderMode === 'sorted'; const isMiniChatSurface = chatSurfaceMode === 'mini-chat'; @@ -1506,7 +1509,16 @@ const AssistantMessageBody = React.memo(({ // Flat rendering: iterate parts in natural order. // Group consecutive static tools (read, grep, glob, etc.) into compact rows. // Expandable tools (bash, edit, task) get individual rows. - // Text and reasoning render inline at their natural position. + // Text renders inline at its natural position. + // Reasoning: all reasoning parts for this message are merged into ONE block + // at the position of the first reasoning part (VSCode Copilot pattern). + const flatReasoningParts = visibleParts.filter((p) => { + if (p.type !== 'reasoning') return false; + const a = activityByPart.get(p); + return a?.kind !== 'reasoning'; + }); + let reasoningMergeRendered = false; + let i = 0; while (i < visibleParts.length) { const part = visibleParts[i]; @@ -1553,17 +1565,8 @@ const AssistantMessageBody = React.memo(({ continue; } if (showReasoningTraces) { - if (isSortedRenderMode) { - rendered.push( - - ); - } else { + if (!collapsibleThinkingBlocks) { + // Non-collapsible mode: render thinking blocks as plain text inline. rendered.push( ); + } else if (groupReasoningBlocks) { + // Merged mode (VSCode pattern): one block for all reasoning parts. + if (!reasoningMergeRendered) { + reasoningMergeRendered = true; + rendered.push( + + ); + } + } else { + // Per-part mode: each reasoning block at its natural position. + rendered.push( + + ); } } i++; @@ -1661,6 +1687,8 @@ const AssistantMessageBody = React.memo(({ animatedToolIdsLookup, animateActivityRows, chatRenderMode, + collapsibleThinkingBlocks, + groupReasoningBlocks, collapsedPreviewCount, expandedTools, isMobile, diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx new file mode 100644 index 00000000..8485d181 --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { describe, expect, test } from 'bun:test'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { I18nProvider } from '@/lib/i18n'; +import { ReasoningTimelineBlock } from './ReasoningPart'; + +// A reasoning text whose summary (first 120 chars) fits in the header but +// whose expanded body content should only appear when the disclosure is open. +const LONG_REASONING = + 'First thought about the task at hand and how to approach it carefully.\n' + + 'This second line goes into much deeper detail about the internal reasoning ' + + 'process that should remain hidden in the collapsed header view.'; + +// A long text that should render the collapsible header with a label +const LONG_JUSTIFICATION = + 'Sorting by activity first because the active session needs immediate attention.\n' + + 'Secondary sort by last updated timestamp ensures a stable deterministic ordering ' + + 'when multiple sessions have the same activity state.'; + +describe('ReasoningTimelineBlock', () => { + test('renders reasoning traces behind an accessible collapsed disclosure by default', () => { + const markup = renderToStaticMarkup( + + + , + ); + + // Accessible toggle row is rendered + expect(markup).toContain('role="button"'); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain('aria-label="Expand reasoning trace"'); + + // Summary preview (beginning of text) is visible in the header + expect(markup).toContain('First thought'); + + // Expanded body (MarkdownRenderer) is NOT rendered while collapsed + expect(markup).not.toContain('data-message-text-export-source'); + }); + + test('renders "Justification" label for justification variant when pre-expanded and not streaming', () => { + const markup = renderToStaticMarkup( + + + , + ); + + // Label shown in expanded header should be "Justification" not "Thinking" + expect(markup).toContain('Justification'); + expect(markup).not.toContain('Thinking'); + }); + + test('renders "Thinking" label for thinking variant when pre-expanded and not streaming', () => { + const markup = renderToStaticMarkup( + + + , + ); + + // Label shown in expanded header should be "Thinking" + expect(markup).toContain('Thinking'); + }); + + test('header summary is a truncated excerpt from the beginning', () => { + const markup = renderToStaticMarkup( + + + , + ); + + // Deep body content beyond 120 chars should be cut from the summary span + expect(markup).not.toContain('remain hidden in the collapsed header view'); + // The ellipsis character marks that the text was truncated + expect(markup).toContain('…'); + }); +}); diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx index 60151390..6db40dc4 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx @@ -3,10 +3,10 @@ import type { Part } from '@opencode-ai/sdk/v2'; import { cn } from '@/lib/utils'; import type { ContentChangeReason } from '@/hooks/useChatAutoFollow'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; -import { Icon } from "@/components/icon/Icon"; -import type { IconName } from "@/components/icon/icons"; +import { Icon } from '@/components/icon/Icon'; +import { BusyDots } from './BusyDots'; +import { useI18n } from '@/lib/i18n'; import { useUIStore } from '@/stores/useUIStore'; -import { useDurationTickerNow } from './useDurationTicker'; import { MarkdownRenderer } from '../../MarkdownRenderer'; import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle'; @@ -14,14 +14,6 @@ type PartWithText = Part & { text?: string; content?: string; time?: { start?: n export type ReasoningVariant = 'thinking' | 'justification'; -const variantConfig: Record< - ReasoningVariant, - { label: string; Icon: IconName } -> = { - thinking: { label: 'Thinking', Icon: 'brain-ai-3' }, - justification: { label: 'Justification', Icon: 'chat-ai-3' }, -}; - const cleanReasoningText = (text: string): string => { if (typeof text !== 'string' || text.trim().length === 0) { return ''; @@ -35,39 +27,46 @@ const cleanReasoningText = (text: string): string => { .trim(); }; +const SUMMARY_MAX_CHARS = 80; +const INLINE_THRESHOLD = 120; + +/** Strip common markdown syntax so the header preview reads as plain text. */ +const stripMarkdown = (text: string): string => + text + // Fenced code blocks → keep inner text on one line + .replace(/```[\w]*\n?([\s\S]*?)```/g, (_, inner: string) => inner.trim()) + // Inline code + .replace(/`([^`]+)`/g, '$1') + // Bold + italic (*** / __) + .replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1') + .replace(/_{1,3}([^_]+)_{1,3}/g, '$1') + // Headings (# ## ###) + .replace(/^#{1,6}\s+/gm, '') + // Links [label](url) → label + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + // Blockquote markers + .replace(/^>\s?/gm, '') + // Horizontal rules + .replace(/^[-*_]{3,}\s*$/gm, '') + // Remaining leading/trailing punctuation from stripped markers + .trim(); + const getReasoningSummary = (text: string): string => { if (!text) { return ''; } - const trimmed = text.trim(); - const newlineIndex = trimmed.indexOf('\n'); - const periodIndex = trimmed.indexOf('.'); + // Strip markdown, then collapse all whitespace runs into single spaces. + const flat = stripMarkdown(text).replace(/\s+/g, ' ').trim(); - const cutoffCandidates = [ - newlineIndex >= 0 ? newlineIndex : Infinity, - periodIndex >= 0 ? periodIndex : Infinity, - ]; - const cutoff = Math.min(...cutoffCandidates); - - if (!Number.isFinite(cutoff)) { - return trimmed; + if (flat.length <= SUMMARY_MAX_CHARS) { + return flat; } - return trimmed.substring(0, cutoff).trim(); -}; - -const formatDuration = (start: number, end?: number, now: number = Date.now()): string => { - const duration = end ? end - start : now - start; - const seconds = duration / 1000; - const displaySeconds = seconds < 0.05 && end !== undefined ? 0.1 : seconds; - return `${displaySeconds.toFixed(1)}s`; -}; - -const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> = ({ start, end, active }) => { - const now = useDurationTickerNow(active, 250); - - return <>{formatDuration(start, end, now)}; + // Cut at a word boundary before the limit, then append ellipsis. + const cut = flat.lastIndexOf(' ', SUMMARY_MAX_CHARS); + const end = cut > 0 ? cut : SUMMARY_MAX_CHARS; + return `${flat.substring(0, end).trimEnd()}…`; }; type ReasoningTimelineBlockProps = { @@ -79,7 +78,8 @@ type ReasoningTimelineBlockProps = { showDuration?: boolean; isStreaming?: boolean; actions?: React.ReactNode; - alwaysShowActions?: boolean; + /** Override the initial expanded state. Defaults to `isStreaming`. */ + defaultExpanded?: boolean; }; export const ReasoningTimelineBlock: React.FC = ({ @@ -87,18 +87,44 @@ export const ReasoningTimelineBlock: React.FC = ({ variant, onContentChange, blockId, - time, - showDuration = true, isStreaming = false, actions, - alwaysShowActions = false, + defaultExpanded, }) => { - const [isExpanded, setIsExpanded] = React.useState(false); + const { t } = useI18n(); + const [isExpanded, setIsExpanded] = React.useState(defaultExpanded ?? isStreaming); + const contentId = React.useId(); + const scrollRef = React.useRef(null); + // Track previous isStreaming so the effect only collapses on true→false + // transitions and does NOT override defaultExpanded on initial mount. + const prevIsStreamingRef = React.useRef(isStreaming); const summary = React.useMemo(() => getReasoningSummary(text), [text]); - const { label, Icon: iconName } = variantConfig[variant]; - const timeStart = typeof time?.start === 'number' && Number.isFinite(time.start) ? time.start : undefined; - const timeEnd = typeof time?.end === 'number' && Number.isFinite(time.end) ? time.end : undefined; + const toggleAriaLabel = isExpanded + ? t('chat.reasoningTrace.collapseAria') + : t('chat.reasoningTrace.expandAria'); + + const handleToggle = React.useCallback(() => { + setIsExpanded((prev) => !prev); + onContentChange?.('structural'); + }, [onContentChange]); + + const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleToggle(); + } + }, [handleToggle]); + + React.useEffect(() => { + const wasStreaming = prevIsStreamingRef.current; + prevIsStreamingRef.current = isStreaming; + // Auto-collapse only when streaming ends (true → false). + // Do not fire on mount so that defaultExpanded is respected. + if (wasStreaming && !isStreaming) { + setIsExpanded(false); + } + }, [isStreaming]); React.useEffect(() => { if (text.trim().length === 0) { @@ -107,70 +133,136 @@ export const ReasoningTimelineBlock: React.FC = ({ onContentChange?.('structural'); }, [onContentChange, text]); + React.useEffect(() => { + if (isStreaming && isExpanded && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [text, isStreaming, isExpanded]); + if (!text || text.trim().length === 0) { return null; } + const isShort = !isStreaming && text.trim().length < INLINE_THRESHOLD; + + // Short blocks: render content directly without a collapsible toggle. + if (isShort) { + return ( +
+
+ +
+ {actions ? ( +
+
+ {actions} +
+
+ ) : null} +
+ ); + } + return (
setIsExpanded((prev) => !prev)} + onClick={handleToggle} + onKeyDown={handleKeyDown} > -
-
+
+
- +
{isExpanded ? : }
- {label} + + {isStreaming ? ( + + {t('chat.reasoningTrace.reasoning')} + + + ) : isExpanded ? ( + + {t(variant === 'justification' ? 'chat.reasoningTrace.justification' : 'chat.reasoningTrace.thinking')} + + ) : ( + + {t(variant === 'justification' ? 'chat.reasoningTrace.justification' : 'chat.reasoningTrace.thinking')} + + )}
- {(summary || (showDuration && typeof timeStart === 'number')) ? ( -
- {summary ? {summary} : null} - {showDuration && typeof timeStart === 'number' ? ( - - - - - - ) : null} -
- ) : null} +
+ {!isStreaming && !isExpanded && summary ? ( + + {summary} + + ) : ( + + )} +
+ {/* Expanded content — left border matching ToolPart */} {isExpanded && (
+