Skip to content

Commit ce094ba

Browse files
luoxuanzaoQoder-AI
andauthored
Feat/narrow sidebar adaptivity (#18)
* fix: adapt composer toolbar and dropdowns to narrow sidebars Context chips collapse behind a "+N more" pill when the row is too narrow, the toolbar wraps instead of clipping, and the permission mode and model dropdowns now anchor to the toolbar so they shrink to fit the sidebar, with long model names ellipsized. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com> * docs: note narrow-sidebar composer adaptivity in changelog Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com> --------- Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com>
1 parent 28bf0fd commit ce094ba

11 files changed

Lines changed: 650 additions & 6 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+
- The composer now adapts to narrow sidebars: context chips that do
17+
not fit collapse behind a "+N more" pill (click to expand or
18+
collapse), the toolbar wraps instead of clipping, and the permission
19+
mode and model dropdowns shrink to stay inside the sidebar, with
20+
long model names ellipsized.
21+
1422
## [1.0.4] - 2026-08-12
1523

1624
### Fixed
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/**
2+
* Collapses context chips that do not fit the current row width behind a
3+
* "+N more" pill, so narrow sidebars degrade gracefully instead of
4+
* clipping chips. Clicking the pill expands the row (wrapped) so hidden
5+
* chips stay reachable; clicking again collapses it.
6+
*/
7+
export class ContextRowOverflowController {
8+
private readonly rowEl: HTMLElement;
9+
private readonly hostEl: HTMLElement;
10+
private readonly pillEl: HTMLElement;
11+
private readonly measureEl: HTMLElement;
12+
private readonly resizeObserver: ResizeObserver;
13+
private readonly mutationObserver: MutationObserver;
14+
private layoutScheduled = false;
15+
private expanded = false;
16+
private destroyed = false;
17+
18+
constructor(rowEl: HTMLElement) {
19+
this.rowEl = rowEl;
20+
// Measurement surface lives outside the observed subtree so measuring
21+
// never re-triggers the mutation observer.
22+
const host = rowEl.parentElement;
23+
if (!host) throw new Error('ContextRowOverflowController requires an attached context row');
24+
this.hostEl = host;
25+
26+
this.pillEl = rowEl.createDiv({ cls: 'qoderian-context-overflow-pill qoderian-hidden' });
27+
this.pillEl.setAttribute('role', 'button');
28+
this.pillEl.setAttribute('tabindex', '0');
29+
this.pillEl.addEventListener('click', () => this.toggleExpanded());
30+
this.pillEl.addEventListener('keydown', (event) => {
31+
if (event.key === 'Enter' || event.key === ' ') {
32+
event.preventDefault();
33+
this.toggleExpanded();
34+
}
35+
});
36+
37+
this.measureEl = this.hostEl.createDiv({ cls: 'qoderian-context-overflow-measure' });
38+
39+
this.resizeObserver = new ResizeObserver(() => this.scheduleLayout());
40+
this.resizeObserver.observe(rowEl);
41+
42+
this.mutationObserver = new MutationObserver(() => this.scheduleLayout());
43+
this.mutationObserver.observe(rowEl, {
44+
childList: true,
45+
subtree: true,
46+
attributes: true,
47+
attributeFilter: ['class'],
48+
characterData: true,
49+
});
50+
51+
this.scheduleLayout();
52+
}
53+
54+
destroy(): void {
55+
this.destroyed = true;
56+
this.resizeObserver.disconnect();
57+
this.mutationObserver.disconnect();
58+
this.pillEl.remove();
59+
this.measureEl.remove();
60+
}
61+
62+
private scheduleLayout(): void {
63+
if (this.layoutScheduled) return;
64+
this.layoutScheduled = true;
65+
window.requestAnimationFrame(() => {
66+
this.layoutScheduled = false;
67+
if (!this.destroyed) this.layout();
68+
});
69+
}
70+
71+
/** Content items are row children that are currently meant to be visible. */
72+
private contentItems(): HTMLElement[] {
73+
return Array.from(this.rowEl.children).filter(
74+
(el): el is HTMLElement =>
75+
el.instanceOf(HTMLElement) && el !== this.pillEl && !el.hasClass('qoderian-hidden')
76+
);
77+
}
78+
79+
private layout(): void {
80+
const items = this.contentItems();
81+
82+
if (items.length === 0 || !this.rowEl.hasClass('has-content')) {
83+
this.applyState(items, items.length, false);
84+
return;
85+
}
86+
87+
const rowStyles = getComputedStyle(this.rowEl);
88+
const available =
89+
this.rowEl.clientWidth -
90+
parseFloat(rowStyles.paddingLeft) -
91+
parseFloat(rowStyles.paddingRight);
92+
// Row not rendered (e.g. inactive tab): skip, ResizeObserver re-runs once visible.
93+
if (available <= 0) return;
94+
const gap = parseFloat(rowStyles.columnGap) || 0;
95+
96+
const widths = this.measureWidths(items);
97+
const total = widths.reduce((sum, width) => sum + width, 0) + gap * (items.length - 1);
98+
99+
if (this.expanded) {
100+
// Auto-collapse once everything fits on a single line again.
101+
this.applyState(items, items.length, total > available);
102+
return;
103+
}
104+
105+
if (total <= available) {
106+
this.applyState(items, items.length, false);
107+
return;
108+
}
109+
110+
// Keep as many leading chips as fit alongside the pill; when even one
111+
// chip cannot fit, collapse everything behind the pill if the pill fits.
112+
let visibleCount = 0;
113+
for (let k = items.length - 1; k >= 1; k--) {
114+
const pillWidth = this.measurePillWidth(items.length - k);
115+
const used =
116+
widths.slice(0, k).reduce((sum, width) => sum + width, 0) +
117+
gap * (k - 1) +
118+
gap +
119+
pillWidth;
120+
if (used <= available) {
121+
visibleCount = k;
122+
break;
123+
}
124+
}
125+
if (visibleCount === 0 && this.measurePillWidth(items.length) > available) {
126+
// Even the pill does not fit: show one chip rather than nothing.
127+
visibleCount = 1;
128+
}
129+
130+
this.applyState(items, visibleCount, false);
131+
}
132+
133+
private toggleExpanded(): void {
134+
this.expanded = !this.expanded;
135+
this.layout();
136+
}
137+
138+
/** Natural single-line widths, measured on clones so hidden items work too. */
139+
private measureWidths(items: HTMLElement[]): number[] {
140+
this.measureEl.empty();
141+
const clones = items.map((item) => {
142+
const clone = item.cloneNode(true) as HTMLElement;
143+
clone.classList.remove('qoderian-context-overflow-hidden');
144+
this.measureEl.appendChild(clone);
145+
return clone;
146+
});
147+
const widths = clones.map(clone => clone.offsetWidth);
148+
this.measureEl.empty();
149+
return widths;
150+
}
151+
152+
private measurePillWidth(hiddenCount: number): number {
153+
this.measureEl.empty();
154+
const clone = this.pillEl.cloneNode(false) as HTMLElement;
155+
clone.classList.remove('qoderian-hidden');
156+
clone.setText(this.pillLabel(hiddenCount));
157+
this.measureEl.appendChild(clone);
158+
const width = clone.offsetWidth;
159+
this.measureEl.empty();
160+
return width;
161+
}
162+
163+
private pillLabel(hiddenCount: number): string {
164+
return this.expanded ? 'Show less' : `+${hiddenCount} more`;
165+
}
166+
167+
private applyState(items: HTMLElement[], visibleCount: number, expanded: boolean): void {
168+
this.expanded = expanded && items.length > 0;
169+
170+
items.forEach((el, index) => {
171+
const shouldHide = !this.expanded && index >= visibleCount;
172+
if (shouldHide !== el.hasClass('qoderian-context-overflow-hidden')) {
173+
el.toggleClass('qoderian-context-overflow-hidden', shouldHide);
174+
}
175+
});
176+
177+
this.rowEl.toggleClass('qoderian-context-row--expanded', this.expanded);
178+
179+
const hiddenCount = items.length - (this.expanded ? items.length : visibleCount);
180+
const showPill = this.expanded || hiddenCount > 0;
181+
182+
if (showPill) {
183+
const label = this.pillLabel(hiddenCount);
184+
if (this.pillEl.textContent !== label) this.pillEl.setText(label);
185+
if (this.pillEl.hasClass('qoderian-hidden')) this.pillEl.removeClass('qoderian-hidden');
186+
// The pill always trails the chips.
187+
if (this.pillEl.nextElementSibling) this.rowEl.appendChild(this.pillEl);
188+
} else if (!this.pillEl.hasClass('qoderian-hidden')) {
189+
this.pillEl.addClass('qoderian-hidden');
190+
}
191+
}
192+
}

src/features/chat/tabs/tab-lifecycle.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export async function destroyTab(tab: TabData): Promise<void> {
2828
tab.controllers.canvasSelectionController?.stop();
2929
tab.controllers.canvasSelectionController?.clear();
3030
tab.controllers.navigationController?.dispose();
31+
tab.controllers.contextRowOverflow?.destroy();
32+
tab.controllers.contextRowOverflow = null;
3133

3234
cleanupThinkingBlock(tab.state.currentThinkingState);
3335
tab.state.currentThinkingState = null;

src/features/chat/tabs/tab.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '../../../shared/components/slash-command-dropdown';
1616
import { BrowserSelectionController } from '../controllers/browser-selection-controller';
1717
import { CanvasSelectionController } from '../controllers/canvas-selection-controller';
18+
import { ContextRowOverflowController } from '../controllers/context-row-overflow';
1819
import { ConversationController } from '../controllers/conversation-controller';
1920
import { InputController } from '../controllers/input-controller';
2021
import { NavigationController } from '../controllers/navigation-controller';
@@ -135,6 +136,7 @@ export function createTab(options: TabCreateOptions): TabData {
135136
streamController: null,
136137
inputController: null,
137138
navigationController: null,
139+
contextRowOverflow: null,
138140
},
139141
services: {
140142
subagentManager,
@@ -514,6 +516,9 @@ export function initializeTabUI(
514516
'network'
515517
);
516518

519+
// Collapse chips into "+N more" when the sidebar is too narrow.
520+
tab.controllers.contextRowOverflow = new ContextRowOverflowController(dom.contextRowEl);
521+
517522
const catalogInfo = options.getQoderCatalogConfig?.() ?? null;
518523
initializeSlashCommands(
519524
tab,

src/features/chat/tabs/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { AppTabManagerState, InstructionRefineService, TitleGenerationServi
55
import type { SlashCommandDropdown } from '../../../shared/components/slash-command-dropdown';
66
import type { BrowserSelectionController } from '../controllers/browser-selection-controller';
77
import type { CanvasSelectionController } from '../controllers/canvas-selection-controller';
8+
import type { ContextRowOverflowController } from '../controllers/context-row-overflow';
89
import type { ConversationController } from '../controllers/conversation-controller';
910
import type { InputController } from '../controllers/input-controller';
1011
import type { NavigationController } from '../controllers/navigation-controller';
@@ -97,6 +98,7 @@ export interface TabControllers {
9798
streamController: StreamController | null;
9899
inputController: InputController | null;
99100
navigationController: NavigationController | null;
101+
contextRowOverflow: ContextRowOverflowController | null;
100102
}
101103

102104
/**

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ export class ModelSelector {
183183
ownerDocument: option.ownerDocument,
184184
width: 12,
185185
}));
186-
option.createSpan({ text: model.label });
186+
option.createSpan({ cls: 'qoderian-model-option-label', text: model.label });
187187
if (model.promotionLabel || model.priceLabel) {
188188
const meta = option.createSpan({ cls: 'qoderian-model-meta' });
189189
if (model.promotionLabel) {

src/style/accessibility.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
.qoderian-action-btn:focus-visible,
1818
.qoderian-file-chip:focus-visible,
1919
.qoderian-image-chip:focus-visible,
20+
.qoderian-context-overflow-pill:focus-visible,
2021
.qoderian-file-chip-remove:focus-visible,
2122
.qoderian-image-remove:focus-visible,
2223
.qoderian-image-modal-close:focus-visible,

src/style/components/input.css

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
/* Collapsed by default; expanded via .has-content class; textarea fills remaining space */
3434
.qoderian-context-row {
3535
display: none;
36+
position: relative;
3637
align-items: flex-start;
3738
justify-content: flex-start;
3839
flex-shrink: 0;
@@ -45,6 +46,50 @@
4546
display: flex;
4647
}
4748

49+
/* Overflow collapse: chips that do not fit a narrow row are parked behind a
50+
"+N more" pill. Hidden items stay measurable (absolute + invisible). */
51+
.qoderian-context-row > .qoderian-context-overflow-hidden {
52+
position: absolute;
53+
visibility: hidden;
54+
pointer-events: none;
55+
}
56+
57+
/* Expanded state (pill clicked): wrap so every chip stays reachable. */
58+
.qoderian-context-row.qoderian-context-row--expanded {
59+
flex-wrap: wrap;
60+
}
61+
62+
.qoderian-context-overflow-pill {
63+
display: inline-flex;
64+
align-items: center;
65+
flex-shrink: 0;
66+
padding: 3px 8px;
67+
background: var(--background-modifier-hover);
68+
border-radius: 12px;
69+
font-size: 12px;
70+
line-height: 1;
71+
color: var(--text-muted);
72+
cursor: pointer;
73+
}
74+
75+
.qoderian-context-overflow-pill:hover {
76+
color: var(--text-normal);
77+
}
78+
79+
/* Off-screen surface used to measure natural chip widths. */
80+
.qoderian-context-overflow-measure {
81+
position: absolute;
82+
visibility: hidden;
83+
display: flex;
84+
flex-wrap: nowrap;
85+
width: max-content;
86+
pointer-events: none;
87+
}
88+
89+
.qoderian-context-overflow-measure > * {
90+
flex: none;
91+
}
92+
4893
/* Nav row (tab badges start, action icons end) - above input wrapper */
4994
.qoderian-input-nav-row {
5095
display: flex;
@@ -156,9 +201,12 @@
156201

157202
/* Input toolbar */
158203
.qoderian-input-toolbar {
204+
position: relative;
159205
display: flex;
160206
align-items: center;
161207
justify-content: flex-start;
208+
flex-wrap: wrap;
209+
row-gap: 2px;
162210
flex-shrink: 0;
163211
padding: 4px 6px 6px 6px;
164212
}

0 commit comments

Comments
 (0)