Skip to content
Closed
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
10 changes: 0 additions & 10 deletions apps/desktop/src/renderer/styles/chat-message.css
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,6 @@
width: 100%;
}

/* Inserting earlier turns above the reader must not move what they are
reading. The browser's scroll anchoring does exactly that, so state the
dependency on the scroller that runs it rather than inheriting the `auto`
default: Maka reads no geometry and restores no position of its own. The
one case anchoring declines is a scroller sitting at zero, compensated in
useChatScroll after the turns land. */
[data-chat-scroll-container='true'] {
overflow-anchor: auto;
}

.maka-transcript-turn {
display: flex;
width: 100%;
Expand Down
74 changes: 62 additions & 12 deletions packages/ui/src/__tests__/transcript-scroll-authority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ interface FakeRoot {
clientHeight: number;
/** The boxes `scrollHeight` is made of, which is what the authority watches. */
children: readonly unknown[];
/** `overflow-anchor` is the authority's to set, so the fake carries it. */
style: { overflowAnchor: string };
addEventListener(type: string, listener: () => void): void;
removeEventListener(type: string, listener: () => void): void;
/** Dispatch the scroll event the browser would, one frame later. */
Expand All @@ -54,6 +56,7 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F
scrollHeight: options?.scrollHeight ?? 3_000,
clientHeight: options?.clientHeight ?? 600,
children: [{}],
style: { overflowAnchor: '' },
addEventListener(type, listener) {
if (type === 'scroll') listeners.add(listener);
},
Expand Down Expand Up @@ -231,26 +234,47 @@ test('a scroll event that arrives late is still this authority\'s own write', ()
});
});

