From a60e539be7e1475d8047ca602a163d8bdc26cbe6 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Tue, 15 Sep 2026 20:26:05 +0800 Subject: [PATCH] fix(chat): keep hover dropdowns inside the input toolbar The external-context and MCP server panels are centered on their toolbar icons and grow upward with a fixed width. When the icon sits near the left edge of the toolbar, the centered panel overflowed the chat container's clipped edge, cutting off the panel title and the "~" path prefixes. A new pure placement module clamps the panel center so it stays within the toolbar with an 8px edge inset, and caps the width only when the toolbar itself is narrower than the panel. The selectors publish the result as CSS custom properties on hover and on dropdown render, with the previous static values kept as fallbacks. Co-authored-by: QoderAI (Qwen 3.8 Max) --- CHANGELOG.md | 8 + src/features/chat/ui/input-toolbar.ts | 79 +++++++++- .../ui/toolbar/hover-dropdown-placement.ts | 52 +++++++ src/style/toolbar/external-context.css | 9 +- src/style/toolbar/mcp-selector.css | 9 +- .../chat/ui/hover-dropdown-placement.test.ts | 145 ++++++++++++++++++ 6 files changed, 292 insertions(+), 10 deletions(-) create mode 100644 src/features/chat/ui/toolbar/hover-dropdown-placement.ts create mode 100644 tests/unit/features/chat/ui/hover-dropdown-placement.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 13055cf..514ca72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ version with its date and start a fresh empty `[Unreleased]` above it. ## [Unreleased] +### Fixed + +- Hover dropdowns no longer lose their leading characters when their icon + sits near the left edge of the input toolbar or the sidebar is narrow. The + external-context and MCP server panels now shift back inside the toolbar + (and shrink as a last resort) instead of overflowing the chat container's + clipped edge. + ## [1.0.9] - 2026-09-15 ### Added diff --git a/src/features/chat/ui/input-toolbar.ts b/src/features/chat/ui/input-toolbar.ts index 0ec25d8..bc302d0 100644 --- a/src/features/chat/ui/input-toolbar.ts +++ b/src/features/chat/ui/input-toolbar.ts @@ -10,6 +10,7 @@ import type { } from '../../../core/types'; import type { McpServerManager } from '../../../qoder/mcp/mcp-server-manager'; import { appendCheckIcon, appendMcpIcon } from '../../../shared/icons'; +import { placeHoverDropdown } from './toolbar/hover-dropdown-placement'; import { ModelSelector, PermissionToggle, @@ -38,6 +39,54 @@ export type AddExternalContextResult = | { success: true; normalizedPath: string } | { success: false; error: string }; +/** + * Keep an icon-hover dropdown inside the input toolbar: centered on its icon + * when it fits, clamped otherwise. The chat container clips overflow, so an + * unclamped dropdown lost its leading characters in narrow sidebars. Runs on + * every open (and content change) so panel resizes are picked up. + * + * The values are published as CSS custom properties consumed by the + * dropdown stylesheets (`--qoderian-hover-dropdown-left/-min-width/-max-width`). + */ +export function positionHoverDropdown( + selectorEl: HTMLElement, + iconEl: HTMLElement, + dropdownEl: HTMLElement, +): void { + const toolbarEl = selectorEl.closest('.qoderian-input-toolbar'); + // Layout-less DOM shims used in tests return a non-element from closest(). + if (!toolbarEl || typeof toolbarEl.getBoundingClientRect !== 'function') { + return; + } + + const toolbarRect = toolbarEl.getBoundingClientRect(); + const selectorRect = selectorEl.getBoundingClientRect(); + const iconRect = iconEl.getBoundingClientRect(); + + // Measure the natural width: caps applied by an earlier pass would + // otherwise masquerade as the content width and keep shrinking the cap. + dropdownEl.setCssProps({ + '--qoderian-hover-dropdown-min-width': '', + '--qoderian-hover-dropdown-max-width': '', + }); + const dropdownWidth = dropdownEl.getBoundingClientRect().width; + + const placement = placeHoverDropdown( + iconRect.left - toolbarRect.left + iconRect.width / 2, + dropdownWidth, + toolbarRect.width, + ); + if (!placement) { + return; + } + + dropdownEl.setCssProps({ + '--qoderian-hover-dropdown-left': `${toolbarRect.left + placement.center - selectorRect.left}px`, + '--qoderian-hover-dropdown-min-width': placement.maxWidth !== null ? '0' : '', + '--qoderian-hover-dropdown-max-width': placement.maxWidth !== null ? `${placement.maxWidth}px` : '', + }); +} + export class ExternalContextSelector { private container: HTMLElement; private iconEl: HTMLElement | null = null; @@ -236,9 +285,21 @@ export class ExternalContextSelector { }); this.dropdownEl = this.container.createDiv({ cls: 'qoderian-external-context-dropdown' }); + + // CSS reveals the dropdown on hover; reposition before it becomes visible + // so panel-width changes since the last render are picked up. + this.container.addEventListener('mouseenter', () => { + this.positionDropdown(); + }); + this.renderDropdown(); } + private positionDropdown(): void { + if (!this.dropdownEl || !this.iconEl) return; + positionHoverDropdown(this.container, this.iconEl, this.dropdownEl); + } + private async openFolderPicker() { try { // Access Electron's dialog through remote @@ -334,6 +395,9 @@ export class ExternalContextSelector { }); } } + + // Content changes can change the width, so re-clamp against the toolbar. + this.positionDropdown(); } /** Shorten path for display (replace home dir with ~) */ @@ -508,12 +572,19 @@ export class McpServerSelector { if (servers.length === 0) { const emptyEl = listEl.createDiv({ cls: 'qoderian-mcp-selector-empty' }); emptyEl.setText(allServers.length === 0 ? 'No MCP servers configured' : 'All MCP servers disabled'); - return; + } else { + for (const server of servers) { + this.renderServerItem(listEl, server); + } } - for (const server of servers) { - this.renderServerItem(listEl, server); - } + // Content changes can change the width, so re-clamp against the toolbar. + this.positionDropdown(); + } + + private positionDropdown(): void { + if (!this.dropdownEl || !this.iconEl) return; + positionHoverDropdown(this.container, this.iconEl, this.dropdownEl); } private renderServerItem(listEl: HTMLElement, server: ManagedMcpServer) { diff --git a/src/features/chat/ui/toolbar/hover-dropdown-placement.ts b/src/features/chat/ui/toolbar/hover-dropdown-placement.ts new file mode 100644 index 0000000..9769cf0 --- /dev/null +++ b/src/features/chat/ui/toolbar/hover-dropdown-placement.ts @@ -0,0 +1,52 @@ +/** + * Pure placement decisions for the input toolbar's icon-hover dropdowns. + * + * The dropdowns visually center on their anchor icon, but the chat container + * clips overflow: in a narrow sidebar a fixed-width dropdown anchored near + * the toolbar's leading edge lost its first characters. The placement keeps + * the icon-centered look when it fits, clamps the center so the dropdown + * stays inside the toolbar, and caps the width only when the dropdown is + * wider than the available space. Keeping the decision pure (no DOM access) + * makes it unit-testable without a layout engine; the selector components + * only measure and apply the result. + */ + +/** Breathing room kept between the dropdown and the toolbar edges. */ +export const HOVER_DROPDOWN_EDGE_INSET = 8; + +export interface HoverDropdownPlacement { + /** Dropdown center, relative to the toolbar's left edge. */ + center: number; + /** Width cap when the dropdown cannot fit the toolbar, else null. */ + maxWidth: number | null; +} + +export function placeHoverDropdown( + iconCenter: number, + dropdownWidth: number, + toolbarWidth: number, + inset: number = HOVER_DROPDOWN_EDGE_INSET, +): HoverDropdownPlacement | null { + if (!Number.isFinite(iconCenter) + || !Number.isFinite(dropdownWidth) + || !Number.isFinite(toolbarWidth) + || dropdownWidth <= 0 + || toolbarWidth <= 0) { + return null; + } + + const available = toolbarWidth - inset * 2; + const maxWidth = available > 0 && dropdownWidth > available ? available : null; + const width = maxWidth ?? dropdownWidth; + const half = width / 2; + const minCenter = inset + half; + const maxCenter = toolbarWidth - inset - half; + + // A dropdown wider than the toolbar cannot respect both insets; center it + // so any remaining clipping stays symmetric. + const center = minCenter > maxCenter + ? toolbarWidth / 2 + : Math.min(Math.max(iconCenter, minCenter), maxCenter); + + return { center, maxWidth }; +} diff --git a/src/style/toolbar/external-context.css b/src/style/toolbar/external-context.css index fcee2a9..faf70c0 100644 --- a/src/style/toolbar/external-context.css +++ b/src/style/toolbar/external-context.css @@ -56,12 +56,15 @@ .qoderian-external-context-dropdown { position: absolute; - left: 50%; + /* JS clamps the position and width to the toolbar bounds so narrow + sidebars cannot clip the leading characters (positionHoverDropdown); + the fallbacks keep the icon-centered look before the first measurement. */ + left: var(--qoderian-hover-dropdown-left, 50%); transform: translateX(-50%); bottom: 100%; margin-bottom: 4px; - min-width: 260px; - max-width: 320px; + min-width: var(--qoderian-hover-dropdown-min-width, 260px); + max-width: var(--qoderian-hover-dropdown-max-width, 320px); background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 8px; diff --git a/src/style/toolbar/mcp-selector.css b/src/style/toolbar/mcp-selector.css index 5fbf690..0c504c1 100644 --- a/src/style/toolbar/mcp-selector.css +++ b/src/style/toolbar/mcp-selector.css @@ -56,12 +56,15 @@ .qoderian-mcp-selector-dropdown { position: absolute; - left: 50%; + /* JS clamps the position and width to the toolbar bounds so narrow + sidebars cannot clip the leading characters (positionHoverDropdown); + the fallbacks keep the icon-centered look before the first measurement. */ + left: var(--qoderian-hover-dropdown-left, 50%); transform: translateX(-50%); bottom: 100%; margin-bottom: 4px; - min-width: 200px; - max-width: 280px; + min-width: var(--qoderian-hover-dropdown-min-width, 200px); + max-width: var(--qoderian-hover-dropdown-max-width, 280px); background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 8px; diff --git a/tests/unit/features/chat/ui/hover-dropdown-placement.test.ts b/tests/unit/features/chat/ui/hover-dropdown-placement.test.ts new file mode 100644 index 0000000..d33f546 --- /dev/null +++ b/tests/unit/features/chat/ui/hover-dropdown-placement.test.ts @@ -0,0 +1,145 @@ +import { createMockEl } from '@test/helpers/mock-element'; + +import { positionHoverDropdown } from '@/features/chat/ui/input-toolbar'; +import { + HOVER_DROPDOWN_EDGE_INSET, + placeHoverDropdown, +} from '@/features/chat/ui/toolbar/hover-dropdown-placement'; + +describe('placeHoverDropdown', () => { + it('centers on the icon when the dropdown fits', () => { + const placement = placeHoverDropdown(200, 280, 600); + expect(placement).toEqual({ center: 200, maxWidth: null }); + }); + + it('clamps the center at the leading edge', () => { + const placement = placeHoverDropdown(80, 320, 600); + expect(placement).toEqual({ center: HOVER_DROPDOWN_EDGE_INSET + 160, maxWidth: null }); + }); + + it('clamps the center at the trailing edge', () => { + const placement = placeHoverDropdown(560, 320, 600); + expect(placement).toEqual({ center: 600 - HOVER_DROPDOWN_EDGE_INSET - 160, maxWidth: null }); + }); + + it('treats an exact fit at both insets as fitting', () => { + const toolbarWidth = 320 + HOVER_DROPDOWN_EDGE_INSET * 2; + const placement = placeHoverDropdown(135, 320, toolbarWidth); + expect(placement).toEqual({ center: HOVER_DROPDOWN_EDGE_INSET + 160, maxWidth: null }); + }); + + it('caps the width and centers when the toolbar is barely narrower than the dropdown', () => { + const placement = placeHoverDropdown(135, 320, 324); + expect(placement).toEqual({ center: 162, maxWidth: 324 - HOVER_DROPDOWN_EDGE_INSET * 2 }); + }); + + it('caps the width down to the inset-bounded space in a narrow sidebar', () => { + const placement = placeHoverDropdown(135, 320, 300); + expect(placement).toEqual({ center: 150, maxWidth: 284 }); + }); + + it('still centers symmetrically when wider than the whole toolbar', () => { + const placement = placeHoverDropdown(135, 320, 200); + expect(placement).toEqual({ center: 100, maxWidth: 184 }); + }); + + it('centers symmetrically when the toolbar is smaller than the insets', () => { + const placement = placeHoverDropdown(135, 320, 12); + expect(placement).toEqual({ center: 6, maxWidth: null }); + }); + + it('returns null for unusable measurements', () => { + expect(placeHoverDropdown(Number.NaN, 320, 600)).toBeNull(); + expect(placeHoverDropdown(200, Number.NaN, 600)).toBeNull(); + expect(placeHoverDropdown(200, 320, Number.NaN)).toBeNull(); + expect(placeHoverDropdown(200, 0, 600)).toBeNull(); + expect(placeHoverDropdown(200, 320, 0)).toBeNull(); + }); +}); + +describe('positionHoverDropdown', () => { + interface RectInit { + left: number; + width: number; + } + + const rect = ({ left, width }: RectInit) => ({ + top: 0, + left, + width, + height: 40, + right: left + width, + bottom: 40, + x: left, + y: 0, + toJSON: () => ({}), + }); + + function createTree(toolbar: RectInit, icon: RectInit, dropdownWidth: number) { + const toolbarEl = createMockEl(); + const selectorEl = createMockEl(); + const iconEl = createMockEl(); + const dropdownEl = createMockEl(); + + toolbarEl.getBoundingClientRect = () => rect(toolbar); + selectorEl.getBoundingClientRect = () => rect({ left: icon.left, width: icon.width }); + iconEl.getBoundingClientRect = () => rect(icon); + dropdownEl.getBoundingClientRect = () => rect({ left: 0, width: dropdownWidth }); + selectorEl.closest = () => toolbarEl; + + return { toolbarEl, selectorEl, iconEl, dropdownEl }; + } + + it('keeps the dropdown centered on the icon in a wide toolbar', () => { + const { selectorEl, iconEl, dropdownEl } = createTree( + { left: 0, width: 600 }, + { left: 188, width: 24 }, + 280, + ); + + positionHoverDropdown(selectorEl, iconEl, dropdownEl); + + expect(dropdownEl.style['--qoderian-hover-dropdown-left']).toBe('12px'); + expect(dropdownEl.style['--qoderian-hover-dropdown-max-width']).toBe(''); + expect(dropdownEl.style['--qoderian-hover-dropdown-min-width']).toBe(''); + }); + + it('shifts the dropdown inwards when the icon sits near the leading edge', () => { + const { selectorEl, iconEl, dropdownEl } = createTree( + { left: 100, width: 324 }, + { left: 223, width: 24 }, + 320, + ); + + positionHoverDropdown(selectorEl, iconEl, dropdownEl); + + // Center 162 relative to the toolbar -> 39px relative to the selector. + expect(dropdownEl.style['--qoderian-hover-dropdown-left']).toBe('39px'); + expect(dropdownEl.style['--qoderian-hover-dropdown-max-width']).toBe('308px'); + expect(dropdownEl.style['--qoderian-hover-dropdown-min-width']).toBe('0'); + }); + + it('releases a stale width cap once the toolbar fits the natural width again', () => { + const { selectorEl, iconEl, dropdownEl } = createTree( + { left: 0, width: 600 }, + { left: 188, width: 24 }, + 280, + ); + dropdownEl.style['--qoderian-hover-dropdown-min-width'] = '0'; + dropdownEl.style['--qoderian-hover-dropdown-max-width'] = '200px'; + + positionHoverDropdown(selectorEl, iconEl, dropdownEl); + + expect(dropdownEl.style['--qoderian-hover-dropdown-min-width']).toBe(''); + expect(dropdownEl.style['--qoderian-hover-dropdown-max-width']).toBe(''); + }); + + it('skips layout-less shims that cannot report a toolbar rect', () => { + const selectorEl = createMockEl(); + const iconEl = createMockEl(); + const dropdownEl = createMockEl(); + + expect(() => positionHoverDropdown(selectorEl, iconEl, dropdownEl)).not.toThrow(); + expect(dropdownEl.style['--qoderian-hover-dropdown-left']).toBeUndefined(); + }); +});