fix(web): recover restored transcript pagination (#3252)

This commit is contained in:
Carlos Florêncio
2026-09-01 15:20:56 +00:00
committed by GitHub
parent 3f1962662b
commit 57f90475cf
14 changed files with 787 additions and 12 deletions
@@ -0,0 +1,173 @@
---
created: 2026-09-01
status: implemented
requirements:
- REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001
system_design:
- ../../specs/ui/system-design/task-prompt-transcript-visibility.md
legacy_specs: []
---
# Implementation plan: Restored transcript pagination recovery
## Overview
Repair older-history pagination when a persistent Dockview chat is initialized
while inactive and later becomes visible at its restored top edge. The fix will
turn the existing panel-visibility signal and hard-top input into guarded
current-geometry checks, without changing backend cursors, eager-opening
history, or explicit failure recovery.
## Scope
### In scope
- Pass real chat-host visibility into the native transcript lifecycle.
- Re-evaluate the oldest-page sentinel once after a hidden Dockview transcript
becomes visible and its saved scroll offset is restored.
- Accept upward input at `scrollTop === 0` as fresh pagination intent even when
the browser emits no `scroll` event.
- Reuse current sentinel geometry, serialization, session-epoch, and recovery
guards for both paths.
- Add focused unit regressions and a desktop restored-secondary-tab browser
regression.
- Retain mobile pagination, anchor, and recovery behavior through the shared
native transcript path.
### Out of scope
- Backend message queries, pagination cursors, ordering, persistence, or page
sizes.
- Eagerly loading older history when a task opens away from the oldest loaded
edge.
- Retrying rejected or zero-progress requests on panel activation.
- Changes to Prompt History pagination, Dockview layout persistence, or chat
scroll ownership.
- New user-facing copy or a new mobile composition.
## Confirmed root cause
The affected secondary session is restored as an inactive Dockview panel. Its
message request succeeds with 100 newest rows, `has_more: true`, and a valid
oldest cursor. The backend also returns older rows when that cursor is supplied.
The inactive transcript mounts behind a zero-size panel. When it becomes
visible already at `scrollTop = 0`, its `IntersectionObserver` does not deliver
a fresh eligible entry. The fallback in
`useRetryPaginationOnUpwardScroll` requires
`nextScrollTop < previousScrollTop`; at the hard top that comparison is
`0 < 0`, so another upward gesture cannot invoke the sentinel retry. The
background newest-window fetch then repeats the same 100 rows and cursor while
the older-page path is never called.
## Technical approach
### Visibility-aware sentinel recovery
- Extend the message-list contract with the existing `TaskChatPanel`
visibility state and carry it into native scroll management.
- On a hidden-to-visible transition, wait for the panel's existing saved-offset
restoration to commit before issuing one lifecycle recheck.
- Make the shared sentinel recheck current root/sentinel geometry instead of
trusting an intersection sample captured while hidden.
- Fire only when visible pagination remains, initial/refetch loading is clear,
the sentinel is inside preload, no request is active, the session is current,
and transcript recovery is not active.
- Keep the first visible mount bounded: a lifecycle recheck does not paginate
unless the restored viewport is already at the oldest loaded edge.
### Hard-top user intent
- Observe directional wheel, keyboard, touch, and scroll movement at the
transcript scroll owner.
- Treat an upward action at the hard top as fresh intent even when scroll
position does not change.
- Route the action through the same current-geometry sentinel recheck and
programmatic-scroll guard.
- Coalesce one physical gesture so it cannot start duplicate cursor requests.
### Diagnostics and failure behavior
- Retain the existing bounded pagination diagnostics so the cursor-based older
request remains distinguishable from newest-window running backfill.
- Preserve explicit retry for rejected or zero-progress requests; activation
must not create an automatic retry loop.
- Preserve anchor restoration and continuation stop reasons after a positive
older page commits.
## Tests
| Acceptance criteria | Evidence |
| --- | --- |
| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.5` to `.9` | Existing sentinel and native transcript suites plus new current-geometry and hard-top regressions |
| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.10` | Existing no-eager-open browser assertion remains green |
| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.11` | Existing mobile pagination suite remains green after the shared-path change |
| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.12` | Existing inactive-session reconciliation coverage remains green |
| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.14` | New hidden-to-visible unit regression and restored secondary-session desktop browser flow |
The red-first unit case mounts a transcript hidden with `hasMore: true`, makes
it visible at restored `scrollTop = 0`, and expects exactly one older-page
request after the restore frame. A second case dispatches upward input at hard
top without changing `scrollTop` and expects the guarded retry path to run.
## E2E tests
- Desktop Chromium: extend
`apps/web/e2e/tests/chat/message-pagination.spec.ts` with two seeded sessions
in one restored Dockview group. Keep the target session inactive during
hydration, suppress the fresh observer entry that can be missed during
restoration, activate its tab, supply upward input at the hard top, and
assert that the next request includes the previous oldest cursor and reveals
older content.
- Keep the watcher capable of distinguishing newest-window refreshes from
`before=<cursor>` older-page requests. Assert that activation starts one
cursor advance, not repeated newest-window backfill.
- Mobile Chrome: rerun
`apps/web/e2e/tests/chat/mobile-message-pagination.spec.ts` as regression
evidence for shared sentinel, anchor, and recovery behavior. No new mobile
layout is required because the defect depends on inactive Dockview tabs.
## Mobile design contract
- Desktop outcome: activating a restored secondary chat at its saved top edge
resumes older-history pagination without scroll-away and scroll-back.
- Mobile entry point: the existing full-height Chat tab.
- Nearest exemplar: `apps/web/components/task/task-layout.tsx` and the existing
mobile message-pagination specification.
- Hierarchy and surface: the transcript remains the only vertical scroll
owner; no sheet, drawer, or compact desktop tab is added.
- Shared logic: visibility and hard-top intent use the native transcript's
sentinel guards. Mobile supplies mounted visibility rather than Dockview
activation.
- Mobile proof: the existing mobile long-history, prompt boundary, recovery,
and prepend-anchor cases remain green.
## Work orders
- [x] [Task 01: Recover pagination after transcript activation](task-01-recover-pagination-after-activation.md) (`done`)
## Verification results
- Focused Vitest: 87 tests passed across the shared sentinel, native transcript,
and lazy-message pagination suites.
- TypeScript typecheck, targeted ESLint, targeted Prettier, and the complete spec
linter passed.
- Desktop Chromium: all eight message-pagination cases passed, including the
restored inactive-session hard-top regression.
- Mobile Chrome: all eight mobile message-pagination cases passed, including
the hard-top touch recovery regression.
- A fresh desktop PR screenshot was captured after the restored secondary chat
loaded its older marker.
## Risks
- Running the lifecycle check before `SessionPanelContent` restores scroll can
read zero-size or transient geometry and either miss or spuriously load.
- Treating every resize or active render as intent can duplicate requests or
create an automatic loop.
- Direct changes to the shared sentinel can regress Prompt History's
bottom-pinned append behavior.
- Wheel, keyboard, scrollbar, and touch signals need coalescing while retaining
true hard-top intent.
- The browser fixture must reproduce restored inactive tabs, not only switch
between two already-active panels.
@@ -0,0 +1,147 @@
---
id: "01-recover-pagination-after-activation"
title: "Recover pagination after transcript activation"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001
acceptance_criteria:
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.5
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.7
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.8
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.9
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.10
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.11
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.12
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.14
system_design:
- ../../specs/ui/system-design/task-prompt-transcript-visibility.md
---
# Task 01: Recover pagination after transcript activation
## Summary
Make a restored, previously hidden transcript re-evaluate older-page
eligibility after activation, and make an upward action at hard top recover
even when the browser emits no scroll-position change.
## In scope
- Add failing native transcript and sentinel regressions before production
changes.
- Carry real host visibility from `TaskChatPanel` through `MessageList` to
native scroll management.
- Add a one-shot hidden-to-visible current-geometry recheck after saved scroll
restoration.
- Add directional hard-top input recovery without treating programmatic scroll
restoration as user intent.
- Preserve request serialization, session epochs, continuation, failure
recovery, and prepend anchoring.
- Add a restored secondary-session desktop browser regression and run the
existing mobile pagination suite.
## Out of scope
- Backend cursor, message-list, WebSocket, or persistence changes.
- Prompt History behavior changes.
- Automatic retries after rejected or zero-progress requests.
- New UI controls, localized copy, or mobile navigation.
## Acceptance
- A transcript hydrated while hidden issues exactly one cursor-based older-page
request after it becomes visible at its restored oldest loaded edge.
- Upward input at `scrollTop = 0` can start the same guarded request without a
scroll event, while repeated lifecycle renders and one physical gesture do
not duplicate it.
- Desktop restored-tab E2E reaches older content, and existing desktop/mobile
pagination, prompt-`#1`, bounded-opening, recovery, and anchor cases remain
green.
## Verification
Run from `apps/web`:
```bash
pnpm exec vitest run \
hooks/use-lazy-load-sentinel.test.ts \
components/task/chat/message-list-native.test.tsx \
hooks/use-lazy-load-messages.test.ts
pnpm run typecheck
pnpm exec eslint \
hooks/use-lazy-load-sentinel.ts \
hooks/use-lazy-load-sentinel.test.ts \
components/task/task-chat-panel.tsx \
components/task/chat/message-list-shared.tsx \
components/task/chat/message-list-native.tsx \
components/task/chat/message-list-native-scroll.ts \
components/task/chat/message-list-native.test.tsx \
e2e/tests/chat/message-pagination-helpers.ts \
e2e/tests/chat/message-pagination.spec.ts
pnpm exec prettier --check \
hooks/use-lazy-load-sentinel.ts \
hooks/use-lazy-load-sentinel.test.ts \
components/task/task-chat-panel.tsx \
components/task/chat/message-list-shared.tsx \
components/task/chat/message-list-native.tsx \
components/task/chat/message-list-native-scroll.ts \
components/task/chat/message-list-native.test.tsx \
e2e/tests/chat/message-pagination-helpers.ts \
e2e/tests/chat/message-pagination.spec.ts
pnpm e2e:run --host --project chromium -- \
tests/chat/message-pagination.spec.ts --grep "restored inactive session"
pnpm e2e:run --host --no-build --project mobile-chrome -- \
tests/chat/mobile-message-pagination.spec.ts
```
## Files likely touched
- `apps/web/components/task/task-chat-panel.tsx`
- `apps/web/components/task/chat/message-list-shared.tsx`
- `apps/web/components/task/chat/message-list-native.tsx`
- `apps/web/components/task/chat/message-list-native-scroll.ts`
- `apps/web/components/task/chat/message-list-native.test.tsx`
- `apps/web/hooks/use-lazy-load-sentinel.ts`
- `apps/web/hooks/use-lazy-load-sentinel.test.ts`
- `apps/web/e2e/tests/chat/message-pagination-helpers.ts`
- `apps/web/e2e/tests/chat/message-pagination.spec.ts`
## Dependencies
None. The existing `usePanelActive` signal, `SessionPanelContent` scroll restore,
message cursor API, and pagination fixtures are inputs to this work.
## Risks
- A recheck scheduled too early can run before restored geometry stabilizes.
- Relaxing the sentinel's disarmed state can bypass explicit failure recovery.
- Shared hook changes can affect Prompt History's bottom sentinel.
- Input listeners can double-fire when one gesture also creates a scroll event.
## Parallelism
`sequential`
## Inputs
- `REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001` acceptance criteria `.5`,
`.7` to `.14`.
- Upward pagination, failure and recovery, observability, and responsive
sections of the paired system design.
- Existing panel visibility, scroll restoration, sentinel, desktop pagination,
and mobile pagination implementations and tests.
## Results
- Added a guarded current-geometry recheck to the shared lazy-load sentinel.
- Carried Dockview visibility into native transcript scroll management and
rechecked after hidden-to-visible restoration settles.
- Added hard-top wheel, keyboard, and touch intent recovery while preserving
programmatic-scroll and explicit-recovery guards.
- Added red-first unit regressions and a restored secondary-session Chromium
regression that proves the older request includes a cursor.
- Verified 87 focused unit tests, eight desktop pagination cases, eight mobile
pagination cases, typecheck, targeted lint/formatting, and spec lint.
@@ -50,6 +50,7 @@ start.
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.11:** Desktop and mobile transcripts shall provide the same history boundary, continuous upward loading, recovery, and scroll-position behavior.
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.12:** When a previously opened session is revisited after more than one message page was persisted while it was inactive, the transcript shall reconcile to a contiguous newest window and upward pagination shall reach every persisted user prompt without gaps or duplicate rows.
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.13:** When an upward lazy-load operation sizes its batch, it shall target 20 newly loaded text parts. Tool calls and other activity rows shall not advance that target; the operation can stop earlier when prompt `#1` is loaded, persisted history is exhausted, a request makes no progress, or its bounded safety limit is reached.
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.14:** When a previously hidden transcript becomes visible with its restored viewport at the oldest loaded edge and older history remains, the transcript shall resume older-history loading without requiring the user to move away from and return to that edge.
## Exclusions
@@ -44,9 +44,16 @@ and `prompt_index` field.
pagination available to explicit recovery consumers.
- The native transcript consumes `useLazyLoadMessages`. Prompt History consumes
its own user-message request and pagination state.
- The native transcript owns upward intent, committed sentinel geometry,
scroll restoration, and retry-control visibility.
- `useLazyLoadSentinel` owns observer lifecycle and request serialization.
- `TaskChatPanel` supplies the native transcript with the host's real visibility
state. Dockview hosts use panel activation; mobile and other direct hosts use
their existing mounted visibility.
- The native transcript owns upward intent, committed sentinel geometry, scroll
restoration, host-visibility recovery, and retry-control visibility.
- `SessionPanelContent` restores the saved scroll offset after a hidden panel
receives non-zero geometry. The native transcript waits for that restore to
commit before it asks the sentinel to re-evaluate the viewport.
- `useLazyLoadSentinel` owns observer lifecycle, current-geometry eligibility,
request serialization, and guarded retries from lifecycle or user intent.
- Transcript navigation first uses loaded messages. When a selected prompt is
absent, it requests an around window and merges the result before scrolling.
- `requestOlderMessages` keeps transcript request coordination and raw cursor
@@ -136,6 +143,21 @@ The transcript starts a load cycle when the user reaches the oldest loaded
edge. Before each request it captures a stable rendered-row anchor. After the
page commits, scroll restoration keeps that anchor at the same visual position.
A hidden Dockview panel can keep its transcript mounted while its scroll root
has zero geometry. When that panel becomes visible, the native transcript
waits until `SessionPanelContent` has restored the saved scroll offset, then
performs one current-geometry eligibility check. If the restored sentinel is
inside the preload region and pagination is otherwise eligible, that check
starts or resumes the normal load cycle. It does not use an intersection entry
captured while the panel was hidden.
An upward wheel, touch, keyboard, or scrollbar action at the hard top can
produce no `scroll` event because `scrollTop` cannot decrease below zero. The
native transcript therefore treats directional input at that boundary as
fresh pagination intent and checks current sentinel geometry. Input and panel
activation use the same request guards and cannot bypass loading, visible-start,
failure-recovery, or session-epoch stops.
Continuation uses committed sentinel geometry, not the identity of the oldest
standalone row:
@@ -161,6 +183,12 @@ automatic cycle is disarmed and the explicit older-page retry control is
visible. A successful retry clears recovery state and resumes the geometry
rule. Session changes, prompt `#1`, and raw exhaustion also clear it.
Panel activation does not retry a request that already entered recovery state.
The lifecycle check repairs a missed observer transition only; request failure
and no-progress outcomes still require the explicit retry control. Repeated
active renders and resize notifications do not create additional pagination
intent.
Routine successful pagination does not render the retry control. The control
keeps the existing localized label and has a coarse-pointer hit area of at
least 44 pixels without increasing fine-pointer desktop density.
@@ -182,6 +210,12 @@ prompt returns the phone to Chat and uses the around-window path when the row is
not loaded. The recovery button uses the same placement on both surfaces with a
coarse-pointer touch target.
Dockview activation recovery is desktop-specific because inactive desktop tabs
remain mounted with zero geometry. The eligibility and hard-top gesture rules
remain in the shared native transcript path. Mobile has no hidden Dockview tab,
so it keeps mounted visibility and must retain the same upward pagination,
anchor, and failure-recovery behavior.
## Around-window navigation
The chat panel sends `GET /api/v1/task-sessions/:id/messages?around=<message-id>&sort=desc&limit=N` after it confirms that the target belongs to the active session. The backend returns the target and newer rows in newest-first order. The chat panel merges the response into `messages.bySession`, waits for the target row to render, and then scrolls to it. The target token is session-scoped and is cleared only after a successful scroll or a confirmed missing target. A stale response cannot consume a newer selection.
@@ -199,10 +233,10 @@ anchor behavior as desktop.
- Session-state tests cover initialization through boot hydration, newest
fetch, purge, and refetch without inferring exhaustion from defaults.
- Sentinel tests cover committed in-region continuation, out-of-region stop,
stale observer entries, no-progress recovery, successful retry, and session
reset.
- Native transcript tests cover recovery-control visibility and prepend anchor
preservation.
stale observer entries, one-shot lifecycle rechecks, hard-top intent,
no-progress recovery, successful retry, and session reset.
- Native transcript tests cover hidden-to-visible recovery, recovery-control
visibility, and prepend anchor preservation.
- Lazy-load hook tests cover mixed `message`, `content`, legacy, and tool rows,
the 20-text-part target, every early stop, and unchanged raw/single-page
consumers.
@@ -215,3 +249,8 @@ anchor behavior as desktop.
must still cross that page and reveal the targeted older text batch.
- Existing prompt-`#1`, hidden pre-prompt, and inactive-session reconciliation
scenarios remain covered.
- A desktop browser test restores two session tabs with the target tab inactive,
suppresses the fresh observer entry that can be missed during restoration,
activates the target, supplies upward input at the hard top, and proves that
the next request carries an older-page cursor instead of repeating only the
newest 100-row window.