Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 75 additions & 4 deletions src/features/chat/ui/input-toolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<HTMLElement>('.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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ~) */
Expand Down Expand Up @@ -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) {
Expand Down
52 changes: 52 additions & 0 deletions src/features/chat/ui/toolbar/hover-dropdown-placement.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
9 changes: 6 additions & 3 deletions src/style/toolbar/external-context.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 6 additions & 3 deletions src/style/toolbar/mcp-selector.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
145 changes: 145 additions & 0 deletions tests/unit/features/chat/ui/hover-dropdown-placement.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading