Skip to content

Commit a60e539

Browse files
luoxuanzaoQoder-AI
andcommitted
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) <qoder_ai@qoder.com>
1 parent 8882e60 commit a60e539

6 files changed

Lines changed: 292 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ version with its date and start a fresh empty `[Unreleased]` above it.
1111

1212
## [Unreleased]
1313

14+
### Fixed
15+
16+
- Hover dropdowns no longer lose their leading characters when their icon
17+
sits near the left edge of the input toolbar or the sidebar is narrow. The
18+
external-context and MCP server panels now shift back inside the toolbar
19+
(and shrink as a last resort) instead of overflowing the chat container's
20+
clipped edge.
21+
1422
## [1.0.9] - 2026-09-15
1523

1624
### Added

src/features/chat/ui/input-toolbar.ts

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
} from '../../../core/types';
1111
import type { McpServerManager } from '../../../qoder/mcp/mcp-server-manager';
1212
import { appendCheckIcon, appendMcpIcon } from '../../../shared/icons';
13+
import { placeHoverDropdown } from './toolbar/hover-dropdown-placement';
1314
import {
1415
ModelSelector,
1516
PermissionToggle,
@@ -38,6 +39,54 @@ export type AddExternalContextResult =
3839
| { success: true; normalizedPath: string }
3940
| { success: false; error: string };
4041

42+
/**
43+
* Keep an icon-hover dropdown inside the input toolbar: centered on its icon
44+
* when it fits, clamped otherwise. The chat container clips overflow, so an
45+
* unclamped dropdown lost its leading characters in narrow sidebars. Runs on
46+
* every open (and content change) so panel resizes are picked up.
47+
*
48+
* The values are published as CSS custom properties consumed by the
49+
* dropdown stylesheets (`--qoderian-hover-dropdown-left/-min-width/-max-width`).
50+
*/
51+
export function positionHoverDropdown(
52+
selectorEl: HTMLElement,
53+
iconEl: HTMLElement,
54+
dropdownEl: HTMLElement,
55+
): void {
56+
const toolbarEl = selectorEl.closest<HTMLElement>('.qoderian-input-toolbar');
57+
// Layout-less DOM shims used in tests return a non-element from closest().
58+
if (!toolbarEl || typeof toolbarEl.getBoundingClientRect !== 'function') {
59+
return;
60+
}
61+
62+
const toolbarRect = toolbarEl.getBoundingClientRect();
63+
const selectorRect = selectorEl.getBoundingClientRect();
64+
const iconRect = iconEl.getBoundingClientRect();
65+
66+
// Measure the natural width: caps applied by an earlier pass would
67+
// otherwise masquerade as the content width and keep shrinking the cap.
68+
dropdownEl.setCssProps({
69+
'--qoderian-hover-dropdown-min-width': '',
70+
'--qoderian-hover-dropdown-max-width': '',
71+
});
72+
const dropdownWidth = dropdownEl.getBoundingClientRect().width;
73+
74+
const placement = placeHoverDropdown(
75+
iconRect.left - toolbarRect.left + iconRect.width / 2,
76+
dropdownWidth,
77+
toolbarRect.width,
78+
);
79+
if (!placement) {
80+
return;
81+
}
82+
83+
dropdownEl.setCssProps({
84+
'--qoderian-hover-dropdown-left': `${toolbarRect.left + placement.center - selectorRect.left}px`,
85+
'--qoderian-hover-dropdown-min-width': placement.maxWidth !== null ? '0' : '',
86+
'--qoderian-hover-dropdown-max-width': placement.maxWidth !== null ? `${placement.maxWidth}px` : '',
87+
});
88+
}
89+
4190
export class ExternalContextSelector {
4291
private container: HTMLElement;
4392
private iconEl: HTMLElement | null = null;
@@ -236,9 +285,21 @@ export class ExternalContextSelector {
236285
});
237286

238287
this.dropdownEl = this.container.createDiv({ cls: 'qoderian-external-context-dropdown' });
288+
289+
// CSS reveals the dropdown on hover; reposition before it becomes visible
290+
// so panel-width changes since the last render are picked up.
291+
this.container.addEventListener('mouseenter', () => {
292+
this.positionDropdown();
293+
});
294+
239295
this.renderDropdown();
240296
}
241297

298+
private positionDropdown(): void {
299+
if (!this.dropdownEl || !this.iconEl) return;
300+
positionHoverDropdown(this.container, this.iconEl, this.dropdownEl);
301+
}
302+
242303
private async openFolderPicker() {
243304
try {
244305
// Access Electron's dialog through remote
@@ -334,6 +395,9 @@ export class ExternalContextSelector {
334395
});
335396
}
336397
}
398+
399+
// Content changes can change the width, so re-clamp against the toolbar.
400+
this.positionDropdown();
337401
}
338402

339403
/** Shorten path for display (replace home dir with ~) */
@@ -508,12 +572,19 @@ export class McpServerSelector {
508572
if (servers.length === 0) {
509573
const emptyEl = listEl.createDiv({ cls: 'qoderian-mcp-selector-empty' });
510574
emptyEl.setText(allServers.length === 0 ? 'No MCP servers configured' : 'All MCP servers disabled');
511-
return;
575+
} else {
576+
for (const server of servers) {
577+
this.renderServerItem(listEl, server);
578+
}
512579
}
513580

514-
for (const server of servers) {
515-
this.renderServerItem(listEl, server);
516-
}
581+
// Content changes can change the width, so re-clamp against the toolbar.
582+
this.positionDropdown();
583+
}
584+
585+
private positionDropdown(): void {
586+
if (!this.dropdownEl || !this.iconEl) return;
587+
positionHoverDropdown(this.container, this.iconEl, this.dropdownEl);
517588
}
518589

