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

- 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
Expand Down
192 changes: 192 additions & 0 deletions src/features/chat/controllers/context-row-overflow.ts
Original file line number Diff line number Diff line change
@@ -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');
}
}
}
2 changes: 2 additions & 0 deletions src/features/chat/tabs/tab-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export async function destroyTab(tab: TabData): Promise<void> {
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;
Expand Down
5 changes: 5 additions & 0 deletions src/features/chat/tabs/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -135,6 +136,7 @@ export function createTab(options: TabCreateOptions): TabData {
streamController: null,
inputController: null,
navigationController: null,
contextRowOverflow: null,
},
services: {
subagentManager,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/features/chat/tabs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -97,6 +98,7 @@ export interface TabControllers {
streamController: StreamController | null;
inputController: InputController | null;
navigationController: NavigationController | null;
contextRowOverflow: ContextRowOverflowController | null;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/features/chat/ui/toolbar/toolbar-selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/style/accessibility.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
48 changes: 48 additions & 0 deletions src/style/components/input.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading