From 373ced902ea8bf18830a892b9e52038c8ada1b65 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Fri, 14 Aug 2026 08:07:16 +0800 Subject: [PATCH 1/2] 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) --- .../chat/controllers/context-row-overflow.ts | 192 +++++++++ src/features/chat/tabs/tab-lifecycle.ts | 2 + src/features/chat/tabs/tab.ts | 5 + src/features/chat/tabs/types.ts | 2 + .../chat/ui/toolbar/toolbar-selectors.ts | 2 +- src/style/accessibility.css | 1 + src/style/components/input.css | 48 +++ src/style/toolbar/model-selector.css | 21 +- src/style/toolbar/permission-toggle.css | 2 +- .../controllers/context-row-overflow.test.ts | 373 ++++++++++++++++++ 10 files changed, 642 insertions(+), 6 deletions(-) create mode 100644 src/features/chat/controllers/context-row-overflow.ts create mode 100644 tests/unit/features/chat/controllers/context-row-overflow.test.ts diff --git a/src/features/chat/controllers/context-row-overflow.ts b/src/features/chat/controllers/context-row-overflow.ts new file mode 100644 index 0000000..77f1382 --- /dev/null +++ b/src/features/chat/controllers/context-row-overflow.ts @@ -0,0 +1,192 @@ +/** + * Collapses context chips that do not fit the current row width behind a + * "+N more" pill, so narrow sidebars degrade gracefully instead of + * clipping chips. Clicking the pill expands the row (wrapped) so hidden + * chips stay reachable; clicking again collapses it. + */ +export class ContextRowOverflowController { + private readonly rowEl: HTMLElement; + private readonly hostEl: HTMLElement; + private readonly pillEl: HTMLElement; + private readonly measureEl: HTMLElement; + private readonly resizeObserver: ResizeObserver; + private readonly mutationObserver: MutationObserver; + private layoutScheduled = false; + private expanded = false; + private destroyed = false; + + constructor(rowEl: HTMLElement) { + this.rowEl = rowEl; + // Measurement surface lives outside the observed subtree so measuring + // never re-triggers the mutation observer. + const host = rowEl.parentElement; + if (!host) throw new Error('ContextRowOverflowController requires an attached context row'); + this.hostEl = host; + + this.pillEl = rowEl.createDiv({ cls: 'qoderian-context-overflow-pill qoderian-hidden' }); + this.pillEl.setAttribute('role', 'button'); + this.pillEl.setAttribute('tabindex', '0'); + this.pillEl.addEventListener('click', () => this.toggleExpanded()); + this.pillEl.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.toggleExpanded(); + } + }); + + this.measureEl = this.hostEl.createDiv({ cls: 'qoderian-context-overflow-measure' }); + + this.resizeObserver = new ResizeObserver(() => this.scheduleLayout()); + this.resizeObserver.observe(rowEl); + + this.mutationObserver = new MutationObserver(() => this.scheduleLayout()); + this.mutationObserver.observe(rowEl, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class'], + characterData: true, + }); + + this.scheduleLayout(); + } + + destroy(): void { + this.destroyed = true; + this.resizeObserver.disconnect(); + this.mutationObserver.disconnect(); + this.pillEl.remove(); + this.measureEl.remove(); + } + + private scheduleLayout(): void { + if (this.layoutScheduled) return; + this.layoutScheduled = true; + window.requestAnimationFrame(() => { + this.layoutScheduled = false; + if (!this.destroyed) this.layout(); + }); + } + + /** Content items are row children that are currently meant to be visible. */ + private contentItems(): HTMLElement[] { + return Array.from(this.rowEl.children).filter( + (el): el is HTMLElement => + el.instanceOf(HTMLElement) && el !== this.pillEl && !el.hasClass('qoderian-hidden') + ); + } + + private layout(): void { + const items = this.contentItems(); + + if (items.length === 0 || !this.rowEl.hasClass('has-content')) { + this.applyState(items, items.length, false); + return; + } + + const rowStyles = getComputedStyle(this.rowEl); + const available = + this.rowEl.clientWidth - + parseFloat(rowStyles.paddingLeft) - + parseFloat(rowStyles.paddingRight); + // Row not rendered (e.g. inactive tab): skip, ResizeObserver re-runs once visible. + if (available <= 0) return; + const gap = parseFloat(rowStyles.columnGap) || 0; + + const widths = this.measureWidths(items); + const total = widths.reduce((sum, width) => sum + width, 0) + gap * (items.length - 1); + + if (this.expanded) { + // Auto-collapse once everything fits on a single line again. + this.applyState(items, items.length, total > available); + return; + } + + if (total <= available) { + this.applyState(items, items.length, false); + return; + } + + // Keep as many leading chips as fit alongside the pill; when even one + // chip cannot fit, collapse everything behind the pill if the pill fits. + let visibleCount = 0; + for (let k = items.length - 1; k >= 1; k--) { + const pillWidth = this.measurePillWidth(items.length - k); + const used = + widths.slice(0, k).reduce((sum, width) => sum + width, 0) + + gap * (k - 1) + + gap + + pillWidth; + if (used <= available) { + visibleCount = k; + break; + } + } + if (visibleCount === 0 && this.measurePillWidth(items.length) > available) { + // Even the pill does not fit: show one chip rather than nothing. + visibleCount = 1; + } + + this.applyState(items, visibleCount, false); + } + + private toggleExpanded(): void { + this.expanded = !this.expanded; + this.layout(); + } + + /** Natural single-line widths, measured on clones so hidden items work too. */ + private measureWidths(items: HTMLElement[]): number[] { + this.measureEl.empty(); + const clones = items.map((item) => { + const clone = item.cloneNode(true) as HTMLElement; + clone.classList.remove('qoderian-context-overflow-hidden'); + this.measureEl.appendChild(clone); + return clone; + }); + const widths = clones.map(clone => clone.offsetWidth); + this.measureEl.empty(); + return widths; + } + + private measurePillWidth(hiddenCount: number): number { + this.measureEl.empty(); + const clone = this.pillEl.cloneNode(false) as HTMLElement; + clone.classList.remove('qoderian-hidden'); + clone.setText(this.pillLabel(hiddenCount)); + this.measureEl.appendChild(clone); + const width = clone.offsetWidth; + this.measureEl.empty(); + return width; + } + + private pillLabel(hiddenCount: number): string { + return this.expanded ? 'Show less' : `+${hiddenCount} more`; + } + + private applyState(items: HTMLElement[], visibleCount: number, expanded: boolean): void { + this.expanded = expanded && items.length > 0; + + items.forEach((el, index) => { + const shouldHide = !this.expanded && index >= visibleCount; + if (shouldHide !== el.hasClass('qoderian-context-overflow-hidden')) { + el.toggleClass('qoderian-context-overflow-hidden', shouldHide); + } + }); + + this.rowEl.toggleClass('qoderian-context-row--expanded', this.expanded); + + const hiddenCount = items.length - (this.expanded ? items.length : visibleCount); + const showPill = this.expanded || hiddenCount > 0; + + if (showPill) { + const label = this.pillLabel(hiddenCount); + if (this.pillEl.textContent !== label) this.pillEl.setText(label); + if (this.pillEl.hasClass('qoderian-hidden')) this.pillEl.removeClass('qoderian-hidden'); + // The pill always trails the chips. + if (this.pillEl.nextElementSibling) this.rowEl.appendChild(this.pillEl); + } else if (!this.pillEl.hasClass('qoderian-hidden')) { + this.pillEl.addClass('qoderian-hidden'); + } + } +} diff --git a/src/features/chat/tabs/tab-lifecycle.ts b/src/features/chat/tabs/tab-lifecycle.ts index ec3eda3..a84a4ef 100644 --- a/src/features/chat/tabs/tab-lifecycle.ts +++ b/src/features/chat/tabs/tab-lifecycle.ts @@ -28,6 +28,8 @@ export async function destroyTab(tab: TabData): Promise { tab.controllers.canvasSelectionController?.stop(); tab.controllers.canvasSelectionController?.clear(); tab.controllers.navigationController?.dispose(); + tab.controllers.contextRowOverflow?.destroy(); + tab.controllers.contextRowOverflow = null; cleanupThinkingBlock(tab.state.currentThinkingState); tab.state.currentThinkingState = null; diff --git a/src/features/chat/tabs/tab.ts b/src/features/chat/tabs/tab.ts index 6227d6b..ca180af 100644 --- a/src/features/chat/tabs/tab.ts +++ b/src/features/chat/tabs/tab.ts @@ -15,6 +15,7 @@ import { } from '../../../shared/components/slash-command-dropdown'; import { BrowserSelectionController } from '../controllers/browser-selection-controller'; import { CanvasSelectionController } from '../controllers/canvas-selection-controller'; +import { ContextRowOverflowController } from '../controllers/context-row-overflow'; import { ConversationController } from '../controllers/conversation-controller'; import { InputController } from '../controllers/input-controller'; import { NavigationController } from '../controllers/navigation-controller'; @@ -135,6 +136,7 @@ export function createTab(options: TabCreateOptions): TabData { streamController: null, inputController: null, navigationController: null, + contextRowOverflow: null, }, services: { subagentManager, @@ -514,6 +516,9 @@ export function initializeTabUI( 'network' ); + // Collapse chips into "+N more" when the sidebar is too narrow. + tab.controllers.contextRowOverflow = new ContextRowOverflowController(dom.contextRowEl); + const catalogInfo = options.getQoderCatalogConfig?.() ?? null; initializeSlashCommands( tab, diff --git a/src/features/chat/tabs/types.ts b/src/features/chat/tabs/types.ts index ce58be2..e1bbcc5 100644 --- a/src/features/chat/tabs/types.ts +++ b/src/features/chat/tabs/types.ts @@ -5,6 +5,7 @@ import type { AppTabManagerState, InstructionRefineService, TitleGenerationServi import type { SlashCommandDropdown } from '../../../shared/components/slash-command-dropdown'; import type { BrowserSelectionController } from '../controllers/browser-selection-controller'; import type { CanvasSelectionController } from '../controllers/canvas-selection-controller'; +import type { ContextRowOverflowController } from '../controllers/context-row-overflow'; import type { ConversationController } from '../controllers/conversation-controller'; import type { InputController } from '../controllers/input-controller'; import type { NavigationController } from '../controllers/navigation-controller'; @@ -97,6 +98,7 @@ export interface TabControllers { streamController: StreamController | null; inputController: InputController | null; navigationController: NavigationController | null; + contextRowOverflow: ContextRowOverflowController | null; } /** diff --git a/src/features/chat/ui/toolbar/toolbar-selectors.ts b/src/features/chat/ui/toolbar/toolbar-selectors.ts index 7a644f9..f5b7b60 100644 --- a/src/features/chat/ui/toolbar/toolbar-selectors.ts +++ b/src/features/chat/ui/toolbar/toolbar-selectors.ts @@ -183,7 +183,7 @@ export class ModelSelector { ownerDocument: option.ownerDocument, width: 12, })); - option.createSpan({ text: model.label }); + option.createSpan({ cls: 'qoderian-model-option-label', text: model.label }); if (model.promotionLabel || model.priceLabel) { const meta = option.createSpan({ cls: 'qoderian-model-meta' }); if (model.promotionLabel) { diff --git a/src/style/accessibility.css b/src/style/accessibility.css index 1c5248a..9899f1b 100644 --- a/src/style/accessibility.css +++ b/src/style/accessibility.css @@ -17,6 +17,7 @@ .qoderian-action-btn:focus-visible, .qoderian-file-chip:focus-visible, .qoderian-image-chip:focus-visible, +.qoderian-context-overflow-pill:focus-visible, .qoderian-file-chip-remove:focus-visible, .qoderian-image-remove:focus-visible, .qoderian-image-modal-close:focus-visible, diff --git a/src/style/components/input.css b/src/style/components/input.css index 85e8ac0..588084c 100644 --- a/src/style/components/input.css +++ b/src/style/components/input.css @@ -33,6 +33,7 @@ /* Collapsed by default; expanded via .has-content class; textarea fills remaining space */ .qoderian-context-row { display: none; + position: relative; align-items: flex-start; justify-content: flex-start; flex-shrink: 0; @@ -45,6 +46,50 @@ display: flex; } +/* Overflow collapse: chips that do not fit a narrow row are parked behind a + "+N more" pill. Hidden items stay measurable (absolute + invisible). */ +.qoderian-context-row > .qoderian-context-overflow-hidden { + position: absolute; + visibility: hidden; + pointer-events: none; +} + +/* Expanded state (pill clicked): wrap so every chip stays reachable. */ +.qoderian-context-row.qoderian-context-row--expanded { + flex-wrap: wrap; +} + +.qoderian-context-overflow-pill { + display: inline-flex; + align-items: center; + flex-shrink: 0; + padding: 3px 8px; + background: var(--background-modifier-hover); + border-radius: 12px; + font-size: 12px; + line-height: 1; + color: var(--text-muted); + cursor: pointer; +} + +.qoderian-context-overflow-pill:hover { + color: var(--text-normal); +} + +/* Off-screen surface used to measure natural chip widths. */ +.qoderian-context-overflow-measure { + position: absolute; + visibility: hidden; + display: flex; + flex-wrap: nowrap; + width: max-content; + pointer-events: none; +} + +.qoderian-context-overflow-measure > * { + flex: none; +} + /* Nav row (tab badges start, action icons end) - above input wrapper */ .qoderian-input-nav-row { display: flex; @@ -156,9 +201,12 @@ /* Input toolbar */ .qoderian-input-toolbar { + position: relative; display: flex; align-items: center; justify-content: flex-start; + flex-wrap: wrap; + row-gap: 2px; flex-shrink: 0; padding: 4px 6px 6px 6px; } diff --git a/src/style/toolbar/model-selector.css b/src/style/toolbar/model-selector.css index f778d99..734a163 100644 --- a/src/style/toolbar/model-selector.css +++ b/src/style/toolbar/model-selector.css @@ -1,12 +1,15 @@ /* Model selector */ .qoderian-model-selector { - position: relative; + min-width: 0; + max-width: 100%; } .qoderian-model-btn { display: flex; align-items: center; gap: 4px; + min-width: 0; + max-width: 100%; padding: 4px 8px; border-radius: 4px; cursor: pointer; @@ -20,6 +23,9 @@ .qoderian-model-label { font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .qoderian-model-chevron { @@ -35,7 +41,7 @@ .qoderian-model-dropdown { position: absolute; bottom: 100%; - left: 0; + right: 8px; margin-bottom: 0; display: flex; flex-direction: column; @@ -46,7 +52,7 @@ box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.15); z-index: 1000; width: max-content; - max-width: min(340px, calc(100vw - 24px)); + max-width: min(340px, calc(100% - 16px)); padding: 4px; opacity: 0; visibility: hidden; @@ -58,7 +64,7 @@ flex-direction: column; align-items: flex-start; gap: 6px; - width: 280px; + width: 100%; padding: 8px; color: var(--text-muted); white-space: normal; @@ -129,6 +135,7 @@ display: flex; align-items: center; gap: 5px; + min-width: 0; padding: 4px 8px; cursor: pointer; font-size: 12px; @@ -138,6 +145,11 @@ white-space: nowrap; } +.qoderian-model-option-label { + overflow: hidden; + text-overflow: ellipsis; +} + .qoderian-model-option:hover { background: var(--background-modifier-hover); color: var(--text-normal); @@ -156,6 +168,7 @@ gap: 5px; margin-left: auto; padding-left: 12px; + flex-shrink: 0; } .qoderian-model-price { diff --git a/src/style/toolbar/permission-toggle.css b/src/style/toolbar/permission-toggle.css index 288dfe3..2f8ac0b 100644 --- a/src/style/toolbar/permission-toggle.css +++ b/src/style/toolbar/permission-toggle.css @@ -1,6 +1,5 @@ /* Permission mode selector */ .qoderian-permission-toggle { - position: relative; display: flex; align-items: center; margin-left: auto; @@ -69,6 +68,7 @@ display: flex; flex-direction: column; width: 250px; + max-width: calc(100% - 16px); margin-bottom: 4px; padding: 4px; border: 1px solid var(--background-modifier-border); diff --git a/tests/unit/features/chat/controllers/context-row-overflow.test.ts b/tests/unit/features/chat/controllers/context-row-overflow.test.ts new file mode 100644 index 0000000..f669bce --- /dev/null +++ b/tests/unit/features/chat/controllers/context-row-overflow.test.ts @@ -0,0 +1,373 @@ +/** @jest-environment jsdom */ + +import { ContextRowOverflowController } from '@/features/chat/controllers/context-row-overflow'; + +/** + * Functional coverage for the narrow-sidebar chip collapse. jsdom has no + * layout, so widths are mocked: every element reports offsetWidth from its + * data-mock-width attribute (clones keep it), and the row reports a + * test-controlled clientWidth. + */ + +let rowClientWidth = 400; +let resizeCallback: (() => void) | null = null; + +class FakeResizeObserver { + constructor(callback: () => void) { + // Captured so tests can simulate a sidebar resize. + resizeCallback = callback; + } + + observe(): void {} + + disconnect(): void {} + + unobserve(): void {} +} + +function installDomMocks(): void { + const proto = HTMLElement.prototype as unknown as Record; + + if (!proto.hasClass) { + proto.hasClass = function hasClass(this: HTMLElement, cls: string) { + return this.classList.contains(cls); + }; + } + if (!proto.addClass) { + proto.addClass = function addClass(this: HTMLElement, cls: string) { + cls.split(/\s+/).filter(Boolean).forEach(c => this.classList.add(c)); + return this; + }; + } + if (!proto.removeClass) { + proto.removeClass = function removeClass(this: HTMLElement, cls: string) { + cls.split(/\s+/).filter(Boolean).forEach(c => this.classList.remove(c)); + return this; + }; + } + if (!proto.toggleClass) { + proto.toggleClass = function toggleClass(this: HTMLElement, cls: string, force: boolean) { + this.classList.toggle(cls, force); + return this; + }; + } + if (!proto.setText) { + proto.setText = function setText(this: HTMLElement, text: string) { + this.textContent = text; + }; + } + if (!proto.empty) { + proto.empty = function empty(this: HTMLElement) { + while (this.firstChild) this.removeChild(this.firstChild); + }; + } + if (!proto.createDiv) { + proto.createDiv = function createDiv(this: HTMLElement, opts?: { cls?: string }) { + const el = this.ownerDocument.createElement('div'); + if (opts?.cls) el.setAttribute('class', opts.cls); + this.appendChild(el); + return el; + }; + } + if (!proto.instanceOf) { + proto.instanceOf = function instanceOf(this: HTMLElement, type: unknown) { + return this instanceof (type as new () => HTMLElement); + }; + } + + Object.defineProperty(proto, 'offsetWidth', { + configurable: true, + get(this: HTMLElement) { + if (this.dataset.mockWidth !== undefined) return Number(this.dataset.mockWidth); + // Pills without an explicit mock width derive theirs from the label, + // so tests exercise the label-dependent measurement path. + if (this.classList.contains('qoderian-context-overflow-pill')) { + return 40 + (this.textContent ?? '').length * 6; + } + return 0; + }, + }); + + (globalThis as Record).ResizeObserver = FakeResizeObserver; +} + +function createChip(width: number): HTMLElement { + const chip = document.createElement('div'); + chip.className = 'qoderian-file-indicator qoderian-visible-flex'; + chip.dataset.mockWidth = String(width); + return chip; +} + +function createRow(): HTMLElement { + const wrapper = document.createElement('div'); + const row = document.createElement('div'); + row.className = 'qoderian-context-row has-content'; + row.style.paddingLeft = '10px'; + row.style.paddingRight = '10px'; + row.style.columnGap = '8px'; + wrapper.appendChild(row); + + Object.defineProperty(row, 'clientWidth', { + configurable: true, + get: () => rowClientWidth, + }); + return row; +} + +async function settle(): Promise { + // rAF-scheduled layout plus mutation-observer reschedules. + await new Promise(resolve => setTimeout(resolve, 40)); +} + +function readGap(row: HTMLElement): number { + return parseFloat(getComputedStyle(row).columnGap) || 0; +} + +describe('ContextRowOverflowController', () => { + beforeAll(installDomMocks); + + beforeEach(() => { + rowClientWidth = 400; + resizeCallback = null; + document.body.textContent = ''; + }); + + it('keeps every chip visible and hides the pill when the row is wide', async () => { + const row = createRow(); + const chips = [createChip(100), createChip(100), createChip(100)]; + chips.forEach(chip => row.appendChild(chip)); + + const controller = new ContextRowOverflowController(row); + await settle(); + + chips.forEach(chip => expect(chip.hasClass('qoderian-context-overflow-hidden')).toBe(false)); + const pill = row.querySelector('.qoderian-context-overflow-pill') as HTMLElement; + expect(pill).not.toBeNull(); + expect(pill.hasClass('qoderian-hidden')).toBe(true); + + controller.destroy(); + }); + + it('collapses overflowing chips into a "+N more" pill when narrow', async () => { + const row = createRow(); + const chips = [createChip(100), createChip(100), createChip(100)]; + chips.forEach(chip => row.appendChild(chip)); + + const controller = new ContextRowOverflowController(row); + await settle(); + + rowClientWidth = 200; // available 180: one 100px chip + gap + 70px pill fits + const pill = row.querySelector('.qoderian-context-overflow-pill') as HTMLElement; + pill.dataset.mockWidth = '70'; + + // Simulate the sidebar resize the ResizeObserver would report. + resizeCallback?.(); + await settle(); + + expect(chips[0].hasClass('qoderian-context-overflow-hidden')).toBe(false); + expect(chips[1].hasClass('qoderian-context-overflow-hidden')).toBe(true); + expect(chips[2].hasClass('qoderian-context-overflow-hidden')).toBe(true); + expect(pill.hasClass('qoderian-hidden')).toBe(false); + expect(pill.textContent).toBe('+2 more'); + + controller.destroy(); + }); + + it('expands on pill click and collapses again on second click', async () => { + const row = createRow(); + const chips = [createChip(100), createChip(100), createChip(100)]; + chips.forEach(chip => row.appendChild(chip)); + + const controller = new ContextRowOverflowController(row); + await settle(); + + rowClientWidth = 200; + const pill = row.querySelector('.qoderian-context-overflow-pill') as HTMLElement; + pill.dataset.mockWidth = '70'; + resizeCallback?.(); + await settle(); + + expect(pill.textContent).toBe('+2 more'); + + pill.click(); + await settle(); + + chips.forEach(chip => expect(chip.hasClass('qoderian-context-overflow-hidden')).toBe(false)); + expect(row.hasClass('qoderian-context-row--expanded')).toBe(true); + expect(pill.textContent).toBe('Show less'); + + pill.click(); + await settle(); + + expect(row.hasClass('qoderian-context-row--expanded')).toBe(false); + expect(chips[1].hasClass('qoderian-context-overflow-hidden')).toBe(true); + expect(pill.textContent).toBe('+2 more'); + + controller.destroy(); + }); + + it('updates the pill when chips are removed', async () => { + const row = createRow(); + const chips = [createChip(100), createChip(100), createChip(100)]; + chips.forEach(chip => row.appendChild(chip)); + + const controller = new ContextRowOverflowController(row); + await settle(); + + rowClientWidth = 200; + const pill = row.querySelector('.qoderian-context-overflow-pill') as HTMLElement; + pill.dataset.mockWidth = '70'; + resizeCallback?.(); + await settle(); + expect(pill.textContent).toBe('+2 more'); + + chips[2].remove(); + await settle(); + + expect(pill.textContent).toBe('+1 more'); + expect(chips[0].hasClass('qoderian-context-overflow-hidden')).toBe(false); + expect(chips[1].hasClass('qoderian-context-overflow-hidden')).toBe(true); + + controller.destroy(); + }); + + it('hides the pill again when the row widens', async () => { + const row = createRow(); + const chips = [createChip(100), createChip(100), createChip(100)]; + chips.forEach(chip => row.appendChild(chip)); + + const controller = new ContextRowOverflowController(row); + await settle(); + + rowClientWidth = 200; + const pill = row.querySelector('.qoderian-context-overflow-pill') as HTMLElement; + pill.dataset.mockWidth = '70'; + resizeCallback?.(); + await settle(); + expect(pill.hasClass('qoderian-hidden')).toBe(false); + + rowClientWidth = 400; + resizeCallback?.(); + await settle(); + + expect(pill.hasClass('qoderian-hidden')).toBe(true); + chips.forEach(chip => expect(chip.hasClass('qoderian-context-overflow-hidden')).toBe(false)); + + controller.destroy(); + }); + + it('reads the row gap from computed style', () => { + const row = createRow(); + expect(readGap(row)).toBe(8); + }); + + it('throws when the row is not attached to a host', () => { + const detached = document.createElement('div'); + expect(() => new ContextRowOverflowController(detached)).toThrow(); + }); + + it('derives pill width from its label and collapses everything when nothing fits', async () => { + const row = createRow(); + const chips = [createChip(100), createChip(100)]; + chips.forEach(chip => row.appendChild(chip)); + + const controller = new ContextRowOverflowController(row); + await settle(); + + // available 185: one chip (100) + gap (8) + "+1 more" pill (82) = 190 > 185, + // so even a single chip cannot share the row with the pill. + rowClientWidth = 205; + resizeCallback?.(); + await settle(); + + expect(chips[0].hasClass('qoderian-context-overflow-hidden')).toBe(true); + expect(chips[1].hasClass('qoderian-context-overflow-hidden')).toBe(true); + const pill = row.querySelector('.qoderian-context-overflow-pill') as HTMLElement; + expect(pill.textContent).toBe('+2 more'); + + controller.destroy(); + }); + + it('toggles expansion from the keyboard', async () => { + const row = createRow(); + const chips = [createChip(100), createChip(100), createChip(100)]; + chips.forEach(chip => row.appendChild(chip)); + + const controller = new ContextRowOverflowController(row); + await settle(); + + rowClientWidth = 200; + const pill = row.querySelector('.qoderian-context-overflow-pill') as HTMLElement; + pill.dataset.mockWidth = '70'; + resizeCallback?.(); + await settle(); + expect(pill.textContent).toBe('+2 more'); + + pill.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await settle(); + expect(row.hasClass('qoderian-context-row--expanded')).toBe(true); + expect(pill.textContent).toBe('Show less'); + + pill.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await settle(); + expect(row.hasClass('qoderian-context-row--expanded')).toBe(false); + expect(pill.textContent).toBe('+2 more'); + + controller.destroy(); + }); + + it('leaves state untouched while the row is not rendered', async () => { + const row = createRow(); + const chips = [createChip(100), createChip(100), createChip(100)]; + chips.forEach(chip => row.appendChild(chip)); + + const controller = new ContextRowOverflowController(row); + await settle(); + + rowClientWidth = 200; + const pill = row.querySelector('.qoderian-context-overflow-pill') as HTMLElement; + pill.dataset.mockWidth = '70'; + resizeCallback?.(); + await settle(); + expect(pill.textContent).toBe('+2 more'); + + // Simulate display:none (inactive tab): zero width must not re-collapse. + rowClientWidth = 0; + resizeCallback?.(); + await settle(); + + expect(pill.textContent).toBe('+2 more'); + expect(chips[0].hasClass('qoderian-context-overflow-hidden')).toBe(false); + expect(chips[1].hasClass('qoderian-context-overflow-hidden')).toBe(true); + + controller.destroy(); + }); + + it('auto-collapses an expanded row once everything fits again', async () => { + const row = createRow(); + const chips = [createChip(100), createChip(100), createChip(100)]; + chips.forEach(chip => row.appendChild(chip)); + + const controller = new ContextRowOverflowController(row); + await settle(); + + rowClientWidth = 200; + const pill = row.querySelector('.qoderian-context-overflow-pill') as HTMLElement; + pill.dataset.mockWidth = '70'; + resizeCallback?.(); + await settle(); + + pill.click(); + await settle(); + expect(row.hasClass('qoderian-context-row--expanded')).toBe(true); + + rowClientWidth = 400; + resizeCallback?.(); + await settle(); + + expect(row.hasClass('qoderian-context-row--expanded')).toBe(false); + expect(pill.hasClass('qoderian-hidden')).toBe(true); + + controller.destroy(); + }); +}); From 6c054eda558f407f919a7f42cd21a20a29f9a2d0 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Fri, 14 Aug 2026 08:10:31 +0800 Subject: [PATCH 2/2] docs: note narrow-sidebar composer adaptivity in changelog Co-authored-by: QoderAI (Qwen 3.8 Max) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1e51bd..f739b6c 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 + +- The composer now adapts to narrow sidebars: context chips that do + not fit collapse behind a "+N more" pill (click to expand or + collapse), the toolbar wraps instead of clipping, and the permission + mode and model dropdowns shrink to stay inside the sidebar, with + long model names ellipsized. + ## [1.0.4] - 2026-08-12 ### Fixed