Skip to content

feat(mobile,desktop): open a thread at the last-read reply - #5983

Open
mfethe1 wants to merge 4 commits into
block:mainfrom
mfethe1:feat/thread-resume-at-last-read
Open

feat(mobile,desktop): open a thread at the last-read reply#5983
mfethe1 wants to merge 4 commits into
block:mainfrom
mfethe1:feat/thread-resume-at-last-read

Conversation

@mfethe1

@mfethe1 mfethe1 commented Aug 15, 2026

Copy link
Copy Markdown

Summary

Threads settle on the newest reply. That is right when you are caught up and wrong when
you are not: returning to a thread you were part-way through drops you past everything you
had not read yet, with no sign of what you skipped.

This opens a thread at the oldest unread reply instead, on mobile and desktop, with an
unread divider marking the boundary.

The contract, as the code now implements it: resuming applies only to a thread the
reader has read something in before. A reader with no read marker anywhere has nowhere to
return to, so a first open still settles on the tail exactly as it does today. The scroll
is what the guard suppresses, not the marker — the divider still renders.

Desktop did not honour that second paragraph when this PR was first reviewed. It
resumed never-opened threads to their first unread reply while mobile pinned them to the
tail, so the two clients disagreed and the description above was true of only one of them.
@themiguelamador caught it. It is fixed in f11ca54, along with the reason the tests
could not have caught it themselves (below).

The four commits, on d8281b9

Commit What it does
bd1fd08 The resume itself, mobile + desktop, plus the mobile unread divider.
ba0dbdd Desktop: latch the resume only once replies have landed (fetchStatus === "idle"), plus the e2e spec that catches the failure.
f11ca54 Desktop: match mobile's never-read tail pin (hasReadHistory); landing coverage on both clients; relocate latch commentary out of ChannelScreen.tsx to stay under the 1000-line ratchet.
f4802a6 Desktop: keep committed thread replies rendered while their query refetches — see Thread panel loading state.

Mobile

The resume is expressed as the existing InitialThreadTailSettle target rather than as
a competing scroll, so it inherits that machinery's generation guard and hydration-race
handling instead of racing it.

Read markers are snapshotted during build, before the post-frame pass marks the thread
read — markers are monotonic, so reading them afterwards reports the whole thread as
already seen and leaves nothing to resume to. The snapshot folds the channel and thread
markers in via effectiveMessageReadAt rather than reading a bare msg: marker; without
that, a thread whose replies were only ever covered by the channel marker reads as entirely
unread. A captured null is a real answer ("never read"), so the map is built with
putIfAbsent and tested with containsKey, never collapsed with ??.

The divider itself is new on this client: unread_divider.dart is a "New" separator built
like DayDivider but tinted with the error colour, rendered on the matching reply.

Desktop

selectThreadResumeTargetId computes the target and returns null when hasReadHistory
is false, mirroring mobile's guard. useThreadOpenResumeTarget latches the result once per
thread open and yields to any deep link, which names its own target and wins.

Two things about that latch are load-bearing:

  • It is gated on the reply query being idle. A thread reopened from cache renders one reply
    off the channel timeline before the query returns, and latching there captures a null
    the hook then keeps as a deliberate decision, ignoring the real target when it arrives.
    fetchStatus is the signal rather than isPending, since a refetch over cached data
    reports isPending: false while still in flight.
  • hasReadHistory rides that same gate and arrives already frozen from
    useChannelUnreadState. It cannot be re-derived from getMessageReadAt, which is
    hierarchical: msg:<id> resolves to max(own marker, active channel marker), so once the
    channel is open every reply reports a read time and a snapshot taken here claims read
    history for a thread the reader has never opened. That was measured, not theorised — the
    first attempt at the fix did exactly this and still resumed to the top.

The resume rides the panel's existing external-scroll-target slot, so a deep link simply
outranks it (ChannelPane.tsx:466-471), and it never pulses the row the way a citation
does (scrollTargetHighlights, ChannelPane.tsx:838-846).

Thread panel loading state (f4802a6)

Reopening a thread refetches its replies over a list that is already committed, and two
places treated that refetch as "no content yet":

  • useAnchoredScroll was handed isLoading: threadRepliesPending || repliesRenderState === "pending", so it refused to position against a list it could already measure. It now
    keys on repliesRenderState alone (MessageThreadPanel.tsx:512).
  • the replies skeleton rendered whenever threadRepliesPending was true, replacing a
    committed list mid-read. It is now also gated on repliesRenderState !== "list"
    (MessageThreadPanel.tsx:649-651).

