feat(webui): enhance ChatViewer and components

This commit is contained in:
yiliang114 2026-01-21 23:51:20 +08:00
parent 5900162229
commit 2ea44966ec
11 changed files with 157 additions and 52 deletions

View file

@ -82,15 +82,16 @@
gap: 0;
align-items: flex-start;
text-align: left;
padding: 8px 0;
padding-top: 8px;
padding-bottom: 8px;
flex-direction: column;
position: relative;
animation: chatViewerFadeIn 0.2s ease-in;
}
.chat-viewer-messages > *:not(:last-child) {
padding-bottom: 0;
padding-top: 0;
.chat-viewer-messages > .chat-viewer-scroll-anchor {
padding: 0;
display: block;
}
/* Disable overflow anchoring on individual items for manual scroll control */
@ -103,15 +104,6 @@
margin-top: 0;
}
/* ===========================
Timeline Wrapper Styles
=========================== */
/* Tool call wrapper - transparent container for timeline continuity */
.chat-viewer-toolcall-wrapper {
position: relative;
}
/* ===========================
Animations
=========================== */

View file

@ -303,21 +303,17 @@ export const ChatViewer = forwardRef<ChatViewerHandle, ChatViewerProps>(
if (msg.type === 'tool_call' && msg.toolCall) {
const ToolCallComponent = getToolCallComponent(msg.toolCall.kind);
if (!ToolCallComponent) {
return null;
}
return (
<div
<ToolCallComponent
key={key}
className="chat-viewer-toolcall-wrapper"
data-first={isFirst}
data-last={isLast}
>
{ToolCallComponent && (
<ToolCallComponent
toolCall={msg.toolCall}
isFirst={isFirst}
isLast={isLast}
/>
)}
</div>
toolCall={msg.toolCall}
isFirst={isFirst}
isLast={isLast}
/>
);
}
@ -380,7 +376,10 @@ export const ChatViewer = forwardRef<ChatViewerHandle, ChatViewerProps>(
return (
<div className={containerClasses}>
<div ref={scrollContainerRef} className="chat-viewer-messages">
<div
ref={scrollContainerRef}
className="chat-viewer-messages chat-messages"
>
{sortedMessages.length === 0 ? (
<div className="chat-viewer-empty">
{showEmptyIcon && (

View file

@ -27,9 +27,13 @@ const EditToolCallContainer: React.FC<ToolCallContainerProps> = ({
toolCallId: _toolCallId,
labelSuffix,
className: _className,
isFirst = false,
isLast = false,
}) => (
<div
className={`qwen-message message-item ${_className || ''} relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`}
data-first={isFirst}
data-last={isLast}
>
<div className="EditToolCall toolcall-content-wrapper flex flex-col gap-1 min-w-0 max-w-full">
<div className="flex items-baseline gap-1.5 relative min-w-0">
@ -71,7 +75,11 @@ const getDiffSummary = (
* Specialized component for Edit tool calls
* Optimized for displaying file editing operations with diffs
*/
export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
export const EditToolCall: React.FC<BaseToolCallProps> = ({
toolCall,
isFirst,
isLast,
}) => {
const { content, locations, toolCallId } = toolCall;
// Group content by type; memoize to avoid new array identities on every render
@ -85,6 +93,8 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
return (
<div
className={`qwen-message message-item relative py-2 select-text toolcall-container toolcall-status-${containerStatus}`}
data-first={isFirst}
data-last={isLast}
>
<div className="toolcall-edit-content flex flex-col gap-1 min-w-0 max-w-full">
<div className="flex items-center justify-between min-w-0">
@ -118,6 +128,8 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
label={'Edit'}
status="error"
toolCallId={toolCallId}
isFirst={isFirst}
isLast={isLast}
labelSuffix={
path ? (
<FileLink
@ -142,6 +154,8 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
return (
<div
className={`qwen-message message-item relative py-2 select-text toolcall-container toolcall-status-${containerStatus}`}
data-first={isFirst}
data-last={isLast}
>
<div className="toolcall-edit-content flex flex-col gap-1 min-w-0 max-w-full">
<div className="flex items-center justify-between min-w-0">
@ -175,6 +189,8 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
label={`Edit`}
status={containerStatus}
toolCallId={toolCallId}
isFirst={isFirst}
isLast={isLast}
labelSuffix={
<FileLink
path={locations[0].path}

View file

@ -22,7 +22,11 @@ import type { BaseToolCallProps } from './shared/index.js';
* Used as fallback for unknown tool call kinds
* Minimal display: show description and outcome
*/
export const GenericToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
export const GenericToolCall: FC<BaseToolCallProps> = ({
toolCall,
isFirst,
isLast,
}) => {
const { kind, title, content, locations, toolCallId } = toolCall;
const operationText = safeTitle(title);
@ -94,6 +98,8 @@ export const GenericToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
label={getDisplayLabel()}
status={statusFlag}
toolCallId={toolCallId}
isFirst={isFirst}
isLast={isLast}
>
{operationText || output}
</ToolCallContainer>
@ -111,6 +117,8 @@ export const GenericToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
label={getDisplayLabel()}
status={statusFlag}
toolCallId={toolCallId}
isFirst={isFirst}
isLast={isLast}
>
<LocationsList locations={locations} />
</ToolCallContainer>
@ -128,6 +136,8 @@ export const GenericToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
label={getDisplayLabel()}
status={statusFlag}
toolCallId={toolCallId}
isFirst={isFirst}
isLast={isLast}
>
{operationText}
</ToolCallContainer>

View file

@ -30,9 +30,13 @@ const ReadToolCallContainer: FC<ToolCallContainerProps> = ({
toolCallId: _toolCallId,
labelSuffix,
className: _className,
isFirst = false,
isLast = false,
}) => (
<div
className={`ReadToolCall qwen-message message-item ${_className || ''} relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`}
data-first={isFirst}
data-last={isLast}
>
<div className="toolcall-content-wrapper flex flex-col gap-1 min-w-0 max-w-full">
<div className="flex items-baseline gap-1.5 relative min-w-0">
@ -56,7 +60,11 @@ const ReadToolCallContainer: FC<ToolCallContainerProps> = ({
* ReadToolCall - displays file reading operations
* Shows: Read filename (no content preview)
*/
export const ReadToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
export const ReadToolCall: FC<BaseToolCallProps> = ({
toolCall,
isFirst,
isLast,
}) => {
const { content, locations, toolCallId } = toolCall;
const platform = usePlatform();
const openedDiffsRef = useRef<Map<string, string>>(new Map());
@ -137,6 +145,8 @@ export const ReadToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
className="read-tool-call-error"
status="error"
toolCallId={toolCallId}
isFirst={isFirst}
isLast={isLast}
labelSuffix={
path ? (
<FileLink
@ -163,6 +173,8 @@ export const ReadToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
className="read-tool-call-error"
status="error"
toolCallId={toolCallId}
isFirst={isFirst}
isLast={isLast}
labelSuffix={
path ? (
<FileLink
@ -187,6 +199,8 @@ export const ReadToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
className="read-tool-call-success"
status={containerStatus}
toolCallId={toolCallId}
isFirst={isFirst}
isLast={isLast}
labelSuffix={
path ? (
<FileLink
@ -211,6 +225,8 @@ export const ReadToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
className="read-tool-call-success"
status={containerStatus}
toolCallId={toolCallId}
isFirst={isFirst}
isLast={isLast}
labelSuffix={
path ? (
<FileLink

View file

@ -34,11 +34,15 @@ const ExecuteToolCallContainer: FC<ToolCallContainerProps> = ({
toolCallId: _toolCallId,
labelSuffix,
className: _className,
isFirst = false,
isLast = false,
}) => (
<div
className={`ExecuteToolCall qwen-message message-item ${_className || ''} relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`}
data-first={isFirst}
data-last={isLast}
>
<div className="toolcall-content-wrapper flex flex-col gap-0 min-w-0 max-w-full">
<div className="toolcall-content-wrapper flex flex-col min-w-0 max-w-full">
<div className="flex items-baseline gap-1.5 relative min-w-0">
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
{label}
@ -95,6 +99,8 @@ const getInputCommand = (
const ShellToolCallImpl: FC<BaseToolCallProps & { variant: ShellVariant }> = ({
toolCall,
variant,
isFirst,
isLast,
}) => {
const { title, content, rawInput, toolCallId } = toolCall;
const classPrefix = variant;
@ -157,7 +163,13 @@ const ShellToolCallImpl: FC<BaseToolCallProps & { variant: ShellVariant }> = ({
// Error case
if (errors.length > 0) {
return (
<Container label={label} status={containerStatus} toolCallId={toolCallId}>
<Container
label={label}
status={containerStatus}
toolCallId={toolCallId}
isFirst={isFirst}
isLast={isLast}
>
{/* Branch connector summary */}
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
<span className="flex-shrink-0 relative top-[-0.1em]"></span>
@ -203,7 +215,13 @@ const ShellToolCallImpl: FC<BaseToolCallProps & { variant: ShellVariant }> = ({
output.length > 500 ? output.substring(0, 500) + '...' : output;
return (
<Container label={label} status={containerStatus} toolCallId={toolCallId}>
<Container
label={label}
status={containerStatus}
toolCallId={toolCallId}
isFirst={isFirst}
isLast={isLast}
>
{/* Branch connector summary */}
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
<span className="flex-shrink-0 relative top-[-0.1em]"></span>
@ -248,7 +266,13 @@ const ShellToolCallImpl: FC<BaseToolCallProps & { variant: ShellVariant }> = ({
// Success without output: show command with branch connector
return (
<Container label={label} status={containerStatus} toolCallId={toolCallId}>
<Container
label={label}
status={containerStatus}
toolCallId={toolCallId}
isFirst={isFirst}
isLast={isLast}
>
<div
className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1"
onClick={handleInClick}

View file

@ -20,7 +20,11 @@ import type { BaseToolCallProps } from './shared/index.js';
* Optimized for displaying AI reasoning and thought processes
* Minimal display: just show the thoughts (no context)
*/
export const ThinkToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
export const ThinkToolCall: FC<BaseToolCallProps> = ({
toolCall,
isFirst,
isLast,
}) => {
const { content } = toolCall;
// Group content by type
@ -29,7 +33,12 @@ export const ThinkToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
// Error case (rare for thinking)
if (errors.length > 0) {
return (
<ToolCallContainer label="SaveMemory" status="error">
<ToolCallContainer
label="SaveMemory"
status="error"
isFirst={isFirst}
isLast={isLast}
>
{errors.join('\n')}
</ToolCallContainer>
);
@ -61,7 +70,12 @@ export const ThinkToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
? 'loading'
: 'default';
return (
<ToolCallContainer label="SaveMemory" status={status}>
<ToolCallContainer
label="SaveMemory"
status={status}
isFirst={isFirst}
isLast={isLast}
>
<span className="italic opacity-90">{thoughts}</span>
</ToolCallContainer>
);

View file

@ -27,9 +27,13 @@ const PlanToolCallContainer: FC<ToolCallContainerProps> = ({
toolCallId: _toolCallId,
labelSuffix,
className: _className,
isFirst = false,
isLast = false,
}) => (
<div
className={`qwen-message message-item ${_className || ''} relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`}
data-first={isFirst}
data-last={isLast}
>
<div className="UpdatedPlanToolCall toolcall-content-wrapper flex flex-col gap-2 min-w-0 max-w-full">
<div className="flex items-baseline gap-1 relative min-w-0">
@ -113,14 +117,23 @@ const parsePlanEntries = (textOutputs: string[]): PlanEntry[] => {
* Specialized component for UpdatedPlan tool calls
* Optimized for displaying plan update operations
*/
export const UpdatedPlanToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
export const UpdatedPlanToolCall: FC<BaseToolCallProps> = ({
toolCall,
isFirst,
isLast,
}) => {
const { content, status } = toolCall;
const { errors, textOutputs } = groupContent(content);
// Error-first display
if (errors.length > 0) {
return (
<PlanToolCallContainer label="TodoWrite" status="error">
<PlanToolCallContainer
label="TodoWrite"
status="error"
isFirst={isFirst}
isLast={isLast}
>
{errors.join('\n')}
</PlanToolCallContainer>
);
@ -134,6 +147,8 @@ export const UpdatedPlanToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
label={label}
status={mapToolStatusToBullet(status)}
className="update-plan-toolcall"
isFirst={isFirst}
isLast={isLast}
>
<ul className="Fr list-none p-0 m-0 flex flex-col gap-1">
{entries.map((entry, idx) => {

View file

@ -19,7 +19,11 @@ import { FileLink } from '../layout/FileLink.js';
* Specialized component for Write tool calls
* Shows: Write filename + error message + content preview
*/
export const WriteToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
export const WriteToolCall: FC<BaseToolCallProps> = ({
toolCall,
isFirst,
isLast,
}) => {
const { content, locations, rawInput, toolCallId } = toolCall;
// Group content by type
@ -50,6 +54,8 @@ export const WriteToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
label={'WriteFile'}
status="error"
toolCallId={toolCallId}
isFirst={isFirst}
isLast={isLast}
labelSuffix={
path ? (
<FileLink
@ -85,6 +91,8 @@ export const WriteToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
label={'WriteFile'}
status={containerStatus}
toolCallId={toolCallId}
isFirst={isFirst}
isLast={isLast}
labelSuffix={
path ? (
<FileLink
@ -111,6 +119,8 @@ export const WriteToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
label="WriteFile"
status={containerStatus}
toolCallId={toolCallId}
isFirst={isFirst}
isLast={isLast}
>
{textOutputs.join('\n')}
</ToolCallContainer>

View file

@ -53,7 +53,7 @@ export const ToolCallContainer: FC<ToolCallContainerProps> = ({
data-last={isLast}
>
<div className="toolcall-content-wrapper flex flex-col min-w-0 max-w-full">
<div className="flex items-baseline relative min-w-0">
<div className="flex items-baseline gap-1.5 relative min-w-0">
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
{label}
</span>

View file

@ -37,22 +37,31 @@
.qwen-message.message-item:not(.user-message-container):first-child::after,
.user-message-container
+ .qwen-message.message-item:not(.user-message-container)::after,
.chat-viewer-toolcall-wrapper:first-child .toolcall-container::after,
.user-message-container + .chat-viewer-toolcall-wrapper .toolcall-container::after {
.chat-messages
> :not(.qwen-message.message-item)
+ .qwen-message.message-item:not(.user-message-container)::after,
.chat-viewer-messages
> :not(.qwen-message.message-item)
+ .qwen-message.message-item:not(.user-message-container)::after {
top: var(--timeline-center-offset, 13px);
}
.qwen-message.message-item:not(.user-message-container):has(+ .user-message-container)::after,
.qwen-message.message-item:not(.user-message-container):has(
+ :not(.qwen-message.message-item):not(.chat-viewer-toolcall-wrapper)
+ :not(.qwen-message.message-item)
)::after,
.qwen-message.message-item:not(.user-message-container):last-child::after,
.chat-viewer-toolcall-wrapper:has(+ .user-message-container)
.toolcall-container::after,
.chat-viewer-toolcall-wrapper:has(
+ :not(.chat-viewer-toolcall-wrapper):not(.qwen-message.message-item)
)
.toolcall-container::after,
.chat-viewer-toolcall-wrapper:last-child .toolcall-container::after {
.qwen-message.message-item:not(.user-message-container):last-child::after {
bottom: calc(100% - var(--timeline-center-offset, 13px));
}
.qwen-message.message-item[data-first='true']::after {
top: var(--timeline-center-offset, 13px);
}
.qwen-message.message-item[data-last='true']::after {
bottom: calc(100% - var(--timeline-center-offset, 13px));
}
.qwen-message.message-item[data-first='true'][data-last='true']::after {
display: none;
}