test('growth that outruns the write does not read as the reader scrolling up', () => {
test('a reader who scrolls up mid-stream is not swallowed by the growth', () => {
withObservers((resize) => {
const root = fakeRoot();
const authority = createTranscriptScrollAuthority();
authority.attach(root as unknown as HTMLElement);
assert.equal(root.scrollTop, 2_400);

// The transcript grew, and the scroll event for it arrives before this
// authority has been told to follow it. The offset is 302px from a tail
// that moved — identical, as a position, to a reader who scrolled up.
// Streaming: content lands, and the reader's gesture reaches this authority
// in the same event as the growth it landed with. Geometry changed, so the
// released rule would swallow it — and swallowing it is how a reader gets
// written back to the tail for as long as the answer keeps coming.
root.grow(302);
root.scrollTop = 2_402;
root.scrollTop = 900;
root.emitScroll();
assert.equal(authority.getSnapshot().pinned, true);

// The affordance still knows how far the tail now is, and the next growth
// signal takes the reader back to it.
assert.equal(authority.getSnapshot().pinned, false);
assert.equal(authority.getSnapshot().awayFromTail, true);

// And released means released: the next growth leaves them where they are.
resize();
assert.equal(root.scrollTop, 2_702);
assert.equal(root.scrollTop, 900);
});
});

test('the pin owns anchoring, and hands it back on release', () => {
withObservers(() => {
const root = fakeRoot();
const authority = createTranscriptScrollAuthority();
const detach = authority.attach(root as unknown as HTMLElement);

// Pinned, this authority writes the tail every frame, so anchoring can only
// produce events for adjustments that were never drawn.
assert.equal(root.style.overflowAnchor, 'none');

authority.releasePin();
assert.equal(root.style.overflowAnchor, '');

authority.pinToTail();
assert.equal(root.style.overflowAnchor, 'none');

detach();
assert.equal(root.style.overflowAnchor, '');
});
});

Expand Down Expand Up @@ -292,9 +316,9 @@ test('only the reader\'s own movement reaches a reader-scroll listener', () => {
root.emitScroll();
assert.equal(heard, 0);

// Content arriving, with anchoring moving the offset to hold the reader.
// Content arriving. Anchoring is off while pinned and growth below moves no
// offset, so the event carries the offset this authority wrote: its echo.
root.grow(500);
root.scrollTop = 2_900;
root.emitScroll();
assert.equal(heard, 0);

Expand All @@ -309,3 +333,29 @@ test('only the reader\'s own movement reaches a reader-scroll listener', () => {
assert.equal(heard, 1);
});
});

test('a clamp that leaves the reader on the tail is not the reader', () => {
withObservers(() => {
const root = fakeRoot();
const authority = createTranscriptScrollAuthority();
let heard = 0;
authority.subscribeToReaderScroll(() => {
heard += 1;
});
authority.attach(root as unknown as HTMLElement);
assert.equal(root.scrollTop, 2_400);

// Content shrinks under a reader who is already at the end — a notice that
// retires, a turn that settles shorter than it first laid out. The browser
// clamps the offset itself and the event carries an offset this authority
// never wrote, but the reader is still on the tail: they did not move up,
// so there is nobody to report. Telling anyone otherwise is how a
// transcript that nobody scrolled goes and asks for history.
root.scrollHeight -= 500;
root.scrollTop = root.scrollHeight - root.clientHeight;
root.emitScroll();
assert.equal(heard, 0);
assert.equal(authority.getSnapshot().pinned, true);
assert.equal(root.style.overflowAnchor, 'none');
});
});
61 changes: 46 additions & 15 deletions packages/ui/src/transcript-scroll-authority.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,17 @@
* pinned → content that grows writes `scrollTop = scrollHeight`
* !pinned → nothing here writes `scrollTop`, ever
*
* "Keep the reader where they were reading" is the definition of
* `overflow-anchor: auto`, which is already the initial value and costs nothing,
* and "the reader is dragging" is also just don't touch it — so both of those
* are the same instruction to this code: stay out of the way.
* While released, "keep the reader where they were reading" is the definition
* of `overflow-anchor: auto`, so that case is just don't touch it: stay out of
* the way and let the browser hold their place.
*
* While pinned it is the opposite instruction. Anchoring holds an anchor node
* still; the pin holds the tail. Both cannot be obeyed, and the pin wins every
* time — this authority overwrites anchoring's adjustment on the very next
* frame, so it was never drawn. What survives is its `scroll` event, which
* arrives with the offset moved and no reader behind it, and that is a lie this
* file cannot see through. So the pin turns anchoring off, and turns it back on
* when it releases.
*
* Being the only writer is what makes the state exact rather than guessed. It
* remembers the offset it wrote, so a scroll event that finds the scroller
Expand Down Expand Up @@ -123,6 +130,16 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority {
const distanceToTail = (): number =>
root ? root.scrollHeight - root.scrollTop - root.clientHeight : 0;

/**
* Anchoring is a writer, and while pinned it is a writer whose every write is
* about to be overwritten. Turning it off there leaves the reader and this
* authority as the only two, which is what lets a non-echo event be read as
* the reader without inferring anything.
*/
const applyAnchoring = (): void => {
if (root) root.style.overflowAnchor = pinned ? 'none' : '';
};

const writeToTail = (): void => {
if (!root) return;
root.scrollTop = root.scrollHeight;
Expand Down Expand Up @@ -152,26 +169,36 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority {
lastClientHeight = target.clientHeight;
return;
}
// An event that arrives with the scroll geometry changed is the content
// or the viewport moving under the reader, not the reader moving:
// anchoring holding them still as turns land above, growth that outran
// this authority's own write, or a resize the browser answered by
// clamping the offset. Their offset changed and their intent did not,
// so the pin — which is that intent — must not be re-derived from where
// they now are, and nobody may be told the reader asked for anything.
// The affordance still follows the new distance, because that is a fact
// about the viewport rather than about them.
// While released, an event that arrives with the scroll geometry changed
// is the content or the viewport moving under the reader, not the reader
// moving: anchoring holding them still as turns land above, or a resize
// the browser answered by clamping the offset. Their offset changed and
// their intent did not, so the pin — which is that intent — must not be
// re-derived from where they now are, and nobody may be told the reader
// asked for anything. The affordance still follows the new distance,
// because that is a fact about the viewport rather than about them.
//
// While pinned, swallowing an event because content grew in the same
// frame is exactly how a reader who scrolls up during streaming gets
// written back to the tail. Anchoring is off there, so growth does not
// move `scrollTop` and the event that finds them away from the tail is
// theirs. An event that leaves them still on it is not: they did not
// move up, so there is nobody to report and nothing to release. That is
// where the browser's own clamp lands when the content shrinks under a
// reader who is already at the end.
const moved =
target.scrollHeight !== lastScrollHeight || target.clientHeight !== lastClientHeight;
lastScrollHeight = target.scrollHeight;
lastClientHeight = target.clientHeight;
const distance = distanceToTail();
awayFromTail = distance > BUTTON_THRESHOLD_PX;
if (moved) {
const atTail = distance <= PIN_THRESHOLD_PX;
if (moved && (!pinned || atTail)) {
publish();
return;
}
pinned = distance <= PIN_THRESHOLD_PX;
pinned = atTail;
applyAnchoring();
publish();
for (const listener of [...readerListeners]) listener();
};
Expand Down Expand Up @@ -207,22 +234,26 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority {
const childList = new MutationObserver(observeBox);
childList.observe(target, { childList: true });
observeBox();
applyAnchoring();
if (pinned) writeToTail();
return () => {
childList.disconnect();
box.disconnect();
target.removeEventListener('scroll', onScroll);
target.style.overflowAnchor = '';
lastWrittenTop = undefined;
if (root === target) root = null;
};
},
pinToTail() {
pinned = true;
applyAnchoring();
writeToTail();
publish();
},
releasePin() {
pinned = false;
applyAnchoring();
awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX;
publish();
},
Expand Down