feat: minor UI adjustments
This commit is contained in:
@@ -398,12 +398,99 @@
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.message-tool-result-title,
|
||||
.message-tool-summary {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.message-tool-summary {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.message-tool-summary-prefix {
|
||||
white-space: pre;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.message-tool-summary-content {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.message-tool-summary-content > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.message-tool-summary-content > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.message-performance-info {
|
||||
margin-top: 8px;
|
||||
font-size: 10px;
|
||||
color: #909090;
|
||||
}
|
||||
|
||||
.tool-call-code-block {
|
||||
position: relative;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.tool-call-code-block-inner {
|
||||
overflow: hidden;
|
||||
transition: max-height 0.24s ease;
|
||||
}
|
||||
|
||||
.tool-call-code-block-inner > div {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.tool-call-code-block-inner pre {
|
||||
margin: 0 !important;
|
||||
overflow-x: auto !important;
|
||||
overflow-y: hidden !important;
|
||||
}
|
||||
|
||||
.tool-call-code-block-toggle {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool-call-code-block-toggle-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px 12px 10px;
|
||||
color: #d7e7f6;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tool-call-code-block-toggle:hover .tool-call-code-block-toggle-content {
|
||||
color: #f0f7ff;
|
||||
}
|
||||
|
||||
.tool-call-code-block-fade {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(180deg, rgba(26, 26, 26, 0) 0%, rgba(26, 26, 26, 0.92) 58%, #1a1a1a 100%);
|
||||
}
|
||||
|
||||
.message-divider-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -527,7 +527,10 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
performanceInfo={message.performanceInfo}
|
||||
toolName={message.toolName}
|
||||
toolSuccess={message.toolSuccess}
|
||||
toolRawResult={message.toolRawResult}
|
||||
toolResultDisplayContent={message.toolResultDisplayContent}
|
||||
todoSnapshot={message.todoSnapshot}
|
||||
isToolCallMessage={message.isToolCallMessage}
|
||||
onAbort={message.isStreaming ? handleAbort : undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { TodoItem } from '../../agent/core/todo';
|
||||
describe('AssistantMessage', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -95,6 +96,68 @@ describe('AssistantMessage', () => {
|
||||
expect(codeElement).toHaveTextContent('const value = 1;');
|
||||
});
|
||||
|
||||
it('keeps non-tool-call code blocks rendered without the expander', () => {
|
||||
const { container } = render(
|
||||
<AssistantMessage content={'```json\n{"foo": 1}\n```'} />
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="tool-call-code-block"]')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Click to expand' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses long tool-call code blocks and expands them in place', () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(280);
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 0;
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<div className="chatbox-messages">
|
||||
<AssistantMessage
|
||||
content={'🔧 **Calling tool: add_notes**\n\n```json\n{\n "notes": []\n}\n```'}
|
||||
isToolCallMessage={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const toolCallBlock = screen.getByTestId('tool-call-code-block');
|
||||
const toolCallInner = container.querySelector('.tool-call-code-block-inner') as HTMLDivElement;
|
||||
|
||||
expect(toolCallBlock).toBeInTheDocument();
|
||||
expect(toolCallInner.style.maxHeight).toBe('200px');
|
||||
expect(screen.getByRole('button', { name: 'Click to expand' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Click to expand' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Click to collapse' })).toBeInTheDocument();
|
||||
expect(toolCallInner.style.maxHeight).toBe('280px');
|
||||
});
|
||||
|
||||
it('preserves chat scroll position when expanding a tool-call block', () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(320);
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 0;
|
||||
});
|
||||
|
||||
render(
|
||||
<div className="chatbox-messages">
|
||||
<AssistantMessage
|
||||
content={'🔧 **Calling tool: add_notes**\n\n```json\n{\n "notes": []\n}\n```'}
|
||||
isToolCallMessage={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const scrollContainer = document.querySelector('.chatbox-messages') as HTMLDivElement;
|
||||
scrollContainer.scrollTop = 96;
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Click to expand' }));
|
||||
|
||||
expect(scrollContainer.scrollTop).toBe(96);
|
||||
});
|
||||
|
||||
it('renders compacting and compacted messages as divider banners', () => {
|
||||
const { rerender, container } = render(
|
||||
<AssistantMessage content="Compacting Conversation" />
|
||||
@@ -133,4 +196,38 @@ describe('AssistantMessage', () => {
|
||||
expect(screen.getByText('Working on: Writing harmony')).toBeInTheDocument();
|
||||
expect(screen.queryByText('fallback content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the add_notes summary instead of the raw tool result text', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
content="✅ **add_notes**\n\n └── Successfully created 81 notes: C4..."
|
||||
toolName="add_notes"
|
||||
toolSuccess={true}
|
||||
toolRawResult="Successfully created 81 notes: C4..."
|
||||
toolResultDisplayContent="Successfully created 81 notes in region **Verse Melody** on track **Lead Vox**, spanning bars 5 to 12."
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('add_notes')).toBeInTheDocument();
|
||||
expect(screen.getByText(/└──/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Successfully created 81 notes in region/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('Verse Melody')).toBeInTheDocument();
|
||||
expect(screen.getByText('Lead Vox')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Successfully created 81 notes: C4/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders generic tool results with the stable shell and raw body by default', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
content="✅ **read_music**\n\n └── raw fallback"
|
||||
toolName="read_music"
|
||||
toolSuccess={true}
|
||||
toolRawResult="C D E F"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('read_music')).toBeInTheDocument();
|
||||
expect(screen.getByText(/└──/)).toBeInTheDocument();
|
||||
expect(screen.getByText('C D E F')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { memo, useEffect, useState } from 'react';
|
||||
import React, { memo, useEffect, useRef, useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import { FaCaretDown, FaCaretUp } from 'react-icons/fa';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import type { PerformanceInfo } from '../../agent/llm/StreamingTypes';
|
||||
@@ -16,13 +17,109 @@ interface AssistantMessageProps {
|
||||
performanceInfo?: PerformanceInfo;
|
||||
toolName?: string;
|
||||
toolSuccess?: boolean;
|
||||
toolRawResult?: string;
|
||||
toolResultDisplayContent?: string;
|
||||
todoSnapshot?: TodoItem[];
|
||||
isToolCallMessage?: boolean;
|
||||
}
|
||||
|
||||
// Memoized code component to prevent SyntaxHighlighter re-renders
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const CodeComponent = memo(({ inline, className, children, ...props }: any) => {
|
||||
const COLLAPSED_TOOL_CALL_HEIGHT_PX = 200;
|
||||
const TOOL_CALL_FADE_HEIGHT_PX = 50;
|
||||
|
||||
interface MarkdownCodeProps {
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ToolCallCodeBlock = memo(({
|
||||
className,
|
||||
children,
|
||||
}: Omit<MarkdownCodeProps, 'inline'>) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isExpandable, setIsExpandable] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [expandedHeight, setExpandedHeight] = useState(COLLAPSED_TOOL_CALL_HEIGHT_PX);
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const codeText = String(children).replace(/\n$/, '');
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const measuredHeight = container.scrollHeight;
|
||||
setExpandedHeight(measuredHeight);
|
||||
setIsExpandable(measuredHeight > COLLAPSED_TOOL_CALL_HEIGHT_PX);
|
||||
}, [codeText]);
|
||||
|
||||
const handleToggleExpanded = () => {
|
||||
const scrollContainer = containerRef.current?.closest('.chatbox-messages') as HTMLDivElement | null;
|
||||
const previousScrollTop = scrollContainer?.scrollTop ?? null;
|
||||
|
||||
setIsExpanded((current) => !current);
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
if (scrollContainer && previousScrollTop !== null) {
|
||||
scrollContainer.scrollTop = previousScrollTop;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const maxHeight = isExpanded ? `${expandedHeight}px` : `${COLLAPSED_TOOL_CALL_HEIGHT_PX}px`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`tool-call-code-block${isExpandable ? ' is-expandable' : ''}${isExpanded ? ' is-expanded' : ''}`}
|
||||
data-testid="tool-call-code-block"
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="tool-call-code-block-inner"
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
<SyntaxHighlighter
|
||||
style={vscDarkPlus}
|
||||
language={match?.[1]}
|
||||
PreTag="div"
|
||||
>
|
||||
{codeText}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
{isExpandable && (
|
||||
<button
|
||||
type="button"
|
||||
className="tool-call-code-block-toggle"
|
||||
onClick={handleToggleExpanded}
|
||||
>
|
||||
<span
|
||||
className="tool-call-code-block-fade"
|
||||
aria-hidden="true"
|
||||
style={{ height: `${TOOL_CALL_FADE_HEIGHT_PX}px` }}
|
||||
/>
|
||||
<span className="tool-call-code-block-toggle-content">
|
||||
{isExpanded ? <FaCaretUp aria-hidden="true" /> : <FaCaretDown aria-hidden="true" />}
|
||||
<span>{isExpanded ? 'Click to collapse' : 'Click to expand'}</span>
|
||||
{isExpanded ? <FaCaretUp aria-hidden="true" /> : <FaCaretDown aria-hidden="true" />}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Memoized code component to prevent SyntaxHighlighter re-renders
|
||||
const CodeComponent = memo(({ inline, className, children, isToolCallMessage, ...props }: MarkdownCodeProps & { isToolCallMessage: boolean }) => {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
if (!inline && match && isToolCallMessage) {
|
||||
return (
|
||||
<ToolCallCodeBlock className={className}>
|
||||
{children}
|
||||
</ToolCallCodeBlock>
|
||||
);
|
||||
}
|
||||
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
style={vscDarkPlus}
|
||||
@@ -70,7 +167,10 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({
|
||||
performanceInfo,
|
||||
toolName,
|
||||
toolSuccess,
|
||||
toolRawResult,
|
||||
toolResultDisplayContent,
|
||||
todoSnapshot,
|
||||
isToolCallMessage = false,
|
||||
}) => {
|
||||
const prefillTps = formatTps(performanceInfo?.prefillTps);
|
||||
const generationTps = formatTps(performanceInfo?.generationTps);
|
||||
@@ -82,6 +182,8 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({
|
||||
|| content === COMPACTION_DONE_LABEL
|
||||
|| content === COMPACTION_EMPTY_LABEL;
|
||||
const isTodoSnapshotCard = toolName === 'update_todo_list' && Array.isArray(todoSnapshot);
|
||||
const shouldRenderGenericToolResult = Boolean(toolName) && typeof toolSuccess === 'boolean' && !isTodoSnapshotCard;
|
||||
const genericToolDisplayContent = toolResultDisplayContent ?? toolRawResult ?? content;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isThinking) {
|
||||
@@ -184,12 +286,37 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRenderGenericToolResult && toolName) {
|
||||
return (
|
||||
<div className="message-tool-result">
|
||||
<p className="message-tool-result-title">
|
||||
<span aria-hidden="true">{toolSuccess ? '✅' : '❌'}</span>{' '}
|
||||
<strong>{toolName}</strong>
|
||||
</p>
|
||||
<div className="message-tool-summary">
|
||||
<span className="message-tool-summary-prefix" aria-hidden="true">└── </span>
|
||||
<div className="message-tool-summary-content">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
code: (props) => <CodeComponent {...props} isToolCallMessage={false} />,
|
||||
}}
|
||||
>
|
||||
{genericToolDisplayContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
code: CodeComponent,
|
||||
code: (props) => <CodeComponent {...props} isToolCallMessage={isToolCallMessage} />,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
|
||||
Reference in New Issue
Block a user