519590
private renderServerItem(listEl: HTMLElement, server: ManagedMcpServer) {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Pure placement decisions for the input toolbar's icon-hover dropdowns.
3+
*
4+
* The dropdowns visually center on their anchor icon, but the chat container
5+
* clips overflow: in a narrow sidebar a fixed-width dropdown anchored near
6+
* the toolbar's leading edge lost its first characters. The placement keeps
7+
* the icon-centered look when it fits, clamps the center so the dropdown
8+
* stays inside the toolbar, and caps the width only when the dropdown is
9+
* wider than the available space. Keeping the decision pure (no DOM access)
10+
* makes it unit-testable without a layout engine; the selector components
11+
* only measure and apply the result.
12+
*/
13+
14+
/** Breathing room kept between the dropdown and the toolbar edges. */
15+
export const HOVER_DROPDOWN_EDGE_INSET = 8;
16+
17+
export interface HoverDropdownPlacement {
18+
/** Dropdown center, relative to the toolbar's left edge. */
19+
center: number;
20+
/** Width cap when the dropdown cannot fit the toolbar, else null. */
21+
maxWidth: number | null;
22+
}
23+
24+
export function placeHoverDropdown(
25+
iconCenter: number,
26+
dropdownWidth: number,
27+
toolbarWidth: number,
28+
inset: number = HOVER_DROPDOWN_EDGE_INSET,
29+
): HoverDropdownPlacement | null {
30+
if (!Number.isFinite(iconCenter)
31+
|| !Number.isFinite(dropdownWidth)
32+
|| !Number.isFinite(toolbarWidth)
33+
|| dropdownWidth <= 0
34+
|| toolbarWidth <= 0) {
35+
return null;
36+
}
37+
38+
const available = toolbarWidth - inset * 2;
39+
const maxWidth = available > 0 && dropdownWidth > available ? available : null;
40+
const width = maxWidth ?? dropdownWidth;
41+
const half = width / 2;
42+
const minCenter = inset + half;
43+
const maxCenter = toolbarWidth - inset - half;
44+
45+
// A dropdown wider than the toolbar cannot respect both insets; center it
46+
// so any remaining clipping stays symmetric.
47+
const center = minCenter > maxCenter
48+
? toolbarWidth / 2
49+
: Math.min(Math.max(iconCenter, minCenter), maxCenter);
50+
51+
return { center, maxWidth };
52+
}

src/style/toolbar/external-context.css

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,15 @@
5656

5757
.qoderian-external-context-dropdown {
5858
position: absolute;
59-
left: 50%;
59+
/* JS clamps the position and width to the toolbar bounds so narrow
60+
sidebars cannot clip the leading characters (positionHoverDropdown);
61+
the fallbacks keep the icon-centered look before the first measurement. */
62+
left: var(--qoderian-hover-dropdown-left, 50%);
6063
transform: translateX(-50%);
6164
bottom: 100%;
6265
margin-bottom: 4px;
63-
min-width: 260px;
64-
max-width: 320px;
66+
min-width: var(--qoderian-hover-dropdown-min-width, 260px);
67+
max-width: var(--qoderian-hover-dropdown-max-width, 320px);
6568
background: var(--background-secondary);
6669
border: 1px solid var(--background-modifier-border);
6770
border-radius: 8px;

src/style/toolbar/mcp-selector.css

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,15 @@
5656

5757
.qoderian-mcp-selector-dropdown {
5858
position: absolute;
59-
left: 50%;
59+
/* JS clamps the position and width to the toolbar bounds so narrow
60+
sidebars cannot clip the leading characters (positionHoverDropdown);
61+
the fallbacks keep the icon-centered look before the first measurement. */
62+
left: var(--qoderian-hover-dropdown-left, 50%);
6063
transform: translateX(-50%);
6164
bottom: 100%;
6265
margin-bottom: 4px;
63-
min-width: 200px;
64-
max-width: 280px;
66+
min-width: var(--qoderian-hover-dropdown-min-width, 200px);
67+
max-width: var(--qoderian-hover-dropdown-max-width, 280px);
6568
background: var(--background-secondary);
6669
border: 1px solid var(--background-modifier-border);
6770
border-radius: 8px;
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { createMockEl } from '@test/helpers/mock-element';
2+
3+
import { positionHoverDropdown } from '@/features/chat/ui/input-toolbar';
4+
import {
5+
HOVER_DROPDOWN_EDGE_INSET,
6+
placeHoverDropdown,
7+
} from '@/features/chat/ui/toolbar/hover-dropdown-placement';
8+
9+
describe('placeHoverDropdown', () => {
10+
it('centers on the icon when the dropdown fits', () => {
11+
const placement = placeHoverDropdown(200, 280, 600);
12+
expect(placement).toEqual({ center: 200, maxWidth: null });
13+
});
14+
15+
it('clamps the center at the leading edge', () => {
16+
const placement = placeHoverDropdown(80, 320, 600);
17+
expect(placement).toEqual({ center: HOVER_DROPDOWN_EDGE_INSET + 160, maxWidth: null });
18+
});
19+
20+
it('clamps the center at the trailing edge', () => {
21+
const placement = placeHoverDropdown(560, 320, 600);
22+
expect(placement).toEqual({ center: 600 - HOVER_DROPDOWN_EDGE_INSET - 160, maxWidth: null });
23+
});
24+
25+
it('treats an exact fit at both insets as fitting', () => {
26+
const toolbarWidth = 320 + HOVER_DROPDOWN_EDGE_INSET * 2;
27+
const placement = placeHoverDropdown(135, 320, toolbarWidth);
28+
expect(placement).toEqual({ center: HOVER_DROPDOWN_EDGE_INSET + 160, maxWidth: null });
29+
});
30+
31+
it('caps the width and centers when the toolbar is barely narrower than the dropdown', () => {
32+
const placement = placeHoverDropdown(135, 320, 324);
33+
expect(placement).toEqual({ center: 162, maxWidth: 324 - HOVER_DROPDOWN_EDGE_INSET * 2 });
34+
});
35+
36+
it('caps the width down to the inset-bounded space in a narrow sidebar', () => {
37+
const placement = placeHoverDropdown(135, 320, 300);
38+
expect(placement).toEqual({ center: 150, maxWidth: 284 });
39+
});
40+
41+
it('still centers symmetrically when wider than the whole toolbar', () => {
42+
const placement = placeHoverDropdown(135, 320, 200);
43+
expect(placement).toEqual({ center: 100, maxWidth: 184 });
44+
});
45+
46+
it('centers symmetrically when the toolbar is smaller than the insets', () => {
47+
const placement = placeHoverDropdown(135, 320, 12);
48+
expect(placement).toEqual({ center: 6, maxWidth: null });
49+
});
50+
51+
it('returns null for unusable measurements', () => {
52+
expect(placeHoverDropdown(Number.NaN, 320, 600)).toBeNull();
53+
expect(placeHoverDropdown(200, Number.NaN, 600)).toBeNull();
54+
expect(placeHoverDropdown(200, 320, Number.NaN)).toBeNull();
55+
expect(placeHoverDropdown(200, 0, 600)).toBeNull();
56+
expect(placeHoverDropdown(200, 320, 0)).toBeNull();
57+
});
58+
});
59+
60+
describe('positionHoverDropdown', () => {
61+
interface RectInit {
62+
left: number;
63+
width: number;
64+
}
65+
66+
const rect = ({ left, width }: RectInit) => ({
67+
top: 0,
68+
left,
69+
width,
70+
height: 40,
71+
right: left + width,
72+
bottom: 40,
73+
x: left,
74+
y: 0,
75+
toJSON: () => ({}),
76+
});
77+
78+
function createTree(toolbar: RectInit, icon: RectInit, dropdownWidth: number) {
79+
const toolbarEl = createMockEl();
80+
const selectorEl = createMockEl();
81+
const iconEl = createMockEl();
82+
const dropdownEl = createMockEl();
83+
84+
toolbarEl.getBoundingClientRect = () => rect(toolbar);
85+
selectorEl.getBoundingClientRect = () => rect({ left: icon.left, width: icon.width });
86+
iconEl.getBoundingClientRect = () => rect(icon);
87+
dropdownEl.getBoundingClientRect = () => rect({ left: 0, width: dropdownWidth });
88+
selectorEl.closest = () => toolbarEl;
89+
90+
return { toolbarEl, selectorEl, iconEl, dropdownEl };
91+
}
92+
93+
it('keeps the dropdown centered on the icon in a wide toolbar', () => {
94+
const { selectorEl, iconEl, dropdownEl } = createTree(
95+
{ left: 0, width: 600 },
96+
{ left: 188, width: 24 },
97+
280,
98+
);
99+
100+
positionHoverDropdown(selectorEl, iconEl, dropdownEl);
101+
102+
expect(dropdownEl.style['--qoderian-hover-dropdown-left']).toBe('12px');
103+
expect(dropdownEl.style['--qoderian-hover-dropdown-max-width']).toBe('');
104+
expect(dropdownEl.style['--qoderian-hover-dropdown-min-width']).toBe('');
105+
});
106+
107+
it('shifts the dropdown inwards when the icon sits near the leading edge', () => {
108+
const { selectorEl, iconEl, dropdownEl } = createTree(
109+
{ left: 100, width: 324 },
110+
{ left: 223, width: 24 },
111+
320,
112+
);
113+
114+
positionHoverDropdown(selectorEl, iconEl, dropdownEl);
115+
116+
// Center 162 relative to the toolbar -> 39px relative to the selector.
117+
expect(dropdownEl.style['--qoderian-hover-dropdown-left']).toBe('39px');
118+
expect(dropdownEl.style['--qoderian-hover-dropdown-max-width']).toBe('308px');
119+
expect(dropdownEl.style['--qoderian-hover-dropdown-min-width']).toBe('0');
120+
});
121+
122+
it('releases a stale width cap once the toolbar fits the natural width again', () => {
123+
const { selectorEl, iconEl, dropdownEl } = createTree(
124+
{ left: 0, width: 600 },
125+
{ left: 188, width: 24 },
126+
280,
127+
);
128+
dropdownEl.style['--qoderian-hover-dropdown-min-width'] = '0';
129+
dropdownEl.style['--qoderian-hover-dropdown-max-width'] = '200px';
130+
131+
positionHoverDropdown(selectorEl, iconEl, dropdownEl);
132+
133+
expect(dropdownEl.style['--qoderian-hover-dropdown-min-width']).toBe('');
134+
expect(dropdownEl.style['--qoderian-hover-dropdown-max-width']).toBe('');
135+
});
136+
137+
it('skips layout-less shims that cannot report a toolbar rect', () => {
138+
const selectorEl = createMockEl();
139+
const iconEl = createMockEl();
140+
const dropdownEl = createMockEl();
141+
142+
expect(() => positionHoverDropdown(selectorEl, iconEl, dropdownEl)).not.toThrow();
143+
expect(dropdownEl.style['--qoderian-hover-dropdown-left']).toBeUndefined();
144+
});
145+
});

0 commit comments

Comments
 (0)