feat: enhance task tool rendering with live duration, collapsible output, and unified markdown display

This commit is contained in:
Bohdan Triapitsyn
2025-12-16 21:31:22 +02:00
parent 2088f69ba4
commit 6adb53fe61
8 changed files with 324 additions and 51 deletions
+4
View File
@@ -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
+1 -1
View File
@@ -2847,7 +2847,7 @@ dependencies = [
[[package]]
name = "openchamber-desktop"
version = "1.2.0"
version = "1.2.1"
dependencies = [
"anyhow",
"axum",
@@ -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<MarkdownRendererProps> = ({
@@ -426,6 +429,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
isAnimated = true,
className,
isStreaming = false,
variant = 'assistant',
}) => {
const shikiThemes = useMarkdownShikiThemes();
const componentKey = React.useMemo(() => {
@@ -433,12 +437,16 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
return `markdown-${signature}`;
}, [messageId, part?.id]);
const streamdownClassName = variant === 'tool'
? 'streamdown-content streamdown-tool'
: 'streamdown-content';
const markdownContent = (
<div className={cn('break-words', className)}>
<Streamdown
mode={isStreaming ? 'streaming' : 'static'}
shikiTheme={shikiThemes}
className="streamdown-content"
className={streamdownClassName}
controls={{ code: false, table: false }}
components={streamdownComponents}
>
@@ -461,14 +469,20 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
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 (
<div className={cn('break-words', className)}>
<Streamdown
mode="static"
shikiTheme={shikiThemes}
className="streamdown-content"
className={streamdownClassName}
controls={{ code: false, table: false }}
components={streamdownComponents}
>
@@ -542,6 +542,9 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
const toolConnections = React.useMemo(() => {
const connections: Record<string, { hasPrev: boolean; hasNext: boolean }> = {};
const displayableTools = toolParts.filter((toolPart) => {
if (toolPart.tool === 'task') {
return false;
}
if (shouldHoldTools) {
return false;
}
@@ -577,10 +580,19 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
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<Omit<MessageBodyProps, 'isUser'>> = ({
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<Omit<MessageBodyProps, 'isUser'>> = ({
);
}
// Task tool: show immediately and update progressively from metadata.
const taskTools = toolParts.filter((toolPart) => toolPart.tool === 'task');
taskTools.forEach((taskPart) => {
rendered.push(
<FadeInOnReveal key={`task-${taskPart.id}`}>
<ToolPart
part={taskPart}
isExpanded={expandedTools.has(taskPart.id)}
onToggle={onToggleTool}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
onContentChange={onContentChange}
hasPrevTool={false}
hasNextTool={false}
/>
</FadeInOnReveal>
);
});
const partsWithTime: Array<{
part: Part;
index: number;
@@ -708,6 +742,11 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
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<Omit<MessageBodyProps, 'isUser'>> = ({
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<Omit<MessageBodyProps, 'isUser'>> = ({
turnGroupingContext,
visibleActivityPartsForTurn,
visibleParts,
toolParts,
]);
const userMessageId = turnGroupingContext?.turnId;
@@ -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<ToolOutputDialogProps> = ({ popup, onOpenChange
if (tool === 'task' || tool === 'reasoning') {
return (
<div
className={tool === 'reasoning' ? "text-muted-foreground/70" : ""}
style={{ fontSize: tool === 'task' ? 'var(--text-code)' : 'var(--text-meta)' }}
>
<Streamdown mode="static" className="streamdown-content streamdown-tool">
{popup.content}
</Streamdown>
<div className={tool === 'reasoning' ? "text-muted-foreground/70" : ""}>
<SimpleMarkdownRenderer content={popup.content} variant="tool" />
</div>
);
}
@@ -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 <RiToolsLine className={iconClass} />;
};
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<string, unknown>): { added: number; removed: number } | null => {
if (!metadata?.diff || typeof metadata.diff !== 'string') return null;
@@ -185,6 +201,123 @@ const ToolScrollableSection: React.FC<ToolScrollableSectionProps> = ({
</ScrollableOverlay>
);
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 <task_metadata>...</task_metadata> block.
return output.replace(/\n*<task_metadata>[\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 (
<div
className={cn(
'relative pr-2 pb-2 pt-2 space-y-2 pl-[1.4375rem]',
'before:absolute before:left-[0.4375rem] before:w-px before:bg-border/80 before:content-[""]',
hasPrevTool ? 'before:top-[-0.45rem]' : 'before:top-[-0.25rem]',
hasNextTool ? 'before:bottom-[-0.6rem]' : 'before:bottom-0'
)}
>
{completedEntries.length > 0 ? (
<ToolScrollableSection maxHeightClass={isExpanded ? 'max-h-[40vh]' : 'max-h-56'} disableHorizontal>
<div className="w-full min-w-0 space-y-1">
{hiddenCount > 0 ? (
<div className="typography-micro text-muted-foreground/70">+{hiddenCount} more</div>
) : 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 (
<div key={entry.id ?? `${toolName}-${idx}`} className="flex items-center gap-2 min-w-0">
<span className="flex-shrink-0 text-foreground/80">{getToolIcon(toolName)}</span>
<span className="typography-meta text-foreground/80 flex-shrink-0">{displayName}</span>
<span className="typography-meta text-muted-foreground/70 truncate">{label}</span>
</div>
);
})}
</div>
</ToolScrollableSection>
) : null}
{hasOutput ? (
<div className={cn('space-y-1', completedEntries.length > 0 && 'pt-1')}
>
<button
type="button"
className="flex items-center gap-2 typography-meta text-foreground/80 hover:text-foreground w-full"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
setIsOutputExpanded((prev) => !prev);
}}
>
{isOutputExpanded ? (
<RiArrowDownSLine className="h-3.5 w-3.5 flex-shrink-0" />
) : (
<RiArrowRightSLine className="h-3.5 w-3.5 flex-shrink-0" />
)}
<span className="typography-meta text-foreground/80 font-medium">Output</span>
</button>
{isOutputExpanded ? (
<ToolScrollableSection maxHeightClass="max-h-[50vh]">
<div className="w-full min-w-0">
<SimpleMarkdownRenderer content={trimmedOutput} variant="tool" />
</div>
</ToolScrollableSection>
) : null}
</div>
) : null}
</div>
);
};
interface DiffPreviewProps {
diff: string;
syntaxTheme: { [key: string]: React.CSSProperties };
@@ -455,10 +588,8 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
if (part.tool === 'task' && hasStringOutput) {
return renderScrollableBlock(
<div className="w-full min-w-0" style={{ fontSize: 'var(--text-code)' }}>
<Streamdown mode="static" className="streamdown-content streamdown-tool">
{outputString}
</Streamdown>
<div className="w-full min-w-0">
<SimpleMarkdownRenderer content={outputString} variant="tool" />
</div>
);
}
@@ -476,10 +607,8 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
if (part.tool === 'codesearch' && hasStringOutput) {
return renderScrollableBlock(
<div className="w-full min-w-0" style={{ fontSize: 'var(--text-code)' }}>
<Streamdown mode="static" className="streamdown-content streamdown-tool">
{outputString}
</Streamdown>
<div className="w-full min-w-0">
<SimpleMarkdownRenderer content={outputString} variant="tool" />
</div>
);
}
@@ -648,25 +777,18 @@ const ToolPart: React.FC<ToolPartProps> = ({ 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<boolean | undefined>(isExpanded);
React.useEffect(() => {
if (!isFinalized) {
if (!isFinalized && !isTaskTool) {
return;
}
if (previousExpandedRef.current === isExpanded) {
@@ -676,11 +798,61 @@ const ToolPart: React.FC<ToolPartProps> = ({ 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<string>(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<TaskToolSummaryEntry[]>(() => {
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<number>(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<ToolPartProps> = ({ 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<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
}
};
if (!isFinalized) {
if (!isFinalized && !isTaskTool) {
return null;
}
@@ -735,7 +907,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ 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)}
</div>
@@ -753,7 +925,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
</div>
<span
className="typography-meta font-medium"
style={isError ? { color: 'var(--status-error)' } : {}}
style={!isTaskTool && isError ? { color: 'var(--status-error)' } : {}}
>
{displayName}
</span>
@@ -772,16 +944,30 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
<span style={{ color: 'var(--status-error)' }}>-{diffStats.removed}</span>
</span>
)}
{'time' in state && state.time && (
{typeof effectiveTimeStart === 'number' && (
<span className="text-muted-foreground/80 flex-shrink-0">
{formatDuration(state.time.start, isFinalized && 'end' in state.time ? state.time.end : currentTime)}
<LiveDuration
start={effectiveTimeStart}
end={typeof effectiveTimeEnd === 'number' ? effectiveTimeEnd : undefined}
active={Boolean(isTaskTool && isActive && typeof effectiveTimeEnd !== 'number')}
/>
</span>
)}
</div>
</div>
{}
{isExpanded && (
{isTaskTool && (taskSummaryEntries.length > 0 || isActive || isFinalized) ? (
<TaskToolSummary
entries={taskSummaryEntries}
isExpanded={isExpanded}
hasPrevTool={hasPrevTool}
hasNextTool={hasNextTool}
output={typeof stateWithData.output === 'string' ? stateWithData.output : undefined}
/>
) : null}
{!isTaskTool && isExpanded ? (
<ToolExpandedContent
part={part}
state={state}
@@ -791,7 +977,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
hasPrevTool={hasPrevTool}
hasNextTool={hasNextTool}
/>
)}
) : null}
</div>
);
};
@@ -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(/^<file>\s*\n?/, '').replace(/\n?<\/file>\s*$/, '');
@@ -352,9 +352,7 @@ export const renderWebSearchOutput = (output: string, _syntaxTheme: { [key: stri
)}
style={typography.tool.popup}
>
<Streamdown mode="static" className="streamdown-content streamdown-tool">
{output}
</Streamdown>
<SimpleMarkdownRenderer content={output} variant="tool" />
</div>
);
} catch {
+31
View File
@@ -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 {