diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 6c5be6727..1ed40e136 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -152,6 +152,7 @@ "vitest": "^3.2.4" }, "dependencies": { + "@qwen-code/webui": "workspace:*", "semver": "^7.7.2", "@modelcontextprotocol/sdk": "^1.25.1", "cors": "^2.8.5", diff --git a/packages/webui/example/ExampleComponent.tsx b/packages/webui/example/ExampleComponent.tsx new file mode 100644 index 000000000..c29800049 --- /dev/null +++ b/packages/webui/example/ExampleComponent.tsx @@ -0,0 +1,78 @@ +// Example of how to use shared UI components +// This would typically be integrated into existing components + +import React, { useState } from 'react'; +import { + Button, + Input, + Message, + PermissionDrawer, + Tooltip, +} from '@qwen-code/webui'; + +const ExampleComponent: React.FC = () => { + const [inputValue, setInputValue] = useState(''); + const [showPermissionDrawer, setShowPermissionDrawer] = useState(false); + + const handleConfirmPermission = () => { + console.log('Permissions confirmed'); + setShowPermissionDrawer(false); + }; + + return ( +
+

Shared Components Demo

+ + {/* Example of using shared Button component */} +
+ +
+ + {/* Example of using shared Input component */} +
+ +
+ + {/* Example of using shared Message component */} +
+ +
+ + {/* Example of using shared Tooltip component */} +
+ + + +
+ + {/* Example of using shared PermissionDrawer component */} + setShowPermissionDrawer(false)} + onConfirm={handleConfirmPermission} + permissions={[ + 'Access browser history', + 'Read current page', + 'Capture screenshots', + ]} + /> +
+ ); +}; + +export default ExampleComponent; diff --git a/packages/webui/package.json b/packages/webui/package.json new file mode 100644 index 000000000..757a6d812 --- /dev/null +++ b/packages/webui/package.json @@ -0,0 +1,36 @@ +{ + "name": "@qwen-code/webui", + "version": "0.1.0", + "description": "Shared UI components for Qwen Code packages", + "main": "dist/index.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts", + "type": "module", + "scripts": { + "build": "tsc && rollup -c", + "dev": "tsc --watch", + "lint": "eslint src --ext .ts,.tsx", + "lint:fix": "eslint src --ext .ts,.tsx --fix", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "devDependencies": { + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "typescript": "^5.0.0", + "@rollup/plugin-typescript": "^11.0.0", + "rollup": "^4.0.0", + "rollup-plugin-dts": "^6.0.0" + }, + "keywords": [ + "qwen", + "ui", + "components", + "shared" + ], + "author": "Qwen Team", + "license": "MIT" +} diff --git a/packages/webui/rollup.config.js b/packages/webui/rollup.config.js new file mode 100644 index 000000000..c6340858a --- /dev/null +++ b/packages/webui/rollup.config.js @@ -0,0 +1,47 @@ +import typescript from '@rollup/plugin-typescript'; +import { dts } from 'rollup-plugin-dts'; +import pkg from './package.json' with { type: 'json' }; + +const name = pkg.name; + +export default [ + // Browser-friendly version + { + input: 'src/index.ts', + output: { + name, + file: 'dist/index.min.js', + format: 'iife', + globals: { + react: 'React', + 'react-dom': 'ReactDOM', + }, + }, + external: ['react', 'react-dom'], + plugins: [ + typescript({ + tsconfig: './tsconfig.json', + }), + ], + }, + // ES module version + { + input: 'src/index.ts', + output: [ + { file: 'dist/index.esm.js', format: 'es' }, + { file: 'dist/index.cjs.js', format: 'cjs' }, + ], + external: ['react', 'react-dom'], + plugins: [ + typescript({ + tsconfig: './tsconfig.json', + }), + ], + }, + // Type declarations + { + input: 'dist/dts/src/index.d.ts', + output: [{ file: 'dist/index.d.ts', format: 'es' }], + plugins: [dts()], + }, +]; diff --git a/packages/webui/src/components/PermissionDrawer.tsx b/packages/webui/src/components/PermissionDrawer.tsx new file mode 100644 index 000000000..2805915cb --- /dev/null +++ b/packages/webui/src/components/PermissionDrawer.tsx @@ -0,0 +1,93 @@ +import type React from 'react'; +import { useState, useEffect } from 'react'; + +interface PermissionDrawerProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + permissions: string[]; +} + +const PermissionDrawer: React.FC = ({ + isOpen, + onClose, + onConfirm, + permissions, +}) => { + const [checkedPermissions, setCheckedPermissions] = useState( + Array(permissions.length).fill(false), + ); + + useEffect(() => { + if (!isOpen) { + setCheckedPermissions(Array(permissions.length).fill(false)); + } + }, [isOpen, permissions]); + + const handleTogglePermission = (index: number) => { + const newChecked = [...checkedPermissions]; + newChecked[index] = !newChecked[index]; + setCheckedPermissions(newChecked); + }; + + const handleConfirm = () => { + onConfirm(); + onClose(); + }; + + if (!isOpen) return null; + + return ( +
+
+
+

Permissions Required

+ +
+ +
+
    + {permissions.map((permission, index) => ( +
  • + handleTogglePermission(index)} + className="mr-2 h-4 w-4" + /> + {permission} +
  • + ))} +
+
+ +
+ + +
+
+
+ ); +}; + +export default PermissionDrawer; diff --git a/packages/webui/src/components/icons/CloseIcon.tsx b/packages/webui/src/components/icons/CloseIcon.tsx new file mode 100644 index 000000000..ac559d299 --- /dev/null +++ b/packages/webui/src/components/icons/CloseIcon.tsx @@ -0,0 +1,30 @@ +import type React from 'react'; + +interface CloseIconProps { + size?: number; + color?: string; + className?: string; +} + +const CloseIcon: React.FC = ({ + size = 24, + color = 'currentColor', + className = '', +}) => ( + + + + + ); + +export default CloseIcon; diff --git a/packages/webui/src/components/icons/Icon.tsx b/packages/webui/src/components/icons/Icon.tsx new file mode 100644 index 000000000..2f874d594 --- /dev/null +++ b/packages/webui/src/components/icons/Icon.tsx @@ -0,0 +1,38 @@ +import type React from 'react'; + +interface IconProps { + name: string; + size?: number; + color?: string; + className?: string; +} + +const Icon: React.FC = ({ + name, + size = 24, + color = 'currentColor', + className = '', +}) => + // This is a placeholder - in a real implementation you might use an icon library + ( + + + {name} + + + ) +; + +export default Icon; diff --git a/packages/webui/src/components/icons/SendIcon.tsx b/packages/webui/src/components/icons/SendIcon.tsx new file mode 100644 index 000000000..d2561e033 --- /dev/null +++ b/packages/webui/src/components/icons/SendIcon.tsx @@ -0,0 +1,30 @@ +import type React from 'react'; + +interface SendIconProps { + size?: number; + color?: string; + className?: string; +} + +const SendIcon: React.FC = ({ + size = 24, + color = 'currentColor', + className = '', +}) => ( + + + + + ); + +export default SendIcon; diff --git a/packages/webui/src/components/layout/Container.tsx b/packages/webui/src/components/layout/Container.tsx new file mode 100644 index 000000000..436d4c72e --- /dev/null +++ b/packages/webui/src/components/layout/Container.tsx @@ -0,0 +1,12 @@ +import type React from 'react'; + +interface ContainerProps { + children: React.ReactNode; + className?: string; +} + +const Container: React.FC = ({ children, className = '' }) => ( +
{children}
+ ); + +export default Container; diff --git a/packages/webui/src/components/layout/Footer.tsx b/packages/webui/src/components/layout/Footer.tsx new file mode 100644 index 000000000..557a083b1 --- /dev/null +++ b/packages/webui/src/components/layout/Footer.tsx @@ -0,0 +1,5 @@ +import type React from 'react'; + +const Footer: React.FC = () =>
Footer Component Placeholder
; + +export default Footer; diff --git a/packages/webui/src/components/layout/Header.tsx b/packages/webui/src/components/layout/Header.tsx new file mode 100644 index 000000000..2886c0dfb --- /dev/null +++ b/packages/webui/src/components/layout/Header.tsx @@ -0,0 +1,5 @@ +import type React from 'react'; + +const Header: React.FC = () =>
Header Component Placeholder
; + +export default Header; diff --git a/packages/webui/src/components/layout/Main.tsx b/packages/webui/src/components/layout/Main.tsx new file mode 100644 index 000000000..118f67430 --- /dev/null +++ b/packages/webui/src/components/layout/Main.tsx @@ -0,0 +1,5 @@ +import type React from 'react'; + +const Main: React.FC = () =>
Main Component Placeholder
; + +export default Main; diff --git a/packages/webui/src/components/layout/Sidebar.tsx b/packages/webui/src/components/layout/Sidebar.tsx new file mode 100644 index 000000000..eb3746c20 --- /dev/null +++ b/packages/webui/src/components/layout/Sidebar.tsx @@ -0,0 +1,5 @@ +import type React from 'react'; + +const Sidebar: React.FC = () => ; + +export default Sidebar; diff --git a/packages/webui/src/components/messages/Message.tsx b/packages/webui/src/components/messages/Message.tsx new file mode 100644 index 000000000..e09208528 --- /dev/null +++ b/packages/webui/src/components/messages/Message.tsx @@ -0,0 +1,39 @@ +import type React from 'react'; + +interface MessageProps { + id: string; + content: string; + sender: 'user' | 'system' | 'assistant'; + timestamp?: Date; + className?: string; +} + +const Message: React.FC = ({ + content, + sender, + timestamp, + className = '', +}) => { + const alignment = sender === 'user' ? 'justify-end' : 'justify-start'; + const bgColor = sender === 'user' ? 'bg-blue-500' : 'bg-gray-200'; + + return ( +
+
+ {content} + {timestamp && ( +
+ {timestamp.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} +
+ )} +
+
+ ); +}; + +export default Message; diff --git a/packages/webui/src/components/messages/MessageInput.tsx b/packages/webui/src/components/messages/MessageInput.tsx new file mode 100644 index 000000000..bd4637155 --- /dev/null +++ b/packages/webui/src/components/messages/MessageInput.tsx @@ -0,0 +1,5 @@ +import type React from 'react'; + +const MessageInput: React.FC = () =>
MessageInput Component Placeholder
; + +export default MessageInput; diff --git a/packages/webui/src/components/messages/MessageList.tsx b/packages/webui/src/components/messages/MessageList.tsx new file mode 100644 index 000000000..5544865a0 --- /dev/null +++ b/packages/webui/src/components/messages/MessageList.tsx @@ -0,0 +1,5 @@ +import type React from 'react'; + +const MessageList: React.FC = () =>
MessageList Component Placeholder
; + +export default MessageList; diff --git a/packages/webui/src/components/ui/Button.tsx b/packages/webui/src/components/ui/Button.tsx new file mode 100644 index 000000000..1ee0d3b07 --- /dev/null +++ b/packages/webui/src/components/ui/Button.tsx @@ -0,0 +1,49 @@ +import type React from 'react'; + +interface ButtonProps { + children: React.ReactNode; + onClick?: () => void; + variant?: 'primary' | 'secondary' | 'danger'; + size?: 'sm' | 'md' | 'lg'; + disabled?: boolean; + className?: string; +} + +const Button: React.FC = ({ + children, + onClick, + variant = 'primary', + size = 'md', + disabled = false, + className = '', +}) => { + const baseClasses = + 'inline-flex items-center justify-center rounded font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2'; + + const variantClasses = { + primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500', + secondary: + 'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-500', + danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500', + }; + + const sizeClasses = { + sm: 'px-2 py-1 text-sm', + md: 'px-4 py-2', + lg: 'px-6 py-3 text-lg', + }; + + const disabledClass = disabled ? 'opacity-50 cursor-not-allowed' : ''; + + return ( + + ); +}; + +export default Button; diff --git a/packages/webui/src/components/ui/Input.tsx b/packages/webui/src/components/ui/Input.tsx new file mode 100644 index 000000000..8fd350c67 --- /dev/null +++ b/packages/webui/src/components/ui/Input.tsx @@ -0,0 +1,25 @@ +import type React from 'react'; + +interface InputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; +} + +const Input: React.FC = ({ + value, + onChange, + placeholder, + className = '', +}) => ( + onChange(e.target.value)} + placeholder={placeholder} + className={`border rounded px-3 py-2 ${className}`} + /> + ); + +export default Input; diff --git a/packages/webui/src/components/ui/Tooltip.tsx b/packages/webui/src/components/ui/Tooltip.tsx new file mode 100644 index 000000000..6b9206af9 --- /dev/null +++ b/packages/webui/src/components/ui/Tooltip.tsx @@ -0,0 +1,93 @@ +import React, { useState } from 'react'; + +interface ChildProps { + onMouseEnter?: () => void; + onMouseLeave?: () => void; + onFocus?: () => void; + onBlur?: () => void; + tabIndex?: number; +} + +interface TooltipProps { + children: React.ReactElement; + content: string; + position?: 'top' | 'right' | 'bottom' | 'left'; +} + +const Tooltip: React.FC = ({ + children, + content, + position = 'top', +}) => { + const [isVisible, setIsVisible] = useState(false); + + const positionClasses = { + top: 'bottom-full left-1/2 transform -translate-x-1/2 mb-2', + right: 'top-1/2 left-full transform -translate-y-1/2 ml-2', + bottom: 'top-full left-1/2 transform -translate-x-1/2 mt-2', + left: 'top-1/2 right-full transform -translate-y-1/2 mr-2', + }; + + const arrowPositionClasses = { + top: 'top-full left-1/2 transform -translate-x-1/2 -mt-1', + right: 'top-1/2 left-0 transform -translate-y-1/2 -ml-1', + bottom: 'top-0 left-1/2 transform -translate-x-1/2 -mb-1', + left: 'top-1/2 right-0 transform -translate-y-1/2 -mr-1', + }; + + const tooltipClass = `absolute ${positionClasses[position]} bg-gray-800 text-white text-xs rounded py-1 px-2 pointer-events-none z-10`; + const arrowClass = `absolute w-2 h-2 bg-gray-800 transform rotate-45 ${arrowPositionClasses[position]}`; + + return ( +
+
setIsVisible(true)} + onMouseLeave={() => setIsVisible(false)} + onFocus={() => setIsVisible(true)} + onBlur={() => setIsVisible(false)} + tabIndex={0} + > + {React.cloneElement(children, { + onMouseEnter: () => { + setIsVisible(true); + const typedChildren = children as React.ReactElement; + if (typeof typedChildren.props.onMouseEnter === 'function') { + typedChildren.props.onMouseEnter(); + } + }, + onMouseLeave: () => { + setIsVisible(false); + const typedChildren = children as React.ReactElement; + if (typeof typedChildren.props.onMouseLeave === 'function') { + typedChildren.props.onMouseLeave(); + } + }, + onFocus: () => { + setIsVisible(true); + const typedChildren = children as React.ReactElement; + if (typeof typedChildren.props.onFocus === 'function') { + typedChildren.props.onFocus(); + } + }, + onBlur: () => { + setIsVisible(false); + const typedChildren = children as React.ReactElement; + if (typeof typedChildren.props.onBlur === 'function') { + typedChildren.props.onBlur(); + } + }, + tabIndex: + (children as React.ReactElement).props.tabIndex || 0, + })} +
+ {isVisible && ( +
+ {content} +
+
+ )} +
+ ); +}; + +export default Tooltip; diff --git a/packages/webui/src/hooks/useLocalStorage.ts b/packages/webui/src/hooks/useLocalStorage.ts new file mode 100644 index 000000000..7fb675411 --- /dev/null +++ b/packages/webui/src/hooks/useLocalStorage.ts @@ -0,0 +1,27 @@ +import { useState } from 'react'; + +export const useLocalStorage = (key: string, initialValue: T) => { + // Get value from localStorage or use initial value + const [storedValue, setStoredValue] = useState(() => { + try { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (_error) { + return initialValue; + } + }); + + // Update localStorage when state changes + const setValue = (value: T | ((val: T) => T)) => { + try { + const valueToStore = + value instanceof Function ? value(storedValue) : value; + setStoredValue(valueToStore); + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + } catch (error) { + console.error(error); + } + }; + + return [storedValue, setValue] as const; +}; diff --git a/packages/webui/src/hooks/useTheme.ts b/packages/webui/src/hooks/useTheme.ts new file mode 100644 index 000000000..c4ca0a77a --- /dev/null +++ b/packages/webui/src/hooks/useTheme.ts @@ -0,0 +1,29 @@ +import { useState, useEffect } from 'react'; + +export const useTheme = () => { + const [theme, setTheme] = useState<'light' | 'dark' | 'auto'>('auto'); + + useEffect(() => { + const savedTheme = localStorage.getItem('theme') as + | 'light' + | 'dark' + | 'auto' + | null; + if (savedTheme) { + setTheme(savedTheme); + } else { + const prefersDark = window.matchMedia( + '(prefers-color-scheme: dark)', + ).matches; + setTheme(prefersDark ? 'dark' : 'light'); + } + }, []); + + const toggleTheme = () => { + const newTheme = theme === 'light' ? 'dark' : 'light'; + setTheme(newTheme); + localStorage.setItem('theme', newTheme); + }; + + return { theme, toggleTheme }; +}; diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts new file mode 100644 index 000000000..6f4066229 --- /dev/null +++ b/packages/webui/src/index.ts @@ -0,0 +1,35 @@ +// Shared UI Components Export +// Export all shared components from this package + +// Layout components +export { default as Container } from './components/layout/Container'; +export { default as Header } from './components/layout/Header'; +export { default as Sidebar } from './components/layout/Sidebar'; +export { default as Main } from './components/layout/Main'; +export { default as Footer } from './components/layout/Footer'; + +// Message components +export { default as Message } from './components/messages/Message'; +export { default as MessageInput } from './components/messages/MessageInput'; +export { default as MessageList } from './components/messages/MessageList'; + +// UI Elements +export { default as Button } from './components/ui/Button'; +export { default as Input } from './components/ui/Input'; +export { default as Tooltip } from './components/ui/Tooltip'; + +// Permission components +export { default as PermissionDrawer } from './components/PermissionDrawer'; + +// Icons +export { default as Icon } from './components/icons/Icon'; +export { default as CloseIcon } from './components/icons/CloseIcon'; +export { default as SendIcon } from './components/icons/SendIcon'; + +// Hooks +export { useTheme } from './hooks/useTheme'; +export { useLocalStorage } from './hooks/useLocalStorage'; + +// Types +export type { Theme } from './types/theme'; +export type { MessageProps } from './types/messages'; diff --git a/packages/webui/src/types/messages.ts b/packages/webui/src/types/messages.ts new file mode 100644 index 000000000..9556b6176 --- /dev/null +++ b/packages/webui/src/types/messages.ts @@ -0,0 +1,7 @@ +export interface MessageProps { + id: string; + content: string; + sender: 'user' | 'system' | 'assistant'; + timestamp?: Date; + className?: string; +} diff --git a/packages/webui/src/types/theme.ts b/packages/webui/src/types/theme.ts new file mode 100644 index 000000000..3418c16f4 --- /dev/null +++ b/packages/webui/src/types/theme.ts @@ -0,0 +1 @@ +export type Theme = 'light' | 'dark' | 'auto'; diff --git a/packages/webui/tsconfig.json b/packages/webui/tsconfig.json new file mode 100644 index 000000000..58209e086 --- /dev/null +++ b/packages/webui/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "declaration": true, + "declarationDir": "./dist", + "emitDeclarationOnly": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +}