feat(webui): enhance ChatViewer and AssistantMessage with timeline positioning

- Update ChatViewer to calculate and pass timeline positions (isFirst/isLast) to messages
- Enhance AssistantMessage with timeline connector visualization
- Add storybook stories for AssistantMessage component
- Update SearchToolCall and LayoutComponents for improved UI consistency
- Export new adapters in main index file
- Add timeline styling to components

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
yiliang114 2026-01-20 21:33:49 +08:00
parent 1861557d15
commit df787fff64
12 changed files with 1287 additions and 328 deletions

View file

@ -89,7 +89,8 @@
}
.chat-viewer-messages > *:not(:last-child) {
padding-bottom: 8px;
padding-bottom: 0;
padding-top: 0;
}
/* Disable overflow anchoring on individual items for manual scroll control */
@ -102,6 +103,15 @@
margin-top: 0;
}
/* ===========================
Timeline Wrapper Styles
=========================== */
/* Tool call wrapper - transparent container for timeline continuity */
.chat-viewer-toolcall-wrapper {
position: relative;
}
/* ===========================
Animations
=========================== */
@ -150,3 +160,24 @@
height: 1px;
overflow-anchor: auto;
}
/* ===========================
ChatViewer-specific Styles
=========================== */
/* Better spacing between message groups */
.chat-viewer-messages .user-message-container {
margin-top: 16px;
}
/* Ensure proper stacking context */
.chat-viewer-messages > * {
isolation: isolate;
}
/* Responsive adjustments for ChatViewer */
@media (max-width: 600px) {
.chat-viewer-messages {
padding: 12px;
}
}

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useRef } from 'react';
import { useRef, useState, useCallback, useEffect } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
ChatViewer,
@ -1212,3 +1212,416 @@ export const WithRefControl: Story = {
},
},
};
// Comprehensive sample data for playground with all tool types
const PLAYGROUND_SAMPLE = `[
{
"uuid": "1",
"timestamp": "2026-01-15T14:00:00.000Z",
"type": "user",
"message": {
"role": "user",
"parts": [{ "text": "帮我创建一个 React 组件,并添加到项目中" }]
}
},
{
"uuid": "2",
"timestamp": "2026-01-15T14:00:05.000Z",
"type": "assistant",
"message": {
"role": "assistant",
"content": "好的,我来帮你创建一个 React 组件。首先让我搜索一下项目结构。"
}
},
{
"uuid": "3",
"timestamp": "2026-01-15T14:00:06.000Z",
"type": "tool_call",
"toolCall": {
"toolCallId": "search-1",
"kind": "grep",
"title": "Searching for component patterns",
"status": "completed",
"rawInput": "export.*Component",
"content": [{
"type": "content",
"content": {
"type": "text",
"text": "src/components/Button.tsx:export const Button: FC = () => {\\nsrc/components/Card.tsx:export const Card: FC = () => {"
}
}],
"locations": [
{ "path": "src/components/Button.tsx", "line": 5 },
{ "path": "src/components/Card.tsx", "line": 8 }
]
}
},
{
"uuid": "4",
"timestamp": "2026-01-15T14:00:08.000Z",
"type": "tool_call",
"toolCall": {
"toolCallId": "read-1",
"kind": "read",
"title": "src/components/Button.tsx",
"status": "completed",
"content": [{
"type": "content",
"content": {
"type": "text",
"text": "import type { FC } from 'react';\\n\\nexport interface ButtonProps {\\n label: string;\\n onClick?: () => void;\\n}\\n\\nexport const Button: FC<ButtonProps> = ({ label, onClick }) => (\\n <button onClick={onClick}>{label}</button>\\n);"
}
}],
"locations": [{ "path": "src/components/Button.tsx" }]
}
},
{
"uuid": "5",
"timestamp": "2026-01-15T14:00:10.000Z",
"type": "assistant",
"message": {
"role": "assistant",
"content": "我找到了项目的组件结构。现在我来创建新的组件文件。"
}
},
{
"uuid": "6",
"timestamp": "2026-01-15T14:00:12.000Z",
"type": "tool_call",
"toolCall": {
"toolCallId": "write-1",
"kind": "write",
"title": "Creating src/components/Modal.tsx",
"status": "completed",
"content": [{
"type": "diff",
"path": "src/components/Modal.tsx",
"oldText": null,
"newText": "import type { FC, ReactNode } from 'react';\\nimport './Modal.css';\\n\\nexport interface ModalProps {\\n isOpen: boolean;\\n onClose: () => void;\\n title: string;\\n children: ReactNode;\\n}\\n\\nexport const Modal: FC<ModalProps> = ({\\n isOpen,\\n onClose,\\n title,\\n children,\\n}) => {\\n if (!isOpen) return null;\\n\\n return (\\n <div className=\\"modal-overlay\\">\\n <div className=\\"modal-content\\">\\n <header className=\\"modal-header\\">\\n <h2>{title}</h2>\\n <button onClick={onClose}>×</button>\\n </header>\\n <div className=\\"modal-body\\">\\n {children}\\n </div>\\n </div>\\n </div>\\n );\\n};"
}],
"locations": [{ "path": "src/components/Modal.tsx" }]
}
},
{
"uuid": "7",
"timestamp": "2026-01-15T14:00:15.000Z",
"type": "tool_call",
"toolCall": {
"toolCallId": "edit-1",
"kind": "edit",
"title": "Updating src/components/index.ts",
"status": "completed",
"content": [{
"type": "diff",
"path": "src/components/index.ts",
"oldText": "export { Button } from './Button';\\nexport { Card } from './Card';",
"newText": "export { Button } from './Button';\\nexport { Card } from './Card';\\nexport { Modal } from './Modal';"
}],
"locations": [{ "path": "src/components/index.ts", "line": 3 }]
}
},
{
"uuid": "8",
"timestamp": "2026-01-15T14:00:18.000Z",
"type": "assistant",
"message": {
"role": "assistant",
"content": "组件已创建。让我运行测试确保没有问题。"
}
},
{
"uuid": "9",
"timestamp": "2026-01-15T14:00:20.000Z",
"type": "tool_call",
"toolCall": {
"toolCallId": "bash-1",
"kind": "bash",
"title": "Running tests",
"status": "completed",
"rawInput": "npm run test -- --coverage",
"content": [{
"type": "content",
"content": {
"type": "text",
"text": "PASS src/components/Modal.test.tsx\\n Modal Component\\n ✓ renders when isOpen is true (15ms)\\n ✓ does not render when isOpen is false (3ms)\\n ✓ calls onClose when close button clicked (8ms)\\n\\nTest Suites: 1 passed, 1 total\\nTests: 3 passed, 3 total\\nCoverage: 92.5%"
}
}]
}
},
{
"uuid": "10",
"timestamp": "2026-01-15T14:00:25.000Z",
"type": "tool_call",
"toolCall": {
"toolCallId": "plan-1",
"kind": "todowrite",
"title": "Updating task progress",
"status": "completed",
"content": [{
"type": "content",
"content": {
"type": "plan",
"entries": [
{ "content": "Search project structure", "status": "completed" },
{ "content": "Create Modal component", "status": "completed" },
{ "content": "Update exports", "status": "completed" },
{ "content": "Run tests", "status": "completed" },
{ "content": "Add documentation", "status": "pending" }
]
}
}]
}
},
{
"uuid": "11",
"timestamp": "2026-01-15T14:00:30.000Z",
"type": "assistant",
"message": {
"role": "assistant",
"content": "Modal 组件已成功创建并通过所有测试!\\n\\n**创建的文件:**\\n- \`src/components/Modal.tsx\` - 主组件文件\\n- \`src/components/Modal.css\` - 样式文件\\n\\n**功能特性:**\\n- 支持打开/关闭状态控制\\n- 可自定义标题和内容\\n- 点击关闭按钮触发回调\\n\\n还需要我添加文档吗"
}
}
]`;
// Playground component for testing JSON input with auto-render
const PlaygroundTemplate = () => {
const [jsonInput, setJsonInput] = useState(PLAYGROUND_SAMPLE);
const [messages, setMessages] = useState<ChatMessageData[]>([]);
const [error, setError] = useState<string | null>(null);
const [autoRender, setAutoRender] = useState(true);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const parseAndRender = useCallback((input: string) => {
try {
const parsed = JSON.parse(input);
if (!Array.isArray(parsed)) {
throw new Error('JSON must be an array of messages');
}
setMessages(parsed);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : 'Invalid JSON');
setMessages([]);
}
}, []);
// Auto-render with debounce when JSON input changes
useEffect(() => {
if (!autoRender) return;
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
parseAndRender(jsonInput);
}, 300);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [jsonInput, autoRender, parseAndRender]);
// Parse on initial load
useEffect(() => {
parseAndRender(jsonInput);
}, [parseAndRender, jsonInput]);
return (
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '16px',
height: '700px',
fontFamily: 'system-ui, sans-serif',
}}
>
{/* Left Panel - JSON Input */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
minWidth: 0,
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '8px',
}}
>
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>
JSON Input (Messages Array)
</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '12px',
color: 'var(--app-secondary-foreground, #a1a1aa)',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={autoRender}
onChange={(e) => setAutoRender(e.target.checked)}
style={{ cursor: 'pointer' }}
/>
Auto Render
</label>
{!autoRender && (
<button
onClick={() => parseAndRender(jsonInput)}
style={{
padding: '6px 12px',
background: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500,
}}
>
Render
</button>
)}
</div>
</div>
<textarea
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
style={{
flex: 1,
padding: '12px',
fontFamily: 'ui-monospace, monospace',
fontSize: '12px',
border: '1px solid var(--app-border, #3f3f46)',
borderRadius: '6px',
background: 'var(--app-input-background, #3c3c3c)',
color: 'var(--app-primary-foreground, #e4e4e7)',
resize: 'none',
outline: 'none',
}}
placeholder="Paste your JSON messages array here..."
/>
{error && (
<div
style={{
padding: '8px 12px',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid #ef4444',
borderRadius: '4px',
color: '#ef4444',
fontSize: '13px',
}}
>
Error: {error}
</div>
)}
<div
style={{
fontSize: '11px',
color: 'var(--app-secondary-foreground, #a1a1aa)',
lineHeight: 1.5,
}}
>
<strong>Supported message types:</strong>
<br /> <code>user</code> - User messages with{' '}
<code>message.parts[].text</code> or <code>message.content</code>
<br /> <code>assistant</code> - AI responses
<br /> <code>tool_call</code> - Tool calls with{' '}
<code>toolCall.kind</code> (read, write, edit, bash, grep, etc.)
</div>
</div>
{/* Right Panel - ChatViewer Preview */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
minWidth: 0,
}}
>
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>
ChatViewer Preview
</h3>
<div
style={{
flex: 1,
border: '1px solid var(--app-border, #3f3f46)',
borderRadius: '6px',
overflow: 'hidden',
}}
>
<ChatViewer
messages={messages}
emptyMessage="Paste JSON to preview"
/>
</div>
</div>
</div>
);
};
export const Playground: Story = {
render: () => <PlaygroundTemplate />,
parameters: {
layout: 'fullscreen',
docs: {
description: {
story: `
**Interactive Playground** for testing ChatViewer with custom JSON data.
Paste your chat history JSON (array of messages) on the left, click "Render" to see the result.
### Message Format
\`\`\`json
{
"uuid": "unique-id",
"timestamp": "2026-01-15T14:00:00.000Z",
"type": "user" | "assistant" | "tool_call",
"message": {
"role": "user" | "assistant",
"parts": [{ "text": "..." }], // Qwen format
"content": "..." // Claude format
},
"toolCall": { // For tool_call type
"toolCallId": "...",
"kind": "read" | "write" | "edit" | "bash" | "grep" | ...,
"title": "...",
"status": "completed" | "in_progress" | "failed",
"content": [...],
"locations": [...]
}
}
\`\`\`
`,
},
},
},
decorators: [
(Story) => (
<div
style={{
background: 'var(--app-background, #1e1e1e)',
padding: '20px',
minHeight: '100vh',
}}
>
<Story />
</div>
),
],
};

