feat: enhance task tool rendering with live duration, collapsible output, and unified markdown display
This commit is contained in:
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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.2.1] - 2025-12-16
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -2847,7 +2847,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openchamber-desktop"
|
name = "openchamber-desktop"
|
||||||
version = "1.2.0"
|
version = "1.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
@@ -410,6 +410,8 @@ const streamdownComponents = {
|
|||||||
table: TableWrapper,
|
table: TableWrapper,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MarkdownVariant = 'assistant' | 'tool';
|
||||||
|
|
||||||
interface MarkdownRendererProps {
|
interface MarkdownRendererProps {
|
||||||
content: string;
|
content: string;
|
||||||
part?: Part;
|
part?: Part;
|
||||||
@@ -417,6 +419,7 @@ interface MarkdownRendererProps {
|
|||||||
isAnimated?: boolean;
|
isAnimated?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
|
variant?: MarkdownVariant;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||||
@@ -426,6 +429,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
|||||||
isAnimated = true,
|
isAnimated = true,
|
||||||
className,
|
className,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
|
variant = 'assistant',
|
||||||
}) => {
|
}) => {
|
||||||
const shikiThemes = useMarkdownShikiThemes();
|
const shikiThemes = useMarkdownShikiThemes();
|
||||||
const componentKey = React.useMemo(() => {
|
const componentKey = React.useMemo(() => {
|
||||||
@@ -433,12 +437,16 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
|||||||
return `markdown-${signature}`;
|
return `markdown-${signature}`;
|
||||||
}, [messageId, part?.id]);
|
}, [messageId, part?.id]);
|
||||||
|
|
||||||
|
const streamdownClassName = variant === 'tool'
|
||||||
|
? 'streamdown-content streamdown-tool'
|
||||||
|
: 'streamdown-content';
|
||||||
|
|
||||||
const markdownContent = (
|
const markdownContent = (
|
||||||
<div className={cn('break-words', className)}>
|
<div className={cn('break-words', className)}>
|
||||||
<Streamdown
|
<Streamdown
|
||||||
mode={isStreaming ? 'streaming' : 'static'}
|
mode={isStreaming ? 'streaming' : 'static'}
|
||||||
shikiTheme={shikiThemes}
|
shikiTheme={shikiThemes}
|
||||||
className="streamdown-content"
|
className={streamdownClassName}
|
||||||
controls={{ code: false, table: false }}
|
controls={{ code: false, table: false }}
|
||||||
components={streamdownComponents}
|
components={streamdownComponents}
|
||||||
>
|
>
|
||||||
@@ -461,14 +469,20 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
|||||||
export const SimpleMarkdownRenderer: React.FC<{
|
export const SimpleMarkdownRenderer: React.FC<{
|
||||||
content: string;
|
content: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}> = ({ content, className }) => {
|
variant?: MarkdownVariant;
|
||||||
|
}> = ({ content, className, variant = 'assistant' }) => {
|
||||||
const shikiThemes = useMarkdownShikiThemes();
|
const shikiThemes = useMarkdownShikiThemes();
|
||||||
|
|
||||||
|
const streamdownClassName = variant === 'tool'
|
||||||
|
? 'streamdown-content streamdown-tool'
|
||||||
|
: 'streamdown-content';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('break-words', className)}>
|
<div className={cn('break-words', className)}>
|
||||||
<Streamdown
|
<Streamdown
|
||||||
mode="static"
|
mode="static"
|
||||||
shikiTheme={shikiThemes}
|
shikiTheme={shikiThemes}
|
||||||
className="streamdown-content"
|
className={streamdownClassName}
|
||||||
controls={{ code: false, table: false }}
|
controls={{ code: false, table: false }}
|
||||||
components={streamdownComponents}
|
components={streamdownComponents}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -542,6 +542,9 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
const toolConnections = React.useMemo(() => {
|
const toolConnections = React.useMemo(() => {
|
||||||
const connections: Record<string, { hasPrev: boolean; hasNext: boolean }> = {};
|
const connections: Record<string, { hasPrev: boolean; hasNext: boolean }> = {};
|
||||||
const displayableTools = toolParts.filter((toolPart) => {
|
const displayableTools = toolParts.filter((toolPart) => {
|
||||||
|
if (toolPart.tool === 'task') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (shouldHoldTools) {
|
if (shouldHoldTools) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -577,10 +580,19 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
|
|
||||||
const visibleActivityPartsForTurn = React.useMemo(() => {
|
const visibleActivityPartsForTurn = React.useMemo(() => {
|
||||||
if (!turnGroupingContext) return [];
|
if (!turnGroupingContext) return [];
|
||||||
if (!showReasoningTraces) {
|
|
||||||
return activityPartsForTurn.filter((activity) => activity.kind === 'tool');
|
const base = !showReasoningTraces
|
||||||
}
|
? activityPartsForTurn.filter((activity) => activity.kind === 'tool')
|
||||||
return activityPartsForTurn;
|
: 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]);
|
}, [activityPartsForTurn, showReasoningTraces, turnGroupingContext]);
|
||||||
|
|
||||||
const [hasEverHadMultipleVisibleActivities, setHasEverHadMultipleVisibleActivities] = React.useState(false);
|
const [hasEverHadMultipleVisibleActivities, setHasEverHadMultipleVisibleActivities] = React.useState(false);
|
||||||
@@ -618,6 +630,9 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
|
|
||||||
if (activity.kind === 'tool') {
|
if (activity.kind === 'tool') {
|
||||||
const toolPart = part as ToolPartType;
|
const toolPart = part as ToolPartType;
|
||||||
|
if (toolPart.tool === 'task') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (shouldHoldTools) return;
|
if (shouldHoldTools) return;
|
||||||
if (!isToolFinalized(toolPart)) return;
|
if (!isToolFinalized(toolPart)) return;
|
||||||
} else if (activity.kind === 'reasoning') {
|
} 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<{
|
const partsWithTime: Array<{
|
||||||
part: Part;
|
part: Part;
|
||||||
index: number;
|
index: number;
|
||||||
@@ -708,6 +742,11 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
if (!shouldShowActivityGroup) {
|
if (!shouldShowActivityGroup) {
|
||||||
if (activity.kind === 'tool') {
|
if (activity.kind === 'tool') {
|
||||||
const toolPart = part as ToolPartType;
|
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 toolState = (toolPart as { state?: { time?: { end?: number | null | undefined } | null | undefined } | null | undefined }).state;
|
||||||
const time = toolState?.time;
|
const time = toolState?.time;
|
||||||
const isFinalized = isToolFinalized(toolPart);
|
const isFinalized = isToolFinalized(toolPart);
|
||||||
@@ -766,6 +805,11 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
switch (activity.kind) {
|
switch (activity.kind) {
|
||||||
case 'tool': {
|
case 'tool': {
|
||||||
const toolPart = part as ToolPartType;
|
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 toolState = (toolPart as { state?: { time?: { end?: number | null | undefined } | null | undefined } | null | undefined }).state;
|
||||||
const time = toolState?.time;
|
const time = toolState?.time;
|
||||||
const isFinalized = isToolFinalized(toolPart);
|
const isFinalized = isToolFinalized(toolPart);
|
||||||
@@ -929,6 +973,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
turnGroupingContext,
|
turnGroupingContext,
|
||||||
visibleActivityPartsForTurn,
|
visibleActivityPartsForTurn,
|
||||||
visibleParts,
|
visibleParts,
|
||||||
|
toolParts,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const userMessageId = turnGroupingContext?.turnId;
|
const userMessageId = turnGroupingContext?.turnId;
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import React from 'react';
|
|||||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||||
import { RiBrainAi3Line, RiFileImageLine, RiFilePdfLine, RiFileSearchLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiPencilAiLine, RiSearchLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
|
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 { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import { Streamdown } from 'streamdown';
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
|
||||||
import { toolDisplayStyles } from '@/lib/typography';
|
import { toolDisplayStyles } from '@/lib/typography';
|
||||||
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||||
import {
|
import {
|
||||||
@@ -450,13 +450,8 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
|||||||
|
|
||||||
if (tool === 'task' || tool === 'reasoning') {
|
if (tool === 'task' || tool === 'reasoning') {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={tool === 'reasoning' ? "text-muted-foreground/70" : ""}>
|
||||||
className={tool === 'reasoning' ? "text-muted-foreground/70" : ""}
|
<SimpleMarkdownRenderer content={popup.content} variant="tool" />
|
||||||
style={{ fontSize: tool === 'task' ? 'var(--text-code)' : 'var(--text-meta)' }}
|
|
||||||
>
|
|
||||||
<Streamdown mode="static" className="streamdown-content streamdown-tool">
|
|
||||||
{popup.content}
|
|
||||||
</Streamdown>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||||
import { RiArrowDownSLine, RiArrowRightSLine, RiFileEditLine, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
|
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 { cn } from '@/lib/utils';
|
||||||
|
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
||||||
import { getToolMetadata, getLanguageFromExtension } from '@/lib/toolHelpers';
|
import { getToolMetadata, getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||||
import type { ToolPart as ToolPartType, ToolState as ToolStateUnion } from '@opencode-ai/sdk';
|
import type { ToolPart as ToolPartType, ToolState as ToolStateUnion } from '@opencode-ai/sdk';
|
||||||
import { toolDisplayStyles } from '@/lib/typography';
|
import { toolDisplayStyles } from '@/lib/typography';
|
||||||
@@ -88,14 +88,30 @@ export const getToolIcon = (toolName: string) => {
|
|||||||
return <RiToolsLine className={iconClass} />;
|
return <RiToolsLine className={iconClass} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDuration = (start: number, end?: number) => {
|
const formatDuration = (start: number, end?: number, now: number = Date.now()) => {
|
||||||
const duration = end ? end - start : Date.now() - start;
|
const duration = end ? end - start : now - start;
|
||||||
const seconds = duration / 1000;
|
const seconds = duration / 1000;
|
||||||
|
|
||||||
const displaySeconds = seconds < 0.05 && end !== undefined ? 0.1 : seconds;
|
const displaySeconds = seconds < 0.05 && end !== undefined ? 0.1 : seconds;
|
||||||
return `${displaySeconds.toFixed(1)}s`;
|
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 => {
|
const parseDiffStats = (metadata?: Record<string, unknown>): { added: number; removed: number } | null => {
|
||||||
if (!metadata?.diff || typeof metadata.diff !== 'string') return null;
|
if (!metadata?.diff || typeof metadata.diff !== 'string') return null;
|
||||||
|
|
||||||
@@ -185,6 +201,123 @@ const ToolScrollableSection: React.FC<ToolScrollableSectionProps> = ({
|
|||||||
</ScrollableOverlay>
|
</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 {
|
interface DiffPreviewProps {
|
||||||
diff: string;
|
diff: string;
|
||||||
syntaxTheme: { [key: string]: React.CSSProperties };
|
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||||
@@ -455,10 +588,8 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
|
|||||||
|
|
||||||
if (part.tool === 'task' && hasStringOutput) {
|
if (part.tool === 'task' && hasStringOutput) {
|
||||||
return renderScrollableBlock(
|
return renderScrollableBlock(
|
||||||
<div className="w-full min-w-0" style={{ fontSize: 'var(--text-code)' }}>
|
<div className="w-full min-w-0">
|
||||||
<Streamdown mode="static" className="streamdown-content streamdown-tool">
|
<SimpleMarkdownRenderer content={outputString} variant="tool" />
|
||||||
{outputString}
|
|
||||||
</Streamdown>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -476,10 +607,8 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
|
|||||||
|
|
||||||
if (part.tool === 'codesearch' && hasStringOutput) {
|
if (part.tool === 'codesearch' && hasStringOutput) {
|
||||||
return renderScrollableBlock(
|
return renderScrollableBlock(
|
||||||
<div className="w-full min-w-0" style={{ fontSize: 'var(--text-code)' }}>
|
<div className="w-full min-w-0">
|
||||||
<Streamdown mode="static" className="streamdown-content streamdown-tool">
|
<SimpleMarkdownRenderer content={outputString} variant="tool" />
|
||||||
{outputString}
|
|
||||||
</Streamdown>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -648,25 +777,18 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
|
|||||||
const state = part.state;
|
const state = part.state;
|
||||||
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
|
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
|
||||||
|
|
||||||
|
const isTaskTool = part.tool.toLowerCase() === 'task';
|
||||||
|
|
||||||
const isFinalized = state.status === 'completed' || state.status === 'error';
|
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 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);
|
const previousExpandedRef = React.useRef<boolean | undefined>(isExpanded);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isFinalized) {
|
if (!isFinalized && !isTaskTool) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (previousExpandedRef.current === isExpanded) {
|
if (previousExpandedRef.current === isExpanded) {
|
||||||
@@ -676,11 +798,61 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
|
|||||||
if (typeof isExpanded === 'boolean') {
|
if (typeof isExpanded === 'boolean') {
|
||||||
onContentChange?.('structural');
|
onContentChange?.('structural');
|
||||||
}
|
}
|
||||||
}, [isExpanded, isFinalized, onContentChange]);
|
}, [isExpanded, isFinalized, isTaskTool, onContentChange]);
|
||||||
|
|
||||||
const stateWithData = state as ToolStateWithMetadata;
|
const stateWithData = state as ToolStateWithMetadata;
|
||||||
const metadata = stateWithData.metadata;
|
const metadata = stateWithData.metadata;
|
||||||
const input = stateWithData.input;
|
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 diffStats = (part.tool === 'edit' || part.tool === 'multiedit') ? parseDiffStats(metadata) : null;
|
||||||
const description = getToolDescription(part, state, isMobile, currentDirectory);
|
const description = getToolDescription(part, state, isMobile, currentDirectory);
|
||||||
const displayName = getToolMetadata(part.tool).displayName;
|
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 runtime = React.useContext(RuntimeAPIContext);
|
||||||
|
|
||||||
const handleMainClick = (e: React.MouseEvent) => {
|
const handleMainClick = (e: React.MouseEvent) => {
|
||||||
if (!runtime?.editor) {
|
if (isTaskTool || !runtime?.editor) {
|
||||||
onToggle(part.id);
|
onToggle(part.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -712,7 +884,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isFinalized) {
|
if (!isFinalized && !isTaskTool) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -735,7 +907,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
|
|||||||
isExpanded && 'opacity-0',
|
isExpanded && 'opacity-0',
|
||||||
!isExpanded && !isMobile && 'group-hover/tool: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)}
|
{getToolIcon(part.tool)}
|
||||||
</div>
|
</div>
|
||||||
@@ -753,7 +925,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
|
|||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className="typography-meta font-medium"
|
className="typography-meta font-medium"
|
||||||
style={isError ? { color: 'var(--status-error)' } : {}}
|
style={!isTaskTool && isError ? { color: 'var(--status-error)' } : {}}
|
||||||
>
|
>
|
||||||
{displayName}
|
{displayName}
|
||||||
</span>
|
</span>
|
||||||
@@ -772,16 +944,30 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
|
|||||||
<span style={{ color: 'var(--status-error)' }}>-{diffStats.removed}</span>
|
<span style={{ color: 'var(--status-error)' }}>-{diffStats.removed}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{'time' in state && state.time && (
|
{typeof effectiveTimeStart === 'number' && (
|
||||||
<span className="text-muted-foreground/80 flex-shrink-0">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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
|
<ToolExpandedContent
|
||||||
part={part}
|
part={part}
|
||||||
state={state}
|
state={state}
|
||||||
@@ -791,7 +977,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
|
|||||||
hasPrevTool={hasPrevTool}
|
hasPrevTool={hasPrevTool}
|
||||||
hasNextTool={hasNextTool}
|
hasNextTool={hasNextTool}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Streamdown } from 'streamdown';
|
|
||||||
import { RiCheckLine } from '@remixicon/react';
|
import { RiCheckLine } from '@remixicon/react';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { typography } from '@/lib/typography';
|
import { typography } from '@/lib/typography';
|
||||||
import { formatToolInput, detectToolOutputLanguage } from '@/lib/toolHelpers';
|
import { formatToolInput, detectToolOutputLanguage } from '@/lib/toolHelpers';
|
||||||
|
import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
|
||||||
|
|
||||||
const cleanOutput = (output: string) => {
|
const cleanOutput = (output: string) => {
|
||||||
let cleaned = output.replace(/^<file>\s*\n?/, '').replace(/\n?<\/file>\s*$/, '');
|
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}
|
style={typography.tool.popup}
|
||||||
>
|
>
|
||||||
<Streamdown mode="static" className="streamdown-content streamdown-tool">
|
<SimpleMarkdownRenderer content={output} variant="tool" />
|
||||||
{output}
|
|
||||||
</Streamdown>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1377,6 +1377,37 @@ html:not(.dark) .chat-scroll {
|
|||||||
font-size: inherit !important;
|
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 */
|
/* Code font: IBM Plex Mono */
|
||||||
.streamdown-content code,
|
.streamdown-content code,
|
||||||
.streamdown-content pre {
|
.streamdown-content pre {
|
||||||
|
|||||||
Reference in New Issue
Block a user