Both are inside MessageThreadPanel; no other useAnchoredScroll consumer changes.

Shared scroll hook (useAnchoredScroll.ts:438-447)

scrollToMessageImperative now cancels a pending pinToBottomOnMount frame before
centring. That mount pin is otherwise cancelled only by the channelId reset effect, and the
thread panel is keyed by a constant so it never remounts per thread — left armed, the queued
frame fires after the resume scroll and yanks the reader to the bottom for the rest of the
open. The mount path re-arms the pin itself when centring fails
(useAnchoredScroll.ts:648-657). This hook is shared with MessageTimeline,
InboxDetailPane, AgentSessionThreadPanel and AgentSessionTranscriptList; the change
only bites when a deliberate scroll-to-message is issued while a mount pin is still queued.

Known limits

Three, all pre-existing rather than introduced here, stated because a reviewer will find
them:

  1. Desktop uses the channel marker as its read-history proxy; mobile folds in msg: and
    thread: markers. A reader who read a reply from the Home inbox in a channel they never
    opened gets the tail pin instead of a resume. Conservative — it never drags the reader
    wrongly. Exposing getOwnTimestamp through AppShellContext would close it and is out
    of scope here.
  2. The clients place the divider differently in one case. Desktop gates it on index > 0
    (MessageThreadPanel.tsx:694, pre-existing), so a thread whose very first reply is
    unread shows no divider; mobile renders it on the matching reply unconditionally
    (thread_detail_page.dart:763). Every resume case has a read reply above the boundary,
    so both clients show a divider there — only the all-unread thread differs. Worth a
    decision, not folded in here.
  3. Cold start: if a channel renders before ReadStateManager.initialize() resolves, the
    frozen frontier latches null for that visit and a legitimate resume is skipped. It
    self-heals on re-entry, and the divider already misreports in that same window.

Related issue

Supersedes #5657 (closed), which carried this same resume work plus a mobile
jump-to-latest pill.

Searched for duplicates before opening: #5645 ("Polish mobile message threads and
composer", merged) landed the jump-to-latest pill independently, generalized into the shared
LatestMessageButton. That overlap is not duplicated here — this branch carries only
the resume behaviour, which #5645 does not implement. No other open or merged PR covers
thread resume at the last-read reply.

Filed separately while working on this: #6046 — a future-dated created_at pushes the
channel read marker into the future and silently marks every later message read. It reaches
the same comparison this feature keys off, but it predates this work and affects the unread
badge and divider identically, so it is not folded in here.

Testing

Repo CI has not run on this PR. Every workflow is action_required pending maintainer
approval; DCO is the only check that has executed. Everything below is a local run at
f4802a6 on a Windows host with the repo-pinned toolchain (Flutter 3.41.7 / Dart 3.11.5).

Desktop

  • pnpm exec tsc --noEmit — clean
  • pnpm exec biome check . — clean, 2091 files
  • file-size and px-text guards — pass. Three touched files sit close to the 1000-line
    ratchet: ChannelScreen.tsx 999, MessageThreadPanel.tsx 998, ChannelPane.tsx 966.
    ChannelScreen.tsx got there by relocating commentary into the hook that owns the latch.
    No limit raised, no override added — but review comments that add lines to those three
    files will need the same treatment.
  • pnpm test — 4963/4964. The one failure is
    src/shared/lib/useDocumentVisible.test.mjs:254, a timing-sensitive polling test. Neither
    it nor its subject is touched by this PR (git log <base>..HEAD -- …/useDocumentVisible.*
    is empty), so it runs unmodified upstream code.
  • playwright test --project=smoke across thread-resume (3), thread-unread (13),
    thread-focus-mode (2) — 18/18 passed

Mobile

  • flutter analyze — No issues found
  • dart format --output=none --set-exit-if-changed . — 412 files, 0 changed
  • flutter test1480/1480 passed

Before/after screenshots for both clients are in comments below — desktop captured from the
e2e spec, mobile rendered from the widget tree at phone size. Producing the desktop set is
what caught the original defect on that side: the resume never fired at all, and the unit
tests could not have seen it because they cover the target decision and never exercise
the scroll.

