mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-01 21:20:44 +00:00
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:
parent
1861557d15
commit
df787fff64
12 changed files with 1287 additions and 328 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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%,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue