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

- Qoderian tabs no longer keep Obsidian's renderer busy while idle. The
context row above the composer re-checked its chip layout on every display
frame for as long as Obsidian was visible, once per open tab, and each frame
did more work when a note was attached. The row now re-measures only when
its chips or width change.

## [1.0.7] - 2026-09-02

### Added
Expand Down
32 changes: 25 additions & 7 deletions src/features/chat/controllers/context-row-overflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export class ContextRowOverflowController {
private readonly measureEl: HTMLElement;
private readonly resizeObserver: ResizeObserver;
private readonly mutationObserver: MutationObserver;
private layoutScheduled = false;
private layoutFrame: number | null = null;
private expanded = false;
private destroyed = false;

Expand Down Expand Up @@ -53,21 +53,37 @@ export class ContextRowOverflowController {

destroy(): void {
this.destroyed = true;
if (this.layoutFrame !== null) {
window.cancelAnimationFrame(this.layoutFrame);
this.layoutFrame = null;
}
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.layoutFrame !== null) return;
this.layoutFrame = window.requestAnimationFrame(() => {
this.layoutFrame = null;
if (!this.destroyed) this.layout();
});
}

private layout(): void {
this.applyLayout();
// Obsidian's class helpers rewrite the class attribute even when the class
// does not change, and the observer above reports every such write, so
// applyState checks each class before writing it. Records still queued
// here describe DOM this pass has already read; dropping them saves a
// second clone-and-measure pass a frame after every real change, so the
// row settles in one frame. Either the class checks or this call alone
// stops layout rescheduling itself on every frame, and the regression
// tests cover only the two together.
this.mutationObserver.takeRecords();
}

/** Content items are row children that are currently meant to be visible. */
private contentItems(): HTMLElement[] {
return Array.from(this.rowEl.children).filter(
Expand All @@ -76,7 +92,7 @@ export class ContextRowOverflowController {
);
}

private layout(): void {
private applyLayout(): void {
const items = this.contentItems();

if (items.length === 0 || !this.rowEl.hasClass('has-content')) {
Expand Down Expand Up @@ -174,7 +190,9 @@ export class ContextRowOverflowController {
}
});

this.rowEl.toggleClass('qoderian-context-row--expanded', this.expanded);
if (this.expanded !== this.rowEl.hasClass('qoderian-context-row--expanded')) {
this.rowEl.toggleClass('qoderian-context-row--expanded', this.expanded);
}

const hiddenCount = items.length - (this.expanded ? items.length : visibleCount);
const showPill = this.expanded || hiddenCount > 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,13 @@ function installDomMocks(): void {
};
}
if (!proto.toggleClass) {
// Mirrors Obsidian: toggleClass always calls classList.add or remove, which
// rewrites the class attribute and queues a MutationObserver record even
// when nothing changes. classList.toggle(cls, force) skips that write and
// would hide an observer that keeps re-triggering itself.
proto.toggleClass = function toggleClass(this: HTMLElement, cls: string, force: boolean) {
this.classList.toggle(cls, force);
if (force) this.classList.add(cls);
else this.classList.remove(cls);
return this;
};
}
Expand Down Expand Up @@ -370,4 +375,53 @@ describe('ContextRowOverflowController', () => {

controller.destroy();
});

it('stops scheduling layout frames once a blank row settles', async () => {
// A new tab starts with nothing attached, so its row has no content.
const row = createRow();
row.removeClass('has-content');

const controller = new ContextRowOverflowController(row);
await settle();

const requestFrame = jest.spyOn(window, 'requestAnimationFrame');
await new Promise(resolve => setTimeout(resolve, 200));
expect(requestFrame).not.toHaveBeenCalled();

requestFrame.mockRestore();
controller.destroy();
});

it('stops scheduling layout frames once a collapsed row settles', async () => {
const row = createRow();
[createChip(100), createChip(100), createChip(100)].forEach(chip => row.appendChild(chip));
rowClientWidth = 200;

const controller = new ContextRowOverflowController(row);
await settle();
const pill = row.querySelector('.qoderian-context-overflow-pill') as HTMLElement;
expect(pill.hasClass('qoderian-hidden')).toBe(false);

const requestFrame = jest.spyOn(window, 'requestAnimationFrame');
await new Promise(resolve => setTimeout(resolve, 200));
expect(requestFrame).not.toHaveBeenCalled();

requestFrame.mockRestore();
controller.destroy();
});

it('cancels its pending layout frame on destroy', () => {
const row = createRow();
const requestFrame = jest.spyOn(window, 'requestAnimationFrame');
const cancelFrame = jest.spyOn(window, 'cancelAnimationFrame');

const controller = new ContextRowOverflowController(row);
const pendingFrame = requestFrame.mock.results[0]?.value as number;
controller.destroy();

expect(cancelFrame).toHaveBeenCalledWith(pendingFrame);

requestFrame.mockRestore();
cancelFrame.mockRestore();
});
});
Loading