Coverage added by this PR

  • thread_unread_marker_test.dart — 10 unit tests over the marker predicate: strict
    newer-than at the marker, forced-unread override, own-pubkey skip and its
    case-insensitivity, a captured null meaning never-read, empty thread.
  • channel_detail_page_test.dart — 5 widget tests in two groups. Three open a nested
    (submessage) thread the way tapping a summary row does: divider on a never-read reply, no
    divider once fully read, and a guard that the channel marker alone can mark a nested reply
    read. Two more prove the landing on a 60-reply thread at a 390x844 surface — that the
    first unread reply and the divider are inside the visible rect while the newest and oldest
    are not, that Latest is present and returns to the newest reply, and that a never-read
    thread still opens at the tail.
  • threadResumeTarget.test.mjs — 10 tests, including deep-link precedence, nested thread
    heads, the never-read guard, and its counterpart: a thread that has been seen but whose
    replies are all unread does resume, to the first reply. Those two cases are why
    firstUnreadReplyId alone cannot decide this.
  • thread-resume.spec.ts — 3 desktop e2e tests: resume to the first unread, a fully read
    thread bottom-pinning, and a never-read thread opening at the newest reply. Registered in
    playwright.config.ts under the smoke project. Test 01 reopens the thread after a
    channel switch, which is the refetch path f4802a6 fixes.

On the tests being meaningful. The original coverage on both clients asserted the
divider existed and never that the thread landed on it — a bottom-pinned thread with the
divider off-screen above passed every one of them. That is what let the desktop divergence
survive. Each new test here was run with its guard removed to confirm it fails, and
restored to confirm it passes. The desktop e2e assert with toBeInViewport(); an earlier
revision of this PR used a bounding-box comparison against the scrollable replies container,
whose box is the full content, so every child measured as "inside" and the assertion passed
green while the panel was visibly wrong.

Mobile's own hasThreadReadHistory — the reference implementation desktop was fixed to
match — turned out to have no coverage at all: deleting the guard left all 134 tests in the
file green. It has a test now.

Not in scope

@mfethe1

mfethe1 commented Aug 15, 2026

Copy link
Copy Markdown
Author

Rebuilt on current main (78cbffe); supersedes #5657, which is closed. Verification at this commit is in the description — mobile flutter test 1430/1430 with analyze and dart format clean, desktop tsc/biome clean and threadResumeTarget 8/8. I'll follow up with before/after screenshots per the PR template.

mfethe1 pushed a commit to mfethe1/buzz that referenced this pull request Aug 15, 2026
@mfethe1

mfethe1 commented Aug 15, 2026

Copy link
Copy Markdown
Author

Screenshots — desktop thread resume

A thread with one already-read reply followed by 40 unread ones, reopened.

Before — the reader lands past every unread reply

Today the panel bottom-pins on open. The reader arrives at reply 40, with
replies 1–23 and the unread divider scrolled out of sight above them.

01-before-lands-past-every-unread-reply

After — the reader lands on the first unread reply

The same thread with this PR. The NEW divider and Unread reply 1 are in
view on open; desktop's existing Jump to latest pill is the way back to the
newest reply.

02-after-resume-lands-on-first-unread

A fully read thread is untouched

Nothing unread means no divider and no resume — today's bottom pin stands, so
the newest reply is what you see. This is the guard against the resume firing
when there is nothing to return to.

03-fully-read-thread-unchanged


These come from desktop/tests/e2e/thread-resume.spec.ts, added in this PR.
The "before" frame is the same spec run with the resume latch disabled, which
is how main behaves today.

Capturing these found a real bug, now fixed in 773a36f. The resume never
fired at all. Reopening a thread renders one reply off the channel timeline
before the reply query returns, and the latch — which keeps a captured null
as a deliberate decision — fired on that render and captured null, because
the unread marker had not been recomputed yet. The real target arrived with the
remaining replies and was ignored for the rest of the open. The gate is now
fetchStatus === "idle" rather than isPending, because a refetch over cached
data reports isPending: false while still in flight — precisely the render
that captured null.

The pre-existing unit tests cover selectThreadResumeTargetId's decision and
never exercise the scroll, so they stayed green throughout. Hence the e2e spec.

