From 4dfbdcddca0fa1cfa3c712d394143462ca0265c2 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 23 Nov 2025 22:28:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(vscode-ide-companion):=20=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8=E4=B8=8E=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E8=A1=A8=E5=8D=95=E7=BB=84=E4=BB=B6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 InProgressToolCall 组件用于展示进行中的工具调用状态 - 重构 InputForm 为独立组件,提升代码可维护性 - 改进 tool_call_update 处理逻辑,支持创建缺失的初始工具调用 - 添加思考块(thought chunk)日志以便调试 AI 思维过程 - 更新样式以支持新的进行中工具调用卡片显示 - 在权限请求时自动创建对应的工具调用记录 ``` --- .../src/agents/qwenSessionUpdateHandler.ts | 10 + .../vscode-ide-companion/src/webview/App.scss | 321 +++------- .../vscode-ide-companion/src/webview/App.tsx | 442 +++++--------- .../webview/components/InProgressToolCall.tsx | 100 ++++ .../src/webview/components/InputForm.tsx | 354 +++++++++++ .../webview/components/PermissionDrawer.css | 560 ------------------ .../components/PermissionDrawer.tailwind.tsx | 121 ---- .../webview/components/PermissionDrawer.tsx | 271 ++++++--- .../src/webview/hooks/useCompletionTrigger.ts | 137 ++++- .../vscode-ide-companion/tailwind.config.js | 2 + 10 files changed, 1045 insertions(+), 1273 deletions(-) create mode 100644 packages/vscode-ide-companion/src/webview/components/InProgressToolCall.tsx create mode 100644 packages/vscode-ide-companion/src/webview/components/InputForm.tsx delete mode 100644 packages/vscode-ide-companion/src/webview/components/PermissionDrawer.css delete mode 100644 packages/vscode-ide-companion/src/webview/components/PermissionDrawer.tailwind.tsx diff --git a/packages/vscode-ide-companion/src/agents/qwenSessionUpdateHandler.ts b/packages/vscode-ide-companion/src/agents/qwenSessionUpdateHandler.ts index 61fde052..a138326a 100644 --- a/packages/vscode-ide-companion/src/agents/qwenSessionUpdateHandler.ts +++ b/packages/vscode-ide-companion/src/agents/qwenSessionUpdateHandler.ts @@ -62,11 +62,21 @@ export class QwenSessionUpdateHandler { case 'agent_thought_chunk': // 处理思考块 - 使用特殊回调 + console.log( + '[SessionUpdateHandler] 🧠 THOUGHT CHUNK:', + update.content?.text, + ); if (update.content?.text) { if (this.callbacks.onThoughtChunk) { + console.log( + '[SessionUpdateHandler] 🧠 Calling onThoughtChunk callback', + ); this.callbacks.onThoughtChunk(update.content.text); } else if (this.callbacks.onStreamChunk) { // 回退到常规流处理 + console.log( + '[SessionUpdateHandler] 🧠 Falling back to onStreamChunk', + ); this.callbacks.onStreamChunk(update.content.text); } } diff --git a/packages/vscode-ide-companion/src/webview/App.scss b/packages/vscode-ide-companion/src/webview/App.scss index 298f8009..f4b550a4 100644 --- a/packages/vscode-ide-companion/src/webview/App.scss +++ b/packages/vscode-ide-companion/src/webview/App.scss @@ -233,241 +233,6 @@ button { cursor: not-allowed; } -/* =========================== - Claude Code Style Input Form (.Me > .u) - =========================== */ -/* Outer container (.Me) */ -.input-form-container { - background-color: var(--app-primary-background); - padding: 4px 16px 16px; -} - -/* Inner wrapper */ -.input-form-wrapper { - display: block; -} - -/* Input Form Container - matches Claude Code style */ -.input-form { - background: var(--app-input-secondary-background, var(--app-input-background)); - border: 1px solid var(--app-input-border); - border-radius: var(--corner-radius-large); - color: var(--app-input-foreground); - display: flex; - flex-direction: column; - position: relative; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); - transition: border-color 0.2s ease, box-shadow 0.2s ease; -} - -/* Inner background layer - creates depth effect */ -.input-form-background { - background: var(--app-input-background); - position: absolute; - border-radius: var(--corner-radius-large); - inset: 0; - z-index: 0; -} - -.input-form:focus-within { - border-color: var(--app-qwen-orange); - box-shadow: 0 1px 2px color-mix(in srgb,var(--app-qwen-orange),transparent 80%); -} - -/* Banner area - for warnings/messages */ -.input-banner { - /* Empty for now, can be used for warnings/banners */ -} - -/* Input wrapper - contains the contenteditable field */ -.input-wrapper { - position: relative; - display: flex; - z-index: 1; -} - -/* Contenteditable input field - matches Claude Code */ -.input-field-editable { - padding: 10px 14px; - outline: none; - font-family: inherit; - line-height: 1.5; - overflow-y: auto; - position: relative; - flex: 1; - align-self: stretch; - user-select: text; - min-height: 1.5em; - max-height: 200px; - background-color: transparent; - color: var(--app-input-foreground); - border: none; - border-radius: 0; - font-size: var(--vscode-chat-font-size, 13px); - overflow-x: hidden; - word-wrap: break-word; - white-space: pre-wrap; -} - -.input-field-editable:focus { - outline: none; -} - -.input-field-editable:empty:before { - content: attr(data-placeholder); - color: var(--app-input-placeholder-foreground); - pointer-events: none; - position: absolute; -} - -.input-field-editable:disabled, -.input-field-editable[contenteditable='false'] { - color: #999; - cursor: not-allowed; -} - -/* Actions row - matches Claude Code */ -.input-actions { - display: flex; - align-items: center; - padding: 5px; - color: var(--app-secondary-foreground); - gap: 6px; - min-width: 0; - border-top: 0.5px solid var(--app-input-border); - z-index: 1; -} - -/* Edit mode button (.l) */ -.action-button { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - height: 32px; - background: transparent; - border: 1px solid transparent; - border-radius: var(--corner-radius-small); - color: var(--app-primary-foreground); - cursor: pointer; - font-size: 12px; - // font-weight: 500; - transition: background-color 0.15s; - white-space: nowrap; -} - -.action-button:hover { - background-color: var(--app-ghost-button-hover-background); -} - -.action-button svg { - width: 16px; - height: 16px; - flex-shrink: 0; -} - -/* Divider (.ii) */ -.action-divider { - width: 1px; - height: 24px; - background-color: var(--app-transparent-inner-border); - margin: 0 2px; - flex-shrink: 0; -} - -/* Icon buttons (.H) */ -.action-icon-button { - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - padding: 0; - background: transparent; - border: 1px solid transparent; - border-radius: var(--corner-radius-small); - color: var(--app-secondary-foreground); - cursor: pointer; - transition: background-color 0.15s, color 0.15s; - flex-shrink: 0; -} - -.action-icon-button:hover { - background-color: var(--app-ghost-button-hover-background); - color: var(--app-primary-foreground); -} - -.action-icon-button.active { - background-color: var(--app-qwen-clay-button-orange); - color: var(--app-qwen-ivory); -} - -.action-icon-button.active svg { - stroke: var(--app-qwen-ivory); - fill: var(--app-qwen-ivory); -} - -.action-icon-button svg { - width: 16px; - height: 16px; -} - -/* Spacer to push file indicator to the right */ -.input-actions-spacer { - flex: 1; - min-width: 0; -} - -/* Active file indicator - shows current file selection */ -.active-file-indicator { - // Inherits all styles from .action-button - // Only add specific overrides here if needed - max-width: 200px; - overflow: hidden; - text-overflow: ellipsis; - flex-shrink: 1; - min-width: 0; -} - -/* Hide file indicator on very small screens */ -@media screen and (max-width: 330px) { - .active-file-indicator { - display: none; - } -} - -/* Send button (.r) */ -.send-button-icon { - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - padding: 0; - background: var(--app-qwen-clay-button-orange); - border: 1px solid transparent; - border-radius: var(--corner-radius-small); - color: var(--app-qwen-ivory); - cursor: pointer; - transition: background-color 0.15s, filter 0.15s; - margin-left: auto; - flex-shrink: 0; -} - -.send-button-icon:hover:not(:disabled) { - filter: brightness(1.1); -} - -.send-button-icon:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.send-button-icon svg { - width: 20px; - height: 20px; -} - /* =========================== Tool Call Card Styles (Grid Layout) =========================== */ @@ -549,6 +314,92 @@ button { background: #f44336; } +/* =========================== + In-Progress Tool Call Styles (Claude Code style) + =========================== */ +.in-progress-tool-call { + display: flex; + flex-direction: column; + gap: var(--app-spacing-small); + padding: var(--app-spacing-medium); + margin: var(--app-spacing-small) 0; + background: var(--app-input-background); + border: 1px solid var(--app-input-border); + border-radius: var(--corner-radius-small); + animation: fadeIn 0.2s ease-in; +} + +.in-progress-tool-call-header { + display: flex; + align-items: center; + gap: var(--app-spacing-medium); +} + +.in-progress-tool-call-kind { + font-weight: 600; + font-size: 13px; + color: var(--app-primary-foreground); +} + +.in-progress-tool-call-status { + display: inline-flex; + align-items: center; + font-size: 12px; + color: var(--app-secondary-foreground); + position: relative; + padding-left: 14px; +} + +.in-progress-tool-call-status::before { + content: ''; + position: absolute; + left: 0; + width: 6px; + height: 6px; + border-radius: 50%; + margin-right: 6px; +} + +.in-progress-tool-call-status.pending::before { + background: #ffc107; +} + +.in-progress-tool-call-status.in_progress::before { + background: #2196f3; + animation: pulse 1.5s ease-in-out infinite; +} + +.in-progress-tool-call-status.completed::before { + background: #4caf50; +} + +.in-progress-tool-call-status.failed::before { + background: #f44336; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +.in-progress-tool-call-title { + font-size: 12px; + color: var(--app-secondary-foreground); + font-family: var(--app-monospace-font-family); + padding-left: 2px; +} + +.in-progress-tool-call-locations { + display: flex; + flex-direction: column; + gap: 4px; + padding-left: 2px; +} + .code-block { font-family: var(--app-monospace-font-family); font-size: var(--app-monospace-font-size); diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 2790019d..e4bdff9d 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -14,6 +14,7 @@ import { import { PermissionDrawer } from './components/PermissionDrawer.js'; import { ToolCall, type ToolCallData } from './components/ToolCall.js'; import { hasToolCallOutput } from './components/toolcalls/shared/utils.js'; +import { InProgressToolCall } from './components/InProgressToolCall.js'; import { EmptyState } from './components/EmptyState.js'; import { PlanDisplay, type PlanEntry } from './components/PlanDisplay.js'; import { @@ -31,6 +32,7 @@ import { StreamingMessage, WaitingMessage, } from './components/messages/index.js'; +import { InputForm } from './components/InputForm.js'; interface ToolCallUpdate { type: 'tool_call' | 'tool_call_update'; @@ -612,8 +614,8 @@ export const App: React.FC = () => { content, locations: update.locations, }); - } else if (update.type === 'tool_call_update' && existing) { - // Update existing tool call + } else if (update.type === 'tool_call_update') { + // Update existing tool call, or create if it doesn't exist const updatedContent = update.content ? update.content.map((item) => ({ type: item.type as 'content' | 'diff', @@ -624,14 +626,28 @@ export const App: React.FC = () => { })) : undefined; - newMap.set(update.toolCallId, { - ...existing, - ...(update.kind && { kind: update.kind }), - ...(update.title && { title: safeTitle(update.title) }), - ...(update.status && { status: update.status }), - ...(updatedContent && { content: updatedContent }), - ...(update.locations && { locations: update.locations }), - }); + if (existing) { + // Update existing tool call + newMap.set(update.toolCallId, { + ...existing, + ...(update.kind && { kind: update.kind }), + ...(update.title && { title: safeTitle(update.title) }), + ...(update.status && { status: update.status }), + ...(updatedContent && { content: updatedContent }), + ...(update.locations && { locations: update.locations }), + }); + } else { + // Create new tool call if it doesn't exist (missed the initial tool_call message) + newMap.set(update.toolCallId, { + toolCallId: update.toolCallId, + kind: update.kind || 'other', + title: safeTitle(update.title), + status: update.status || 'pending', + rawInput: update.rawInput as string | object | undefined, + content: updatedContent, + locations: update.locations, + }); + } } return newMap; @@ -717,12 +733,14 @@ export const App: React.FC = () => { case 'thoughtChunk': { const chunkData = message.data; + console.log('[App] 🧠 THOUGHT CHUNK RECEIVED:', chunkData); // Handle thought chunks for AI thinking display const thinkingMessage: TextMessage = { role: 'thinking', content: chunkData.content || chunkData.chunk || '', timestamp: Date.now(), }; + console.log('[App] 🧠 Adding thinking message:', thinkingMessage); setMessages((prev) => [...prev, thinkingMessage]); break; } @@ -760,10 +778,58 @@ export const App: React.FC = () => { // console.log('[App] Set notLoggedInMessage to:', (message.data as { message: string })?.message); // break; - case 'permissionRequest': + case 'permissionRequest': { // Show permission dialog handlePermissionRequest(message.data); + + // Also create a tool call entry for the permission request + // This ensures that if it's rejected, we can show it properly + const permToolCall = message.data?.toolCall as { + toolCallId?: string; + kind?: string; + title?: string; + status?: string; + content?: unknown[]; + locations?: Array<{ path: string; line?: number | null }>; + }; + + if (permToolCall?.toolCallId) { + // Infer kind from title if not provided + let kind = permToolCall.kind || 'execute'; + if (permToolCall.title) { + const title = permToolCall.title.toLowerCase(); + if (title.includes('touch') || title.includes('echo')) { + kind = 'execute'; + } else if (title.includes('read') || title.includes('cat')) { + kind = 'read'; + } else if (title.includes('write') || title.includes('edit')) { + kind = 'edit'; + } + } + + handleToolCallUpdate({ + type: 'tool_call', + toolCallId: permToolCall.toolCallId, + kind, + title: permToolCall.title, + status: permToolCall.status || 'pending', + content: permToolCall.content as Array<{ + type: 'content' | 'diff'; + content?: { + type: string; + text?: string; + [key: string]: unknown; + }; + path?: string; + oldText?: string | null; + newText?: string; + [key: string]: unknown; + }>, + locations: permToolCall.locations, + }); + } break; + } case 'plan': // Update plan entries @@ -972,67 +1038,6 @@ export const App: React.FC = () => { setThinkingEnabled((prev) => !prev); }; - // Get edit mode display info - const getEditModeInfo = () => { - switch (editMode) { - case 'ask': - return { - text: 'Ask before edits', - title: 'Qwen will ask before each edit. Click to switch modes.', - icon: ( - - ), - }; - case 'auto': - return { - text: 'Edit automatically', - title: 'Qwen will edit files automatically. Click to switch modes.', - icon: ( - - ), - }; - case 'plan': - return { - text: 'Plan mode', - title: 'Qwen will plan before executing. Click to switch modes.', - icon: ( - - ), - }; - default: - return { - text: 'Unknown mode', - title: 'Unknown edit mode', - icon: null, - }; - } - }; - const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -1417,9 +1422,28 @@ export const App: React.FC = () => { ); })} - {/* Tool Calls - only show those with actual output */} + {/* In-Progress Tool Calls - show only pending/in_progress */} {Array.from(toolCalls.values()) - .filter((toolCall) => hasToolCallOutput(toolCall)) + .filter( + (toolCall) => + toolCall.status === 'pending' || + toolCall.status === 'in_progress', + ) + .map((toolCall) => ( + + ))} + + {/* Completed Tool Calls - only show those with actual output */} + {Array.from(toolCalls.values()) + .filter( + (toolCall) => + (toolCall.status === 'completed' || + toolCall.status === 'failed') && + hasToolCallOutput(toolCall), + ) .map((toolCall) => ( ))} @@ -1477,223 +1501,65 @@ export const App: React.FC = () => { }} /> -
-
- {/* Context Pills - Removed: now using inline @mentions in input */} + setIsComposing(true)} + onCompositionEnd={() => setIsComposing(false)} + onKeyDown={() => {}} + onSubmit={handleSubmit} + onToggleEditMode={handleToggleEditMode} + onToggleThinking={handleToggleThinking} + onFocusActiveEditor={() => { + vscode.postMessage({ + type: 'focusActiveEditor', + data: {}, + }); + }} + onShowCommandMenu={async () => { + if (inputFieldRef.current) { + inputFieldRef.current.focus(); -
-
-
-
-
{ - const target = e.target as HTMLDivElement; - setInputText(target.textContent || ''); - }} - onCompositionStart={() => { - setIsComposing(true); - }} - onCompositionEnd={() => { - setIsComposing(false); - }} - onKeyDown={(e) => { - // 如果正在进行中文输入法输入(拼音输入),不处理回车键 - if (e.key === 'Enter' && !e.shiftKey && !isComposing) { - // 如果 CompletionMenu 打开,让它处理 Enter 键(选中文件) - if (completion.isOpen) { - return; - } - e.preventDefault(); - handleSubmit(e); - } - }} - suppressContentEditableWarning - /> -
-
- - {activeFileName && ( - - )} -
- {/* Spacer 将右侧按钮推到右边 */} -
- - - - - -
- -
-
+ await completion.openCompletion('/', '', position); + } + }} + onAttachContext={handleAttachContextClick} + completionIsOpen={completion.isOpen} + /> {/* Save Session Dialog */} { + const kindMap: Record = { + read: 'Read', + write: 'Write', + edit: 'Edit', + execute: 'Execute', + bash: 'Execute', + command: 'Execute', + search: 'Search', + grep: 'Search', + glob: 'Search', + find: 'Search', + think: 'Think', + thinking: 'Think', + fetch: 'Fetch', + delete: 'Delete', + move: 'Move', + }; + + return kindMap[kind.toLowerCase()] || 'Tool Call'; +}; + +/** + * Get status display text + */ +const getStatusText = (status: string): string => { + const statusMap: Record = { + pending: 'Pending', + in_progress: 'In Progress', + completed: 'Completed', + failed: 'Failed', + }; + + return statusMap[status] || status; +}; + +/** + * Component to display in-progress tool calls with Claude Code styling + * Shows kind, status, and file locations + */ +export const InProgressToolCall: React.FC = ({ + toolCall, +}) => { + const { kind, status, title, locations } = toolCall; + + // Format the kind label + const kindLabel = formatKind(kind); + + // Get status text + const statusText = getStatusText(status || 'in_progress'); + + return ( +
+
+ {kindLabel} + + {statusText} + +
+ + {title && title !== kindLabel && ( +
{title}
+ )} + + {locations && locations.length > 0 && ( +
+ {locations.map((loc, idx) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/InputForm.tsx new file mode 100644 index 00000000..13ec8d85 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/InputForm.tsx @@ -0,0 +1,354 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; + +type EditMode = 'ask' | 'auto' | 'plan'; + +interface InputFormProps { + inputText: string; + inputFieldRef: React.RefObject; + isStreaming: boolean; + isComposing: boolean; + editMode: EditMode; + thinkingEnabled: boolean; + activeFileName: string | null; + activeSelection: { startLine: number; endLine: number } | null; + onInputChange: (text: string) => void; + onCompositionStart: () => void; + onCompositionEnd: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onSubmit: (e: React.FormEvent) => void; + onToggleEditMode: () => void; + onToggleThinking: () => void; + onFocusActiveEditor: () => void; + onShowCommandMenu: () => void; + onAttachContext: () => void; + completionIsOpen: boolean; +} + +// Get edit mode display info +const getEditModeInfo = (editMode: EditMode) => { + switch (editMode) { + case 'ask': + return { + text: 'Ask before edits', + title: 'Qwen will ask before each edit. Click to switch modes.', + icon: ( + + ), + }; + case 'auto': + return { + text: 'Edit automatically', + title: 'Qwen will edit files automatically. Click to switch modes.', + icon: ( + + ), + }; + case 'plan': + return { + text: 'Plan mode', + title: 'Qwen will plan before executing. Click to switch modes.', + icon: ( + + ), + }; + default: + return { + text: 'Unknown mode', + title: 'Unknown edit mode', + icon: null, + }; + } +}; + +export const InputForm: React.FC = ({ + inputText, + inputFieldRef, + isStreaming, + isComposing, + editMode, + thinkingEnabled, + activeFileName, + activeSelection, + onInputChange, + onCompositionStart, + onCompositionEnd, + onKeyDown, + onSubmit, + onToggleEditMode, + onToggleThinking, + onFocusActiveEditor, + onShowCommandMenu, + onAttachContext, + completionIsOpen, +}) => { + const editModeInfo = getEditModeInfo(editMode); + + const handleKeyDown = (e: React.KeyboardEvent) => { + // If composing (Chinese IME input), don't process Enter key + if (e.key === 'Enter' && !e.shiftKey && !isComposing) { + // If CompletionMenu is open, let it handle Enter key + if (completionIsOpen) { + return; + } + e.preventDefault(); + onSubmit(e); + } + onKeyDown(e); + }; + + return ( +
+
+
+ {/* Inner background layer */} +
+ + {/* Banner area */} +
+ + {/* Input wrapper */} +
+
{ + const target = e.target as HTMLDivElement; + onInputChange(target.textContent || ''); + }} + onCompositionStart={onCompositionStart} + onCompositionEnd={onCompositionEnd} + onKeyDown={handleKeyDown} + suppressContentEditableWarning + /> +
+ + {/* Actions row */} +
+ {/* Edit mode button */} + + + {/* Active file indicator */} + {activeFileName && ( + + )} + + {/* Divider */} +
+ + {/* Spacer */} +
+ + {/* Thinking button */} + + + {/* Command button */} + + + {/* Attach button */} + + + {/* Send button */} + +
+ +
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/PermissionDrawer.css b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer.css deleted file mode 100644 index e91684eb..00000000 --- a/packages/vscode-ide-companion/src/webview/components/PermissionDrawer.css +++ /dev/null @@ -1,560 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * Permission Drawer - Bottom sheet style for permission requests - */ - -/* Backdrop overlay */ -.permission-drawer-backdrop { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: var(--app-modal-background); - z-index: 998; - animation: fadeIn 0.2s ease-in-out; -} - -/* Drawer container - bottom sheet style */ -.permission-drawer { - display: flex; - flex-direction: column; - padding: 8px; - background-color: var(--app-input-secondary-background); - border: 1px solid var(--app-input-border); - border-radius: var(--corner-radius-large); - max-height: 70vh; - outline: 0; - position: relative; - margin-bottom: 6px; - z-index: 999; - animation: slideUpFromBottom 0.3s cubic-bezier(0.4, 0, 0.2, 1); -} - -/* Background layer */ -.permission-drawer-background { - background-color: var(--app-input-background); - border-radius: var(--corner-radius-large); - position: absolute; - inset: 0; -} - -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes slideUpFromBottom { - from { - transform: translateY(100%); - } - to { - transform: translateY(0); - } -} - -/* Drawer header */ -.permission-drawer-header { - position: relative; - display: flex; - align-items: center; - justify-content: space-between; - padding: 28px 28px 24px; - border-bottom: 1px solid var(--app-primary-border-color); - background-color: var(--app-header-background); - border-top-left-radius: 20px; - border-top-right-radius: 20px; - flex-shrink: 0; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05); -} - -.permission-drawer-title { - font-weight: 700; - color: var(--app-primary-foreground); - margin-bottom: 4px; -} - -.permission-drawer-close { - width: 36px; - height: 36px; - padding: 0; - background: var(--app-secondary-background); - border: 1px solid var(--app-transparent-inner-border); - border-radius: 8px; - color: var(--app-secondary-foreground); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s ease; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); -} - -.permission-drawer-close:hover { - background-color: var(--app-ghost-button-hover-background); - color: var(--app-primary-foreground); - transform: scale(1.05); - border-color: var(--app-primary-border-color); - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); -} - -.permission-drawer-close:active { - transform: scale(0.98); -} - -/* Drawer content */ -.permission-drawer-content { - font-size: 1.1em; - color: var(--app-primary-foreground); - display: flex; - flex-direction: column; - min-height: 0; - z-index: 1; - flex: 1; - overflow-y: auto; - padding: 0; - min-height: 0; -} - -/* Override permission card styles when in drawer */ -.permission-drawer-content .permission-request-card { - border: none; - margin: 0; - background: transparent; - box-shadow: none; -} - -.permission-drawer-content .permission-card-body { - padding: 0; -} - -/* Add a subtle border at the top of the card when in drawer */ -.permission-drawer-content .permission-request-card::before { - content: ''; - display: block; - height: 1px; - background: var(--app-primary-border-color); - margin-bottom: 24px; - opacity: 0.5; -} - -/* Scrollbar for drawer content */ -.permission-drawer-content::-webkit-scrollbar { - width: 8px; -} - -.permission-drawer-content::-webkit-scrollbar-track { - background: transparent; -} - -.permission-drawer-content::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.2); - border-radius: 4px; -} - -.permission-drawer-content::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.3); -} - -/* Add a drag handle indicator at the top */ -.permission-drawer-header::before { - content: ''; - position: absolute; - top: 12px; - left: 50%; - transform: translateX(-50%); - width: 48px; - height: 5px; - background-color: var(--app-secondary-foreground); - opacity: 0.2; - border-radius: 3px; -} - -/* Responsive adjustments */ -@media (max-width: 768px) { - .permission-drawer { - max-height: 90vh; - border-top-left-radius: 16px; - border-top-right-radius: 16px; - } - - .permission-drawer-header { - padding: 24px 24px 20px; - border-top-left-radius: 16px; - border-top-right-radius: 16px; - } - - .permission-drawer-header::before { - top: 10px; - width: 40px; - height: 4px; - } - - .permission-drawer-content { - padding: 24px; - } - - .permission-drawer-content::after { - height: 24px; - } -} - -/* =========================================== - Permission Request Card Styles - =========================================== */ - -.permission-request-card { - background: var(--app-primary-background); - border-radius: 8px; - padding: 16px; - border: 1px solid var(--app-primary-border-color); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); -} - -.permission-card-body { - display: flex; - flex-direction: column; - gap: 16px; -} - -/* Permission Header */ -.permission-header { - display: flex; - align-items: flex-start; - gap: 16px; - padding-bottom: 16px; -} - -.permission-icon-wrapper { - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - background: var(--app-secondary-background); - border-radius: 8px; - border: 1px solid var(--app-transparent-inner-border); -} - -.permission-icon { - font-size: 24px; - line-height: 1; -} - -.permission-info { - flex: 1; - display: flex; - flex-direction: column; - gap: 6px; -} - -.permission-title { - font-size: 16px; - font-weight: 600; - color: var(--app-primary-foreground); - line-height: 1.4; - word-break: break-word; -} - -.permission-subtitle { - font-size: 13px; - color: var(--app-secondary-foreground); - opacity: 0.8; -} - -/* Command Section - Bash style */ -.permission-command-section { - display: flex; - flex-direction: column; - gap: 8px; - background: var(--app-secondary-background); - border: 1px solid var(--app-transparent-inner-border); - border-radius: 8px; - overflow: hidden; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); -} - -.permission-command-header { - display: flex; - align-items: center; - padding: 12px 16px; - background: var(--app-secondary-background); - border-bottom: 1px solid var(--app-transparent-inner-border); -} - -.permission-command-status { - display: flex; - align-items: center; - gap: 8px; -} - -.permission-command-dot { - color: var(--app-qwen-orange, #ff8c00); - font-size: 10px; - line-height: 1; -} - -.permission-command-label { - font-size: 12px; - font-weight: 600; - color: var(--app-secondary-foreground); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.permission-command-content { - display: flex; - flex-direction: column; - gap: 0; -} - -.permission-command-input-section { - display: grid; - grid-template-columns: 40px 1fr; - align-items: flex-start; - padding: 12px 0; - background: var(--app-primary-background); -} - -.permission-command-io-label { - font-size: 12px; - font-weight: 600; - color: var(--app-secondary-foreground); - opacity: 0.7; - text-align: right; - padding-right: 16px; - padding-top: 4px; -} - -.permission-command-code { - font-family: var(--vscode-editor-font-family, 'Monaco', 'Courier New', monospace); - font-size: 13px; - line-height: 1.6; - color: var(--app-primary-foreground); - background: transparent; - border: none; - padding: 0 16px 0 0; - white-space: pre-wrap; - word-break: break-word; - overflow-wrap: break-word; -} - -.permission-command-description { - font-size: 13px; - color: var(--app-secondary-foreground); - opacity: 0.8; - padding: 12px 16px; - border-top: 1px solid var(--app-transparent-inner-border); - background: var(--app-secondary-background); -} - -/* Locations Section */ -.permission-locations-section { - display: flex; - flex-direction: column; - gap: 8px; -} - -.permission-locations-label { - font-size: 13px; - font-weight: 500; - color: var(--app-secondary-foreground); - opacity: 0.9; -} - -.permission-location-item { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - background: var(--app-secondary-background); - border-radius: 6px; - font-size: 13px; - border: 1px solid var(--app-transparent-inner-border); - transition: all 0.15s ease; -} - -.permission-location-item:hover { - background: var(--app-ghost-button-hover-background); - border-color: var(--app-primary-border-color); -} - -.permission-location-icon { - font-size: 16px; - flex-shrink: 0; - color: var(--app-qwen-orange, #ff8c00); -} - -.permission-location-path { - flex: 1; - color: var(--app-primary-foreground); - font-family: var(--vscode-editor-font-family, 'Monaco', 'Courier New', monospace); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-weight: 500; -} - -.permission-location-line { - color: var(--app-secondary-foreground); - opacity: 0.7; - font-family: var(--vscode-editor-font-family, 'Monaco', 'Courier New', monospace); - font-weight: 500; -} - -/* Options Section */ -.permission-options-section { - display: flex; - flex-direction: column; - gap: 16px; -} - -.permission-options-label { - font-size: 14px; - font-weight: 500; - color: var(--app-secondary-foreground); - opacity: 0.9; -} - -.permission-options-list { - display: flex; - flex-direction: column; - gap: 8px; -} - -.permission-option { - display: flex; - align-items: center; - gap: 12px; - padding: 12px 16px; - background: var(--app-secondary-background); - border: 1px solid var(--app-transparent-inner-border); - border-radius: 6px; - cursor: pointer; - transition: all 0.2s ease; -} - -.permission-option:hover { - background: var(--app-ghost-button-hover-background); - border-color: var(--app-primary-border-color); - transform: translateY(-1px); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); -} - -.permission-option.selected { - background: var(--app-qwen-clay-button-orange); - border-color: var(--app-qwen-orange, #ff8c00); - box-shadow: 0 2px 6px rgba(255, 140, 0, 0.2); -} - -.permission-radio { - width: 18px; - height: 18px; - margin: 0; - cursor: pointer; - accent-color: var(--app-qwen-orange, #ff8c00); -} - -.permission-option-content { - flex: 1; - display: flex; - align-items: center; - gap: 8px; - font-size: 14px; - color: var(--app-primary-foreground); - font-weight: 500; -} - -.permission-option-number { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - background: var(--app-qwen-orange, #ff8c00); - color: var(--app-qwen-ivory, #fff); - border-radius: 6px; - font-size: 12px; - font-weight: 600; - flex-shrink: 0; -} - -.permission-always-badge { - font-size: 16px; - flex-shrink: 0; - color: #ffd700; -} - -.permission-no-options { - padding: 16px; - text-align: center; - color: var(--app-secondary-foreground); - opacity: 0.6; - font-size: 13px; -} - -/* Actions */ -.permission-actions { - display: flex; - justify-content: flex-end; - gap: 12px; - padding-top: 16px; -} - -.permission-confirm-button { - padding: 10px 20px; - background: var(--app-qwen-orange, #ff8c00); - color: var(--app-qwen-ivory, #fff); - border: none; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - box-shadow: 0 2px 4px rgba(255, 140, 0, 0.3); -} - -.permission-confirm-button:hover:not(:disabled) { - background: #e67e00; - transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(255, 140, 0, 0.4); -} - -.permission-confirm-button:disabled { - opacity: 0.5; - cursor: not-allowed; - transform: none; - box-shadow: none; -} - -/* Success Message */ -.permission-success { - display: flex; - align-items: center; - gap: 12px; - padding: 16px; - background: var(--app-qwen-green, #6BCF7F); - color: var(--app-qwen-ivory, #fff); - border-radius: 8px; - font-size: 14px; - box-shadow: 0 2px 8px rgba(107, 207, 127, 0.3); - animation: fadeIn 0.3s ease-in-out; -} - -.permission-success-icon { - font-size: 20px; - flex-shrink: 0; -} - -.permission-success-text { - flex: 1; - font-weight: 500; -} diff --git a/packages/vscode-ide-companion/src/webview/components/PermissionDrawer.tailwind.tsx b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer.tailwind.tsx deleted file mode 100644 index 621cf843..00000000 --- a/packages/vscode-ide-companion/src/webview/components/PermissionDrawer.tailwind.tsx +++ /dev/null @@ -1,121 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * PermissionDrawer component using Tailwind CSS - */ - -import type React from 'react'; -import { useEffect } from 'react'; -import { - PermissionRequest, - type PermissionOption, - type ToolCall, -} from './PermissionRequest.js'; -import { buttonClasses, commonClasses } from '../../lib/tailwindUtils.js'; - -interface PermissionDrawerProps { - isOpen: boolean; - options: PermissionOption[]; - toolCall: ToolCall; - onResponse: (optionId: string) => void; - onClose?: () => void; -} - -/** - * Permission drawer component - displays permission requests in a bottom sheet - * Uses Tailwind CSS for styling - * @param isOpen - Whether the drawer is open - * @param options - Permission options to display - * @param toolCall - Tool call information - * @param onResponse - Callback when user responds - * @param onClose - Optional callback when drawer closes - */ -export const PermissionDrawerTailwind: React.FC = ({ - isOpen, - options, - toolCall, - onResponse, - onClose, -}) => { - // Close drawer on Escape key - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (!isOpen) { - return; - } - - // Close on Escape - if (e.key === 'Escape' && onClose) { - onClose(); - return; - } - - // Quick select with number keys (1-9) - const numMatch = e.key.match(/^[1-9]$/); - if (numMatch) { - const index = parseInt(e.key, 10) - 1; - if (index < options.length) { - e.preventDefault(); - onResponse(options[index].optionId); - } - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [isOpen, onClose, options, onResponse]); - - if (!isOpen) { - return null; - } - - return ( - <> - {/* Backdrop */} -
- - {/* Drawer */} -
-
-
-

Permission Required

- {onClose && ( - - )} -
- -
- -
-
- - ); -}; \ No newline at end of file diff --git a/packages/vscode-ide-companion/src/webview/components/PermissionDrawer.tsx b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer.tsx index e8227915..0777ec9e 100644 --- a/packages/vscode-ide-companion/src/webview/components/PermissionDrawer.tsx +++ b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer.tsx @@ -5,13 +5,8 @@ */ import type React from 'react'; -import { useEffect } from 'react'; -import { - PermissionRequest, - type PermissionOption, - type ToolCall, -} from './PermissionRequest.js'; -import './PermissionDrawer.css'; +import { useEffect, useState, useRef } from 'react'; +import type { PermissionOption, ToolCall } from './PermissionRequest.js'; interface PermissionDrawerProps { isOpen: boolean; @@ -22,12 +17,7 @@ interface PermissionDrawerProps { } /** - * Permission drawer component - displays permission requests in a bottom sheet - * @param isOpen - Whether the drawer is open - * @param options - Permission options to display - * @param toolCall - Tool call information - * @param onResponse - Callback when user responds - * @param onClose - Optional callback when drawer closes + * Permission drawer component - Claude Code style bottom sheet */ export const PermissionDrawer: React.FC = ({ isOpen, @@ -36,80 +26,229 @@ export const PermissionDrawer: React.FC = ({ onResponse, onClose, }) => { - // Close drawer on Escape key + const [focusedIndex, setFocusedIndex] = useState(0); + const [customMessage, setCustomMessage] = useState(''); + const containerRef = useRef(null); + const customInputRef = useRef(null); + + // Get the title for the permission request + const getTitle = () => { + if (toolCall.kind === 'edit' || toolCall.kind === 'write') { + const fileName = + toolCall.locations?.[0]?.path?.split('/').pop() || 'file'; + return ( + <> + Allow write to{' '} + + {fileName} + + ? + + ); + } + if (toolCall.kind === 'execute' || toolCall.kind === 'bash') { + return 'Allow command execution?'; + } + if (toolCall.kind === 'read') { + const fileName = + toolCall.locations?.[0]?.path?.split('/').pop() || 'file'; + return ( + <> + Allow read from{' '} + + {fileName} + + ? + + ); + } + return toolCall.title || 'Permission Required'; + }; + + // Handle keyboard navigation useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (!isOpen) { - return; - } + if (!isOpen) return; - // Close on Escape - if (e.key === 'Escape' && onClose) { - onClose(); - return; - } - - // Quick select with number keys (1-9) + // Number keys 1-9 for quick select const numMatch = e.key.match(/^[1-9]$/); - if (numMatch) { + if ( + numMatch && + !customInputRef.current?.contains(document.activeElement) + ) { const index = parseInt(e.key, 10) - 1; if (index < options.length) { e.preventDefault(); onResponse(options[index].optionId); } + return; + } + + // Arrow keys for navigation + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + const totalItems = options.length + 1; // +1 for custom input + if (e.key === 'ArrowDown') { + setFocusedIndex((prev) => (prev + 1) % totalItems); + } else { + setFocusedIndex((prev) => (prev - 1 + totalItems) % totalItems); + } + } + + // Enter to select + if ( + e.key === 'Enter' && + !customInputRef.current?.contains(document.activeElement) + ) { + e.preventDefault(); + if (focusedIndex < options.length) { + onResponse(options[focusedIndex].optionId); + } + } + + // Escape to close + if (e.key === 'Escape' && onClose) { + onClose(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [isOpen, onClose, options, onResponse]); + }, [isOpen, options, onResponse, onClose, focusedIndex]); - if (!isOpen) { - return null; - } + // Focus container when opened + useEffect(() => { + if (isOpen && containerRef.current) { + containerRef.current.focus(); + } + }, [isOpen]); + + if (!isOpen) return null; return ( - <> - {/* Backdrop */} -
+
+ {/* Main container */} +
+ {/* Background layer */} +
- {/* Drawer */} -
-
-
-

Permission Required

- {onClose && ( - - )} + {/* Title */} +
+
+ {getTitle()} +
-
- + {/* Options */} +
+ {options.map((option, index) => { + const isAlways = option.kind.includes('always'); + const isFocused = focusedIndex === index; + + return ( + + ); + })} + + {/* Custom message input */} +
setFocusedIndex(options.length)} + > +
+ Tell Qwen what to do instead +
+
{ + const target = e.target as HTMLDivElement; + setCustomMessage(target.textContent || ''); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) { + e.preventDefault(); + const rejectOption = options.find((o) => + o.kind.includes('reject'), + ); + if (rejectOption) { + onResponse(rejectOption.optionId); + } + } + }} + /> +
- + + +
); }; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts index 0b9cabb0..e4f285d7 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts @@ -109,18 +109,118 @@ export function useCompletionTrigger( const handleInput = async () => { const text = inputElement.textContent || ''; const selection = window.getSelection(); + + console.log( + '[useCompletionTrigger] handleInput - text:', + JSON.stringify(text), + 'length:', + text.length, + ); + if (!selection || selection.rangeCount === 0) { + console.log('[useCompletionTrigger] No selection or rangeCount === 0'); return; } const range = selection.getRangeAt(0); - const cursorPosition = range.startOffset; + console.log( + '[useCompletionTrigger] range.startContainer:', + range.startContainer, + 'startOffset:', + range.startOffset, + ); + console.log( + '[useCompletionTrigger] startContainer === inputElement:', + range.startContainer === inputElement, + ); + console.log( + '[useCompletionTrigger] startContainer.nodeType:', + range.startContainer.nodeType, + 'TEXT_NODE:', + Node.TEXT_NODE, + ); + + // Get cursor position more reliably + // For contentEditable, we need to calculate the actual text offset + let cursorPosition = text.length; // Default to end of text + + if (range.startContainer === inputElement) { + // Cursor is directly in the container (e.g., empty or at boundary) + // Use childNodes to determine position + const childIndex = range.startOffset; + let offset = 0; + for ( + let i = 0; + i < childIndex && i < inputElement.childNodes.length; + i++ + ) { + offset += inputElement.childNodes[i].textContent?.length || 0; + } + cursorPosition = offset || text.length; + console.log( + '[useCompletionTrigger] Container mode - childIndex:', + childIndex, + 'offset:', + offset, + 'cursorPosition:', + cursorPosition, + ); + } else if (range.startContainer.nodeType === Node.TEXT_NODE) { + // Cursor is in a text node - calculate offset from start of input + const walker = document.createTreeWalker( + inputElement, + NodeFilter.SHOW_TEXT, + null, + ); + + let offset = 0; + let found = false; + let node: Node | null = walker.nextNode(); + while (node) { + if (node === range.startContainer) { + offset += range.startOffset; + found = true; + break; + } + offset += node.textContent?.length || 0; + node = walker.nextNode(); + } + // If we found the node, use the calculated offset; otherwise use text length + cursorPosition = found ? offset : text.length; + console.log( + '[useCompletionTrigger] Text node mode - found:', + found, + 'offset:', + offset, + 'cursorPosition:', + cursorPosition, + ); + } // Find trigger character before cursor - const textBeforeCursor = text.substring(0, cursorPosition); + // Use text length if cursorPosition is 0 but we have text (edge case for first character) + const effectiveCursorPosition = + cursorPosition === 0 && text.length > 0 ? text.length : cursorPosition; + console.log( + '[useCompletionTrigger] cursorPosition:', + cursorPosition, + 'effectiveCursorPosition:', + effectiveCursorPosition, + ); + + const textBeforeCursor = text.substring(0, effectiveCursorPosition); const lastAtMatch = textBeforeCursor.lastIndexOf('@'); const lastSlashMatch = textBeforeCursor.lastIndexOf('/'); + console.log( + '[useCompletionTrigger] textBeforeCursor:', + JSON.stringify(textBeforeCursor), + 'lastAtMatch:', + lastAtMatch, + 'lastSlashMatch:', + lastSlashMatch, + ); + // Check if we're in a trigger context let triggerPos = -1; let triggerChar: '@' | '/' | null = null; @@ -133,19 +233,46 @@ export function useCompletionTrigger( triggerChar = '/'; } + console.log( + '[useCompletionTrigger] triggerPos:', + triggerPos, + 'triggerChar:', + triggerChar, + ); + // Check if trigger is at word boundary (start of line or after space) if (triggerPos >= 0 && triggerChar) { const charBefore = triggerPos > 0 ? text[triggerPos - 1] : ' '; const isValidTrigger = charBefore === ' ' || charBefore === '\n' || triggerPos === 0; + console.log( + '[useCompletionTrigger] charBefore:', + JSON.stringify(charBefore), + 'isValidTrigger:', + isValidTrigger, + ); + if (isValidTrigger) { - const query = text.substring(triggerPos + 1, cursorPosition); + const query = text.substring(triggerPos + 1, effectiveCursorPosition); + + console.log( + '[useCompletionTrigger] query:', + JSON.stringify(query), + 'hasSpace:', + query.includes(' '), + 'hasNewline:', + query.includes('\n'), + ); // Only show if query doesn't contain spaces (still typing the reference) if (!query.includes(' ') && !query.includes('\n')) { // Get precise cursor position for menu const cursorPos = getCursorPosition(); + console.log( + '[useCompletionTrigger] Opening completion - cursorPos:', + cursorPos, + ); if (cursorPos) { await openCompletion(triggerChar, query, cursorPos); return; @@ -155,6 +282,10 @@ export function useCompletionTrigger( } // Close if no valid trigger + console.log( + '[useCompletionTrigger] No valid trigger, state.isOpen:', + state.isOpen, + ); if (state.isOpen) { closeCompletion(); } diff --git a/packages/vscode-ide-companion/tailwind.config.js b/packages/vscode-ide-companion/tailwind.config.js index b973f78b..d0cec1f1 100644 --- a/packages/vscode-ide-companion/tailwind.config.js +++ b/packages/vscode-ide-companion/tailwind.config.js @@ -8,6 +8,8 @@ module.exports = { './src/webview/components/messages/**/*.{js,jsx,ts,tsx}', './src/webview/components/MessageContent.tsx', './src/webview/components/InfoBanner.tsx', + './src/webview/components/InputForm.tsx', + './src/webview/components/PermissionDrawer.tsx', // 当需要在更多组件中使用Tailwind时,可以逐步添加路径 // "./src/webview/components/NewComponent/**/*.{js,jsx,ts,tsx}", // "./src/webview/pages/**/*.{js,jsx,ts,tsx}",