From 6adb53fe6147e27a55e59fa76916d3c3b53b0c0d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 16 Dec 2025 21:31:22 +0200 Subject: [PATCH] feat: enhance task tool rendering with live duration, collapsible output, and unified markdown display --- CHANGELOG.md | 4 + packages/desktop/src-tauri/Cargo.lock | 2 +- .../src/components/chat/MarkdownRenderer.tsx | 20 +- .../components/chat/message/MessageBody.tsx | 53 +++- .../chat/message/ToolOutputDialog.tsx | 11 +- .../chat/message/parts/ToolPart.tsx | 248 +++++++++++++++--- .../components/chat/message/toolRenderers.tsx | 6 +- packages/ui/src/index.css | 31 +++ 8 files changed, 324 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7475398b..a1098d50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Task tool now renders progressively with live duration, completed sub-tools summary, and collapsible output +- Unified markdown rendering between assistant messages and tool outputs (syntax highlighting, code/table controls) +- Reduced markdown header sizes for better visual balance + ## [1.2.1] - 2025-12-16 diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 66afdfb0..1e3fa607 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2847,7 +2847,7 @@ dependencies = [ [[package]] name = "openchamber-desktop" -version = "1.2.0" +version = "1.2.1" dependencies = [ "anyhow", "axum", diff --git a/packages/ui/src/components/chat/MarkdownRenderer.tsx b/packages/ui/src/components/chat/MarkdownRenderer.tsx index a32f468b..07cfe643 100644 --- a/packages/ui/src/components/chat/MarkdownRenderer.tsx +++ b/packages/ui/src/components/chat/MarkdownRenderer.tsx @@ -410,6 +410,8 @@ const streamdownComponents = { table: TableWrapper, }; +export type MarkdownVariant = 'assistant' | 'tool'; + interface MarkdownRendererProps { content: string; part?: Part; @@ -417,6 +419,7 @@ interface MarkdownRendererProps { isAnimated?: boolean; className?: string; isStreaming?: boolean; + variant?: MarkdownVariant; } export const MarkdownRenderer: React.FC = ({ @@ -426,6 +429,7 @@ export const MarkdownRenderer: React.FC = ({ isAnimated = true, className, isStreaming = false, + variant = 'assistant', }) => { const shikiThemes = useMarkdownShikiThemes(); const componentKey = React.useMemo(() => { @@ -433,12 +437,16 @@ export const MarkdownRenderer: React.FC = ({ return `markdown-${signature}`; }, [messageId, part?.id]); + const streamdownClassName = variant === 'tool' + ? 'streamdown-content streamdown-tool' + : 'streamdown-content'; + const markdownContent = (
@@ -461,14 +469,20 @@ export const MarkdownRenderer: React.FC = ({ export const SimpleMarkdownRenderer: React.FC<{ content: string; className?: string; -}> = ({ content, className }) => { + variant?: MarkdownVariant; +}> = ({ content, className, variant = 'assistant' }) => { const shikiThemes = useMarkdownShikiThemes(); + + const streamdownClassName = variant === 'tool' + ? 'streamdown-content streamdown-tool' + : 'streamdown-content'; + return (
diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index cfc228e6..3395fe46 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -542,6 +542,9 @@ const AssistantMessageBody: React.FC> = ({ const toolConnections = React.useMemo(() => { const connections: Record = {}; const displayableTools = toolParts.filter((toolPart) => { + if (toolPart.tool === 'task') { + return false; + } if (shouldHoldTools) { return false; } @@ -577,10 +580,19 @@ const AssistantMessageBody: React.FC> = ({ const visibleActivityPartsForTurn = React.useMemo(() => { if (!turnGroupingContext) return []; - if (!showReasoningTraces) { - return activityPartsForTurn.filter((activity) => activity.kind === 'tool'); - } - return activityPartsForTurn; + + const base = !showReasoningTraces + ? activityPartsForTurn.filter((activity) => activity.kind === 'tool') + : activityPartsForTurn; + + // Task tool gets its own progressive card (not part of Activity group). + return base.filter((activity) => { + if (activity.kind !== 'tool') { + return true; + } + const toolName = (activity.part as ToolPartType).tool; + return !(typeof toolName === 'string' && toolName.toLowerCase() === 'task'); + }); }, [activityPartsForTurn, showReasoningTraces, turnGroupingContext]); const [hasEverHadMultipleVisibleActivities, setHasEverHadMultipleVisibleActivities] = React.useState(false); @@ -618,6 +630,9 @@ const AssistantMessageBody: React.FC> = ({ if (activity.kind === 'tool') { const toolPart = part as ToolPartType; + if (toolPart.tool === 'task') { + return; + } if (shouldHoldTools) return; if (!isToolFinalized(toolPart)) return; } else if (activity.kind === 'reasoning') { @@ -685,6 +700,25 @@ const AssistantMessageBody: React.FC> = ({ ); } + // Task tool: show immediately and update progressively from metadata. + const taskTools = toolParts.filter((toolPart) => toolPart.tool === 'task'); + taskTools.forEach((taskPart) => { + rendered.push( + + + + ); + }); + const partsWithTime: Array<{ part: Part; index: number; @@ -708,6 +742,11 @@ const AssistantMessageBody: React.FC> = ({ if (!shouldShowActivityGroup) { if (activity.kind === 'tool') { const toolPart = part as ToolPartType; + + if (toolPart.tool === 'task') { + return; + } + const toolState = (toolPart as { state?: { time?: { end?: number | null | undefined } | null | undefined } | null | undefined }).state; const time = toolState?.time; const isFinalized = isToolFinalized(toolPart); @@ -766,6 +805,11 @@ const AssistantMessageBody: React.FC> = ({ switch (activity.kind) { case 'tool': { const toolPart = part as ToolPartType; + + if (toolPart.tool === 'task') { + break; + } + const toolState = (toolPart as { state?: { time?: { end?: number | null | undefined } | null | undefined } | null | undefined }).state; const time = toolState?.time; const isFinalized = isToolFinalized(toolPart); @@ -929,6 +973,7 @@ const AssistantMessageBody: React.FC> = ({ turnGroupingContext, visibleActivityPartsForTurn, visibleParts, + toolParts, ]); const userMessageId = turnGroupingContext?.turnId; diff --git a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx index 0b1dbf6e..3af50a52 100644 --- a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx +++ b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx @@ -2,9 +2,9 @@ import React from 'react'; import { Dialog, DialogContent } from '@/components/ui/dialog'; import { RiBrainAi3Line, RiFileImageLine, RiFilePdfLine, RiFileSearchLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiPencilAiLine, RiSearchLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { Streamdown } from 'streamdown'; import { cn } from '@/lib/utils'; +import { SimpleMarkdownRenderer } from '../MarkdownRenderer'; import { toolDisplayStyles } from '@/lib/typography'; import { getLanguageFromExtension } from '@/lib/toolHelpers'; import { @@ -450,13 +450,8 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange if (tool === 'task' || tool === 'reasoning') { return ( -
- - {popup.content} - +
+
); } diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 4159f490..b41c02a6 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -2,8 +2,8 @@ import React from 'react'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import { RiArrowDownSLine, RiArrowRightSLine, RiFileEditLine, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react'; -import { Streamdown } from 'streamdown'; import { cn } from '@/lib/utils'; +import { SimpleMarkdownRenderer } from '../../MarkdownRenderer'; import { getToolMetadata, getLanguageFromExtension } from '@/lib/toolHelpers'; import type { ToolPart as ToolPartType, ToolState as ToolStateUnion } from '@opencode-ai/sdk'; import { toolDisplayStyles } from '@/lib/typography'; @@ -88,14 +88,30 @@ export const getToolIcon = (toolName: string) => { return ; }; -const formatDuration = (start: number, end?: number) => { - const duration = end ? end - start : Date.now() - start; +const formatDuration = (start: number, end?: number, now: number = Date.now()) => { + 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, setNow] = React.useState(() => Date.now()); + + React.useEffect(() => { + if (!active) { + return; + } + const timer = window.setInterval(() => { + setNow(Date.now()); + }, 100); + return () => window.clearInterval(timer); + }, [active]); + + return <>{formatDuration(start, end, now)}; +}; + const parseDiffStats = (metadata?: Record): { added: number; removed: number } | null => { if (!metadata?.diff || typeof metadata.diff !== 'string') return null; @@ -185,6 +201,123 @@ const ToolScrollableSection: React.FC = ({ ); +type TaskToolSummaryEntry = { + id?: string; + tool?: string; + state?: { + status?: string; + title?: string; + }; +}; + +const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => { + const title = entry.state?.title; + if (typeof title === 'string' && title.trim().length > 0) { + return title; + } + if (typeof entry.tool === 'string' && entry.tool.trim().length > 0) { + return entry.tool; + } + return 'tool'; +}; + +const stripTaskMetadataFromOutput = (output: string): string => { + // OpenCode appends a non-user-facing session marker for task tools. + // Strip only a trailing ... block. + return output.replace(/\n*[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd(); +}; + +const TaskToolSummary: React.FC<{ + entries: TaskToolSummaryEntry[]; + isExpanded: boolean; + hasPrevTool: boolean; + hasNextTool: boolean; + output?: string; +}> = ({ entries, isExpanded, hasPrevTool, hasNextTool, output }) => { + const completedEntries = React.useMemo(() => { + return entries.filter((entry) => entry.state?.status === 'completed'); + }, [entries]); + + const trimmedOutput = typeof output === 'string' + ? stripTaskMetadataFromOutput(output) + : ''; + const hasOutput = trimmedOutput.length > 0; + const [isOutputExpanded, setIsOutputExpanded] = React.useState(false); + + if (completedEntries.length === 0 && !hasOutput) { + return null; + } + + const visibleEntries = isExpanded ? completedEntries : completedEntries.slice(-6); + const hiddenCount = Math.max(0, completedEntries.length - visibleEntries.length); + + return ( +
+ {completedEntries.length > 0 ? ( + +
+ {hiddenCount > 0 ? ( +
+{hiddenCount} more…
+ ) : null} + + {visibleEntries.map((entry, idx) => { + const toolName = typeof entry.tool === 'string' && entry.tool.trim().length > 0 ? entry.tool : 'tool'; + const label = getTaskSummaryLabel(entry); + + const displayName = getToolMetadata(toolName).displayName; + + return ( +
+ {getToolIcon(toolName)} + {displayName} + {label} +
+ ); + })} +
+
+ ) : null} + + {hasOutput ? ( +
0 && 'pt-1')} + > + + + {isOutputExpanded ? ( + +
+ +
+
+ ) : null} +
+ ) : null} +
+ ); +}; + interface DiffPreviewProps { diff: string; syntaxTheme: { [key: string]: React.CSSProperties }; @@ -455,10 +588,8 @@ const ToolExpandedContent: React.FC = ({ if (part.tool === 'task' && hasStringOutput) { return renderScrollableBlock( -
- - {outputString} - +
+
); } @@ -476,10 +607,8 @@ const ToolExpandedContent: React.FC = ({ if (part.tool === 'codesearch' && hasStringOutput) { return renderScrollableBlock( -
- - {outputString} - +
+
); } @@ -648,25 +777,18 @@ const ToolPart: React.FC = ({ part, isExpanded, onToggle, syntaxT const state = part.state; const currentDirectory = useDirectoryStore((s) => s.currentDirectory); + const isTaskTool = part.tool.toLowerCase() === 'task'; + const isFinalized = state.status === 'completed' || state.status === 'error'; - const isRunning = state.status === 'running'; + const isActive = state.status === 'running' || state.status === 'pending'; const isError = state.status === 'error'; - const [currentTime, setCurrentTime] = React.useState(Date.now()); - React.useEffect(() => { - if (isRunning) { - const timer = setInterval(() => { - setCurrentTime(Date.now()); - }, 100); - return () => clearInterval(timer); - } - }, [isRunning]); const previousExpandedRef = React.useRef(isExpanded); React.useEffect(() => { - if (!isFinalized) { + if (!isFinalized && !isTaskTool) { return; } if (previousExpandedRef.current === isExpanded) { @@ -676,11 +798,61 @@ const ToolPart: React.FC = ({ part, isExpanded, onToggle, syntaxT if (typeof isExpanded === 'boolean') { onContentChange?.('structural'); } - }, [isExpanded, isFinalized, onContentChange]); + }, [isExpanded, isFinalized, isTaskTool, onContentChange]); const stateWithData = state as ToolStateWithMetadata; const metadata = stateWithData.metadata; const input = stateWithData.input; + const time = stateWithData.time; + + // Pin start/end so a server-side time reset doesn't reset UI duration. + const pinnedTaskTimeRef = React.useRef<{ start?: number; end?: number }>({}); + const lastPinnedTaskIdRef = React.useRef(part.id); + + if (lastPinnedTaskIdRef.current !== part.id) { + lastPinnedTaskIdRef.current = part.id; + pinnedTaskTimeRef.current = {}; + } + + if (isTaskTool) { + if (typeof time?.start === 'number') { + const pinnedStart = pinnedTaskTimeRef.current.start; + if (typeof pinnedStart !== 'number' || time.start < pinnedStart) { + pinnedTaskTimeRef.current.start = time.start; + } + } + if (typeof time?.end === 'number') { + pinnedTaskTimeRef.current.end = time.end; + } + } + + const effectiveTimeStart = isTaskTool ? (pinnedTaskTimeRef.current.start ?? time?.start) : time?.start; + const effectiveTimeEnd = isTaskTool ? (pinnedTaskTimeRef.current.end ?? time?.end) : time?.end; + + const taskSummaryEntries = React.useMemo(() => { + if (!isTaskTool) { + return []; + } + const candidate = (metadata as { summary?: unknown } | undefined)?.summary; + if (!Array.isArray(candidate)) { + return []; + } + return candidate.filter((entry): entry is TaskToolSummaryEntry => typeof entry === 'object' && entry !== null) as TaskToolSummaryEntry[]; + }, [isTaskTool, metadata]); + + + const taskSummaryLenRef = React.useRef(taskSummaryEntries.length); + React.useEffect(() => { + if (!isTaskTool) { + return; + } + if (taskSummaryLenRef.current === taskSummaryEntries.length) { + return; + } + taskSummaryLenRef.current = taskSummaryEntries.length; + onContentChange?.('structural'); + }, [isTaskTool, onContentChange, taskSummaryEntries.length]); + const diffStats = (part.tool === 'edit' || part.tool === 'multiedit') ? parseDiffStats(metadata) : null; const description = getToolDescription(part, state, isMobile, currentDirectory); const displayName = getToolMetadata(part.tool).displayName; @@ -688,7 +860,7 @@ const ToolPart: React.FC = ({ part, isExpanded, onToggle, syntaxT const runtime = React.useContext(RuntimeAPIContext); const handleMainClick = (e: React.MouseEvent) => { - if (!runtime?.editor) { + if (isTaskTool || !runtime?.editor) { onToggle(part.id); return; } @@ -712,7 +884,7 @@ const ToolPart: React.FC = ({ part, isExpanded, onToggle, syntaxT } }; - if (!isFinalized) { + if (!isFinalized && !isTaskTool) { return null; } @@ -735,7 +907,7 @@ const ToolPart: React.FC = ({ part, isExpanded, onToggle, syntaxT isExpanded && 'opacity-0', !isExpanded && !isMobile && 'group-hover/tool:opacity-0' )} - style={isError ? { color: 'var(--status-error)' } : {}} + style={!isTaskTool && isError ? { color: 'var(--status-error)' } : {}} > {getToolIcon(part.tool)}
@@ -753,7 +925,7 @@ const ToolPart: React.FC = ({ part, isExpanded, onToggle, syntaxT
{displayName} @@ -772,16 +944,30 @@ const ToolPart: React.FC = ({ part, isExpanded, onToggle, syntaxT -{diffStats.removed} )} - {'time' in state && state.time && ( + {typeof effectiveTimeStart === 'number' && ( - {formatDuration(state.time.start, isFinalized && 'end' in state.time ? state.time.end : currentTime)} + )}
{} - {isExpanded && ( + {isTaskTool && (taskSummaryEntries.length > 0 || isActive || isFinalized) ? ( + + ) : null} + + {!isTaskTool && isExpanded ? ( = ({ part, isExpanded, onToggle, syntaxT hasPrevTool={hasPrevTool} hasNextTool={hasNextTool} /> - )} + ) : null}
); }; diff --git a/packages/ui/src/components/chat/message/toolRenderers.tsx b/packages/ui/src/components/chat/message/toolRenderers.tsx index ef82c597..bac9d60e 100644 --- a/packages/ui/src/components/chat/message/toolRenderers.tsx +++ b/packages/ui/src/components/chat/message/toolRenderers.tsx @@ -1,9 +1,9 @@ -import { Streamdown } from 'streamdown'; import { RiCheckLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { typography } from '@/lib/typography'; import { formatToolInput, detectToolOutputLanguage } from '@/lib/toolHelpers'; +import { SimpleMarkdownRenderer } from '../MarkdownRenderer'; const cleanOutput = (output: string) => { let cleaned = output.replace(/^\s*\n?/, '').replace(/\n?<\/file>\s*$/, ''); @@ -352,9 +352,7 @@ export const renderWebSearchOutput = (output: string, _syntaxTheme: { [key: stri )} style={typography.tool.popup} > - - {output} - + ); } catch { diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index a5e238a8..2567eac4 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -1377,6 +1377,37 @@ html:not(.dark) .chat-scroll { font-size: inherit !important; } +/* Streamdown headers: keep proportional but not oversized */ +.streamdown-content h1 { + font-size: 1.125em; + font-weight: 600; + margin-top: 1em; + margin-bottom: 0.5em; +} + +.streamdown-content h2 { + font-size: 1.0625em; + font-weight: 600; + margin-top: 0.875em; + margin-bottom: 0.375em; +} + +.streamdown-content h3 { + font-size: 1em; + font-weight: 600; + margin-top: 0.75em; + margin-bottom: 0.25em; +} + +.streamdown-content h4, +.streamdown-content h5, +.streamdown-content h6 { + font-size: 1em; + font-weight: 600; + margin-top: 0.625em; + margin-bottom: 0.25em; +} + /* Code font: IBM Plex Mono */ .streamdown-content code, .streamdown-content pre {