One note on that spec, since it is easy to get wrong: it asserts with
toBeInViewport, which honours clipping by scrolling ancestors. Measuring the
divider against the replies container's own box instead reports every child as
"inside", because that box is the full scrollable content — it passes green
while the panel is visibly bottom-pinned. The first version did exactly that,
and only comparing against the captured image caught it.

Screenshots are hosted on raw.githubusercontent.com pinned to an immutable
commit SHA, per the repo's convention. They sit on a branch of my fork rather
than block/buzz only because I do not have push access here.

🤖 Generated with Claude Code

@mfethe1

mfethe1 commented Aug 15, 2026

Copy link
Copy Markdown
Author

@klopez4212 — flagging this to you since #5645 touched the same thread surface.

Some context on how these two relate, because there was overlap and it's now
resolved:

This branch originally carried both a mobile jump-to-latest pill and a
last-read thread resume (as #5657). #5645 landed the pill independently and
generalized it into the shared LatestMessageButton, which is the better
shape — so I closed #5657, rebuilt on top of current main, and dropped the
pill entirely. What's left here is only the resume: opening a thread at your
first unread reply instead of past it, with a divider marking the boundary,
on mobile and desktop. Nothing in #5645 covers that.

Worth a look regardless of who lands it: capturing the before/after
screenshots surfaced a real bug in the desktop half, which had never actually
worked. The resume latch fired on a partially-loaded reply list and captured
null, which the hook keeps as a deliberate decision, so the real target was
ignored for the life of the thread open. The existing unit tests cover the
target decision and never exercise the scroll, so they stayed green the whole
time. Fixed in 773a36f, with an e2e spec that fails without it.

Mobile is flutter test 1430/1430 with analyze and dart format clean;
desktop is 4962 unit tests, 17 thread e2e tests, tsc and biome clean.

No reviewer was ever auto-assigned here — CODEOWNERS is
* @block/buzz-oss-team but it doesn't fire on fork PRs, which is why #5657
sat untouched for a week. If this should go to someone else, happy to be
redirected.

mfethe1 pushed a commit to mfethe1/buzz that referenced this pull request Aug 15, 2026
@mfethe1

mfethe1 commented Aug 15, 2026

Copy link
Copy Markdown
Author

Screenshots — mobile thread resume

Companion to the desktop screenshots above. A nested thread with 30 replies,
opened the way tapping a thread summary row does.

These are rendered from the widget tree at a 390×844 phone surface with the
app's real Inter font and Lucide icon font loaded — not device captures.
That makes them exact about layout, ordering and scroll position, which is
what this change affects, while the surrounding OS chrome is absent.

Resuming — the thread opens at the first unread reply

The channel was last read through Reply 15; replies 16–30 arrived after.
Opening the thread lands on Reply 16 with the New divider directly
above it, rather than at Reply 30. The existing ↓ Latest pill is the way
to the newest reply.

04-mobile-resume-first-unread

Nothing unread — today's behaviour is untouched

The read marker is past every reply, so there is no divider and no resume:
the thread settles on the newest reply exactly as it does on main.

05-mobile-fully-read

The never-opened guard renders identically — deliberately

A reader with no marker over any reply has nowhere to return to, so an
ordinary first open still settles on the tail. Captured, that frame is
byte-identical to the one above — both sha256 16cebd38c81fd717… — which
is the intended result rather than a duplicate screenshot: when there is
nothing to resume to, there is nothing to see. It is posted as a hash rather
than a third identical image.

Worth stating because it is the case most likely to feel wrong if it were
wrong: every reply in that thread is unread, and the divider is present in
the list. The guard only suppresses the scroll, so the reader is not dragged
to the top of a thread they have never seen.


Verified on this commit with the pinned toolchain (Flutter 3.41.7 / Dart
3.11.5): flutter analyze — No issues found; dart format — 408 files, 0
changed; flutter test1430/1430 passed. Reproduced on both macOS and
Windows hosts.

The states above come from the widget tests already in this PR
(nested thread resume at last read in channel_detail_page_test.dart),
driven through the same ThreadDetailPage entry point, so the screenshots and
the assertions exercise one code path rather than two.

🤖 Generated with Claude Code

@themiguelamador themiguelamador left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found and fixed two blocking issues plus one gate failure.

  1. Desktop resumed never-opened threads at their first unread reply, while mobile intentionally keeps first-open threads pinned to the tail. That contradicted the PR's cross-client contract. The fix carries a frozen hasReadHistory signal into the desktop selector and adds a direct never-read regression test.
  2. Mobile coverage asserted only that the unread divider existed; it did not prove the thread actually landed there. The new 60-reply widget test verifies the first unread reply is inside the list viewport and that Latest remains available.
  3. The PR grew ChannelScreen.tsx beyond the 1,000-line file-size ratchet. The fix moves its load-bearing resume commentary to the dedicated hook and leaves the screen at 994 lines.

Verified: desktop Biome, TypeScript, file-size ratchet, full desktop unit suite, E2E build, thread-resume Playwright spec (2/2), Flutter analyze, Dart format, and relevant Flutter tests (144/144). Rendered desktop behavior was also inspected with the mock bridge.

Fix: https://github.com/Complear/buzz/commit/816270b2a
Branch: https://github.com/Complear/buzz/tree/review/pr-5983-fix

mfethe1 pushed a commit to mfethe1/buzz that referenced this pull request Aug 16, 2026
…n tests

Addresses review feedback on block#5983.

Desktop resumed a never-opened thread at its first unread reply while mobile
kept it pinned to the tail, contradicting the cross-client contract this PR
describes. selectThreadResumeTargetId now takes hasReadHistory and returns
null when it is false, so a thread the reader has never been in keeps today's
tail pin. The signal comes from useChannelUnreadState's openFrontierSeconds —
the per-visit channel frontier captured during render before the mark-read
effect — and rides the existing settled-query latch rather than one of its
own, so a partially loaded reply list cannot latch it false.

It is deliberately not derived from getMessageReadAt: desktop markers are
hierarchical, so msg:<id> resolves to max(own, active channel marker) and
every reply reports read history the moment the channel is open. An earlier
attempt did exactly that and still resumed a never-opened thread to the top.

Coverage was the second finding, and the more important one. Both clients
asserted the unread divider existed without ever proving the thread landed on
it — a bottom-pinned thread with the divider off-screen above passed every
existing test. Desktop adds 03-never-read-thread-opens-at-newest, asserting
with toBeInViewport rather than a bounding box against the scrollable replies
container, whose box is the full content and reports every child as inside.
Mobile adds a 60-reply widget test on a 390x844 surface checking the first
unread reply and the divider are within the visible rect, the newest and
oldest are not, and Latest still returns to the newest reply.

Mobile's own hasThreadReadHistory guard turned out to have no coverage at all:
deleting it left all 134 tests in the file green. Since mobile is the
reference implementation of the contract desktop was being fixed to match, it
now has a never-read test of its own.

ChannelScreen.tsx exceeded the 1000-line ratchet at 1013. The load-bearing
latch and fetchStatus commentary moved to useThreadOpenResumeTarget.ts, where
the latch lives, leaving the screen at 999. No limit raised, no override added.

Known deviation, documented in code: desktop uses the channel marker as its
read-history proxy while mobile folds in msg: and thread: markers, so a reader
who read a reply from the Home inbox in a channel they never opened gets the
tail pin instead of a resume. Conservative — it never drags the reader
wrongly. Exposing getOwnTimestamp through AppShellContext would close it and
is out of scope here.

Verified: tsc clean, Biome clean, file-size and px-text guards pass, desktop
unit 4964/4964, thread-resume e2e 3/3, flutter analyze clean, dart format 408
files unchanged, mobile 1432/1432. Each new test was confirmed to fail with
its guard removed and pass with it restored.

Signed-off-by: Michael Feth <michael@jira-flow.com>
@mfethe1

mfethe1 commented Aug 16, 2026

Copy link
Copy Markdown
Author

@themiguelamador — thanks, this was a good catch. All three are fixed. Taking them in turn,
with the caveats I found along the way.

Your fix branch was unreachable. Complear/buzz returns 404 for both the repo and commit
816270b2a from an authenticated session, so I could not read your diff and implemented these
independently. If it is private and you would rather land yours, say so and I will close mine in
favour of it — I have no attachment to the implementation, only to the behaviour.


1. Desktop resumed never-opened threads. Confirmed and fixed.

selectThreadResumeTargetId now takes hasReadHistory and returns null when it is false, so a
thread the reader has never been in keeps today's tail pin. The signal comes from
useChannelUnreadState's openFrontierSeconds — the frozen per-visit channel frontier captured
during render before the mark-read effect — and rides the existing settled-query latch rather than
one of its own, so a half-loaded reply list cannot latch it false.

Worth recording why it is not derived from getMessageReadAt: the first implementation did
exactly that, and it measurably still resumed a never-opened thread to the top. Desktop's markers
are hierarchical, so msg:<id> resolves to max(own, active channel marker) and every reply
reports read history the moment the channel is open.

2. Mobile proved the divider existed, not that the thread landed on it. Confirmed and fixed.

New 60-reply widget test on a 390x844 surface asserting the first unread reply and the divider
are inside the visible rect, the newest reply is not, the oldest is not, and Latest is present,
hit-testable, and actually returns to the newest reply.

Both new tests are proven to discriminate rather than merely pass, which mattered here — an earlier
revision of this PR shipped a green assertion that compared bounding boxes against the scrollable
replies container, whose box is the full content, so every child measured as "inside". It passed
while the panel was visibly bottom-pinned. The desktop tests use toBeInViewport() and the mobile
helper is checked in both polarities.

3. File-size ratchet. ChannelScreen.tsx is 999 lines. The latch/fetchStatus commentary moved
to useThreadOpenResumeTarget.ts, where the latch actually lives. No limit raised, no override
added.


One thing you did not flag, which your review led me to. Mobile's hasThreadReadHistory — the
reference implementation of the contract item 1 is about — had no test coverage at all. Deleting the
guard left all 134 tests in the file green. So while desktop was being fixed to match mobile, mobile's
own guard could have been deleted silently. There is now a test for it.

Three caveats, all pre-existing, none introduced here:

  • Desktop's hasReadHistory uses the channel marker as its proxy; mobile folds in msg:/thread:
    markers too. One deliberate miss: a reader who read a reply from the Home inbox in a channel they
    never opened gets the tail pin instead of a resume. Conservative — it never drags the reader
    wrongly — and commented in code. The real fix exposes getOwnTimestamp through AppShellContext,
    which is outside this change.
  • The divider is gated on index > 0, so a thread whose very first reply is unread shows no divider.
    Your phrase "the divider still marks the replies as new" holds whenever a read reply sits above the
    boundary, which is every resume case, but not an all-unread thread.
  • Cold start: if a channel renders before ReadStateManager.initialize() resolves, the frontier
    latches null for that visit and a legitimate resume is skipped. It self-heals on re-entry, and the
    divider already misreports in that same window.

All of the above is in 0dcbd75.

Verification (Windows host, pinned toolchain, Flutter 3.41.7 / Dart 3.11.5):
tsc clean, Biome clean, file-size and px-text guards pass, desktop unit 4964/4964, thread-resume
e2e 3/3 across three separate runs, adjacent unread/thread e2e 23/23, flutter analyze clean,
dart format 408 files 0 changed, mobile 1432/1432.

🤖 Generated with Claude Code

Michael Feth added 4 commits August 16, 2026 10:28
Threads settle on the newest reply, which is right when you are caught up
and wrong when you are not: returning to a thread you were part-way
through drops you past everything you had not read yet.

Resume at the oldest unread reply instead, expressed as the existing
initial-settle target rather than a competing scroll, so it inherits the
generation guard and the hydration race handling already there. An
unread divider marks the boundary.

Resuming only applies to a thread the reader has actually seen before. A
reader with no marker over any reply has nowhere to return to, so an
ordinary first open still settles on the tail; the divider still marks
the replies as new.

The mobile jump-to-latest pill this work originally carried landed
separately in block#5645, so only the resume behaviour remains here.

Signed-off-by: Michael Feth <michael@jira-flow.com>
The resume never fired. Reopening a thread renders one reply off the
channel timeline before the reply query returns, and the latch — which
keeps a captured null as a deliberate decision — fired on that render and
captured null, because the unread marker had not been recomputed yet. The
real target arrived with the remaining replies and was then ignored for
the life of the open, leaving the reader bottom-pinned at the newest
reply: exactly what the resume exists to replace.

Gate the latch on `fetchStatus === "idle"`. `isPending` is the wrong
signal — a refetch over cached data reports `isPending: false` while
still in flight, which is precisely the render that captured null. An
idle fetch status means nothing is on the wire, which is true once the
replies have landed and also true for a forum thread whose query is
disabled and whose replies resolve from the channel timeline.

Add an e2e spec that catches this. The existing unit tests cover
`selectThreadResumeTargetId`'s decision and never exercise the scroll, so
they stayed green throughout. The spec asserts with `toBeInViewport`,
which honours clipping by scrolling ancestors — measuring against the
replies container's own box instead reports every child as inside, since
that box is the full scrollable content, and passes while the panel is
visibly bottom-pinned.

Signed-off-by: Michael Feth <michael@jira-flow.com>
…n tests

Addresses review feedback on block#5983.

Desktop resumed a never-opened thread at its first unread reply while mobile
kept it pinned to the tail, contradicting the cross-client contract this PR
describes. selectThreadResumeTargetId now takes hasReadHistory and returns
null when it is false, so a thread the reader has never been in keeps today's
tail pin. The signal comes from useChannelUnreadState's openFrontierSeconds —
the per-visit channel frontier captured during render before the mark-read
effect — and rides the existing settled-query latch rather than one of its
own, so a partially loaded reply list cannot latch it false.

It is deliberately not derived from getMessageReadAt: desktop markers are
hierarchical, so msg:<id> resolves to max(own, active channel marker) and
every reply reports read history the moment the channel is open. An earlier
attempt did exactly that and still resumed a never-opened thread to the top.

Coverage was the second finding, and the more important one. Both clients
asserted the unread divider existed without ever proving the thread landed on
it — a bottom-pinned thread with the divider off-screen above passed every
existing test. Desktop adds 03-never-read-thread-opens-at-newest, asserting
with toBeInViewport rather than a bounding box against the scrollable replies
container, whose box is the full content and reports every child as inside.
Mobile adds a 60-reply widget test on a 390x844 surface checking the first
unread reply and the divider are within the visible rect, the newest and
oldest are not, and Latest still returns to the newest reply.

Mobile's own hasThreadReadHistory guard turned out to have no coverage at all:
deleting it left all 134 tests in the file green. Since mobile is the
reference implementation of the contract desktop was being fixed to match, it
now has a never-read test of its own.

ChannelScreen.tsx exceeded the 1000-line ratchet at 1013. The load-bearing
latch and fetchStatus commentary moved to useThreadOpenResumeTarget.ts, where
the latch lives, leaving the screen at 999. No limit raised, no override added.

Known deviation, documented in code: desktop uses the channel marker as its
read-history proxy while mobile folds in msg: and thread: markers, so a reader
who read a reply from the Home inbox in a channel they never opened gets the
tail pin instead of a resume. Conservative — it never drags the reader
wrongly. Exposing getOwnTimestamp through AppShellContext would close it and
is out of scope here.

Verified: tsc clean, Biome clean, file-size and px-text guards pass, desktop
unit 4964/4964, thread-resume e2e 3/3, flutter analyze clean, dart format 408
files unchanged, mobile 1432/1432. Each new test was confirmed to fail with
its guard removed and pass with it restored.

Signed-off-by: Michael Feth <michael@jira-flow.com>
Co-authored-by: Michael Feth <michael@jira-flow.com>
Signed-off-by: Michael Feth <michael@jira-flow.com>
@mfethe1

mfethe1 commented Aug 18, 2026

Copy link
Copy Markdown
Author

@themiguelamador — separate from the review itself, a process ask, because I think it is
blocking you as much as me.

CI has never run on this PR. DCO Check is the only check that executes; the actual workflow
runs sit at action_required waiting for maintainer approval:

They do not surface in statusCheckRollup, so the PR reads as green-ish from the checks list
while nothing has actually been tested. I only noticed because I went looking in the
actions/runs API.

This is not specific to this PR — 14 of my 16 open PRs are in the same state, none of them
having ever run CI. If it is a per-contributor approval setting rather than a per-PR one, one
pass would clear all of them at once.

Would you be able to approve the runs here, or point me at whoever owns that? I would rather you
were reviewing against a real CI result than against my say-so — I have run the suites locally
and reported the numbers, but that is not the same thing, and you should not have to take my
word for it.

Happy to keep waiting on the code review itself; this is only about getting the pipeline to run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants