feat: improve chat streaming UX and add Mermaid diagram rendering (#438)
* feat: show current branch in empty chat state * fix: display current git branch for worktrees and update them with branch change * refactor: improve read tool output parsing with structured data * feat: add support for message part delta events * fix(chat): improve streaming rendering, scroll behavior, and assistant action visibility * feat: Add mermaid diagram support to chat markdown rendering * fix: update table download functionality to include success notification and remove unused MarkdownRenderer import * refactor: streamline Streamdown component props for improved readability * fix: preserve Streamdown code-block markers and use native Tauri cache clearing * feat: add context overview panel to view conversation details
This commit is contained in:
committed by
GitHub
parent
138772e66e
commit
4d71bb27eb
@@ -329,7 +329,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
||||
const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt);
|
||||
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
|
||||
const hasStopFinish = messageFinish === 'stop';
|
||||
|
||||
|
||||
// TTS for message playback
|
||||
const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS();
|
||||
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
renderWebSearchOutput,
|
||||
formatInputForDisplay,
|
||||
parseDiffToUnified,
|
||||
parseReadToolOutput,
|
||||
type UnifiedDiffHunk,
|
||||
type SideBySideDiffHunk,
|
||||
type SideBySideDiffLine,
|
||||
@@ -740,33 +741,41 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
}
|
||||
|
||||
if (tool === 'read') {
|
||||
const lines = popup.content.split('\n');
|
||||
const parsedReadOutput = parseReadToolOutput(popup.content);
|
||||
|
||||
const inputMeta = popup.metadata?.input;
|
||||
const inputObj = typeof inputMeta === 'object' && inputMeta !== null ? (inputMeta as Record<string, unknown>) : {};
|
||||
const offset = typeof inputObj.offset === 'number' ? inputObj.offset : 0;
|
||||
const limit = typeof inputObj.limit === 'number' ? inputObj.limit : undefined;
|
||||
|
||||
const isInfoMessage = (line: string) => line.trim().startsWith('(');
|
||||
let fallbackLineCursor = offset;
|
||||
const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{lines.map((line: string, idx: number) => {
|
||||
{parsedReadOutput.lines.map((line, idx: number) => {
|
||||
if (line.lineNumber !== null) {
|
||||
fallbackLineCursor = line.lineNumber;
|
||||
}
|
||||
|
||||
const isInfo = isInfoMessage(line);
|
||||
const shouldAssignFallbackLineNumber =
|
||||
parsedReadOutput.type === 'file'
|
||||
&& !hasExplicitLineNumbers
|
||||
&& line.lineNumber === null
|
||||
&& !line.isInfo;
|
||||
|
||||
const lineNumber = offset + idx + 1;
|
||||
const effectiveLineNumber = line.lineNumber ?? (shouldAssignFallbackLineNumber
|
||||
? (fallbackLineCursor += 1)
|
||||
: null);
|
||||
|
||||
const shouldShowLineNumber = !isInfo && (limit === undefined || idx < limit);
|
||||
const shouldShowLineNumber = !line.isInfo && effectiveLineNumber !== null;
|
||||
|
||||
return (
|
||||
<div key={idx} className={`typography-code font-mono flex ${isInfo ? 'text-muted-foreground/70 italic' : ''}`}>
|
||||
<div key={idx} className={`typography-code font-mono flex ${line.isInfo ? 'text-muted-foreground/70 italic' : ''}`}>
|
||||
<span className="w-12 flex-shrink-0 text-right pr-3 select-none border-r mr-3 -my-0.5 py-0.5" style={{ color: 'var(--tools-edit-line-number)', borderColor: 'var(--tools-border)' }}>
|
||||
{shouldShowLineNumber ? lineNumber : ''}
|
||||
{shouldShowLineNumber ? effectiveLineNumber : ''}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
{isInfo ? (
|
||||
<div className="whitespace-pre-wrap break-words">{line}</div>
|
||||
{line.isInfo ? (
|
||||
<div className="whitespace-pre-wrap break-words">{line.text}</div>
|
||||
) : (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
@@ -794,7 +803,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
},
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
{line.text}
|
||||
</SyntaxHighlighter>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
formatEditOutput,
|
||||
detectLanguageFromOutput,
|
||||
formatInputForDisplay,
|
||||
parseReadToolOutput,
|
||||
} from '../toolRenderers';
|
||||
|
||||
type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number } };
|
||||
@@ -808,31 +809,40 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
|
||||
if (hasStringOutput && outputString.trim()) {
|
||||
if (part.tool === 'read') {
|
||||
const formattedOutput = formatEditOutput(outputString, part.tool, metadata);
|
||||
const lines = formattedOutput.split('\n');
|
||||
const parsedReadOutput = parseReadToolOutput(outputString);
|
||||
const offset = typeof input?.offset === 'number' ? input.offset : 0;
|
||||
const limit = typeof input?.limit === 'number' ? input.limit : undefined;
|
||||
const isInfoMessage = (line: string) => line.trim().startsWith('(');
|
||||
const contentForLanguage = parsedReadOutput.lines.map((line) => line.text).join('\n');
|
||||
let fallbackLineCursor = offset;
|
||||
const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null);
|
||||
|
||||
return renderScrollableBlock(
|
||||
<div className="typography-code w-full min-w-0 space-y-1">
|
||||
{lines.map((line: string, idx: number) => {
|
||||
const isInfo = isInfoMessage(line);
|
||||
const lineNumber = offset + idx + 1;
|
||||
const shouldShowLineNumber = !isInfo && (limit === undefined || idx < limit);
|
||||
{parsedReadOutput.lines.map((line, idx) => {
|
||||
if (line.lineNumber !== null) {
|
||||
fallbackLineCursor = line.lineNumber;
|
||||
}
|
||||
const shouldAssignFallbackLineNumber =
|
||||
parsedReadOutput.type === 'file'
|
||||
&& !hasExplicitLineNumbers
|
||||
&& line.lineNumber === null
|
||||
&& !line.isInfo;
|
||||
const effectiveLineNumber = line.lineNumber ?? (shouldAssignFallbackLineNumber
|
||||
? (fallbackLineCursor += 1)
|
||||
: null);
|
||||
const shouldShowLineNumber = !line.isInfo && effectiveLineNumber !== null;
|
||||
|
||||
return (
|
||||
<div key={idx} className={cn('typography-code font-mono flex w-full min-w-0', isInfo && 'text-muted-foreground/70 italic')}>
|
||||
<div key={idx} className={cn('typography-code font-mono flex w-full min-w-0', line.isInfo && 'text-muted-foreground/70 italic')}>
|
||||
<span className="w-10 flex-shrink-0 text-right pr-3 select-none border-r mr-3 -my-0.5 py-0.5" style={{ color: 'var(--tools-edit-line-number)', borderColor: 'var(--tools-border)' }}>
|
||||
{shouldShowLineNumber ? lineNumber : ''}
|
||||
{shouldShowLineNumber ? effectiveLineNumber : ''}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
{isInfo ? (
|
||||
<div className="whitespace-pre-wrap break-words">{line}</div>
|
||||
{line.isInfo ? (
|
||||
<div className="whitespace-pre-wrap break-words">{line.text}</div>
|
||||
) : (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={detectLanguageFromOutput(formattedOutput, part.tool, input as Record<string, unknown>)}
|
||||
language={detectLanguageFromOutput(contentForLanguage, part.tool, input as Record<string, unknown>)}
|
||||
PreTag="div"
|
||||
wrapLines
|
||||
wrapLongLines
|
||||
@@ -855,7 +865,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
},
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
{line.text}
|
||||
</SyntaxHighlighter>
|
||||
)}
|
||||
</div>
|
||||
@@ -1073,6 +1083,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
|
||||
const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity);
|
||||
const justificationText = React.useMemo(() => {
|
||||
if (!showTextJustificationActivity) return null;
|
||||
if (part.tool === 'apply_patch') return null;
|
||||
// Get title or description from state - this is the "yapping" text like "Shows system information"
|
||||
const title = (stateWithData as { title?: string }).title;
|
||||
if (typeof title === 'string' && title.trim().length > 0) {
|
||||
@@ -1083,7 +1094,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
|
||||
return inputDesc;
|
||||
}
|
||||
return null;
|
||||
}, [showTextJustificationActivity, stateWithData, input]);
|
||||
}, [showTextJustificationActivity, part.tool, stateWithData, input]);
|
||||
|
||||
const runtime = React.useContext(RuntimeAPIContext);
|
||||
|
||||
|
||||
@@ -44,6 +44,81 @@ export const formatEditOutput = (output: string, toolName: string, metadata?: Re
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
export interface ParsedReadOutputLine {
|
||||
text: string;
|
||||
lineNumber: number | null;
|
||||
isInfo: boolean;
|
||||
}
|
||||
|
||||
export interface ParsedReadToolOutput {
|
||||
type: 'file' | 'directory' | 'unknown';
|
||||
lines: ParsedReadOutputLine[];
|
||||
}
|
||||
|
||||
export const parseReadToolOutput = (output: string): ParsedReadToolOutput => {
|
||||
const typeMatch = output.match(/<type>(file|directory)<\/type>/i);
|
||||
const detectedType = (typeMatch?.[1]?.toLowerCase() ?? 'unknown') as ParsedReadToolOutput['type'];
|
||||
|
||||
const contentMatch = output.match(/<content>([\s\S]*?)<\/content>/i);
|
||||
const rawContent = contentMatch?.[1] ?? output;
|
||||
const normalizedContent = rawContent.replace(/\r\n/g, '\n');
|
||||
const rawLines = normalizedContent.split('\n');
|
||||
|
||||
const isTruncationInfoLine = (text: string): boolean => {
|
||||
return /\(\s*File has more lines\..*offset.*\)/i.test(text.trim());
|
||||
};
|
||||
|
||||
const parsedLines = rawLines.map((line): ParsedReadOutputLine => {
|
||||
const trimmed = line.trim();
|
||||
const isInfo = (trimmed.startsWith('(') && trimmed.endsWith(')')) || isTruncationInfoLine(trimmed);
|
||||
|
||||
if (detectedType !== 'directory') {
|
||||
const numberedMatch = line.match(/^(\d+):\s?(.*)$/);
|
||||
if (numberedMatch) {
|
||||
const numberedText = numberedMatch[2];
|
||||
const numberedTrimmed = numberedText.trim();
|
||||
const numberedIsInfo =
|
||||
(numberedTrimmed.startsWith('(') && numberedTrimmed.endsWith(')'))
|
||||
|| isTruncationInfoLine(numberedTrimmed);
|
||||
return {
|
||||
lineNumber: numberedIsInfo ? null : Number(numberedMatch[1]),
|
||||
text: numberedText,
|
||||
isInfo: numberedIsInfo,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
lineNumber: null,
|
||||
text: line,
|
||||
isInfo,
|
||||
};
|
||||
});
|
||||
|
||||
const lines = parsedLines.filter((line, index, arr) => {
|
||||
if (line.text.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const prev = arr[index - 1];
|
||||
const next = arr[index + 1];
|
||||
const adjacentToInfo = Boolean(prev?.isInfo || next?.isInfo);
|
||||
const hasNumber = line.lineNumber !== null;
|
||||
|
||||
// Drop numbered blank lines wrapped around helper/info rows.
|
||||
if (adjacentToInfo && hasNumber) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return {
|
||||
type: detectedType,
|
||||
lines,
|
||||
};
|
||||
};
|
||||
|
||||
export const renderListOutput = (output: string, options?: { unstyled?: boolean }) => {
|
||||
try {
|
||||
const lines = output.trim().split('\n').filter(Boolean);
|
||||
|
||||
Reference in New Issue
Block a user