fix(vscode-ide-companion): 修复 Tailwind 可重用组件类和 ESLint 配置, 调整 ChatHeader 按钮样式

- 在 tailwind.css 中正确定义可重用的 Tailwind 组件类
- 修复 ChatHeader 组件中的按钮样式,确保 hover 效果正常工作
- 修复 ESLint 配置中的 importPlugin 导入问题
- 清理 App.css 中重复的 CSS 变量定义
- 为 btn-ghost 类设置 4px border radius
- 为按钮内的 span 添加左右 4px padding (使用 px-1)
- 确保按钮 hover 时有背景色效果
This commit is contained in:
yiliang114
2025-11-29 17:58:18 +08:00
parent 6885138cf0
commit c038745897
10 changed files with 72 additions and 622 deletions

View File

@@ -114,7 +114,10 @@ export default tseslint.config(
'memfs/lib/volume.js',
'yargs/**',
'msw/node',
'**/generated/**'
'**/generated/**',
'./styles/tailwind.css',
'./styles/App.css',
'./styles/ClaudeCodeStyles.css'
],
},
],

View File

@@ -7,6 +7,7 @@
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import reactHooks from 'eslint-plugin-react-hooks';
import importPlugin from 'eslint-plugin-import';
export default [
{
@@ -55,10 +56,13 @@ export default [
],
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
// Restrict deep imports but allow known-safe exceptions used by the webview
// - react-dom/client: required for React 18's createRoot API
// - ./styles/**: local CSS modules loaded by the webview
'import/no-internal-modules': [
'error',
{
allow: ['./styles/**'],
allow: ['react-dom/client', './styles/**'],
},
],

View File

@@ -1,199 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/* Session Manager Styles */
.session-manager {
display: flex;
flex-direction: column;
height: 100%;
font-size: var(--vscode-chat-font-size, 13px);
font-family: var(--vscode-chat-font-family);
}
.session-manager-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid var(--app-primary-border-color);
}
.session-manager-header h3 {
margin: 0;
font-weight: 600;
color: var(--app-primary-foreground);
}
.session-manager-actions {
padding: 16px;
border-bottom: 1px solid var(--app-primary-border-color);
}
.session-manager-actions .secondary-button {
padding: 6px 12px;
border-radius: 4px;
border: none;
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
font-size: var(--vscode-chat-font-size, 13px);
font-family: var(--vscode-chat-font-family);
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
}
.session-manager-actions .secondary-button:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
.session-search {
padding: 12px 16px;
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid var(--app-primary-border-color);
}
.session-search svg {
width: 16px;
height: 16px;
opacity: 0.5;
flex-shrink: 0;
color: var(--app-primary-foreground);
}
.session-search input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--app-primary-foreground);
font-size: var(--vscode-chat-font-size, 13px);
font-family: var(--vscode-chat-font-family);
padding: 0;
}
.session-search input::placeholder {
color: var(--app-input-placeholder-foreground);
opacity: 0.6;
}
.session-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.session-list-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
gap: 8px;
color: var(--app-secondary-foreground);
}
.session-list-empty {
padding: 32px;
text-align: center;
color: var(--app-secondary-foreground);
opacity: 0.6;
}
.session-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
text-align: left;
width: 100%;
color: var(--app-primary-foreground);
transition: background 0.1s ease;
margin-bottom: 4px;
}
.session-item:hover {
background: var(--app-list-hover-background);
}
.session-item.active {
background: var(--app-list-active-background);
color: var(--app-list-active-foreground);
}
.session-item-info {
flex: 1;
min-width: 0;
}
.session-item-name {
font-weight: 500;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-item-meta {
display: flex;
gap: 12px;
font-size: 0.9em;
color: var(--app-secondary-foreground);
}
.session-item-date,
.session-item-count {
white-space: nowrap;
}
.session-item-actions {
display: flex;
gap: 8px;
margin-left: 12px;
}
.session-item-actions .icon-button {
width: 24px;
height: 24px;
padding: 0;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
outline: none;
}
.session-item-actions .icon-button:hover {
background: var(--app-ghost-button-hover-background);
}
.session-item-actions .icon-button svg {
width: 16px;
height: 16px;
color: var(--app-primary-foreground);
}
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

View File

@@ -1,169 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect } from 'react';
import { useVSCode } from '../hooks/useVSCode.js';
import {
RefreshIcon,
SaveDocumentIcon,
SearchIcon,
PlayIcon,
SwitchIcon,
} from './icons/index.js';
interface Session {
id: string;
name: string;
lastUpdated: string;
messageCount: number;
}
interface SessionManagerProps {
currentSessionId: string | null;
onSwitchSession: (sessionId: string) => void;
onSaveSession: () => void;
onResumeSession: (sessionId: string) => void;
}
export const SessionManager: React.FC<SessionManagerProps> = ({
currentSessionId,
onSwitchSession,
onSaveSession,
onResumeSession,
}) => {
const vscode = useVSCode();
const [sessions, setSessions] = useState<Session[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const loadSessions = React.useCallback(() => {
setIsLoading(true);
vscode.postMessage({
type: 'listSavedSessions',
data: {},
});
}, [vscode]);
// Load sessions when component mounts
useEffect(() => {
loadSessions();
}, [loadSessions]);
// Listen for session list updates
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const message = event.data;
if (message.type === 'savedSessionsList') {
setIsLoading(false);
setSessions(message.data.sessions || []);
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);
const filteredSessions = sessions.filter((session) =>
session.name.toLowerCase().includes(searchQuery.toLowerCase()),
);
const handleSaveCurrent = () => {
onSaveSession();
};
const handleResumeSession = (sessionId: string) => {
onResumeSession(sessionId);
};
const handleSwitchSession = (sessionId: string) => {
onSwitchSession(sessionId);
};
return (
<div className="session-manager">
<div className="session-manager-header">
<h3>Saved Conversations</h3>
<button
className="icon-button"
onClick={loadSessions}
disabled={isLoading}
title="Refresh sessions"
>
<RefreshIcon width="16" height="16" />
</button>
</div>
<div className="session-manager-actions">
<button className="secondary-button" onClick={handleSaveCurrent}>
<SaveDocumentIcon width="16" height="16" />
Save Current
</button>
</div>
<div className="session-search">
<SearchIcon width="16" height="16" />
<input
type="text"
placeholder="Search conversations..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="session-list">
{isLoading ? (
<div className="session-list-loading">
<div className="loading-spinner"></div>
<span>Loading conversations...</span>
</div>
) : filteredSessions.length === 0 ? (
<div className="session-list-empty">
{searchQuery
? 'No matching conversations'
: 'No saved conversations yet'}
</div>
) : (
filteredSessions.map((session) => (
<div
key={session.id}
className={`session-item ${session.id === currentSessionId ? 'active' : ''}`}
>
<div className="session-item-info">
<div className="session-item-name">{session.name}</div>
<div className="session-item-meta">
<span className="session-item-date">
{new Date(session.lastUpdated).toLocaleDateString()}
</span>
<span className="session-item-count">
{session.messageCount}{' '}
{session.messageCount === 1 ? 'message' : 'messages'}
</span>
</div>
</div>
<div className="session-item-actions">
<button
className="icon-button"
onClick={() => handleResumeSession(session.id)}
title="Resume this conversation"
>
<PlayIcon width="16" height="16" />
</button>
<button
className="icon-button"
onClick={() => handleSwitchSession(session.id)}
title="Switch to this conversation"
>
<SwitchIcon width="16" height="16" />
</button>
</div>
</div>
))
)}
</div>
</div>
);
};

View File

@@ -21,39 +21,26 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
onNewSession,
}) => (
<div
className="flex gap-1 select-none py-1.5 px-2.5"
className="chat-header flex items-center select-none py-1.5 px-2.5 w-full"
style={{
backgroundColor: 'var(--app-header-background)',
}}
>
{/* Past Conversations Button */}
<button
className="flex-none py-1 px-2 bg-transparent border border-transparent rounded cursor-pointer flex items-center justify-center outline-none font-medium transition-colors duration-200 hover:bg-[var(--app-ghost-button-hover-background)] focus:bg-[var(--app-ghost-button-hover-background)]"
style={{
borderRadius: 'var(--corner-radius-small)',
color: 'var(--app-primary-foreground)',
fontSize: 'var(--vscode-chat-font-size, 13px)',
}}
className="btn-ghost btn-md px-2 flex items-center outline-none font-medium max-w-[70%] min-w-0 overflow-hidden rounded hover:bg-[var(--app-ghost-button-hover-background)] h-6 leading-6"
onClick={onLoadSessions}
title="Past conversations"
>
<span className="flex items-center gap-1">
<span style={{ fontSize: 'var(--vscode-chat-font-size, 13px)' }}>
{currentSessionTitle}
</span>
<ChevronDownIcon className="w-3.5 h-3.5" />
<span className="whitespace-nowrap overflow-hidden text-ellipsis min-w-0">
{currentSessionTitle}
</span>
<ChevronDownIcon className="w-4 h-4 flex-shrink-0 ml-1" />
</button>
{/* Spacer */}
<div className="flex-1"></div>
<div className="flex-1 min-w-2"></div>
{/* New Session Button */}
<button
className="flex-none p-0 bg-transparent border border-transparent rounded cursor-pointer flex items-center justify-center outline-none w-6 h-6 hover:bg-[var(--app-ghost-button-hover-background)] focus:bg-[var(--app-ghost-button-hover-background)]"
style={{
color: 'var(--app-primary-foreground)',
}}
className="btn-ghost btn-sm flex items-center justify-center outline-none rounded hover:bg-[var(--app-ghost-button-hover-background)] h-6 leading-6 w-6"
onClick={onNewSession}
title="New Session"
>

View File

@@ -40,22 +40,25 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
return (
<>
<div className="session-selector-backdrop" onClick={onClose} />
<div
className="session-dropdown"
className="session-selector-backdrop fixed top-0 left-0 right-0 bottom-0 z-[999] bg-transparent"
onClick={onClose}
/>
<div
className="session-dropdown fixed bg-[var(--app-menu-background)] rounded-[var(--corner-radius-small)] w-[min(400px,calc(100vw-32px))] max-h-[min(500px,50vh)] flex flex-col shadow-[0_4px_16px_rgba(0,0,0,0.1)] z-[1000] outline-none text-[var(--vscode-chat-font-size,13px)] font-[var(--vscode-chat-font-family)]"
tabIndex={-1}
style={{
top: '34px',
top: '30px',
left: '10px',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Search Box */}
<div className="session-search">
<SearchIcon className="session-search-icon" />
<div className="session-search p-2 flex items-center gap-2">
<SearchIcon className="session-search-icon w-4 h-4 opacity-50 flex-shrink-0 text-[var(--app-primary-foreground)]" />
<input
type="text"
className="session-search-input"
className="session-search-input flex-1 bg-transparent border-none outline-none text-[var(--app-menu-foreground)] text-[var(--vscode-chat-font-size,13px)] font-[var(--vscode-chat-font-family)] p-0 placeholder:text-[var(--app-input-placeholder-foreground)] placeholder:opacity-60"
placeholder="Search sessions…"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
@@ -63,9 +66,10 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
</div>
{/* Session List with Grouping */}
<div className="session-list-content">
<div className="session-list-content overflow-y-auto flex-1 select-none p-2">
{hasNoSessions ? (
<div
className="p-5 text-center text-[var(--app-secondary-foreground)]"
style={{
padding: '20px',
textAlign: 'center',
@@ -77,8 +81,10 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
) : (
groupSessionsByDate(sessions).map((group) => (
<React.Fragment key={group.label}>
<div className="session-group-label">{group.label}</div>
<div className="session-group">
<div className="session-group-label p-1 px-2 text-[var(--app-primary-foreground)] opacity-50 text-[0.9em] font-medium [&:not(:first-child)]:mt-2">
{group.label}
</div>
<div className="session-group flex flex-col gap-[2px]">
{group.sessions.map((session) => {
const sessionId =
(session.id as string) ||
@@ -97,14 +103,20 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
return (
<button
key={sessionId}
className={`session-item ${isActive ? 'active' : ''}`}
className={`session-item flex items-center justify-between py-1.5 px-2 bg-transparent border-none rounded-md cursor-pointer text-left w-full text-[var(--vscode-chat-font-size,13px)] font-[var(--vscode-chat-font-family)] text-[var(--app-primary-foreground)] transition-colors duration-100 hover:bg-[var(--app-list-hover-background)] ${
isActive
? 'active bg-[var(--app-list-active-background)] text-[var(--app-list-active-foreground)] font-[600]'
: ''
}`}
onClick={() => {
onSelectSession(sessionId);
onClose();
}}
>
<span className="session-item-title">{title}</span>
<span className="session-item-time">
<span className="session-item-title flex-1 overflow-hidden text-ellipsis whitespace-nowrap min-w-0">
{title}
</span>
<span className="session-item-time opacity-60 text-[0.9em] flex-shrink-0 ml-3">
{getTimeAgo(lastUpdated)}
</span>
</button>

View File

@@ -88,9 +88,6 @@
/* Widget */
--app-widget-border: var(--vscode-editorWidget-border);
--app-widget-shadow: var(--vscode-widget-shadow);
/* Ghost Button (from Claude Code) */
--app-ghost-button-hover-background: var(--vscode-toolbar-hoverBackground);
}
/* Light Theme Overrides */
@@ -592,4 +589,4 @@ button {
.permission-success-text {
color: #4caf50;
font-size: 13px;
}
}

View File

@@ -9,7 +9,6 @@
/* Import component styles */
@import '../components/SaveSessionDialog.css';
@import '../components/SessionManager.css';
@import '../components/EmptyState.css';
@import '../components/CompletionMenu.css';
@import '../components/ContextPills.css';
@@ -19,220 +18,6 @@
@import '../components/toolcalls/shared/DiffDisplay.css';
@import '../components/messages/AssistantMessage.css';
/* ===========================
Session Selector Button (from Claude Code .E)
=========================== */
.session-selector-button {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
outline: none;
min-width: 0;
max-width: 300px;
overflow: hidden;
font-size: var(--vscode-chat-font-size, 13px);
font-family: var(--vscode-chat-font-family);
}
.session-selector-button:focus,
.session-selector-button:hover {
background: var(--app-ghost-button-hover-background);
}
/* Session Selector Button Internal Elements */
.session-selector-button-content {
display: flex;
align-items: center;
gap: 4px;
max-width: 300px;
overflow: hidden;
}
.session-selector-button-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.session-selector-button-icon {
flex-shrink: 0;
}
.session-selector-button-icon svg {
width: 16px;
height: 16px;
min-width: 16px;
}
/* ===========================
Icon Button (from Claude Code .j)
=========================== */
.icon-button {
flex: 0 0 auto;
padding: 0;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
outline: none;
width: 24px;
height: 24px;
}
.icon-button:focus,
.icon-button:hover {
background: var(--app-ghost-button-hover-background);
}
/* ===========================
Session Dropdown (from Claude Code .St/.Wt)
=========================== */
.session-dropdown {
position: fixed;
background: var(--app-menu-background);
border: 1px solid var(--app-menu-border);
border-radius: var(--corner-radius-small);
width: min(400px, calc(100vw - 32px));
max-height: min(500px, 50vh);
display: flex;
flex-direction: column;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
z-index: 1000;
outline: none;
font-size: var(--vscode-chat-font-size, 13px);
font-family: var(--vscode-chat-font-family);
}
/* ===========================
Search Box Container (from Claude Code .Lt)
=========================== */
.session-search {
padding: 8px;
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid var(--app-menu-border);
}
.session-search svg,
.session-search-icon {
width: 16px;
height: 16px;
opacity: 0.5;
flex-shrink: 0;
color: var(--app-primary-foreground);
}
/* Search Input (from Claude Code .U) */
.session-search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--app-menu-foreground);
font-size: var(--vscode-chat-font-size, 13px);
font-family: var(--vscode-chat-font-family);
padding: 0;
}
.session-search-input::placeholder {
color: var(--app-input-placeholder-foreground);
opacity: 0.6;
}
/* Session List Content Area (from Claude Code .jt/.It) */
.session-list-content {
overflow-y: auto;
flex: 1;
user-select: none;
padding: 8px;
}
/* Group Label (from Claude Code .ae) */
.session-group-label {
padding: 4px 8px;
color: var(--app-primary-foreground);
opacity: 0.5;
font-size: 0.9em;
font-weight: 500;
}
.session-group-label:not(:first-child) {
margin-top: 8px;
}
/* Session Group Container (from Claude Code .At) */
.session-group {
display: flex;
flex-direction: column;
gap: 2px;
}
/* Session Item Button (from Claude Code .s) */
.session-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
text-align: left;
width: 100%;
font-size: var(--vscode-chat-font-size, 13px);
font-family: var(--vscode-chat-font-family);
color: var(--app-primary-foreground);
transition: background 0.1s ease;
}
.session-item:hover {
background: var(--app-list-hover-background);
}
/* Active Session (from Claude Code .N) */
.session-item.active {
background: var(--app-list-active-background);
color: var(--app-list-active-foreground);
font-weight: 600;
}
/* Session Title (from Claude Code .ce) */
.session-item-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
/* Session Time (from Claude Code .Dt) */
.session-item-time {
opacity: 0.6;
font-size: 0.9em;
flex-shrink: 0;
margin-left: 12px;
}
/* Backdrop for dropdown */
.session-selector-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
background: transparent;
}
/* ===========================
CSS Variables (from Claude Code root styles)

View File

@@ -6,4 +6,33 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind utilities;
/* ===========================
Reusable Component Classes
=========================== */
@layer components {
.btn-ghost {
@apply bg-transparent border border-transparent rounded cursor-pointer outline-none transition-colors duration-200;
color: var(--app-primary-foreground);
font-size: var(--vscode-chat-font-size, 13px);
border-radius: 4px;
}
.btn-ghost:hover,
.btn-ghost:focus {
background: var(--app-ghost-button-hover-background);
}
.btn-sm {
@apply p-small;
}
.btn-md {
@apply py-small px-medium;
}
.icon-sm {
@apply w-4 h-4;
}
}

View File

@@ -19,6 +19,7 @@ export default {
'./src/webview/components/InputForm.tsx',
'./src/webview/components/PermissionDrawer.tsx',
'./src/webview/components/PlanDisplay.tsx',
'./src/webview/components/session/SessionSelector.tsx',
],
theme: {
extend: {