diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx index 3c04525c2..2761ace87 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx @@ -16,10 +16,6 @@ import type { import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js'; import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js'; -// Re-export base types for convenience -export type { EditModeInfo, EditModeIconType } from '@qwen-code/webui'; -export { getEditModeIcon } from '@qwen-code/webui'; - /** * Extended props that accept ApprovalModeValue */ diff --git a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx index 2eddc4d39..79c8791f2 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx @@ -2,48 +2,25 @@ * @license * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 + * + * VSCode-specific Onboarding adapter + * Uses webui Onboarding component with platform-specific icon URL */ +import type React from 'react'; +import { Onboarding as BaseOnboarding } from '@qwen-code/webui'; import { generateIconUrl } from '../../utils/resourceUrl.js'; interface OnboardingPageProps { onLogin: () => void; } +/** + * VSCode Onboarding wrapper + * Provides platform-specific icon URL to the webui Onboarding component + */ export const Onboarding: React.FC = ({ onLogin }) => { const iconUri = generateIconUrl('icon.png'); - return ( -
-
-
- {/* Application icon container */} -
- Qwen Code Logo -
- -
-

- Welcome to Qwen Code -

-

- Unlock the power of AI to understand, navigate, and transform your - codebase faster than ever before. -

-
- - -
-
-
- ); + return ; }; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx index f557084b5..24741a567 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx @@ -4,23 +4,23 @@ * SPDX-License-Identifier: Apache-2.0 * * Tool call component factory - routes to specialized components by kind + * All UI components are now imported from @qwen-code/webui */ import type React from 'react'; import { shouldShowToolCall, - // Pure UI ToolCall components from webui + // All ToolCall components from webui GenericToolCall, ThinkToolCall, EditToolCall, WriteToolCall, SearchToolCall, UpdatedPlanToolCall, + ShellToolCall, + ReadToolCall, } from '@qwen-code/webui'; import type { BaseToolCallProps } from '@qwen-code/webui'; -// VSCode-specific components (have platform dependencies) -import { ReadToolCall } from './Read/ReadToolCall.js'; -import { ShellToolCall } from './Shell/ShellToolCall.js'; /** * Factory function that returns the appropriate tool call component based on kind @@ -33,6 +33,9 @@ export const getToolCallComponent = ( // Route to specialized components switch (normalizedKind) { case 'read': + case 'read_many_files': + case 'list_directory': + case 'ls': return ReadToolCall; case 'write': diff --git a/packages/webui/WEBUI_PLATFORM_ADAPTER_GUIDE_ZH.md b/packages/webui/WEBUI_PLATFORM_ADAPTER_GUIDE_ZH.md new file mode 100644 index 000000000..3be71a22b --- /dev/null +++ b/packages/webui/WEBUI_PLATFORM_ADAPTER_GUIDE_ZH.md @@ -0,0 +1,121 @@ +## WebUI 平台适配指引(Chrome / Web / Share) + +本指引用于后续扩展 `@qwen-code/webui` 到新的运行平台(例如 Chrome 扩展、纯 Web 页、分享页)。 +VSCode 的适配实现可参考: +`packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx` + +--- + +### 1. 核心目标 + +- 在 **不改 UI 组件** 的前提下复用 WebUI。 +- 用 `PlatformProvider` 注入平台能力(消息、文件、登录、剪贴板等)。 +- 针对缺失能力,提供**降级方案**或标记 `features`。 + +--- + +### 2. PlatformContext 要点(最小实现) + +必需字段: + +- `platform`: `'chrome' | 'web' | 'share'` +- `postMessage`: 发送消息到宿主 +- `onMessage`: 订阅宿主消息 + +可选能力(按平台支持): + +- `openFile` +- `openDiff` +- `openTempFile` +- `attachFile` +- `login` +- `copyToClipboard` +- `getResourceUrl` +- `features`(标记能力可用性) + +类型定义位置: +`packages/webui/src/context/PlatformContext.tsx` + +--- + +### 3. 适配步骤(建议流程) + +1. **搭建消息通道** + - Chrome 扩展:`chrome.runtime.sendMessage` + `chrome.runtime.onMessage` + - Web/Share:`window.postMessage` + `message` 事件,或自定义事件总线 + +2. **实现 PlatformProvider** + - 将平台 API 映射到 `PlatformContextValue` + - 缺失能力返回 `undefined`,并设置 `features` + +3. **应用入口接入** + - 在平台入口包裹 `` + - 确保所有 UI 组件处于 Provider 内 + +4. **样式与主题** + - 引入 `@qwen-code/webui/styles.css` + - 在平台侧定义 CSS 变量(可从 `packages/webui/src/styles/variables.css` 复制初始值) + +5. **构建与依赖** + - Tailwind 使用 `@qwen-code/webui/tailwind.preset` + - `content` 需要包含 `node_modules/@qwen-code/webui/dist/**/*.js` + +6. **功能验收** + - 消息收发正常(`postMessage`/`onMessage`) + - 点击文件/差异输出不报错(可降级) + - `@`/`/` 补全与输入框交互正常 + +--- + +### 4. 参考实现(Web 平台示例) + +```tsx +import type React from 'react'; +import { PlatformProvider } from '@qwen-code/webui'; +import type { PlatformContextValue } from '@qwen-code/webui'; + +const platformValue: PlatformContextValue = { + platform: 'web', + postMessage: (message) => { + window.postMessage(message, '*'); + }, + onMessage: (handler) => { + const listener = (event: MessageEvent) => handler(event.data); + window.addEventListener('message', listener); + return () => window.removeEventListener('message', listener); + }, + copyToClipboard: async (text) => navigator.clipboard.writeText(text), + features: { + canCopy: true, + }, +}; + +export const WebPlatformProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => {children}; +``` + +--- + +### 5. Chrome 扩展建议映射 + +- `postMessage` -> `chrome.runtime.sendMessage` +- `onMessage` -> `chrome.runtime.onMessage.addListener` +- `openFile`/`openDiff` -> 触发 background 脚本打开 tab / side panel +- `attachFile` -> `chrome.tabs` 或 `` + +--- + +### 6. Web/Share 场景的降级策略 + +- `openFile/openDiff`:用新窗口/模态框展示内容 +- `openTempFile`:生成 `Blob` 并打开或下载 +- `login`:跳转到登录 URL 或弹出登录窗口 + +--- + +### 7. 常见坑 + +- Tailwind 样式未生效:`content` 缺少 `@qwen-code/webui` +- 主题色失效:未加载 `styles.css` 或未设置 CSS 变量 +- `postMessage` 无响应:宿主侧未注册对应消息通道 diff --git a/packages/webui/src/components/layout/Onboarding.stories.tsx b/packages/webui/src/components/layout/Onboarding.stories.tsx new file mode 100644 index 000000000..475437379 --- /dev/null +++ b/packages/webui/src/components/layout/Onboarding.stories.tsx @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Onboarding } from './Onboarding.js'; + +/** + * Onboarding is the welcome screen shown to new users. + * It displays the app logo, welcome message, and a get started button. + */ +const meta: Meta = { + title: 'Layout/Onboarding', + component: Onboarding, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +/** + * Default onboarding screen + */ +export const Default: Story = { + args: { + onGetStarted: () => console.log('Get started clicked'), + }, +}; + +/** + * With custom icon URL + */ +export const WithIcon: Story = { + args: { + iconUrl: 'https://via.placeholder.com/80', + onGetStarted: () => console.log('Get started clicked'), + }, +}; + +/** + * Custom app name and messages + */ +export const CustomBranding: Story = { + args: { + iconUrl: 'https://via.placeholder.com/80', + appName: 'My AI Assistant', + subtitle: + 'Your personal coding companion powered by advanced AI technology.', + buttonText: 'Start Coding Now', + onGetStarted: () => console.log('Get started clicked'), + }, +}; + +/** + * Minimal (no icon) + */ +export const NoIcon: Story = { + args: { + appName: 'Code Helper', + subtitle: 'Simple and powerful code assistance.', + buttonText: 'Begin', + onGetStarted: () => console.log('Get started clicked'), + }, +}; diff --git a/packages/webui/src/components/layout/Onboarding.tsx b/packages/webui/src/components/layout/Onboarding.tsx new file mode 100644 index 000000000..bdf1a2652 --- /dev/null +++ b/packages/webui/src/components/layout/Onboarding.tsx @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Onboarding component - Pure UI welcome screen + * Platform-specific logic (icon URL) passed via props + */ + +import type React from 'react'; + +export interface OnboardingProps { + /** URL of the application icon */ + iconUrl?: string; + /** Callback when user clicks the get started button */ + onGetStarted: () => void; + /** Application name (defaults to "Qwen Code") */ + appName?: string; + /** Welcome message subtitle */ + subtitle?: string; + /** Button text (defaults to "Get Started with Qwen Code") */ + buttonText?: string; +} + +/** + * Onboarding - Welcome screen for new users + * Pure presentational component + */ +export const Onboarding: React.FC = ({ + iconUrl, + onGetStarted, + appName = 'Qwen Code', + subtitle = 'Unlock the power of AI to understand, navigate, and transform your codebase faster than ever before.', + buttonText = 'Get Started with Qwen Code', +}) => ( +
+
+
+ {/* Application icon container */} + {iconUrl && ( +
+ {`${appName} +
+ )} + +
+

+ Welcome to {appName} +

+

+ {subtitle} +

+
+ + +
+
+
+); + +export default Onboarding; diff --git a/packages/webui/src/components/toolcalls/ReadToolCall.stories.tsx b/packages/webui/src/components/toolcalls/ReadToolCall.stories.tsx new file mode 100644 index 000000000..ac87e1ce7 --- /dev/null +++ b/packages/webui/src/components/toolcalls/ReadToolCall.stories.tsx @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ReadToolCall } from './ReadToolCall.js'; + +/** + * ReadToolCall displays file reading operations. + * Shows the file name being read with appropriate status indicators. + */ +const meta: Meta = { + title: 'ToolCalls/ReadToolCall', + component: ReadToolCall, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +/** + * Successfully read a file + */ +export const Success: Story = { + args: { + toolCall: { + toolCallId: 'read-1', + kind: 'read', + title: 'Read file', + status: 'completed', + locations: [{ path: 'src/components/Button.tsx', line: 1 }], + }, + }, +}; + +/** + * Reading file in progress (loading state) + */ +export const Loading: Story = { + args: { + toolCall: { + toolCallId: 'read-2', + kind: 'read', + title: 'Read file', + status: 'in_progress', + locations: [{ path: 'src/utils/helpers.ts' }], + }, + }, +}; + +/** + * Read file with error + */ +export const WithError: Story = { + args: { + toolCall: { + toolCallId: 'read-3', + kind: 'read', + title: 'Read file', + status: 'failed', + content: [ + { + type: 'content', + content: { + type: 'error', + error: 'File not found: src/missing-file.ts', + }, + }, + ], + locations: [{ path: 'src/missing-file.ts' }], + }, + }, +}; + +/** + * Read multiple files + */ +export const ReadManyFiles: Story = { + args: { + toolCall: { + toolCallId: 'read-4', + kind: 'read_many_files', + title: 'Read multiple files', + status: 'completed', + locations: [ + { path: 'src/index.ts' }, + { path: 'src/App.tsx' }, + { path: 'src/main.ts' }, + ], + }, + }, +}; + +/** + * List directory operation + */ +export const ListDirectory: Story = { + args: { + toolCall: { + toolCallId: 'read-5', + kind: 'list_directory', + title: 'List directory', + status: 'completed', + locations: [{ path: 'src/components' }], + }, + }, +}; + +/** + * Read with diff content + */ +export const WithDiff: Story = { + args: { + toolCall: { + toolCallId: 'read-6', + kind: 'read', + title: 'Read file with diff', + status: 'completed', + content: [ + { + type: 'diff', + path: 'src/config.ts', + oldText: 'const debug = false;', + newText: 'const debug = true;', + }, + ], + locations: [{ path: 'src/config.ts' }], + }, + }, +}; + +/** + * Long file path + */ +export const LongFilePath: Story = { + args: { + toolCall: { + toolCallId: 'read-7', + kind: 'read', + title: 'Read file', + status: 'completed', + locations: [ + { + path: 'packages/vscode-ide-companion/src/webview/components/messages/toolcalls/ReadToolCall.tsx', + line: 42, + }, + ], + }, + }, +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx b/packages/webui/src/components/toolcalls/ReadToolCall.tsx similarity index 66% rename from packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx rename to packages/webui/src/components/toolcalls/ReadToolCall.tsx index c494e0b3c..874dca50c 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx +++ b/packages/webui/src/components/toolcalls/ReadToolCall.tsx @@ -3,23 +3,27 @@ * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 * - * Read tool call component - specialized for file reading operations + * Read tool call component - displays file reading operations + * Pure UI component - platform interactions via usePlatform hook */ import type React from 'react'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { FileLink } from '../layout/FileLink.js'; import { - FileLink, groupContent, mapToolStatusToContainerStatus, - usePlatform, -} from '@qwen-code/webui'; +} from './shared/index.js'; +import { usePlatform } from '../../context/PlatformContext.js'; import type { BaseToolCallProps, ToolCallContainerProps, -} from '@qwen-code/webui'; +} from './shared/index.js'; -export const ToolCallContainer: React.FC = ({ +/** + * Simple container for Read tool calls + */ +const ReadToolCallContainer: React.FC = ({ label, status = 'success', children, @@ -49,15 +53,17 @@ export const ToolCallContainer: React.FC = ({ ); /** - * Specialized component for Read tool calls - * Optimized for displaying file reading operations + * ReadToolCall - displays file reading operations * Shows: Read filename (no content preview) */ export const ReadToolCall: React.FC = ({ toolCall }) => { const { kind, content, locations, toolCallId } = toolCall; const platform = usePlatform(); + const openedDiffsRef = useRef>(new Map()); - // Map tool call kind to appropriate display name + /** + * Map tool call kind to appropriate display name + */ const getDisplayLabel = (): string => { const normalizedKind = kind.toLowerCase(); if (normalizedKind === 'read_many_files') { @@ -67,15 +73,17 @@ export const ReadToolCall: React.FC = ({ toolCall }) => { } else if (normalizedKind === 'skill') { return 'Skill'; } else { - return 'ReadFile'; // default for read_file tools + return 'ReadFile'; } }; // Group content by type; memoize to avoid new array identities on every render const { errors, diffs } = useMemo(() => groupContent(content), [content]); - // Post a message to the extension host to open a VS Code diff tab - const handleOpenDiffInternal = useCallback( + /** + * Open diff view (if platform supports it) + */ + const handleOpenDiff = useCallback( ( path: string | undefined, oldText: string | null | undefined, @@ -88,6 +96,7 @@ export const ReadToolCall: React.FC = ({ toolCall }) => { platform.openDiff(path, oldText, newText); return; } + // Fallback: post message for platforms that handle it differently platform.postMessage({ type: 'openDiff', data: { @@ -100,40 +109,43 @@ export const ReadToolCall: React.FC = ({ toolCall }) => { [platform], ); - // Auto-open diff when a read call returns diff content. - // Only trigger once per toolCallId so we don't spam as in-progress updates stream in. + // Auto-open diff when a read call returns diff content (once per diff signature) useEffect(() => { - if (diffs.length > 0) { - const firstDiff = diffs[0]; - const path = firstDiff.path || (locations && locations[0]?.path) || ''; - - if ( - path && - firstDiff.oldText !== undefined && - firstDiff.newText !== undefined - ) { - const timer = setTimeout(() => { - handleOpenDiffInternal(path, firstDiff.oldText, firstDiff.newText); - }, 100); - return () => timer && clearTimeout(timer); - } + if (diffs.length === 0) { + return; } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [toolCallId]); - // Compute container status based on toolCall.status (pending/in_progress -> loading) - const containerStatus: - | 'success' - | 'error' - | 'warning' - | 'loading' - | 'default' = mapToolStatusToContainerStatus(toolCall.status); + const firstDiff = diffs[0]; + const path = firstDiff.path || locations?.[0]?.path || ''; + if (!path) { + return; + } + + if (firstDiff.oldText === undefined || firstDiff.newText === undefined) { + return; + } + + const signature = `${path}:${firstDiff.oldText ?? ''}:${firstDiff.newText ?? ''}`; + const lastSignature = openedDiffsRef.current.get(toolCallId); + if (lastSignature === signature) { + return; + } + + openedDiffsRef.current.set(toolCallId, signature); + const timer = setTimeout(() => { + handleOpenDiff(path, firstDiff.oldText, firstDiff.newText); + }, 100); + return () => clearTimeout(timer); + }, [diffs, handleOpenDiff, locations, toolCallId]); + + // Compute container status based on toolCall.status + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); // Error case: show error if (errors.length > 0) { const path = locations?.[0]?.path || ''; return ( - = ({ toolCall }) => { } > {errors.join('\n')} - + ); } - // Success case with diff: keep UI compact; VS Code diff is auto-opened above + // Success case with diff if (diffs.length > 0) { const path = diffs[0]?.path || locations?.[0]?.path || ''; return ( - = ({ toolCall }) => { } > {null} - + ); } - // Success case: show which file was read with filename in label + // Success case: show which file was read if (locations && locations.length > 0) { const path = locations[0].path; return ( - = ({ toolCall }) => { } > {null} - + ); } diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.css b/packages/webui/src/components/toolcalls/ShellToolCall.css similarity index 91% rename from packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.css rename to packages/webui/src/components/toolcalls/ShellToolCall.css index 363062ab0..bf4ec3c56 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.css +++ b/packages/webui/src/components/toolcalls/ShellToolCall.css @@ -43,7 +43,7 @@ text-align: left; opacity: 50%; padding: 4px 8px 4px 4px; - font-family: var(--app-monospace-font-family); + font-family: var(--app-font-mono); font-size: 0.85em; } @@ -68,14 +68,14 @@ .bash-toolcall-pre { margin-block: 0; overflow: hidden; - font-family: var(--app-monospace-font-family); + font-family: var(--app-font-mono); font-size: 0.85em; } .bash-toolcall-code { margin: 0; padding: 0; - font-family: var(--app-monospace-font-family); + font-family: var(--app-font-mono); font-size: 0.85em; } @@ -135,7 +135,7 @@ text-align: left; opacity: 50%; padding: 4px 8px 4px 4px; - font-family: var(--app-monospace-font-family); + font-family: var(--app-font-mono); font-size: 0.85em; } @@ -160,14 +160,14 @@ .execute-toolcall-pre { margin-block: 0; overflow: hidden; - font-family: var(--app-monospace-font-family); + font-family: var(--app-font-mono); font-size: 0.85em; } .execute-toolcall-code { margin: 0; padding: 0; - font-family: var(--app-monospace-font-family); + font-family: var(--app-font-mono); font-size: 0.85em; } @@ -188,4 +188,3 @@ position: relative; grid-template-columns: max-content 1fr max-content; } - diff --git a/packages/webui/src/components/toolcalls/ShellToolCall.stories.tsx b/packages/webui/src/components/toolcalls/ShellToolCall.stories.tsx new file mode 100644 index 000000000..1ff26c4e3 --- /dev/null +++ b/packages/webui/src/components/toolcalls/ShellToolCall.stories.tsx @@ -0,0 +1,183 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ShellToolCall } from './ShellToolCall.js'; + +/** + * ShellToolCall displays bash/execute command operations. + * Shows command input (IN) and output (OUT) in a card layout. + */ +const meta: Meta = { + title: 'ToolCalls/ShellToolCall', + component: ShellToolCall, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +/** + * Bash command with successful output + */ +export const BashWithOutput: Story = { + args: { + toolCall: { + toolCallId: 'bash-1', + kind: 'bash', + title: 'ls -la', + status: 'completed', + rawInput: { command: 'ls -la' }, + content: [ + { + type: 'content', + content: { + type: 'text', + text: 'total 24\ndrwxr-xr-x 5 user staff 160 Jan 16 10:00 .\ndrwxr-xr-x 10 user staff 320 Jan 16 09:00 ..\n-rw-r--r-- 1 user staff 1234 Jan 16 10:00 package.json\n-rw-r--r-- 1 user staff 567 Jan 16 10:00 tsconfig.json', + }, + }, + ], + }, + }, +}; + +/** + * Bash command without output (just ran successfully) + */ +export const BashNoOutput: Story = { + args: { + toolCall: { + toolCallId: 'bash-2', + kind: 'bash', + title: 'mkdir -p src/components', + status: 'completed', + rawInput: { command: 'mkdir -p src/components' }, + }, + }, +}; + +/** + * Bash command with error + */ +export const BashWithError: Story = { + args: { + toolCall: { + toolCallId: 'bash-3', + kind: 'bash', + title: 'rm -rf /protected', + status: 'failed', + rawInput: { command: 'rm -rf /protected' }, + content: [ + { + type: 'content', + content: { + type: 'error', + error: 'rm: /protected: Permission denied', + }, + }, + ], + }, + }, +}; + +/** + * Bash command in progress (loading state) + */ +export const BashLoading: Story = { + args: { + toolCall: { + toolCallId: 'bash-4', + kind: 'bash', + title: 'npm install', + status: 'in_progress', + rawInput: { command: 'npm install' }, + }, + }, +}; + +/** + * Execute variant with description + */ +export const ExecuteWithDescription: Story = { + args: { + toolCall: { + toolCallId: 'execute-1', + kind: 'execute', + title: 'Run unit tests', + status: 'completed', + rawInput: { + command: 'npm test', + description: 'Run unit tests', + }, + content: [ + { + type: 'content', + content: { + type: 'text', + text: 'PASS src/utils.test.ts\n ✓ should format date correctly (5ms)\n ✓ should parse input (2ms)\n\nTest Suites: 1 passed, 1 total\nTests: 2 passed, 2 total', + }, + }, + ], + }, + }, +}; + +/** + * Execute variant with long output (truncated) + */ +export const ExecuteLongOutput: Story = { + args: { + toolCall: { + toolCallId: 'execute-2', + kind: 'execute', + title: 'Build project', + status: 'completed', + rawInput: { + command: 'npm run build', + description: 'Build project', + }, + content: [ + { + type: 'content', + content: { + type: 'text', + text: Array(100) + .fill('Building module...') + .map((s, i) => `[${i + 1}/100] ${s}`) + .join('\n'), + }, + }, + ], + }, + }, +}; + +/** + * Command variant (alias for bash) + */ +export const CommandVariant: Story = { + args: { + toolCall: { + toolCallId: 'command-1', + kind: 'command', + title: 'git status', + status: 'completed', + rawInput: { command: 'git status' }, + content: [ + { + type: 'content', + content: { + type: 'text', + text: "On branch main\nYour branch is up to date with 'origin/main'.\n\nnothing to commit, working tree clean", + }, + }, + ], + }, + }, +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.tsx b/packages/webui/src/components/toolcalls/ShellToolCall.tsx similarity index 90% rename from packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.tsx rename to packages/webui/src/components/toolcalls/ShellToolCall.tsx index a488d2603..2ac4030cf 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.tsx +++ b/packages/webui/src/components/toolcalls/ShellToolCall.tsx @@ -3,26 +3,30 @@ * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 * - * Shared Shell tool call component for Execute/Bash/Command + * Shell tool call component for Execute/Bash/Command + * Pure UI component - platform interactions via usePlatform hook */ import type React from 'react'; import { - ToolCallContainer as SharedToolCallContainer, + ToolCallContainer, CopyButton, safeTitle, groupContent, - usePlatform, -} from '@qwen-code/webui'; +} from './shared/index.js'; +import { usePlatform } from '../../context/PlatformContext.js'; import type { BaseToolCallProps, ToolCallContainerProps, -} from '@qwen-code/webui'; +} from './shared/index.js'; import './ShellToolCall.css'; type ShellVariant = 'execute' | 'bash'; +/** + * Custom container for Execute variant with different styling + */ const ExecuteToolCallContainer: React.FC = ({ label, status = 'success', @@ -50,6 +54,9 @@ const ExecuteToolCallContainer: React.FC = ({ ); +/** + * Get command text from tool call data + */ const getCommandText = ( variant: ShellVariant, title: unknown, @@ -65,6 +72,9 @@ const getCommandText = ( return safeTitle(title); }; +/** + * Get input command from raw input + */ const getInputCommand = ( commandText: string, rawInput?: string | object, @@ -80,8 +90,7 @@ const getInputCommand = ( }; /** - * Shared component for Execute/Bash tool calls - * Shows: Shell bullet + description + IN/OUT card + * Shell tool call implementation */ const ShellToolCallImpl: React.FC< BaseToolCallProps & { variant: ShellVariant } @@ -90,11 +99,15 @@ const ShellToolCallImpl: React.FC< const classPrefix = variant; const platform = usePlatform(); + /** + * Open content in a temporary file (if platform supports it) + */ const openTempFile = (content: string, fileName: string) => { if (platform.openTempFile) { platform.openTempFile(content, fileName); return; } + // Fallback: post message for platforms that handle it differently platform.postMessage({ type: 'createAndOpenTempFile', data: { @@ -103,11 +116,12 @@ const ShellToolCallImpl: React.FC< }, }); }; + const commandText = getCommandText(variant, title, rawInput); const inputCommand = getInputCommand(commandText, rawInput); const Container = - variant === 'execute' ? ExecuteToolCallContainer : SharedToolCallContainer; + variant === 'execute' ? ExecuteToolCallContainer : ToolCallContainer; // Group content by type const { textOutputs, errors } = groupContent(content); @@ -245,6 +259,10 @@ const ShellToolCallImpl: React.FC< ); }; +/** + * ShellToolCall - displays bash/execute command tool calls + * Shows command input and output with IN/OUT cards + */ export const ShellToolCall: React.FC = (props) => { const normalizedKind = props.toolCall.kind.toLowerCase(); const variant: ShellVariant = diff --git a/packages/webui/src/components/toolcalls/index.ts b/packages/webui/src/components/toolcalls/index.ts index bd121c9af..81e03b0ed 100644 --- a/packages/webui/src/components/toolcalls/index.ts +++ b/packages/webui/src/components/toolcalls/index.ts @@ -14,5 +14,7 @@ export { EditToolCall } from './EditToolCall.js'; export { WriteToolCall } from './WriteToolCall.js'; export { SearchToolCall } from './SearchToolCall.js'; export { UpdatedPlanToolCall } from './UpdatedPlanToolCall.js'; +export { ShellToolCall } from './ShellToolCall.js'; +export { ReadToolCall } from './ReadToolCall.js'; export { CheckboxDisplay } from './CheckboxDisplay.js'; export type { CheckboxDisplayProps } from './CheckboxDisplay.js'; diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts index be6d8d6ee..2054d9e1c 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -6,6 +6,10 @@ // eslint-disable-next-line import/no-internal-modules import './styles/variables.css'; +// eslint-disable-next-line import/no-internal-modules +import './styles/timeline.css'; +// eslint-disable-next-line import/no-internal-modules +import './styles/components.css'; // Shared UI Components Export // Export all shared components from this package @@ -49,6 +53,8 @@ export type { EditModeInfo, EditModeIconType, } from './components/layout/InputForm'; +export { Onboarding } from './components/layout/Onboarding'; +export type { OnboardingProps } from './components/layout/Onboarding'; // Message components export { default as Message } from './components/messages/Message'; @@ -112,6 +118,8 @@ export { WriteToolCall, SearchToolCall, UpdatedPlanToolCall, + ShellToolCall, + ReadToolCall, CheckboxDisplay, } from './components/toolcalls'; export type { diff --git a/packages/webui/src/styles/components.css b/packages/webui/src/styles/components.css new file mode 100644 index 000000000..787d6ddbc --- /dev/null +++ b/packages/webui/src/styles/components.css @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Common component styles for webui + */ + +/* =========================== + Animations + =========================== */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } +} + +@keyframes typingPulse { + 0%, + 60%, + 100% { + transform: scale(0.7); + opacity: 0.6; + } + 30% { + transform: scale(1); + opacity: 1; + } +} + +/* =========================== + Code Block Styles + =========================== */ +.code-block { + font-family: var(--app-font-mono); + font-size: var(--app-monospace-font-size, 13px); + background: var(--app-primary-background); + border: 1px solid var(--app-input-border); + border-radius: var(--app-radius-sm, 4px); + padding: var(--app-spacing-medium, 8px); + overflow-x: auto; + margin: 4px 0 0 0; + white-space: pre-wrap; + word-break: break-word; + max-height: 300px; + overflow-y: auto; +} + +/* =========================== + Diff Display Styles + =========================== */ +.diff-display-container { + margin: 8px 0; + border: 1px solid var(--app-input-border); + border-radius: var(--app-radius-md, 6px); + overflow: hidden; +} + +.diff-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--app-input-secondary-background, var(--app-background-secondary)); + border-bottom: 1px solid var(--app-input-border); +} + +.diff-file-path { + font-family: var(--app-font-mono); + font-size: 13px; + color: var(--app-primary-foreground); +} + +.open-diff-button { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: transparent; + border: 1px solid var(--app-input-border); + border-radius: var(--app-radius-sm, 4px); + color: var(--app-primary-foreground); + cursor: pointer; + font-size: 12px; + transition: background-color 0.15s; +} + +.open-diff-button:hover { + background: var(--app-ghost-button-hover-background); +} + +.open-diff-button svg { + width: 16px; + height: 16px; +} + +.diff-section { + margin: 0; +} + +.diff-label { + padding: 8px 12px; + background: var(--app-primary-background); + border-bottom: 1px solid var(--app-input-border); + font-size: 11px; + font-weight: 600; + color: var(--app-secondary-foreground); + text-transform: uppercase; +} + +.diff-section .code-block { + border: none; + border-radius: 0; + margin: 0; + max-height: none; + overflow-y: visible; +} + +.diff-section .code-content { + display: block; +} + +/* =========================== + Tool Call Card Styles + =========================== */ +.toolcall-card { + padding-left: 30px; +} + +/* Icon SVG styles */ +.icon-svg { + display: block; +} diff --git a/packages/webui/src/styles/timeline.css b/packages/webui/src/styles/timeline.css new file mode 100644 index 000000000..033e82d22 --- /dev/null +++ b/packages/webui/src/styles/timeline.css @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Unified timeline styles for tool calls and messages + */ + +/* ========================================== + 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; +} + +.message-item { + padding: 8px 0; + width: 100%; + align-items: flex-start; + padding-left: 30px; + user-select: text; + position: relative; + padding-top: 8px; + padding-bottom: 8px; +} diff --git a/packages/webui/src/styles/variables.css b/packages/webui/src/styles/variables.css index c4ea0c012..81e88966a 100644 --- a/packages/webui/src/styles/variables.css +++ b/packages/webui/src/styles/variables.css @@ -24,6 +24,7 @@ --app-background: #1e1e1e; --app-primary-background: #1e1e1e; --app-background-secondary: #252526; + --app-secondary-background: #252526; --app-background-tertiary: #2d2d2d; /* =========================== @@ -52,7 +53,24 @@ Typography =========================== */ --app-font-sans: system-ui, -apple-system, sans-serif; - --app-font-mono: ui-monospace, 'SF Mono', monospace; + --app-font-mono: var( + --app-monospace-font-family, + ui-monospace, + 'SF Mono', + monospace + ); + --app-monospace-font-size: 13px; + + /* =========================== + Link Styles + =========================== */ + --app-link-foreground: #007acc; + --app-link-active-foreground: #005a9e; + + /* =========================== + Brand Colors + =========================== */ + --app-qwen-ivory: #f5f5dc; /* =========================== Border Radius @@ -77,13 +95,18 @@ Input Styles =========================== */ --app-input-background: #3c3c3c; + --app-input-secondary-background: #2d2d2d; --app-input-border: #3f3f46; + --app-input-foreground: #e4e4e7; --app-input-placeholder-foreground: #71717a; /* =========================== Button Styles =========================== */ --app-ghost-button-hover-background: rgba(90, 93, 94, 0.31); + --app-button-background: #3c3c3c; + --app-button-foreground: #ffffff; + --app-transparent-inner-border: rgba(255, 255, 255, 0.1); /* =========================== Header Styles