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(); + }); +});