View file

@ -281,9 +281,9 @@ export const ChatViewer = forwardRef<ChatViewerHandle, ChatViewerProps>(
prevMessageCountRef.current = currentCount;
}, [sortedMessages.length, autoScroll]);
// Determine if a message is a tool call type
const isToolCallType = (msg: ChatMessageData) =>
msg.type === 'tool_call' && msg.toolCall;
// Determine if previous/next is a user message (breaks the AI sequence)
const isUserType = (msg: ChatMessageData | undefined) =>
!msg || msg.type === 'user';
// Render individual message based on type
const renderMessage = (
@ -292,22 +292,32 @@ export const ChatViewer = forwardRef<ChatViewerHandle, ChatViewerProps>(
allMsgs: ChatMessageData[],
) => {
const key = msg.uuid || `msg-${index}`;
const prev = allMsgs[index - 1];
const next = allMsgs[index + 1];
// Calculate timeline position for AI responses
const isFirst = isUserType(prev);
const isLast = isUserType(next);
// Handle tool calls
if (msg.type === 'tool_call' && msg.toolCall) {
const ToolCallComponent = getToolCallComponent(msg.toolCall.kind);
const prev = allMsgs[index - 1];
const next = allMsgs[index + 1];
const isFirst = !isToolCallType(prev);
const isLast = !isToolCallType(next);
return (
<ToolCallComponent
<div
key={key}
toolCall={msg.toolCall}
isFirst={isFirst}
isLast={isLast}
/>
className="chat-viewer-toolcall-wrapper"
data-first={isFirst}
data-last={isLast}
>
{ToolCallComponent && (
<ToolCallComponent
toolCall={msg.toolCall}
isFirst={isFirst}
isLast={isLast}
/>
)}
</div>
);
}
@ -348,6 +358,8 @@ export const ChatViewer = forwardRef<ChatViewerHandle, ChatViewerProps>(
content={content}
timestamp={timestamp}
onFileClick={onFileClick}
isFirst={isFirst}
isLast={isLast}
/>
);

View file

@ -7,7 +7,35 @@
* Pseudo-elements (::before) for bullet points and (::after) for timeline connectors
*/
/* Bullet point indicator using ::before pseudo-element */
/* Base assistant message styles */
.assistant-message-container {
position: relative;
padding-left: 30px;
padding-top: 8px;
padding-bottom: 8px;
transition: background-color 0.15s ease;
border-radius: 6px;
}
/* Hover effect */
.assistant-message-container:hover {
background-color: rgba(255, 255, 255, 0.02);
}
/* Light theme hover */
.light-theme .assistant-message-container:hover {
background-color: rgba(0, 0, 0, 0.02);
}
/*
* Timeline positioning calculation (same as ToolCallContainer):
* - Container padding-top: 8px
* - Bullet font-size: 10px, line-height ~10px
* - Bullet vertical center: 8px + 5px = 13px from top
* - Line left: 12px (centered under bullet at left: 8px + ~4px offset)
*/
/* Bullet point indicator - all states use same position */
.assistant-message-container.assistant-message-default::before,
.assistant-message-container.assistant-message-success::before,
.assistant-message-container.assistant-message-error::before,
@ -16,37 +44,66 @@
content: '\25cf';
position: absolute;
left: 8px;
padding-top: 2px;
top: 8px;
font-size: 10px;
line-height: 10px;
z-index: 1;
}
/* Default state - secondary foreground color */
/* Status colors */
.assistant-message-container.assistant-message-default::before {
color: var(--app-secondary-foreground);
}
/* Success state - green bullet (maps to .ge) */
.assistant-message-container.assistant-message-success::before {
color: #74c991;
}
/* Error state - red bullet (maps to .be) */
.assistant-message-container.assistant-message-error::before {
color: #c74e39;
}
/* Warning state - yellow/orange bullet (maps to .ue) */
.assistant-message-container.assistant-message-warning::before {
color: #e1c08d;
}
/* Loading state - static bullet (maps to .he) */
.assistant-message-container.assistant-message-loading::before {
color: var(--app-secondary-foreground);
background-color: var(--app-secondary-background);
animation: assistantPulse 1s linear infinite;
}
@keyframes assistantPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Timeline connector line - full height by default */
.assistant-message-container::after {
content: '';
position: absolute;
left: 12px;
top: 0;
bottom: 0;
width: 1px;
background-color: var(--app-primary-border-color, rgba(255, 255, 255, 0.15));
}
/* First item in sequence: connector starts from bullet center */
.assistant-message-container[data-first="true"]::after {
top: 13px;
}
/* Last item in sequence: connector ends at bullet center */
.assistant-message-container[data-last="true"]::after {
bottom: calc(100% - 13px);
}
/* Single item (both first and last): no connector */
.assistant-message-container[data-first="true"][data-last="true"]::after {
display: none;
}
/* Loading state doesn't show timeline */
.assistant-message-container.assistant-message-loading::after {
display: none;
}

View file

@ -0,0 +1,207 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { Meta, StoryObj } from '@storybook/react-vite';
import { AssistantMessage } from './AssistantMessage.js';
/**
* AssistantMessage displays AI responses with markdown formatting.
* Supports different status states for timeline bullet coloring.
*/
const meta: Meta<typeof AssistantMessage> = {
title: 'Messages/AssistantMessage',
component: AssistantMessage,
parameters: {
layout: 'padded',
},
tags: ['autodocs'],
argTypes: {
content: {
control: 'text',
description: 'The markdown content to display',
},
status: {
control: 'select',
options: ['default', 'success', 'error', 'warning', 'loading'],
description: 'Status determines the bullet point color',
},
hideStatusIcon: {
control: 'boolean',
description: 'Hide the status bullet point',
},
isFirst: {
control: 'boolean',
description: 'First item in timeline sequence',
},
isLast: {
control: 'boolean',
description: 'Last item in timeline sequence',
},
},
decorators: [
(Story) => (
<div
style={{
background: 'var(--app-background, #1e1e1e)',
padding: '20px',
minHeight: '200px',
}}
>
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
content: 'This is a default assistant message with **markdown** support.',
status: 'default',
isFirst: true,
isLast: true,
},
};
export const Success: Story = {
args: {
content: 'Task completed successfully! All tests passed.',
status: 'success',
isFirst: true,
isLast: true,
},
};
export const Error: Story = {
args: {
content:
'An error occurred while processing your request. Please try again.',
status: 'error',
isFirst: true,
isLast: true,
},
};
export const Warning: Story = {
args: {
content:
'Warning: This operation may take a long time. Consider using a smaller dataset.',
status: 'warning',
isFirst: true,
isLast: true,
},
};
export const Loading: Story = {
args: {
content: 'Processing your request...',
status: 'loading',
isFirst: true,
isLast: true,
},
};
export const WithMarkdown: Story = {
args: {
content: `Here's a detailed response with various markdown elements:
## Code Example
\`\`\`typescript
const greeting = (name: string) => {
return \`Hello, \${name}!\`;
};
\`\`\`
## Key Points
- First point with **bold** text
- Second point with \`inline code\`
- Third point with *italic* text
> This is a blockquote for emphasis.
Check the [documentation](https://example.com) for more details.`,
status: 'success',
isFirst: true,
isLast: true,
},
};
export const LongContent: Story = {
args: {
content: `This is a very long message that demonstrates how the component handles extensive content.
The assistant can provide detailed explanations, code examples, and step-by-step instructions.
### Step 1: Setup
First, initialize your project:
\`\`\`bash
npm init -y
npm install react react-dom
\`\`\`
### Step 2: Create Components
Create your main component file:
\`\`\`tsx
import { FC } from 'react';
export const App: FC = () => {
return <div>Hello World</div>;
};
\`\`\`
### Step 3: Run
Start the development server and verify everything works correctly.`,
status: 'default',
isFirst: true,
isLast: true,
},
};
export const HiddenStatusIcon: Story = {
args: {
content: 'This message has no status bullet point.',
hideStatusIcon: true,
isFirst: true,
isLast: true,
},
};
// Timeline demonstration
export const TimelineFirst: Story = {
args: {
content: 'This is the first message in a sequence.',
status: 'default',
isFirst: true,
isLast: false,
},
};
export const TimelineMiddle: Story = {
args: {
content: 'This is a middle message in a sequence.',
status: 'default',
isFirst: false,
isLast: false,
},
};
export const TimelineLast: Story = {
args: {
content: 'This is the last message in a sequence.',
status: 'success',
isFirst: false,
isLast: true,
},
};

View file

@ -22,6 +22,10 @@ export interface AssistantMessageProps {
status?: AssistantMessageStatus;
/** When true, render without the left status bullet (no ::before dot) */
hideStatusIcon?: boolean;
/** Whether this is the first item in an AI response sequence (for timeline) */
isFirst?: boolean;
/** Whether this is the last item in an AI response sequence (for timeline) */
isLast?: boolean;
}
/**
@ -34,6 +38,8 @@ export const AssistantMessage: FC<AssistantMessageProps> = ({
onFileClick,
status = 'default',
hideStatusIcon = false,
isFirst = false,
isLast = false,
}) => {
// Empty content not rendered directly
if (!content || content.trim().length === 0) {
@ -61,6 +67,8 @@ export const AssistantMessage: FC<AssistantMessageProps> = ({
return (
<div
className={`qwen-message message-item assistant-message-container ${getStatusClass()}`}
data-first={isFirst}
data-last={isLast}
style={{
width: '100%',
alignItems: 'flex-start',

View file

@ -11,101 +11,11 @@ import {
safeTitle,
groupContent,
mapToolStatusToContainerStatus,
ToolCallContainer,
} from './shared/index.js';
import type { BaseToolCallProps } from './shared/index.js';
import type { BaseToolCallProps, ContainerStatus } from './shared/index.js';
import { FileLink } from '../layout/FileLink.js';
/**
* Inline container for compact search results display
*/
const InlineContainer: FC<{
status: 'success' | 'error' | 'warning' | 'loading' | 'default';
labelSuffix?: string;
children?: React.ReactNode;
isFirst?: boolean;
isLast?: boolean;
displayLabel: string;
}> = ({ status, labelSuffix, children, isFirst, isLast, displayLabel }) => {
const beforeStatusClass = `toolcall-container toolcall-status-${status}`;
const lineCropTop = isFirst ? 'top-[24px]' : 'top-0';
const lineCropBottom = isLast
? 'bottom-auto h-[calc(100%-24px)]'
: 'bottom-0';
return (
<div
className={
`qwen-message message-item relative pl-[30px] py-2 select-text ` +
`before:absolute before:left-[8px] before:top-2 before:content-["\\25cf"] before:text-[10px] before:z-[1] ` +
beforeStatusClass
}
>
{/* timeline vertical line */}
<div
className={`absolute left-[12px] ${lineCropTop} ${lineCropBottom} w-px bg-[var(--app-primary-border-color)]`}
aria-hidden
/>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2 min-w-0">
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
{displayLabel}
</span>
{labelSuffix ? (
<span className="text-[11px] text-[var(--app-secondary-foreground)]">
{labelSuffix}
</span>
) : null}
</div>
{children ? (
<div className="mt-1 text-[var(--app-secondary-foreground)]">
{children}
</div>
) : null}
</div>
</div>
);
};
/**
* Card layout for multi-result or error display
*/
const SearchCard: FC<{
status: 'success' | 'error' | 'warning' | 'loading' | 'default';
children: React.ReactNode;
isFirst?: boolean;
isLast?: boolean;
}> = ({ status, children, isFirst, isLast }) => {
const beforeStatusClass =
status === 'success'
? 'before:text-qwen-success'
: status === 'error'
? 'before:text-qwen-error'
: status === 'warning'
? 'before:text-qwen-warning'
: 'before:text-qwen-loading before:opacity-70 before:animate-pulse-slow';
const lineCropTop = isFirst ? 'top-[24px]' : 'top-0';
const lineCropBottom = isLast
? 'bottom-auto h-[calc(100%-24px)]'
: 'bottom-0';
return (
<div
className={
`qwen-message message-item relative pl-[30px] py-2 select-text ` +
`before:absolute before:left-[8px] before:top-2 before:content-["\\25cf"] before:text-[10px] before:z-[1] ` +
beforeStatusClass
}
>
{/* timeline vertical line */}
<div
className={`absolute left-[12px] ${lineCropTop} ${lineCropBottom} w-px bg-[var(--app-primary-border-color)]`}
aria-hidden
/>
<div className="bg-[var(--app-input-background)] border border-[var(--app-input-border)] rounded-medium p-large my-medium">
<div className="flex flex-col gap-3 min-w-0">{children}</div>
</div>
</div>
);
};
/**
* Row component for search card layout
*/
@ -123,6 +33,15 @@ const SearchRow: FC<{ label: string; children: React.ReactNode }> = ({
</div>
);
/**
* Card content wrapper for search results
*/
const SearchCardContent: FC<{ children: React.ReactNode }> = ({ children }) => (
<div className="bg-[var(--app-input-background)] border border-[var(--app-input-border)] rounded-md p-3 mt-1">
<div className="flex flex-col gap-3 min-w-0">{children}</div>
</div>
);
/**
* Local locations list component
*/
@ -164,6 +83,9 @@ export const SearchToolCall: FC<BaseToolCallProps> = ({
const { kind, title, content, locations } = toolCall;
const queryText = safeTitle(title);
const displayLabel = getDisplayLabel(kind);
const containerStatus: ContainerStatus = mapToolStatusToContainerStatus(
toolCall.status,
);
// Group content by type
const { errors, textOutputs } = groupContent(content);
@ -171,58 +93,73 @@ export const SearchToolCall: FC<BaseToolCallProps> = ({
// Error case: show search query + error in card layout
if (errors.length > 0) {
return (
<SearchCard status="error" isFirst={isFirst} isLast={isLast}>
<SearchRow label={displayLabel}>
<div className="font-mono">{queryText}</div>
</SearchRow>
<SearchRow label="Error">
<div className="text-qwen-error font-medium">{errors.join('\n')}</div>
</SearchRow>
</SearchCard>
<ToolCallContainer
label={displayLabel}
labelSuffix={queryText}
status="error"
isFirst={isFirst}
isLast={isLast}
>
<SearchCardContent>
<SearchRow label="Query">
<div className="font-mono">{queryText}</div>
</SearchRow>
<SearchRow label="Error">
<div className="text-[#c74e39] font-medium">
{errors.join('\n')}
</div>
</SearchRow>
</SearchCardContent>
</ToolCallContainer>
);
}
// Success case with results: show search query + file list
if (locations && locations.length > 0) {
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
// Multiple results use card layout
if (locations.length > 1) {
return (
<SearchCard status={containerStatus} isFirst={isFirst} isLast={isLast}>
<SearchRow label={displayLabel}>
<div className="font-mono">{queryText}</div>
</SearchRow>
<SearchRow label={`Found (${locations.length})`}>
<LocationsListLocal locations={locations} />
</SearchRow>
</SearchCard>
<ToolCallContainer
label={displayLabel}
labelSuffix={queryText}
status={containerStatus}
isFirst={isFirst}
isLast={isLast}
>
<SearchCardContent>
<SearchRow label={displayLabel}>
<div className="font-mono">{queryText}</div>
</SearchRow>
<SearchRow label={`Found (${locations.length})`}>
<LocationsListLocal locations={locations} />
</SearchRow>
</SearchCardContent>
</ToolCallContainer>
);
}
// Single result - compact format
return (
<InlineContainer
<ToolCallContainer
label={displayLabel}
labelSuffix={queryText}
status={containerStatus}
labelSuffix={`(${queryText})`}
isFirst={isFirst}
isLast={isLast}
displayLabel={displayLabel}
>
<span className="mx-2 opacity-50"></span>
<LocationsListLocal locations={locations} />
</InlineContainer>
</ToolCallContainer>
);
}
// Show content text if available
if (textOutputs.length > 0) {
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
return (
<InlineContainer
<ToolCallContainer
label={displayLabel}
labelSuffix={queryText || undefined}
status={containerStatus}
labelSuffix={queryText ? `(${queryText})` : undefined}
isFirst={isFirst}
isLast={isLast}
displayLabel={displayLabel}
>
<div className="flex flex-col">
{textOutputs.map((text: string, index: number) => (
@ -235,22 +172,20 @@ export const SearchToolCall: FC<BaseToolCallProps> = ({
</div>
))}
</div>
</InlineContainer>
</ToolCallContainer>
);
}
// No results - show query only
if (queryText) {
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
return (
<InlineContainer
<ToolCallContainer
label={displayLabel}
labelSuffix={queryText}
status={containerStatus}
isFirst={isFirst}
isLast={isLast}
displayLabel={displayLabel}
>
<span className="font-mono">{queryText}</span>
</InlineContainer>
/>
);
}

View file

@ -14,9 +14,29 @@
padding-bottom: 8px;
user-select: text;
align-items: flex-start;
transition: background-color 0.15s ease;
border-radius: 6px;
}
/* Default timeline connector line */
/* Hover effect */
.toolcall-container:hover {
background-color: rgba(255, 255, 255, 0.02);
}
/* Light theme hover (when parent has light-theme class) */
.light-theme .toolcall-container:hover {
background-color: rgba(0, 0, 0, 0.02);
}
/*
* Timeline positioning calculation:
* - Container padding-top: 8px
* - Bullet font-size: 10px, line-height ~10px
* - Bullet vertical center: 8px + 5px = 13px from top
* - Line left: 12px (centered under bullet at left: 8px + ~4px offset)
*/
/* Default timeline connector line - full height */
.toolcall-container::after {
content: '';
position: absolute;
@ -24,72 +44,64 @@
top: 0;
bottom: 0;
width: 1px;
background-color: var(--app-primary-border-color);
background-color: var(--app-primary-border-color, rgba(255, 255, 255, 0.15));
}
/* First item: connector starts from status point position */
.toolcall-container:first-child::after {
top: 24px;
/* First item in sequence: connector starts from bullet center */
.toolcall-container[data-first="true"]::after {
top: 13px;
}
/* Last item: connector shows only upper part */
.toolcall-container:last-child::after {
height: calc(100% - 24px);
top: 0;
bottom: auto;
/* Last item in sequence: connector ends at bullet center */
.toolcall-container[data-last="true"]::after {
bottom: calc(100% - 13px);
}
/* Status-specific styles using ::before pseudo-element for bullet points */
.toolcall-container.toolcall-status-default::before {
content: '\25cf';
position: absolute;
left: 8px;
padding-top: 2px;
font-size: 10px;
color: var(--app-secondary-foreground);
z-index: 1;
}
.toolcall-container.toolcall-status-success::before {
content: '\25cf';
position: absolute;
left: 8px;
font-size: 10px;
color: #74c991;
z-index: 1;
}
.toolcall-container.toolcall-status-error::before {
content: '\25cf';
position: absolute;
left: 8px;
font-size: 10px;
color: #c74e39;
z-index: 1;
}
.toolcall-container.toolcall-status-warning::before {
content: '\25cf';
position: absolute;
left: 8px;
padding-top: 2px;
font-size: 10px;
color: #e1c08d;
z-index: 1;
/* Single item (both first and last): no connector */
.toolcall-container[data-first="true"][data-last="true"]::after {
display: none;
}
/*
* Status bullet points - all use consistent positioning
* Position: left 8px, top 8px (aligned with container padding)
*/
.toolcall-container.toolcall-status-default::before,
.toolcall-container.toolcall-status-success::before,
.toolcall-container.toolcall-status-error::before,
.toolcall-container.toolcall-status-warning::before,
.toolcall-container.toolcall-status-loading::before {
content: '\25cf';
position: absolute;
left: 8px;
padding-top: 2px;
top: 8px;
font-size: 10px;
color: var(--app-secondary-foreground);
background-color: var(--app-secondary-background);
animation: toolcallPulse 1s linear infinite;
line-height: 10px;
z-index: 1;
}
/* Status colors */
.toolcall-container.toolcall-status-default::before {
color: var(--app-secondary-foreground);
}
.toolcall-container.toolcall-status-success::before {
color: #74c991;
}
.toolcall-container.toolcall-status-error::before {
color: #c74e39;
}
.toolcall-container.toolcall-status-warning::before {
color: #e1c08d;
}
.toolcall-container.toolcall-status-loading::before {
color: var(--app-secondary-foreground);
animation: toolcallPulse 1s linear infinite;
}
/* Loading animation */
@keyframes toolcallPulse {
0%,

View file

@ -19,14 +19,18 @@ export interface ToolCallContainerProps {
label: string;
/** Status for bullet color: 'success' | 'error' | 'warning' | 'loading' | 'default' */
status?: 'success' | 'error' | 'warning' | 'loading' | 'default';
/** Main content to display */
children: React.ReactNode;
/** Main content to display (optional - some tool calls only show title) */
children?: React.ReactNode;
/** Tool call ID for debugging */
toolCallId?: string;
/** Optional trailing content rendered next to label (e.g., clickable filename) */
labelSuffix?: React.ReactNode;
/** Optional custom class name */
className?: string;
/** Whether this is the first item in an AI response sequence (for timeline) */
isFirst?: boolean;
/** Whether this is the last item in an AI response sequence (for timeline) */
isLast?: boolean;
}
/**
@ -40,9 +44,13 @@ export const ToolCallContainer: 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="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">

View file

@ -220,5 +220,21 @@ export type { CompletionItem, CompletionItemType } from './types/completion';
export { groupSessionsByDate, getTimeAgo } from './utils/sessionGrouping';
export type { SessionGroup } from './utils/sessionGrouping';
// Adapters - for normalizing different data formats
export {
adaptJSONLMessages,
adaptACPMessages,
filterEmptyMessages,
isToolCallData,
isMessageData,
} from './adapters';
export type {
UnifiedMessage,
UnifiedMessageType,
JSONLMessage,
ACPMessage,
ACPMessageData,
} from './adapters';
// VSCode Webview utilities
export { default as WebviewContainer } from './components/WebviewContainer';

View file

@ -4,8 +4,18 @@
* SPDX-License-Identifier: Apache-2.0
*
* Common component styles for webui
* These styles are shared across all platforms (vscode, web, etc.)
*/
/* ===========================
Global Reset
=========================== */
.webui-root * {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* ===========================
Animations
=========================== */
@ -43,6 +53,100 @@
}
}
/* ===========================
Chat Container Styles
=========================== */
.chat-container {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background-color: var(--app-primary-background);
color: var(--app-primary-foreground);
font-family: var(--vscode-chat-font-family, var(--app-font-sans));
font-size: var(--vscode-chat-font-size, 13px);
}
.chat-messages {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 20px;
display: flex;
flex-direction: column;
position: relative;
min-width: 0;
scroll-behavior: smooth;
}
/* Disable overflow anchoring on individual items for manual scroll control */
.chat-messages > * {
overflow-anchor: none;
}
/* ===========================
Input Form Styles
=========================== */
.input-form {
display: flex;
background-color: var(--app-primary-background);
border-top: 1px solid var(--app-primary-border-color);
}
.input-field {
flex: 1;
padding: 10px 12px;
background-color: var(--app-input-background);
color: var(--app-input-foreground);
border: 1px solid var(--app-input-border);
border-radius: var(--app-radius-sm, 4px);
font-size: var(--vscode-chat-font-size, 13px);
font-family: var(--vscode-chat-font-family, var(--app-font-sans));
outline: none;
line-height: 1.5;
}
.input-field:focus {
border-color: var(--app-primary, #3b82f6);
}
.input-field:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.input-field::placeholder {
color: var(--app-input-placeholder-foreground);
}
.send-button {
padding: 10px 20px;
background-color: var(--app-primary, #3b82f6);
color: var(--app-button-foreground, white);
border: none;
border-radius: var(--app-radius-sm, 4px);
font-size: var(--vscode-chat-font-size, 13px);
font-weight: 500;
cursor: pointer;
transition: filter 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
}
.send-button:hover:not(:disabled) {
filter: brightness(1.1);
}
.send-button:active:not(:disabled) {
filter: brightness(0.9);
}
.send-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ===========================
Code Block Styles
=========================== */
@ -142,7 +246,274 @@
padding-left: 30px;
}
/* Icon SVG styles */
/* ===========================
Button Styles
=========================== */
.btn-ghost {
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
outline: none;
transition: background-color 0.2s;
color: var(--app-primary-foreground);
font-size: var(--vscode-chat-font-size, 13px);
}
.btn-ghost:hover,
.btn-ghost:focus {
background: var(--app-ghost-button-hover-background);
}
/* Icon-only button, compact square */
.btn-icon-compact {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border-radius: var(--app-radius-sm, 4px);
background: transparent;
border: 1px solid transparent;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
color: var(--app-secondary-foreground);
}
.btn-icon-compact:hover {
background-color: var(--app-ghost-button-hover-background);
}
.btn-icon-compact svg {
width: 16px;
height: 16px;
}
/* Active/primary state for icon button */
.btn-icon-compact--active {
background-color: var(--app-primary, #3b82f6);
color: white;
}
/* Text button (icon + label) */
.btn-text-compact {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 4px;
border-radius: 2px;
cursor: pointer;
background: transparent;
border: 0;
min-width: 0;
flex-shrink: 1;
font-size: 0.85em;
transition: background-color 0.15s;
color: var(--app-secondary-foreground);
}
.btn-text-compact:hover {
background-color: var(--app-ghost-button-hover-background);
}
.btn-text-compact > svg {
height: 1em;
width: 1em;
flex-shrink: 0;
}
.btn-text-compact > span {
display: inline-block;
min-width: 0;
max-width: 200px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
vertical-align: middle;
}
/* ===========================
Context Indicator Styles
=========================== */
.context-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 4px;
border-radius: var(--app-radius-sm, 4px);
font-size: 0.8em;
user-select: none;
color: var(--app-secondary-foreground);
}
.context-indicator svg {
width: 20px;
height: 20px;
}
.context-indicator__track,
.context-indicator__progress {
fill: none;
stroke-width: 2.5;
}
.context-indicator__track {
stroke: var(--app-secondary-foreground);
opacity: 0.35;
}
.context-indicator__progress {
stroke: var(--app-secondary-foreground);
stroke-linecap: round;
}
/* ===========================
Composer Styles (Input Form)
=========================== */
.composer-root {
position: absolute;
bottom: 16px;
left: 16px;
right: 16px;
display: flex;
flex-direction: column;
z-index: 20;
}
.composer-form {
position: relative;
display: flex;
flex-direction: column;
max-width: 680px;
margin: 0 auto;
border-radius: var(--app-radius-lg, 8px);
border: 1px solid var(--app-input-border);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: border-color 0.2s;
z-index: 1;
background: var(--app-input-secondary-background, var(--app-background-secondary));
color: var(--app-input-foreground);
}
.composer-form:focus-within {
border-color: var(--app-primary, #3b82f6);
box-shadow: 0 1px 2px rgba(59, 130, 246, 0.2);
}
.composer-input {
flex: 1;
align-self: stretch;
padding: 10px 14px;
outline: none;
overflow-y: auto;
position: relative;
user-select: text;
min-height: 1.5em;
max-height: 200px;
background: transparent;
border: 0;
border-radius: 0;
overflow-x: hidden;
word-break: break-word;
white-space: pre-wrap;
font-family: inherit;
font-size: var(--vscode-chat-font-size, 13px);
color: var(--app-input-foreground);
}
.composer-input:empty::before,
.composer-input[data-empty='true']::before {
content: attr(data-placeholder);
color: var(--app-input-placeholder-foreground);
pointer-events: none;
position: absolute;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: calc(100% - 28px);
}
.composer-input:focus {
outline: none;
}
.composer-input:disabled,
.composer-input[contenteditable='false'] {
color: #999;
cursor: not-allowed;
}
.composer-actions {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
z-index: 1;
padding: 5px;
color: var(--app-secondary-foreground);
border-top: 0.5px solid var(--app-input-border);
}
.composer-overlay {
position: absolute;
inset: 0;
border-radius: var(--app-radius-lg, 8px);
z-index: 0;
background: var(--app-input-background);
}
/* Send button in composer */
.btn-send-compact {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
margin-left: auto;
border-radius: var(--app-radius-sm, 4px);
background-color: var(--app-primary, #3b82f6);
color: white;
border: none;
cursor: pointer;
}
.btn-send-compact:hover:not(:disabled) {
filter: brightness(1.1);
}
.btn-send-compact:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ===========================
File Link Styles
=========================== */
.toolcall-content-wrapper .file-link-path {
font-size: 0.85em;
padding-top: 1px;
word-break: break-all;
min-width: 0;
font-family: var(--app-font-mono);
color: var(--app-link-foreground);
overflow-wrap: anywhere;
}
/* ===========================
Icon SVG Styles
=========================== */
.icon-svg {
display: block;
}
/* ===========================
Utility: Line Clamp
=========================== */
.line-clamp-3 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}

View file

@ -3,133 +3,15 @@
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Unified timeline styles for tool calls and messages
* Shared timeline styles - base classes only
* Individual component styles are in their respective CSS files:
* - LayoutComponents.css (tool calls)
* - AssistantMessage.css (assistant messages)
*
* Timeline is controlled via data-first and data-last attributes
*/
/* ==========================================
ToolCallContainer timeline styles
========================================== */
.toolcall-container {
position: relative;
padding-left: 30px;
padding-top: 8px;
padding-bottom: 8px;
}
/* ToolCallContainer timeline connector */
.toolcall-container::after {
content: '';
position: absolute;
left: 12px;
top: 0;
bottom: 0;
width: 1px;
background-color: var(--app-primary-border-color);
}
/* First item: connector starts from status point position */
.toolcall-container:first-child::after {
top: 24px;
}
/* Last item: connector shows only upper part */
.toolcall-container:last-child::after {
height: calc(100% - 24px);
top: 0;
bottom: auto;
}
/* ==========================================
AssistantMessage timeline styles
========================================== */
.assistant-message-container {
position: relative;
padding-left: 30px;
padding-top: 8px;
padding-bottom: 8px;
}
/* AssistantMessage timeline connector */
.assistant-message-container::after {
content: '';
position: absolute;
left: 12px;
top: 0;
bottom: 0;
width: 1px;
background-color: var(--app-primary-border-color);
}
/* First item: connector starts from status point position */
.assistant-message-container:first-child::after {
top: 24px;
}
/* Last item: connector shows only upper part */
.assistant-message-container:last-child::after {
height: calc(100% - 24px);
top: 0;
bottom: auto;
}
/* ==========================================
Custom timeline styles for qwen-message message-item elements
========================================== */
/* Default connector style - creates full-height connectors for all AI message items */
.qwen-message.message-item:not(.user-message-container)::after {
content: '';
position: absolute;
left: 12px;
top: 0;
bottom: 0;
width: 1px;
background-color: var(--app-primary-border-color);
z-index: 0;
}
/* Single-item AI sequence (both a start and an end): hide the connector entirely */
.qwen-message.message-item:not(.user-message-container):is(
:first-child,
.user-message-container
+ .qwen-message.message-item:not(.user-message-container),
.chat-messages
> :not(.qwen-message.message-item)
+ .qwen-message.message-item:not(.user-message-container)
):is(
:has(+ .user-message-container),
:has(+ :not(.qwen-message.message-item)),
:last-child
)::after {
display: none;
}
/* Handle the start of each AI message sequence - includes the first AI message in the entire message list and new AI messages interrupted by user messages */
.qwen-message.message-item:not(.user-message-container):first-child::after,
.user-message-container + .qwen-message.message-item:not(.user-message-container)::after,
/* If the previous sibling is not .qwen-message.message-item (such as waiting prompts, sentinel elements, or card-style tool calls), also treat as a new group start */
.chat-messages > :not(.qwen-message.message-item)
+ .qwen-message.message-item:not(.user-message-container)::after {
top: 15px;
}
/* Handle the end of each AI message sequence */
/* When the next sibling is a user message */
.qwen-message.message-item:not(.user-message-container):has(+ .user-message-container)::after,
/* Or when the next sibling is not .qwen-message.message-item (such as waiting prompts, sentinel elements, card-style tool calls, etc.) */
.qwen-message.message-item:not(.user-message-container):has(+ :not(.qwen-message.message-item))::after,
/* When it's truly the last child element of the parent container */
.qwen-message.message-item:not(.user-message-container):last-child::after {
/* Note: When setting both top and bottom, the height is (container height - top - bottom).
* Here we expect "15px spacing at the bottom", so bottom should be 15px (not calc(100% - 15px)). */
top: 0;
bottom: calc(100% - 15px);
}
.user-message-container:first-child {
margin-top: 0;
}
/* Base message item styles */
.message-item {
padding: 8px 0;
width: 100%;
@ -137,6 +19,13 @@
padding-left: 30px;
user-select: text;
position: relative;
padding-top: 8px;
padding-bottom: 8px;
}
/* User message container spacing */
.user-message-container {
margin-top: 16px;
}
.user-message-container:first-child {
margin-top: 0;
}