You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/06 01:40:44 UTC
(maka) 01/01: fix(desktop): page shared transcripts in both scroll directions
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch fix/guest-transcript-scroll
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 4409db306b0f487fef57102a18ec98c1cadd9a9d
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Sun Sep 6 09:37:53 2026 +0800
fix(desktop): page shared transcripts in both scroll directions
Generated-by: OpenAI Codex
---
.../desktop-transcript-range-store.test.ts | 231 ++++++++++++++-------
...runtime-host-session-execution-ipc-main.test.ts | 19 ++
.../runtime-host-session-observer.test.ts | 4 +
.../main/__tests__/workhub-session-port.test.ts | 7 +-
.../desktop/src/main/desktop-transcript-replica.ts | 31 ++-
.../runtime-host-session-execution-ipc-main.ts | 7 +
.../runtime-host-session-observation-registry.ts | 12 ++
.../src/main/runtime-host-session-observer.ts | 12 ++
apps/desktop/src/preload/preload.ts | 4 +-
apps/desktop/src/preload/transcript-contract.ts | 1 +
apps/desktop/src/renderer/app-shell.tsx | 6 +-
apps/desktop/src/renderer/chat-message-surface.tsx | 4 +
.../src/renderer/desktop-transcript-range-store.ts | 19 +-
.../__tests__/transcript-scroll-authority.test.ts | 16 ++
packages/ui/src/__tests__/use-chat-scroll.test.tsx | 93 +++++++++
packages/ui/src/chat-view.tsx | 4 +
packages/ui/src/transcript-scroll-authority.tsx | 9 +-
packages/ui/src/use-chat-scroll.ts | 65 ++++--
18 files changed, 434 insertions(+), 110 deletions(-)
diff --git a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts
index c1ee17d385..244258bbbf 100644
--- a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts
+++ b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts
@@ -575,6 +575,82 @@ test('retains the reading anchor while an older page replaces the far edges', as
assert.equal(snapshot.hasNewer, true);
});
+for (const coverage of ['complete', 'projected'] as const) {
+ test(`scrolls both ways through bounded ${coverage} history without losing the reading turn`, async () => {
+ const stride = coverage === 'projected' ? 3 : 1;
+ const messages = Array.from({ length: 40 }, (_, index) => ({
+ identity: index * stride,
+ message: { ...assistantMessage(String(index), `assistant-${index}`), turnId: `turn-${index}` },
+ }));
+ const through = 39 * stride;
+ const pages = new Map<object, { messages: typeof messages; nextCursor: string | null }>();
+ const makePage = (direction: 'older' | 'newer', anchor: number | null) => {
+ const candidates = messages.filter(({ identity }) => anchor === null
+ || (direction === 'older' ? identity < anchor : identity > anchor));
+ const nextCursor = candidates.length > 10 ? 'more' : null;
+ const page = transcriptPage(direction, nextCursor, through);
+ pages.set(page, {
+ messages: direction === 'older' ? candidates.slice(-10) : candidates.slice(0, 10),
+ nextCursor,
+ });
+ return page;
+ };
+ const handle = runtimeHostSessionFixture({
+ snapshot: continuitySnapshot(),
+ transcript: Promise.resolve([]),
+ events: { async *[Symbol.asyncIterator]() {} },
+ async close() {},
+ transcriptBootstrap: {
+ throughSequence: through,
+ durableCoverage: coverage,
+ overlayMessageCount: 0,
+ durable: makePage('older', null),
+ overlay: { ...transcriptPage('older', null, through), source: 'overlay' },
+ },
+ loadTranscriptOverlay: async () => [],
+ decodeTranscriptPage: async (page) => pages.get(page)!,
+ loadTranscriptPage: async (input) => makePage(input.direction, input.anchorSequence),
+ });
+ const store = transcriptStore();
+ const replica = await DesktopTranscriptReplica.prepare(handle, {
+ generation: 'generation-1',
+ onChange: (current, change) => {
+ for (const batch of encodeDesktopTranscriptChange(current.snapshot(), change)) store.accept(batch);
+ },
+ });
+ for (const batch of encodeDesktopTranscriptSnapshot(replica.snapshot())) store.accept(batch);
+ const controller = createDesktopTranscriptRangeController(store, async () => ({
+ sessionId: replica.sessionId, generation: replica.generation, hostEpoch: replica.hostEpoch,
+ readThroughMessageId: null,
+ loadBefore: (anchor, maxBytes) => replica.loadBefore(anchor, maxBytes!),
+ loadAfter: (anchor, maxBytes) => replica.loadAfter(anchor, maxBytes!),
+ loadAround: async () => { throw new Error('ordinary scrolling must not replace the range'); },
+ close: async () => replica.close(),
+ }));
+ for (const direction of ['older', 'newer', 'older', 'newer'] as const) {
+ let steps = 0;
+ while (direction === 'older' ? store.range().hasOlder : store.range().hasNewer) {
+ assert.ok(++steps <= 40, 'paging must make progress');
+ const before = replica.snapshot();
+ const anchor = (direction === 'older' ? before.durable[0] : before.durable.at(-1))!;
+ await (direction === 'older'
+ ? controller.loadBefore(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, anchor.message.turnId)
+ : controller.loadAfter(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, anchor.message.turnId));
+ const after = replica.snapshot();
+ assert.ok(after.durable.some(({ sequence }) => sequence === anchor.sequence));
+ assert.ok(after.durable.length <= DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS);
+ assert.ok(replica.residentBytes <= DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES);
+ for (let i = 1; i < after.durable.length; i++) {
+ assert.equal(after.durable[i]!.sequence - after.durable[i - 1]!.sequence, stride);
+ }
+ }
+ assert.equal(direction === 'older' ? store.range().oldestSequence : store.range().newestSequence,
+ direction === 'older' ? 0 : through);
+ }
+ await controller.close();
+ });
+}
+
test('delivers a mid-session tail append even while a history window is resident', async () => {
// Reproduces the "active session does not show the newest message until you
// switch away and back" bug. Once the resident window has been trimmed off
@@ -892,76 +968,78 @@ test('does not resurrect a discarded replica when a tail re-anchor is in flight'
assert.equal(replica.residentBytes, 0);
});
-test('does not resurrect a discarded replica when a history load is in flight', async () => {
- // Same post-await `#resident` invariant, exercised through `loadBefore`: a
- // history page is in flight when `discard()` reclaims the replica. The
- // resolved older page must not repopulate durable state or publish.
- const messages = [0, 1, 2, 3, 4].map((sequence) => ({
- identity: sequence,
- message: assistantMessage(String(sequence), `assistant-${sequence}`),
- }));
- const page = (nextCursor: string | null) => ({
- kind: 'page' as const,
- sessionId: 'session-1',
- source: 'durable' as const,
- direction: 'older' as const,
- throughSequence: 4,
- rawBytes: 1,
- fragments: [],
- rangeBoundarySequence: null,
- protectedTurnSequence: null,
- nextCursor,
- });
- const bootstrapPage = page('older');
- const olderPage = page(null);
- let releaseOlder: () => void = () => {};
- const olderGate = new Promise<void>((resolve) => {
- releaseOlder = resolve;
- });
- let signalEntered: () => void = () => {};
- const olderEntered = new Promise<void>((resolve) => {
- signalEntered = resolve;
- });
- const changes: { durableUpserts: readonly { sequence: number }[] }[] = [];
- const handle = runtimeHostSessionFixture({
- snapshot: continuitySnapshot(),
- transcript: Promise.resolve([]),
- events: { async *[Symbol.asyncIterator]() {} },
- transcriptBootstrap: {
+for (const direction of ['older', 'newer'] as const) {
+ test(`does not resurrect a discarded replica when ${direction} history load is in flight`, async () => {
+ // A pending page must not repopulate or publish a reclaimed replica.
+ const messages = [0, 1, 2, 3, 4].map((sequence) => ({
+ identity: sequence,
+ message: assistantMessage(String(sequence), `assistant-${sequence}`),
+ }));
+ const page = (nextCursor: string | null) => ({
+ kind: 'page' as const,
+ sessionId: 'session-1',
+ source: 'durable' as const,
+ direction: 'older' as const,
throughSequence: 4,
- durableCoverage: 'complete',
- overlayMessageCount: 0,
- durable: bootstrapPage,
- overlay: { ...page(null), source: 'overlay' },
- },
- loadTranscriptOverlay: async () => [],
- decodeTranscriptPage: async (candidate) => candidate === bootstrapPage
- ? { messages: messages.slice(4), nextCursor: 'older' }
- : { messages: messages.slice(2, 4), nextCursor: null },
- loadTranscriptPage: async () => {
- signalEntered();
- await olderGate;
- return olderPage;
- },
- async close() {},
- });
- const replica = await DesktopTranscriptReplica.prepare(handle, {
- maxResidentBytes: 1024 * 1024,
- onChange: (_replica, change) => changes.push(change),
- });
-
- // Load older history; reclaim memory while its page is pending.
- const loading = replica.loadBefore(4, 128 * 1024);
- await olderEntered;
- replica.discard();
- assert.equal(replica.resident, false);
- releaseOlder();
- await loading;
+ rawBytes: 1,
+ fragments: [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
+ nextCursor,
+ });
+ const bootstrapPage = page('older');
+ const adjacentPage = { ...page(null), direction };
+ let releasePage: () => void = () => {};
+ const pageGate = new Promise<void>((resolve) => {
+ releasePage = resolve;
+ });
+ let signalEntered: () => void = () => {};
+ const pageEntered = new Promise<void>((resolve) => {
+ signalEntered = resolve;
+ });
+ const changes: { durableUpserts: readonly { sequence: number }[] }[] = [];
+ const handle = runtimeHostSessionFixture({
+ snapshot: continuitySnapshot(),
+ transcript: Promise.resolve([]),
+ events: { async *[Symbol.asyncIterator]() {} },
+ transcriptBootstrap: {
+ throughSequence: 4,
+ durableCoverage: 'complete',
+ overlayMessageCount: 0,
+ durable: bootstrapPage,
+ overlay: { ...page(null), source: 'overlay' },
+ },
+ loadTranscriptOverlay: async () => [],
+ decodeTranscriptPage: async (candidate) => candidate === bootstrapPage
+ ? { messages: direction === 'older' ? messages.slice(4) : messages.slice(0, 1), nextCursor: 'older' }
+ : { messages: messages.slice(2, 4), nextCursor: null },
+ loadTranscriptPage: async () => {
+ signalEntered();
+ await pageGate;
+ return adjacentPage;
+ },
+ async close() {},
+ });
+ const replica = await DesktopTranscriptReplica.prepare(handle, {
+ maxResidentBytes: 1024 * 1024,
+ onChange: (_replica, change) => changes.push(change),
+ });
- assert.equal(changes.length, 0, 'a discarded replica must not publish an in-flight history page');
- assert.equal(replica.resident, false);
- assert.equal(replica.residentBytes, 0);
-});
+ // Reclaim memory while an adjacent history page is pending.
+ const loading = direction === 'older'
+ ? replica.loadBefore(4, 128 * 1024)
+ : replica.loadAfter(1, 128 * 1024);
+ await pageEntered;
+ replica.discard();
+ assert.equal(replica.resident, false);
+ releasePage();
+ await loading;
+
+ assert.equal(changes.length, 0, 'a discarded replica must not publish an in-flight history page');
+ assert.equal(replica.resident, false);
+ assert.equal(replica.residentBytes, 0);
+ });
+}
test('does not drive a discarded replica terminal when a contiguous catch-up is in flight', async () => {
// Same post-await `#resident` invariant on the ordinary contiguous catch-up
@@ -1314,6 +1392,7 @@ test('reopens a failed transcript range with a fresh generation', async () => {
hostEpoch: 'host-2',
readThroughMessageId: null,
async loadBefore() {},
+ async loadAfter() {},
async loadAround() {},
async close() {},
};
@@ -1369,17 +1448,21 @@ test('forwards a larger logical history range without changing batch size', asyn
sessionId: 'session-1',
generation: 'generation-1',
hostEpoch: 'host-1',
- durableThrough: 2,
+ durableThrough: 4,
durable: [
{ sequence: 1, message: assistantMessage('earlier') },
{
sequence: 2,
message: { ...assistantMessage('latest', 'assistant-2'), turnId: 'turn-2' },
},
+ {
+ sequence: 3,
+ message: { ...assistantMessage('more', 'assistant-3'), turnId: 'turn-2' },
+ },
],
overlay: [],
hasOlder: true,
- hasNewer: false,
+ hasNewer: true,
})) store.accept(batch);
let request: { anchorSequence: number | null; maxBytes?: number } | undefined;
const controller = createDesktopTranscriptRangeController(store, async () => ({
@@ -1390,6 +1473,9 @@ test('forwards a larger logical history range without changing batch size', asyn
async loadBefore(anchorSequence, maxBytes) {
request = { anchorSequence, maxBytes };
},
+ async loadAfter(anchorSequence, maxBytes) {
+ request = { anchorSequence, maxBytes };
+ },
async loadAround() {},
async close() {},
}));
@@ -1397,6 +1483,11 @@ test('forwards a larger logical history range without changing batch size', asyn
await controller.loadBefore(512 * 1024, 'turn-2');
assert.deepEqual(request, { anchorSequence: 2, maxBytes: 512 * 1024 });
+ await controller.loadAfter(512 * 1024, 'turn-2');
+ assert.deepEqual(request, { anchorSequence: 3, maxBytes: 512 * 1024 },
+ 'forward reads start after the last resident record of the visible turn');
+ await controller.loadAfter(512 * 1024, 'evicted-turn');
+ assert.deepEqual(request, { anchorSequence: 3, maxBytes: 512 * 1024 });
await controller.close();
});
diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts
index 7082dbb81e..c8a73a0652 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts
@@ -58,6 +58,25 @@ test('registers Session observation as one reconnectable operation', () => {
assert.equal(ipc.reconnectableChannels.has('sessions:observe'), true);
});
+test('forward transcript paging is an observation operation scoped to the renderer', async () => {
+ const ipc = ipcHarness();
+ const observations = new RuntimeHostSessionObservationRegistry();
+ const calls: unknown[] = [];
+ observations.loadTranscriptAfter = async (request, targetId) => { calls.push({ request, targetId }); };
+ registerRuntimeHostSessionObservationIpc({ observations, resolveSideConversation: async () => false }, ipc);
+ const request = {
+ consumerId: 'guest-consumer', sessionId: 'shared-session', hostEpoch: 'host-1',
+ anchorSequence: 42, maxBytes: 512 * 1024,
+ };
+ await ipc.invoke('sessions:transcript:load-after', request);
+ assert.deepEqual(calls, [{ request, targetId: 9 }]);
+ await assert.rejects(
+ ipc.invoke('sessions:transcript:load-after', { ...request, anchorSequence: -1 }),
+ /Invalid Desktop transcript range anchor/,
+ );
+ assert.equal(calls.length, 1);
+});
+
test("keeps synthetic E2E interactions visible through Host hydration and retires their answer", async () => {
const observer = observerWithSnapshot();
const ipc = ipcHarness();
diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts
index 852b89f2bd..f32b62a2c9 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts
@@ -451,6 +451,7 @@ test('restores transcript consumers across Host replacement', async () => {
};
},
async loadTranscriptBefore() {},
+ async loadTranscriptAfter() {},
async loadTranscriptAround() {},
async closeTranscript() {},
});
@@ -508,6 +509,7 @@ test('does not hold Host observation recovery on transcript replay', async () =>
return transcriptResult(generation);
},
async loadTranscriptBefore() {},
+ async loadTranscriptAfter() {},
async loadTranscriptAround() {},
async closeTranscript() {},
});
@@ -537,6 +539,7 @@ test('does not hold Host observation recovery on transcript replay', async () =>
async loadTranscriptBefore() {
transcriptRangeStarted = true;
},
+ async loadTranscriptAfter() {},
async loadTranscriptAround() {},
acknowledgeTranscript() {
transcriptAcknowledged = true;
@@ -600,6 +603,7 @@ test('fences transcript range failures to the current registration and Host sour
};
},
loadTranscriptBefore,
+ async loadTranscriptAfter() {},
async loadTranscriptAround() {},
async closeTranscript() {},
});
diff --git a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts
index ed02dcdf78..c6fbca465b 100644
--- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts
+++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts
@@ -98,6 +98,7 @@ function transcriptsWith(messages: readonly StoredMessage[]) {
hostEpoch: 'epoch-reconcile',
readThroughMessageId: null,
loadBefore: async () => {},
+ loadAfter: async () => {},
loadAround: async () => {},
close: async () => {},
};
@@ -408,6 +409,7 @@ test('Coordination transcript adapter never replays history and completes only t
hostEpoch: 'epoch-1',
readThroughMessageId: null,
loadBefore: async () => assert.fail('conversation open must not replay older history'),
+ loadAfter: async () => assert.fail('conversation open must not replay newer history'),
loadAround: async (sequence, maxBytes) => {
latestLoads.push({ sequence, maxBytes });
const message: StoredMessage = {
@@ -525,6 +527,7 @@ test('Coordination transcript adapter retries latest-record completion in the sa
latestLoads += 1;
if (latestLoads === 1) throw new Error('transient latest-record read failure');
},
+ loadAfter: async () => {},
close: async () => {},
};
},
@@ -613,6 +616,7 @@ test('Coordination transcript adapter ignores a stale latest-record failure afte
hostEpoch: 'epoch-1',
readThroughMessageId: null,
loadBefore: async () => assert.fail('conversation open must not replay older history'),
+ loadAfter: async () => assert.fail('conversation open must not replay newer history'),
loadAround: async () => {
latestLoads += 1;
if (latestLoads === 1) {
@@ -786,6 +790,7 @@ test('desktop adapter rebuilds recent turns from the Session transcript and clos
hostEpoch: 'epoch-1',
readThroughMessageId: null,
loadBefore: async () => {},
+ loadAfter: async () => {},
loadAround: async () => {},
close: async () => {
closes += 1;
@@ -849,7 +854,7 @@ test('desktop adapter cancels an unavailable transcript without hiding ready Ses
});
return {
sessionId: readyId, generation: 'generation-ready', hostEpoch: 'epoch-ready',
- readThroughMessageId: null, loadBefore: async () => {}, loadAround: async () => {},
+ readThroughMessageId: null, loadBefore: async () => {}, loadAfter: async () => {}, loadAround: async () => {},
close: async () => {},
};
},
diff --git a/apps/desktop/src/main/desktop-transcript-replica.ts b/apps/desktop/src/main/desktop-transcript-replica.ts
index 195ccc7630..3563c1722d 100644
--- a/apps/desktop/src/main/desktop-transcript-replica.ts
+++ b/apps/desktop/src/main/desktop-transcript-replica.ts
@@ -230,17 +230,27 @@ export class DesktopTranscriptReplica {
anchorSequence: number | null,
maxBytes: number,
): Promise<void> {
- return this.#enqueue(() => this.#loadBefore(anchorSequence, maxBytes));
+ return this.#enqueue(() => this.#loadAdjacent('older', anchorSequence, maxBytes));
}
- async #loadBefore(anchorSequence: number | null, maxBytes: number): Promise<void> {
+ async loadAfter(anchorSequence: number | null, maxBytes: number): Promise<void> {
+ return this.#enqueue(() => this.#loadAdjacent('newer', anchorSequence, maxBytes));
+ }
+
+ async #loadAdjacent(
+ direction: 'older' | 'newer',
+ anchorSequence: number | null,
+ maxBytes: number,
+ ): Promise<void> {
this.#assertOpen();
const throughSequence = this.#durableThrough;
if (throughSequence === null) return;
- const anchor = anchorSequence ?? this.#oldestSequence();
+ const anchor = anchorSequence ?? (direction === 'older'
+ ? this.#oldestSequence()
+ : this.#orderedDurable(false).at(-1)?.sequence ?? null);
const page = await this.#handle.loadTranscriptPage({
source: 'durable',
- direction: 'older',
+ direction,
throughSequence,
cursor: null,
anchorSequence: anchor,
@@ -250,22 +260,25 @@ export class DesktopTranscriptReplica {
this.#assertOpen();
// Same post-await `#resident` invariant as `#replaceWithRange` and the
// paged catch-up: a concurrent `discard()` may have reclaimed this
- // replica while the older page was in flight. Installing the page here
+ // replica while the adjacent page was in flight. Installing the page here
// would repopulate durable state and undo the eviction.
if (!this.#resident) return;
this.#acceptRange(decoded.messages);
if (
anchor !== null &&
decoded.messages.length > 0 &&
- !this.#matchesCoverageStep(anchor, decoded.messages.at(-1)!.identity + 1)
+ !(direction === 'older'
+ ? this.#matchesCoverageStep(anchor, decoded.messages.at(-1)!.identity + 1)
+ : this.#matchesCoverageStep(decoded.messages[0]!.identity, anchor + 1))
) {
- throw correlationError('Desktop transcript older page did not meet its anchor');
+ throw correlationError(`Desktop transcript ${direction} page did not meet its anchor`);
}
const completedOverlayMessageIds = this.#installDurable(decoded.messages);
- this.#hasOlder = decoded.nextCursor !== null;
+ if (direction === 'older') this.#hasOlder = decoded.nextCursor !== null;
+ else this.#hasNewer = decoded.nextCursor !== null;
const evictedDurableSequences = this.#evictToBudget(
undefined,
- 'newest',
+ direction === 'older' ? 'newest' : 'oldest',
anchor ?? undefined,
);
this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences);
diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts
index 3175fb3c4a..dda3a19e05 100644
--- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts
@@ -176,6 +176,7 @@ export interface RuntimeHostSessionObservationIpcDeps {
RuntimeHostSessionObservationRegistry,
| 'loadTranscriptAround'
| 'loadTranscriptBefore'
+ | 'loadTranscriptAfter'
| 'observe'
| 'openTranscript'
>;
@@ -221,6 +222,12 @@ export function registerRuntimeHostSessionObservationIpc(
event.sender.id,
);
});
+ ipcMain.handle('sessions:transcript:load-after', async (event, input: unknown) => {
+ await deps.observations.loadTranscriptAfter(
+ normalizeTranscriptRangeRequest(input),
+ event.sender.id,
+ );
+ });
}
/**
diff --git a/apps/desktop/src/main/runtime-host-session-observation-registry.ts b/apps/desktop/src/main/runtime-host-session-observation-registry.ts
index c8bb8f9db9..67f5fadd8c 100644
--- a/apps/desktop/src/main/runtime-host-session-observation-registry.ts
+++ b/apps/desktop/src/main/runtime-host-session-observation-registry.ts
@@ -37,6 +37,7 @@ type SessionObservationSource = Pick<RuntimeHostSessionObserver, 'observe' | 'un
| 'closeTranscript'
| 'loadTranscriptAround'
| 'loadTranscriptBefore'
+ | 'loadTranscriptAfter'
| 'openTranscript'
>
>;
@@ -47,6 +48,7 @@ type TranscriptSource = Required<
| 'closeTranscript'
| 'loadTranscriptAround'
| 'loadTranscriptBefore'
+ | 'loadTranscriptAfter'
| 'openTranscript'
>
>;
@@ -67,6 +69,7 @@ function requireTranscriptSource(
if (
!source?.openTranscript ||
!source.loadTranscriptBefore ||
+ !source.loadTranscriptAfter ||
!source.loadTranscriptAround ||
!source.closeTranscript
) {
@@ -393,6 +396,15 @@ export class RuntimeHostSessionObservationRegistry {
);
}
+ async loadTranscriptAfter(
+ request: DesktopTranscriptRangeRequest,
+ targetId?: number,
+ ): Promise<void> {
+ await this.#runTranscriptOperation(request.consumerId, (source) =>
+ source.loadTranscriptAfter(request, targetId),
+ );
+ }
+
acknowledgeTranscript(
consumerId: string,
generation: string,
diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts
index 08121b2954..d952a8f993 100644
--- a/apps/desktop/src/main/runtime-host-session-observer.ts
+++ b/apps/desktop/src/main/runtime-host-session-observer.ts
@@ -347,6 +347,18 @@ export class RuntimeHostSessionObserver {
});
}
+ async loadTranscriptAfter(
+ request: DesktopTranscriptRangeRequest,
+ targetId?: number,
+ ): Promise<void> {
+ await this.#runTranscriptRangeOperation(request, targetId, (replica) =>
+ replica.loadAfter(
+ request.anchorSequence,
+ requireTranscriptRangeBytes(request.maxBytes),
+ ),
+ );
+ }
+
async #runTranscriptRangeOperation(
request: DesktopTranscriptRangeRequest,
targetId: number | undefined,
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 1f7e01f312..fdcd3e4337 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -2507,7 +2507,7 @@ const makaBridge = {
if (closed) throw new Error('Desktop transcript open was cancelled');
identity ??= { generation: opened.generation, hostEpoch: opened.hostEpoch };
const range = (
- operation: 'sessions:transcript:load-before' | 'sessions:transcript:load-around',
+ operation: 'sessions:transcript:load-before' | 'sessions:transcript:load-after' | 'sessions:transcript:load-around',
anchorSequence: number | null,
maxBytes = DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES,
): Promise<void> => {
@@ -2528,6 +2528,8 @@ const makaBridge = {
sessionId,
loadBefore: (anchorSequence, maxBytes) =>
range('sessions:transcript:load-before', anchorSequence, maxBytes),
+ loadAfter: (anchorSequence, maxBytes) =>
+ range('sessions:transcript:load-after', anchorSequence, maxBytes),
loadAround: (sequence, maxBytes) =>
range('sessions:transcript:load-around', sequence, maxBytes),
async close() {
diff --git a/apps/desktop/src/preload/transcript-contract.ts b/apps/desktop/src/preload/transcript-contract.ts
index 41f00924c7..d01176dd51 100644
--- a/apps/desktop/src/preload/transcript-contract.ts
+++ b/apps/desktop/src/preload/transcript-contract.ts
@@ -67,6 +67,7 @@ export interface DesktopTranscriptRangeRequest {
export interface DesktopTranscriptHandle extends DesktopTranscriptOpenResult {
loadBefore(anchorSequence: number | null, maxBytes?: number): Promise<void>;
+ loadAfter(anchorSequence: number | null, maxBytes?: number): Promise<void>;
loadAround(sequence: number, maxBytes?: number): Promise<void>;
close(): Promise<void>;
}
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index 8c8f20fa33..7c0b98617c 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -2537,7 +2537,7 @@ function AppShellContent({
setAnchor: sessionUiController.setTranscriptReadingAnchor,
});
}
- async function loadTranscriptHistory(target: 'earlier' | 'latest', anchorTurnId?: string) {
+ async function loadTranscriptHistory(target: 'earlier' | 'later' | 'latest', anchorTurnId?: string) {
const controller = transcriptRangeRef.current;
const sessionId = activeId;
if (!controller || !sessionId || historyLoadPendingRef.current) return;
@@ -2546,6 +2546,8 @@ function AppShellContent({
try {
if (target === 'earlier') {
await controller.loadBefore(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, anchorTurnId);
+ } else if (target === 'later') {
+ await controller.loadAfter(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, anchorTurnId);
} else await controller.loadLatest();
} catch (error) {
if (
@@ -3060,6 +3062,8 @@ function AppShellContent({
onLoadEarlierHistory={(anchorTurnId) =>
loadTranscriptHistory('earlier', anchorTurnId)}
onReturnToLatestHistory={() => loadTranscriptHistory('latest')}
+ onLoadLaterHistory={(anchorTurnId) =>
+ loadTranscriptHistory('later', anchorTurnId)}
liveContentSeedRevision={liveContent.liveContentSeedRevision(activeEventSeed, activeId)}
messages={messages}
transientMessages={transientMessages}
diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx
index d9684f3263..74a7520d2e 100644
--- a/apps/desktop/src/renderer/chat-message-surface.tsx
+++ b/apps/desktop/src/renderer/chat-message-surface.tsx
@@ -93,6 +93,7 @@ interface ChatMessageSurfaceProps extends Omit<
hasNewerHistory: boolean;
historyLoadPending: boolean;
onLoadEarlierHistory: (anchorTurnId?: string) => Promise<void> | void;
+ onLoadLaterHistory: (anchorTurnId?: string) => Promise<void> | void;
onReturnToLatestHistory: () => Promise<void> | void;
}
@@ -130,6 +131,7 @@ export function ChatMessageSurface({
hasNewerHistory,
historyLoadPending,
onLoadEarlierHistory,
+ onLoadLaterHistory,
onReturnToLatestHistory,
...chatViewRest
}: ChatMessageSurfaceProps) {
@@ -249,6 +251,8 @@ export function ChatMessageSurface({
goalIndicator={goalProjection.goalIndicator}
hasOlderHistory={hasOlderHistory}
onLoadEarlierHistory={onLoadEarlierHistory}
+ hasNewerHistory={hasNewerHistory}
+ onLoadLaterHistory={onLoadLaterHistory}
returnToLatest={hasNewerHistory ? {
title: transcriptCopy.partialHistoryTitle,
label: transcriptCopy.returnLatest,
diff --git a/apps/desktop/src/renderer/desktop-transcript-range-store.ts b/apps/desktop/src/renderer/desktop-transcript-range-store.ts
index a3d11b0d56..93bf2fcb5a 100644
--- a/apps/desktop/src/renderer/desktop-transcript-range-store.ts
+++ b/apps/desktop/src/renderer/desktop-transcript-range-store.ts
@@ -32,6 +32,7 @@ export interface DesktopTranscriptRangeController {
ready(): Promise<void>;
waitForDurableMessage(messageId: string, timeoutMs: number): Promise<boolean>;
loadBefore(maxBytes?: number, anchorTurnId?: string): Promise<void>;
+ loadAfter(maxBytes?: number, anchorTurnId?: string): Promise<void>;
loadAround(sequence: number): Promise<void>;
loadLatest(): Promise<void>;
reload(): Promise<void>;
@@ -71,6 +72,16 @@ export function createDesktopTranscriptRangeController(
async loadAround(sequence) {
await (await current()).loadAround(sequence);
},
+ async loadAfter(maxBytes, anchorTurnId) {
+ const range = store.range();
+ if (!range.hasNewer) return;
+ await (await current()).loadAfter(
+ anchorTurnId === undefined
+ ? range.newestSequence
+ : store.sequenceForTurn(anchorTurnId, 'last') ?? range.newestSequence,
+ maxBytes,
+ );
+ },
async loadLatest() {
const range = store.range();
if (!range.hasNewer || range.durableThrough === null) return;
@@ -345,11 +356,9 @@ export class DesktopTranscriptRangeStore {
return this.#newestUserSequence;
}
- sequenceForTurn(turnId: string): number | null {
- for (const sequence of this.#durableOrder) {
- if (this.#durable.get(sequence)?.message.turnId === turnId) return sequence;
- }
- return null;
+ sequenceForTurn(turnId: string, edge: 'first' | 'last' = 'first'): number | null {
+ const order = edge === 'first' ? this.#durableOrder : [...this.#durableOrder].reverse();
+ return order.find((sequence) => this.#durable.get(sequence)?.message.turnId === turnId) ?? null;
}
waitForDurableMessage(messageId: string, timeoutMs: number): Promise<boolean> {
diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts
index c0d5649342..18258df582 100644
--- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts
+++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts
@@ -281,6 +281,22 @@ test('a reader who scrolls up while the answer grows is still the reader', () =>
});
});
+test('reports both directions even when the reader returns to the last written offset', () => {
+ withObservers(() => {
+ const root = fakeRoot();
+ const authority = createTranscriptScrollAuthority();
+ authority.attach(root as unknown as HTMLElement);
+ const directions: string[] = [];
+ authority.subscribeToReaderScroll((direction) => directions.push(direction));
+ root.emitScroll();
+ root.scrollTop = 900;
+ root.emitScroll();
+ root.scrollTop = 2_400;
+ root.emitScroll();
+ assert.deepEqual(directions, ['up', 'down']);
+ });
+});
+
test('a slow reader is a reader, however small each step is', () => {
withObservers((resize) => {
const root = fakeRoot();
diff --git a/packages/ui/src/__tests__/use-chat-scroll.test.tsx b/packages/ui/src/__tests__/use-chat-scroll.test.tsx
index d871415c96..b6c29522e7 100644
--- a/packages/ui/src/__tests__/use-chat-scroll.test.tsx
+++ b/packages/ui/src/__tests__/use-chat-scroll.test.tsx
@@ -35,6 +35,7 @@ const originalGlobals = {
document: globalThis.document,
Element: globalThis.Element,
HTMLElement: globalThis.HTMLElement,
+ getComputedStyle: globalThis.getComputedStyle,
MutationObserver: globalThis.MutationObserver,
Node: globalThis.Node,
ResizeObserver: globalThis.ResizeObserver,
@@ -55,6 +56,98 @@ afterEach(async () => {
});
});
+test('pages only toward reader input, including wheels at a bounded edge', async () => {
+ const { document, window } = parseHTML('<main id="mount"></main><section id="scroller"></section>');
+ const scroller = document.querySelector<HTMLElement>('#scroller')!;
+ let scrollTop = 0;
+ Object.defineProperties(scroller, {
+ clientHeight: { value: 600 },
+ scrollHeight: { value: 2400 },
+ scrollTop: {
+ get: () => scrollTop,
+ set: (value: number) => { scrollTop = Math.max(0, Math.min(value, 1800)); },
+ },
+ });
+ scroller.getBoundingClientRect = () => ({ top: 0, bottom: 600 } as DOMRect);
+ for (let index = 0; index < 4; index++) {
+ const turn = document.createElement('article');
+ turn.dataset.turnId = `turn-${index}`;
+ turn.getBoundingClientRect = () => ({
+ top: index * 600 - scrollTop, bottom: (index + 1) * 600 - scrollTop,
+ } as DOMRect);
+ scroller.append(turn);
+ }
+ class Observer { disconnect() {} observe() {} }
+ Object.assign(globalThis, {
+ document, window, HTMLElement: window.HTMLElement, Element: window.Element,
+ MutationObserver: Observer, ResizeObserver: Observer,
+ getComputedStyle: () => ({ overflowY: 'auto' }),
+ IS_REACT_ACT_ENVIRONMENT: true,
+ });
+ const calls: Array<{ direction: string; anchor?: string }> = [];
+ let authority!: TranscriptScrollAuthority;
+ function Harness({ more }: { more: boolean }) {
+ const scrollRef = useRef(scroller);
+ authority = useTranscriptScrollAuthority();
+ useChatScroll({
+ scrollRef, sessionId: 'guest', messages: [], behavior: 'auto',
+ hasOlderHistory: more, hasNewerHistory: more,
+ onLoadEarlierHistory: (anchor) => { calls.push({ direction: 'up', anchor }); },
+ onLoadLaterHistory: (anchor) => { calls.push({ direction: 'down', anchor }); },
+ });
+ return null;
+ }
+ mountedRoot = createRoot(document.querySelector('#mount')!);
+ const render = async (more: boolean) => act(() => mountedRoot!.render(
+ <TranscriptScrollAuthorityProvider><Harness more={more} /></TranscriptScrollAuthorityProvider>,
+ ));
+ await render(true);
+ assert.deepEqual(calls, [], 'mounting at a partial tail is not a request');
+ scroller.scrollTop = 900;
+ scroller.dispatchEvent(new window.Event('scroll'));
+ scroller.scrollTop = 1000;
+ scroller.dispatchEvent(new window.Event('scroll'));
+ assert.deepEqual(calls, [
+ { direction: 'up', anchor: 'turn-1' },
+ { direction: 'down', anchor: 'turn-2' },
+ ], 'overlapping edge bands must not reverse the requested direction');
+
+ scroller.scrollTop = 1800;
+ scroller.dispatchEvent(new window.Event('scroll'));
+ assert.equal(authority.getSnapshot().pinned, false, 'a partial tail must not follow a page fill');
+ const wheel = (target: HTMLElement, deltaY: number) => {
+ const event = new window.Event('wheel', { bubbles: true });
+ Object.defineProperty(event, 'deltaY', { value: deltaY });
+ target.dispatchEvent(event);
+ };
+ calls.length = 0;
+ wheel(scroller, 100);
+ assert.deepEqual(calls, [{ direction: 'down', anchor: 'turn-3' }]);
+
+ const nested = document.createElement('div');
+ Object.defineProperties(nested, {
+ clientHeight: { value: 100 }, scrollHeight: { value: 500 }, scrollTop: { value: 100 },
+ });
+ scroller.append(nested);
+ calls.length = 0;
+ wheel(nested, 100);
+ assert.deepEqual(calls, [], 'scrolling a nested tool output must not page the transcript');
+
+ scroller.scrollTop = 0;
+ scroller.dispatchEvent(new window.Event('scroll'));
+ calls.length = 0;
+ wheel(scroller, -100);
+ assert.deepEqual(calls, [{ direction: 'up', anchor: 'turn-0' }]);
+ assert.equal(scroller.scrollTop, 1, 'keep native anchoring enabled at the start');
+ await render(false);
+ calls.length = 0;
+ wheel(scroller, -100);
+ scroller.scrollTop = 1800;
+ scroller.dispatchEvent(new window.Event('scroll'));
+ wheel(scroller, 100);
+ assert.deepEqual(calls, [], 'do not request beyond authoritative history edges');
+});
+
test('a session switch restores a Turn anchor after async fill and preserves tail intent', async () => {
const { document, window } = parseHTML(
'<main id="mount"></main><section id="scroller"></section>',
diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx
index da53472516..d7c111ae99 100644
--- a/packages/ui/src/chat-view.tsx
+++ b/packages/ui/src/chat-view.tsx
@@ -299,6 +299,8 @@ export function ChatView(props: {
scrollBehavior: ScrollBehavior;
hasOlderHistory?: boolean;
onLoadEarlierHistory?(anchorTurnId?: string): Promise<void> | void;
+ hasNewerHistory?: boolean;
+ onLoadLaterHistory?(anchorTurnId?: string): Promise<void> | void;
returnToLatest?: {
title: string;
label: string;
@@ -588,6 +590,8 @@ export function ChatView(props: {
behavior: props.scrollBehavior,
hasOlderHistory: props.hasOlderHistory,
onLoadEarlierHistory: props.onLoadEarlierHistory,
+ hasNewerHistory: props.hasNewerHistory,
+ onLoadLaterHistory: props.onLoadLaterHistory,
});
const { quote: selectionQuote, clear: clearSelectionQuote } = useMessageSelectionQuote(
scrollRef,
diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx
index 51bbb09bfa..20a712167d 100644
--- a/packages/ui/src/transcript-scroll-authority.tsx
+++ b/packages/ui/src/transcript-scroll-authority.tsx
@@ -97,7 +97,7 @@ export interface TranscriptScrollAuthority {
* whoever needs "the reader is near the start" asks the position, and this
* says when asking means anything.
*/
- subscribeToReaderScroll(listener: () => void): () => void;
+ subscribeToReaderScroll(listener: (direction: 'up' | 'down') => void): () => void;
subscribe(listener: () => void): () => void;
getSnapshot(): TranscriptScrollSnapshot;
}
@@ -130,7 +130,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority {
let lastScrollTop = 0;
let snapshot: TranscriptScrollSnapshot = { pinned, awayFromTail };
const listeners = new Set<() => void>();
- const readerListeners = new Set<() => void>();
+ const readerListeners = new Set<(direction: 'up' | 'down') => void>();
const publish = (): void => {
// Net height cannot explain anchoring when content shrinks above the
@@ -177,6 +177,9 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority {
lastScrollTop = target.scrollTop;
return;
}
+ // Once the viewport leaves our write, returning to that same pixel is
+ // a new movement, not an echo (bounded windows often have equal heights).
+ lastWrittenTop = undefined;
// Content moves the offset too, and only ever by how much the end of
// the transcript moved. Native anchoring answers content landing above
// the reader by pushing the offset down by exactly what was inserted,
@@ -215,7 +218,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority {
}
pinned = distance <= PIN_THRESHOLD_PX;
publish();
- for (const listener of [...readerListeners]) listener();
+ for (const listener of [...readerListeners]) listener(unexplained < 0 ? 'up' : 'down');
};
lastScrollHeight = target.scrollHeight;
lastClientHeight = target.clientHeight;
diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts
index 5ab67edfb7..814d9bfdf4 100644
--- a/packages/ui/src/use-chat-scroll.ts
+++ b/packages/ui/src/use-chat-scroll.ts
@@ -52,12 +52,17 @@ export function useChatScroll(input: {
behavior: ScrollBehavior;
hasOlderHistory?: boolean;
onLoadEarlierHistory?(anchorTurnId?: string): Promise<void> | void;
+ hasNewerHistory?: boolean;
+ onLoadLaterHistory?(anchorTurnId?: string): Promise<void> | void;
}) {
const [highlightedTurnId, setHighlightedTurnId] = useState<string | null>(null);
const authority = useTranscriptScrollAuthority();
const loadEarlierRef = useRef(input.onLoadEarlierHistory);
loadEarlierRef.current = input.onLoadEarlierHistory;
const canLoadEarlier = input.onLoadEarlierHistory !== undefined;
+ const loadLaterRef = useRef(input.onLoadLaterHistory);
+ loadLaterRef.current = input.onLoadLaterHistory;
+ const canLoadLater = input.onLoadLaterHistory !== undefined;
const handledTarget = useRef<string | null>(null);
const anchorChangeRef = useRef(input.onReadingAnchorChange);
anchorChangeRef.current = input.onReadingAnchorChange;
@@ -136,55 +141,68 @@ export function useChatScroll(input: {
useEffect(() => {
const root = input.scrollRef.current;
- if (!root || !input.hasOlderHistory || !canLoadEarlier) return;
+ if (!root) return;
+ const canLoad = (direction: 'up' | 'down'): boolean => direction === 'up'
+ ? input.hasOlderHistory === true && canLoadEarlier
+ : input.hasNewerHistory === true && canLoadLater;
// Asking twice is the loader's problem, not this one's: it refuses a
// request while one is in flight, and asking for history the reader
// already has is idempotent anyway.
- const requestEarlier = (): void => {
- const anchorTurnId = firstVisibleTurnId(root);
+ const requestHistory = (direction: 'up' | 'down'): void => {
+ authority.releasePin();
+ const anchorTurnId = direction === 'up'
+ ? firstVisibleTurnId(root)
+ : lastVisibleTurnId(root);
// The browser anchors the reader against everything that lands above
// them, with one exception: it declines while the scroller sits at zero,
// which is exactly where a wheel asks for history. One pixel is the whole
// fix — measured in Chromium, an insert of 501px above the reader moves
// `scrollTop` by 501 at an offset of 1 and by 0 at an offset of 0.
- if (root.scrollTop < 1) root.scrollTop = 1;
- void Promise.resolve(loadEarlierRef.current?.(anchorTurnId)).catch(() => undefined);
+ if (direction === 'up' && root.scrollTop < 1) root.scrollTop = 1;
+ const load = direction === 'up' ? loadEarlierRef.current : loadLaterRef.current;
+ void Promise.resolve(load?.(anchorTurnId)).catch(() => undefined);
};
- /** Close enough to the start that the reader is about to reach it. */
- const nearStart = (): boolean =>
- root.scrollTop <= Math.max(640, root.clientHeight * 2);
+ /** Close enough to the requested edge that the reader is about to reach it. */
+ const nearEdge = (direction: 'up' | 'down'): boolean =>
+ (direction === 'up'
+ ? root.scrollTop
+ : root.scrollHeight - root.clientHeight - root.scrollTop)
+ <= Math.max(640, root.clientHeight * 2);
// Nearness alone does not mean the reader wants history — on a transcript
// shorter than about three viewports the tail is inside this band too, so
// following it would ask on every write, and content landing above would
// ask again on every anchoring correction until there was no history left.
// Which movements were the reader's is not re-derived here; the authority
// watches the scroller and says so.
- const stopWatchingReader = authority.subscribeToReaderScroll(() => {
- if (nearStart()) requestEarlier();
+ const stopWatchingReader = authority.subscribeToReaderScroll((direction) => {
+ if (canLoad(direction) && nearEdge(direction)) requestHistory(direction);
});
- // A wheel is the reader asking to go up, which at `scrollTop === 0` is the
- // only way they can: the scroller cannot move, so no scroll event follows
- // and the authority never sees the gesture. Releasing here is what tells it
- // — a reader who asked for what is above them is no longer following what
- // is below.
+ // At either bounded edge a wheel cannot move the scroller, so no scroll
+ // event follows. The gesture still asks for the adjacent page. Do not steal
+ // a wheel from a nested tool output that can consume it itself.
const onWheel = (event: WheelEvent): void => {
- if (event.deltaY >= 0 || !nearStart()) return;
+ if (event.deltaY === 0) return;
+ const direction = event.deltaY < 0 ? 'up' : 'down';
+ if (!canLoad(direction) || !nearEdge(direction)) return;
for (const target of event.composedPath()) {
if (target === root) break;
if (!(target instanceof HTMLElement)) continue;
const overflowY = getComputedStyle(target).overflowY;
if (!['auto', 'scroll', 'overlay'].includes(overflowY)) continue;
- if (target.scrollHeight > target.clientHeight && target.scrollTop > 0) return;
+ const remaining = direction === 'up'
+ ? target.scrollTop
+ : target.scrollHeight - target.clientHeight - target.scrollTop;
+ if (target.scrollHeight > target.clientHeight && remaining > 0) return;
}
- authority.releasePin();
- requestEarlier();
+ requestHistory(direction);
};
root.addEventListener('wheel', onWheel, { passive: true });
return () => {
stopWatchingReader();
root.removeEventListener('wheel', onWheel);
};
- }, [authority, input.hasOlderHistory, canLoadEarlier, input.scrollRef, input.sessionId]);
+ }, [authority, input.hasOlderHistory, input.hasNewerHistory, canLoadEarlier, canLoadLater,
+ input.scrollRef, input.sessionId]);
useEffect(() => {
const explicitTarget = input.target?.turnId
@@ -278,6 +296,13 @@ function firstVisibleTurnId(root: HTMLElement | null): string | undefined {
?.dataset.turnId;
}
+function lastVisibleTurnId(root: HTMLElement): string | undefined {
+ const rootBottom = root.getBoundingClientRect().bottom;
+ return [...root.querySelectorAll<HTMLElement>('[data-turn-id]')]
+ .findLast((turn) => turn.getBoundingClientRect().top < rootBottom)
+ ?.dataset.turnId;
+}
+
function restoreCommandKey(
sessionId: string | undefined,
turnId: string,
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org