From ed0d5f67db228533202d587147d3f0b8542e73e0 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 1 Dec 2025 00:15:18 +0800 Subject: [PATCH] style(vscode-ide-companion): form component style opt --- .../src/commands/index.ts | 93 ++++++++++++++ .../vscode-ide-companion/src/extension.ts | 74 ++--------- .../vscode-ide-companion/src/webview/App.tsx | 115 +++++++++++++----- .../src/webview/components/InputForm.tsx | 19 +-- .../src/webview/components/PlanDisplay.css | 6 - .../components/toolcalls/EditToolCall.tsx | 6 +- .../components/toolcalls/ReadToolCall.tsx | 3 - .../toolcalls/shared/LayoutComponents.tsx | 3 +- ...eCompletionMenu.tsx => CompletionMenu.tsx} | 4 +- .../src/webview/components/ui/FileLink.tsx | 5 +- .../src/webview/styles/App.css | 10 ++ .../vscode-ide-companion/tailwind.config.js | 2 +- 12 files changed, 223 insertions(+), 117 deletions(-) create mode 100644 packages/vscode-ide-companion/src/commands/index.ts rename packages/vscode-ide-companion/src/webview/components/ui/{ClaudeCompletionMenu.tsx => CompletionMenu.tsx} (97%) diff --git a/packages/vscode-ide-companion/src/commands/index.ts b/packages/vscode-ide-companion/src/commands/index.ts new file mode 100644 index 00000000..92c188bd --- /dev/null +++ b/packages/vscode-ide-companion/src/commands/index.ts @@ -0,0 +1,93 @@ +import * as vscode from 'vscode'; +import type { DiffManager } from '../diff-manager.js'; +import type { WebViewProvider } from '../webview/WebViewProvider.js'; + +type Logger = (message: string) => void; + +export function registerNewCommands( + context: vscode.ExtensionContext, + log: Logger, + diffManager: DiffManager, + getWebViewProviders: () => WebViewProvider[], + createWebViewProvider: () => WebViewProvider, +): void { + const disposables: vscode.Disposable[] = []; + + // qwenCode.showDiff + disposables.push( + vscode.commands.registerCommand( + 'qwenCode.showDiff', + async (args: { path: string; oldText: string; newText: string }) => { + log(`[Command] showDiff called for: ${args.path}`); + try { + let absolutePath = args.path; + if (!args.path.startsWith('/') && !args.path.match(/^[a-zA-Z]:/)) { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (workspaceFolder) { + absolutePath = vscode.Uri.joinPath( + workspaceFolder.uri, + args.path, + ).fsPath; + } + } + + await diffManager.showDiff(absolutePath, args.oldText, args.newText); + } catch (error) { + log(`[Command] Error showing diff: ${error}`); + vscode.window.showErrorMessage(`Failed to show diff: ${error}`); + } + }, + ), + ); + + // qwenCode.openChat + disposables.push( + vscode.commands.registerCommand('qwenCode.openChat', () => { + const providers = getWebViewProviders(); + if (providers.length > 0) { + providers[providers.length - 1].show(); + } else { + const provider = createWebViewProvider(); + provider.show(); + } + }), + ); + + // qwenCode.openNewChatTab (not contributed in package.json; used programmatically) + disposables.push( + vscode.commands.registerCommand('qwenCode.openNewChatTab', () => { + const provider = createWebViewProvider(); + provider.show(); + }), + ); + + // qwenCode.clearAuthCache + disposables.push( + vscode.commands.registerCommand('qwenCode.clearAuthCache', async () => { + const providers = getWebViewProviders(); + for (const provider of providers) { + await provider.clearAuthCache(); + } + vscode.window.showInformationMessage( + 'Qwen Code authentication cache cleared. You will need to login again on next connection.', + ); + log('Auth cache cleared by user'); + }), + ); + + // qwenCode.login + disposables.push( + vscode.commands.registerCommand('qwenCode.login', async () => { + const providers = getWebViewProviders(); + if (providers.length > 0) { + await providers[providers.length - 1].forceReLogin(); + } else { + vscode.window.showInformationMessage( + 'Please open Qwen Code chat first before logging in.', + ); + } + }), + ); + + context.subscriptions.push(...disposables); +} diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 21ddb0af..52dcf17b 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -15,6 +15,7 @@ import { type IdeInfo, } from '@qwen-code/qwen-code-core/src/ide/detect-ide.js'; import { WebViewProvider } from './webview/WebViewProvider.js'; +import { registerNewCommands } from './commands/index.js'; const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion'; const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown'; @@ -156,6 +157,15 @@ export async function activate(context: vscode.ExtensionContext) { }), ); + // Register newly added commands via commands module + registerNewCommands( + context, + log, + diffManager, + () => webViewProviders, + createWebViewProvider, + ); + context.subscriptions.push( vscode.workspace.onDidCloseTextDocument((doc) => { if (doc.uri.scheme === DIFF_SCHEME) { @@ -178,70 +188,6 @@ export async function activate(context: vscode.ExtensionContext) { diffManager.cancelDiff(docUri); } }), - vscode.commands.registerCommand( - 'qwenCode.showDiff', - async (args: { path: string; oldText: string; newText: string }) => { - log(`[Command] showDiff called for: ${args.path}`); - try { - // Convert relative path to absolute if needed - let absolutePath = args.path; - if (!args.path.startsWith('/') && !args.path.match(/^[a-zA-Z]:/)) { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (workspaceFolder) { - absolutePath = vscode.Uri.joinPath( - workspaceFolder.uri, - args.path, - ).fsPath; - } - } - - await diffManager.showDiff(absolutePath, args.oldText, args.newText); - } catch (error) { - log(`[Command] Error showing diff: ${error}`); - vscode.window.showErrorMessage(`Failed to show diff: ${error}`); - } - }, - ), - vscode.commands.registerCommand('qwenCode.openChat', () => { - // Open or reveal the most recent chat tab - if (webViewProviders.length > 0) { - const lastProvider = webViewProviders[webViewProviders.length - 1]; - lastProvider.show(); - } else { - // Create first chat tab - const provider = createWebViewProvider(); - provider.show(); - } - }), - vscode.commands.registerCommand('qwenCode.openNewChatTab', () => { - // Always create a new WebviewPanel (tab) in the same view column - // The PanelManager will find existing Qwen Code tabs and open in the same column - const provider = createWebViewProvider(); - provider.show(); - }), - vscode.commands.registerCommand('qwenCode.clearAuthCache', async () => { - // Clear auth state for all WebView providers - for (const provider of webViewProviders) { - await provider.clearAuthCache(); - } - - vscode.window.showInformationMessage( - 'Qwen Code authentication cache cleared. You will need to login again on next connection.', - ); - log('Auth cache cleared by user'); - }), - vscode.commands.registerCommand('qwenCode.login', async () => { - // Get the current WebViewProvider instance - must already exist - if (webViewProviders.length > 0) { - const provider = webViewProviders[webViewProviders.length - 1]; - await provider.forceReLogin(); - } else { - // No WebViewProvider exists, show a message to user - vscode.window.showInformationMessage( - 'Please open Qwen Code chat first before logging in.', - ); - } - }), ); ideServer = new IDEServer(log, diffManager); diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 49649e95..557b510e 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -4,7 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import React, { + useState, + useEffect, + useRef, + useCallback, + useLayoutEffect, +} from 'react'; import { useVSCode } from './hooks/useVSCode.js'; import { useSessionManagement } from './hooks/session/useSessionManagement.js'; import { useFileContext } from './hooks/file/useFileContext.js'; @@ -181,22 +187,40 @@ export const App: React.FC = () => { // Auto-scroll handling: keep the view pinned to bottom when new content arrives, // but don't interrupt the user if they scrolled up. + // We track whether the user is currently "pinned" to the bottom (near the end). + const [pinnedToBottom, setPinnedToBottom] = useState(true); const prevCountsRef = useRef({ msgLen: 0, inProgLen: 0, doneLen: 0 }); + + // Observe scroll position to know if user has scrolled away from the bottom. useEffect(() => { const container = messagesContainerRef.current; - const endEl = messagesEndRef.current; - if (!container || !endEl) { + if (!container) { return; } - const nearBottom = () => { - const threshold = 64; // px tolerance - return ( - container.scrollTop + container.clientHeight >= - container.scrollHeight - threshold - ); + const onScroll = () => { + // Use a small threshold so slight deltas don't flip the state. + // Note: there's extra bottom padding for the input area, so keep this a bit generous. + const threshold = 80; // px tolerance + const distanceFromBottom = + container.scrollHeight - (container.scrollTop + container.clientHeight); + setPinnedToBottom(distanceFromBottom <= threshold); }; + // Initialize once mounted so first render is correct + onScroll(); + container.addEventListener('scroll', onScroll, { passive: true }); + return () => container.removeEventListener('scroll', onScroll); + }, []); + + // When content changes, if the user is pinned to bottom, keep it anchored there. + // Only smooth-scroll when new items are appended; do not smooth for streaming chunk updates. + useLayoutEffect(() => { + const container = messagesContainerRef.current; + if (!container) { + return; + } + // Detect whether new items were appended (vs. streaming chunk updates) const prev = prevCountsRef.current; const newMsg = messageHandling.messages.length > prev.msgLen; @@ -208,26 +232,22 @@ export const App: React.FC = () => { doneLen: completedToolCalls.length, }; - // If user is near bottom, or if we just appended a new item, scroll to bottom - if (nearBottom() || newMsg || newInProg || newDone) { - // Try scrollIntoView first - const smooth = newMsg || newInProg || newDone; // avoid smooth on streaming chunks - endEl.scrollIntoView({ - behavior: smooth ? 'smooth' : 'auto', - block: 'end', - }); - - // Fallback: directly set scrollTop if scrollIntoView doesn't work - setTimeout(() => { - if (container && endEl) { - const shouldScroll = nearBottom() || newMsg || newInProg || newDone; - if (shouldScroll) { - container.scrollTop = container.scrollHeight; - } - } - }, 50); + if (!pinnedToBottom) { + // Do nothing if user scrolled away; avoid stealing scroll. + return; } + + const smooth = newMsg || newInProg || newDone; // avoid smooth on streaming chunks + + // Anchor to the bottom on next frame to avoid layout thrash. + const raf = requestAnimationFrame(() => { + const top = container.scrollHeight - container.clientHeight; + // Use scrollTo to avoid cross-context issues with scrollIntoView. + container.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' }); + }); + return () => cancelAnimationFrame(raf); }, [ + pinnedToBottom, messageHandling.messages, inProgressToolCalls, completedToolCalls, @@ -237,6 +257,45 @@ export const App: React.FC = () => { planEntries, ]); + // When the last rendered item resizes (e.g., images/code blocks load/expand), + // if we're pinned to bottom, keep it anchored there. + useEffect(() => { + const container = messagesContainerRef.current; + const endEl = messagesEndRef.current; + if (!container || !endEl) { + return; + } + + const lastItem = endEl.previousElementSibling as HTMLElement | null; + if (!lastItem) { + return; + } + + let frame = 0; + const ro = new ResizeObserver(() => { + if (!pinnedToBottom) { + return; + } + // Defer to next frame to avoid thrash during rapid size changes + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + const top = container.scrollHeight - container.clientHeight; + container.scrollTo({ top }); + }); + }); + ro.observe(lastItem); + + return () => { + cancelAnimationFrame(frame); + ro.disconnect(); + }; + }, [ + pinnedToBottom, + messageHandling.messages, + inProgressToolCalls, + completedToolCalls, + ]); + // Handle permission response const handlePermissionResponse = useCallback( (optionId: string) => { @@ -418,7 +477,7 @@ export const App: React.FC = () => {
{!hasContent ? ( diff --git a/packages/vscode-ide-companion/src/webview/components/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/InputForm.tsx index ca1bf8f3..819cdd25 100644 --- a/packages/vscode-ide-companion/src/webview/components/InputForm.tsx +++ b/packages/vscode-ide-companion/src/webview/components/InputForm.tsx @@ -15,7 +15,7 @@ import { LinkIcon, ArrowUpIcon, } from './icons/index.js'; -import { ClaudeCompletionMenu } from './ui/ClaudeCompletionMenu.js'; +import { CompletionMenu } from './ui/CompletionMenu.js'; import type { CompletionItem } from './CompletionTypes.js'; type EditMode = 'ask' | 'auto' | 'plan'; @@ -124,11 +124,10 @@ export const InputForm: React.FC = ({ >
= ({ onCompletionSelect && onCompletionClose && ( // Render dropdown above the input, matching Claude Code - = ({ {/* Edit mode button */} {/* Active file indicator */} {activeFileName && (