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