style(vscode-ide-companion): form component style opt

This commit is contained in:
yiliang114
2025-12-01 00:15:18 +08:00
parent 1b37d729cb
commit ed0d5f67db
12 changed files with 223 additions and 117 deletions

View File

@@ -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 = () => {
<div
ref={messagesContainerRef}
className="flex-1 overflow-y-auto overflow-x-hidden pt-5 pr-5 pl-5 pb-[120px] flex flex-col relative min-w-0 focus:outline-none [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-thumb]:rounded-sm [&::-webkit-scrollbar-thumb:hover]:bg-white/30 [&>*]:flex [&>*]:gap-0 [&>*]:items-start [&>*]:text-left [&>*]:py-2 [&>.message-item]:px-0 [&>.message-item]:py-0 [&>*]:flex-col [&>*]:relative [&>*]:animate-[fadeIn_0.2s_ease-in]"
className="chat-messages flex-1 overflow-y-auto overflow-x-hidden pt-5 pr-5 pl-5 pb-[120px] flex flex-col relative min-w-0 focus:outline-none [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-thumb]:rounded-sm [&::-webkit-scrollbar-thumb:hover]:bg-white/30 [&>*]:flex [&>*]:gap-0 [&>*]:items-start [&>*]:text-left [&>*]:py-2 [&>*:not(:last-child)]:mb-[10px] [&>.message-item]:px-0 [&>.message-item]:py-0 [&>*]:flex-col [&>*]:relative [&>*]:animate-[fadeIn_0.2s_ease-in]"
style={{ backgroundColor: 'var(--app-primary-background)' }}
>
{!hasContent ? (

View File

@@ -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<InputFormProps> = ({
>
<div className="block">
<form
className="relative flex flex-col rounded-large border shadow-sm transition-all duration-200 focus-within:shadow-md"
className="relative flex flex-col rounded-large border border-[var(--app-input-border)] shadow-sm transition-colors duration-200 focus-within:border-[var(--app-input-highlight)] focus-within:[box-shadow:0_1px_2px_color-mix(in_srgb,var(--app-input-highlight),transparent_80%)]"
style={{
backgroundColor:
'var(--app-input-secondary-background, var(--app-input-background))',
borderColor: 'var(--app-input-border)',
color: 'var(--app-input-foreground)',
}}
onSubmit={onSubmit}
@@ -151,7 +150,7 @@ export const InputForm: React.FC<InputFormProps> = ({
onCompletionSelect &&
onCompletionClose && (
// Render dropdown above the input, matching Claude Code
<ClaudeCompletionMenu
<CompletionMenu
items={completionItems}
onSelect={onCompletionSelect}
onClose={onCompletionClose}
@@ -193,26 +192,30 @@ export const InputForm: React.FC<InputFormProps> = ({
{/* Edit mode button */}
<button
type="button"
className="flex items-center gap-1.5 px-2.5 py-1.5 h-8 bg-transparent border border-transparent rounded-small cursor-pointer text-xs whitespace-nowrap transition-colors duration-150 hover:bg-[var(--app-ghost-button-hover-background)] [&>svg]:w-4 [&>svg]:h-4 [&>svg]:flex-shrink-0"
className="flex items-center gap-1.5 px-2.5 py-1.5 h-8 bg-transparent border border-transparent rounded-small cursor-pointer text-xs transition-colors duration-150 hover:bg-[var(--app-ghost-button-hover-background)] min-w-0 flex-shrink overflow-hidden [&>svg]:w-4 [&>svg]:h-4 [&>svg]:flex-shrink-0"
style={{ color: 'var(--app-primary-foreground)' }}
title={editModeInfo.title}
onClick={onToggleEditMode}
>
{editModeInfo.icon}
<span>{editModeInfo.text}</span>
{/* Let the label truncate with ellipsis; hide on very small screens */}
<span className="min-w-0 truncate hidden sm:inline">
{editModeInfo.text}
</span>
</button>
{/* Active file indicator */}
{activeFileName && (
<button
type="button"
className="flex items-center gap-1.5 px-2.5 py-1.5 h-8 bg-transparent border border-transparent rounded-small cursor-pointer text-xs whitespace-nowrap transition-colors duration-150 hover:bg-[var(--app-ghost-button-hover-background)] [&>svg]:w-4 [&>svg]:h-4 [&>svg]:flex-shrink-0 max-w-[200px] overflow-hidden text-ellipsis flex-shrink min-w-0"
className="flex items-center gap-1.5 px-2.5 py-1.5 h-8 bg-transparent border border-transparent rounded-small cursor-pointer text-xs transition-colors duration-150 hover:bg-[var(--app-ghost-button-hover-background)] [&>svg]:w-4 [&>svg]:h-4 [&>svg]:flex-shrink-0 max-w-[200px] flex-shrink min-w-0 overflow-hidden"
style={{ color: 'var(--app-primary-foreground)' }}
title={`Showing Qwen Code your current file selection: ${activeFileName}${activeSelection ? `#${activeSelection.startLine}-${activeSelection.endLine}` : ''}`}
onClick={onFocusActiveEditor}
>
<CodeBracketsIcon />
<span>
{/* Truncate file path/selection; hide label on very small screens */}
<span className="min-w-0 truncate hidden sm:inline">
{activeFileName}
{activeSelection &&
` #${activeSelection.startLine}${activeSelection.startLine !== activeSelection.endLine ? `-${activeSelection.endLine}` : ''}`}

View File

@@ -4,12 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
/**
* PlanDisplay.css -> Tailwind 化
* 说明:尽量用 @apply把原有类名保留便于调试
* 仅在必须的地方保留少量原生 CSS如关键帧
*/
/* 容器 */
.plan-display {
@apply bg-transparent border-0 py-2 px-4 my-2;

View File

@@ -102,7 +102,8 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
onClick={openFirstDiff}
title="Open diff in VS Code"
>
<span className="absolute left-2 top-[10px] text-[10px] text-[#74c991]">
{/* Center the bullet vertically like the shared container */}
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-[10px] leading-none text-[#74c991]">
</span>
{/* Keep content within overall width: pl-[30px] provides the bullet indent; */}
@@ -110,7 +111,8 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
<div className="toolcall-edit-content flex flex-col gap-1 pl-[30px] min-w-0 max-w-full">
<div className="flex items-center justify-between min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className="text-[13px] font-medium text-[var(--app-primary-foreground)]">
{/* Align the inline Edit label styling with shared toolcall label: larger + bold */}
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
Edit
</span>
{path && (

View File

@@ -23,9 +23,6 @@ export const ReadToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
// Group content by type
const { errors } = groupContent(content);
// Extract filename from path
// const getFileName = (path: string): string => path.split('/').pop() || path;
// Error case: show error
if (errors.length > 0) {
const path = locations?.[0]?.path || '';

View File

@@ -68,7 +68,8 @@ export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
>
</span>
<span className="text-[13px] font-medium text-[var(--app-primary-foreground)]">
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
{label}
</span>
{/* {toolCallId && (

View File

@@ -8,7 +8,7 @@ import type React from 'react';
import { useEffect, useRef, useState } from 'react';
import type { CompletionItem } from '../CompletionTypes.js';
interface ClaudeCompletionMenuProps {
interface CompletionMenuProps {
items: CompletionItem[];
onSelect: (item: CompletionItem) => void;
onClose: () => void;
@@ -20,7 +20,7 @@ interface ClaudeCompletionMenuProps {
* Claude Code-like anchored dropdown rendered above the input field.
* Keyboard: Up/Down to move, Enter to select, Esc to close.
*/
export const ClaudeCompletionMenu: React.FC<ClaudeCompletionMenuProps> = ({
export const CompletionMenu: React.FC<CompletionMenuProps> = ({
items,
onSelect,
onClose,

View File

@@ -115,7 +115,8 @@ export const FileLink: React.FC<FileLinkProps> = ({
// Keep a semantic handle for scoped overrides (e.g. DiffDisplay.css)
'file-link',
// Layout + interaction
'inline-flex items-baseline',
// Use items-center + leading-none to vertically center within surrounding rows
'inline-flex items-center leading-none',
disableClick
? 'pointer-events-none cursor-[inherit] hover:no-underline'
: 'cursor-pointer',
@@ -136,7 +137,7 @@ export const FileLink: React.FC<FileLinkProps> = ({
aria-label={`Open file: ${fullDisplayText}`}
// Inherit font family from context so it matches theme body text.
>
<span className="file-link-path font-medium">{displayPath}</span>
<span className="file-link-path">{displayPath}</span>
{line !== null && line !== undefined && (
<span className="file-link-location opacity-70 text-[0.9em] font-normal dark:opacity-60">
:{line}

View File

@@ -45,6 +45,8 @@
--app-input-active-border: var(--vscode-inputOption-activeBorder);
--app-input-placeholder-foreground: var(--vscode-input-placeholderForeground);
--app-input-secondary-background: var(--vscode-menu-background);
/* Input Highlight (focus ring/border) */
--app-input-highlight: var(--app-qwen-orange);
/* Code Highlighting */
--app-code-background: var(--vscode-textCodeBlock-background, rgba(0, 0, 0, 0.05));
@@ -93,6 +95,8 @@
/* Light Theme Overrides */
.vscode-light {
--app-transparent-inner-border: rgba(0, 0, 0, 0.07);
/* Slightly different brand shade in light theme for better contrast */
--app-input-highlight: var(--app-qwen-clay-button-orange);
}
/* Icon SVG styles */
@@ -144,6 +148,12 @@ button {
color: var(--app-primary-foreground);
}
/* Message list container: prevent browser scroll anchoring from fighting our manual pin-to-bottom logic */
.chat-messages > * {
/* Disable overflow anchoring on individual items so the UA doesn't auto-adjust scroll */
overflow-anchor: none;
}
/* ===========================
Animations (used by message components)
=========================== */