Skip to content

feat(mobile): add channel scroll navigation - #4239

Merged
brow merged 8 commits into
mainfrom
tho/mobile-channel-scroll-navigation
Aug 4, 2026
Merged

feat(mobile): add channel scroll navigation#4239
brow merged 8 commits into
mainfrom
tho/mobile-channel-scroll-navigation

Conversation

@tellaho

@tellaho tellaho commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Category: improvement
User Impact: Mobile readers can jump directly to their oldest unread message and return to the latest message with compact directional controls.
Problem: Opening an active channel at its newest message makes it easy to miss where unread conversation began, while moving back through history lacks a lightweight route to the live edge.
Solution: Capture the channel's unread boundary when it opens, offer an accessible up-chevron beneath the app bar to reach that stable target, then reveal the inverse down-chevron at the bottom whenever the reader is away from latest. Deep links retain precedence, and live-follow, pagination, composer resizing, and explicit scroll ownership continue to use the existing timeline behavior.

File changes

mobile/lib/features/channels/channel_detail_page.dart
Captures the channel's read state at open time and passes a stable unread snapshot into the timeline before the normal deferred read update advances it.

mobile/lib/features/channels/channel_detail_page/message_list.dart
Adds mutually exclusive oldest-unread and latest navigation, with accessible icon controls positioned at opposite edges of the message surface while preserving existing follow and deep-link behavior.

mobile/test/features/channels/channel_detail_page_test.dart
Covers the unread target, compact inverse controls, accessible tooltips, and placement beneath the frosted app bar.

Reproduction steps

  1. Open a Flutter mobile channel that has unread messages without entering through a message or thread deep link.
  2. Confirm an up-chevron appears directly below the channel app bar while the timeline remains at latest.
  3. Tap the up-chevron and confirm the timeline scrolls to the oldest message that was unread when the channel opened.
  4. Confirm the unread control is replaced by a down-chevron at the bottom of the timeline.
  5. Tap the down-chevron and confirm the timeline returns to latest and resumes following new messages.

Screenshots

At latest — up-chevron to oldest unread Away from latest — down-chevron to latest
Up-chevron beneath the mobile channel app bar Down-chevron above the mobile channel composer

Real iPhone 17 Pro Simulator captures from the neutral buzz-mobile-scroll-to channel.

Originating Buzz thread: buzz://message?channel=5b16c478-22d8-4ddd-951a-6036e19b81ff&id=6a78af32d7ac6f531b182c4e70dd5a04c503a2dab2ce2c0c74b2c6baa5921741&thread=6a78af32d7ac6f531b182c4e70dd5a04c503a2dab2ce2c0c74b2c6baa5921741

@tellaho

tellaho commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

@codex

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 30192c8d1f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mobile/lib/features/channels/channel_detail_page/message_list.dart Outdated
Comment thread mobile/lib/features/channels/channel_detail_page.dart Outdated
Comment thread mobile/lib/features/channels/channel_detail_page.dart Outdated
@tellaho
tellaho marked this pull request as ready for review August 2, 2026 04:17
@tellaho
tellaho requested a review from a team as a code owner August 2, 2026 04:17
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w and others added 5 commits August 3, 2026 09:15
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
@tellaho
tellaho force-pushed the tho/mobile-channel-scroll-navigation branch from c152f6f to a0aaa85 Compare August 3, 2026 16:28
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
@brow

brow commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Thanks for the rewrite. Collapsing to a single pre-computed target id, filtering to rootId == null, and bounding the walk-back are all clear improvements, and the raw-timestamp forced-channel branch being gone closes the last open half of the earlier review. Reviewed at 9dd68e5e5.

Three issues below block merge for me. All three are behavior losses against the previous head a0aaa853b, and they share one root cause: the target model went from a set of unread ids to a single id, so whenever that one id is not reachable in the main timeline, the whole control disappears even though other genuinely-unread rows are loaded and on screen.

Each one has a passing control, an A/B against a0aaa853b, and a cause confirmed by mutating the specific line named (a red test alone would not prove the mechanism). All measurements are widget tests with fake message/channel/read-state notifiers driving the real _MessageList and the real buildMainTimelineEntries. No live relay, no real paged window responses, no device rendering.

1. Cap exhaustion suppresses the control even when an unread row is already on screen

message_list.dart:87-90 sets unreadBoundaryLoadFailed and returns, and :65 has that flag in the effect's early-return guard. So once the four-fetch cap latches, candidate resolution at :104-120 never runs again, and any other unread row already in entries is discarded.

fixture head fetchOlder() calls chevron
two ordinary unread: one 5 pages back, one on the newest page 9dd68e5e5 4 absent
same fixture a0aaa853b 5 present
control: only the on-screen unread 9dd68e5e5 0 present
control: only the on-screen unread a0aaa853b 5 present

The controls pass on both heads, so the instrument separates the paths. This is the case I care most about, because it needs nothing unusual: just more unread history than four pages, which is the exact scenario the bounding was added for. The user has unread mail visible on screen and is offered no way to navigate to the boundary.

2. One unreachable target id suppresses the control for every other unread row

No pagination involved. If the snapshotted oldest-unread id cannot appear in the main timeline, oldestUnreadMessageId (message_list.dart:120) stays null permanently.

My fixture uses deletion as the vehicle: all rows loaded, reachedOldest == true, two ordinary unread events, and a kind:5 targeting the older one so formatTimeline drops it (deletion targets collected at timeline_message.dart:340-350, row skipped at :357). Chevron absent at 9dd68e5e5, present at a0aaa853b. Identical fixture minus the deletion: present on both heads.

Deletion is just the easiest way to construct it. Nothing prunes a deleted id out of observedUnreadEventsByChannel: the only removals I found are the catch-up cap eviction (unread_badge/observed_unread_event.dart:50-61) and read-coverage clearing (channels_provider.dart:757-768), neither of which is deletion-aware. Searched within mobile/lib/features/channels/. The general hazard is any snapshotted id that buildMainTimelineEntries will not admit.

3. A mixed ordinary plus forced target jumps to the wrong message

message_list.dart:82-83, hasLoadedKnownTarget = hasLoadedOrdinaryTarget || hasLoadedForcedTarget. An already-loaded forced msg: target satisfies the loop-exit test at :84-86 even when the known ordinary oldest-unread target is older and still unloaded. The walk-back never runs, and candidates at :118 then holds only the forced message.

Fixture: three pages, ordinary oldest-unread on page 1 (two pages back), a forced msg: unread on the newest page. Probe: 0 fetchOlder() calls, tap lands on the newest-page message. Control with the forced target removed: 2 calls, lands on the correct oldest message. At a0aaa853b the same fixture lands on the correct message. The latch is permanent: the target stays wrong after the older page arrives. If the ordinary target happens to be in the first window already, targeting is correct, so this needs the ordinary target outside the initial window.

Worth noting that _loadDeepLinkEvents (channel_detail_page.dart:251-272) now also preloads the ordinary target by id, which could pin it into the window and mask both this and issue 1 in practice. I could not measure whether that preload wins the race, so I am not relying on it either way.

Suggested direction

I tried this locally and it clears issues 1 and 2, with your checked-in suite unchanged at 71 passing and flutter analyze clean:

  • Keep the single id as the fetch target, but resolve the displayed candidate from the reachable set rather than requiring that one id.
  • Scope the give-up latch to fetching only, so exhausting the cap stops paginating but still selects from the rows already loaded.
  • For issue 3, prefer the ordinary target when one is known, for example initialOldestOrdinaryUnreadMessageId != null ? hasLoadedOrdinaryTarget : hasLoadedForcedTarget in the loop-exit test.

Two smaller notes, not blocking:

  • unreadBoundaryLoadFailed is set but never surfaced anywhere, so a bounded give-up is invisible to the user and to a test that is not looking for its absence.
  • A test covering the deep-link-precedence and unread-boundary combination would be worth adding: a mutant in that interaction escaped the current suite.

Otherwise this is in good shape. The cap holds at exactly four even under repeated notifier rebuilds, threaded-only unread is a genuine no-op, rootId is provably the same discriminator as parentId here (nostr_models.dart:156-179) and matches main-timeline admission, and dropping the entries.first.message.id fallback correctly removes a false-positive chevron for a channel-level mark-unread with no unread messages. Happy to re-review as soon as you push.

npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w and others added 2 commits August 3, 2026 16:47
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…roll-navigation

* origin/main:
  feat(mobile): bring channel menus to desktop parity (#3940)
  feat(agents): model-tuning parity in global Agent Defaults editor (#4578)
  Polish Share Compute settings (#3735)
  fix(reactions): wrap long popover names (#3834)
  fix(desktop): clarify inherited agent parallelism (#4010)
  feat(desktop): make onboarding model defaults skippable (#3968)
  ci: add guarded desktop release cache prewarm (#4575)
  fix(mobile): recover stale relay sessions (#4372)
  chore(release): release Buzz Desktop version 0.5.4 (#4562)
  test(mobile): assert follow boundary semantics (#4559)
  docs(release): align desktop handoff instructions (#3988)
  fix: report agent usage per provider round, not once per turn (#4545)
  fix(desktop): harden Windows installs against Defender block and orphaned Node (#4382)
  feat(desktop): improve channel template discovery (#4549)
  fix(desktop): save key backups to authorized path (#4022)
  Add channel activity hover menu (#3935)

Signed-off-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
@brow
brow merged commit d5da74e into main Aug 4, 2026
46 of 48 checks passed
@brow
brow deleted the tho/mobile-channel-scroll-navigation branch August 4, 2026 00:48
tellaho pushed a commit that referenced this pull request Aug 4, 2026
…er-snapshots

* origin/main:
  feat(mobile): sync per-group channel sorting (#4231)
  feat(mobile): add channel scroll navigation (#4239)
  feat(desktop): redesign the Huddle experience (#4281)
  feat(mobile): bring channel menus to desktop parity (#3940)

Signed-off-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>

# Conflicts:
#	desktop/src-tauri/src/lib.rs
tellaho added a commit that referenced this pull request Aug 4, 2026
…onfig

* origin/main:
  feat(mobile): sync per-group channel sorting (#4231)
  feat(mobile): add channel scroll navigation (#4239)
  feat(desktop): redesign the Huddle experience (#4281)
  feat(mobile): bring channel menus to desktop parity (#3940)
  feat(agents): model-tuning parity in global Agent Defaults editor (#4578)

Signed-off-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
tellaho added a commit that referenced this pull request Aug 4, 2026
…theme-config

* origin/main: (81 commits)
  feat(mobile): sync per-group channel sorting (#4231)
  feat(mobile): add channel scroll navigation (#4239)
  feat(desktop): redesign the Huddle experience (#4281)
  feat(mobile): bring channel menus to desktop parity (#3940)
  feat(agents): model-tuning parity in global Agent Defaults editor (#4578)
  Polish Share Compute settings (#3735)
  fix(reactions): wrap long popover names (#3834)
  fix(desktop): clarify inherited agent parallelism (#4010)
  feat(desktop): make onboarding model defaults skippable (#3968)
  ci: add guarded desktop release cache prewarm (#4575)
  fix(mobile): recover stale relay sessions (#4372)
  chore(release): release Buzz Desktop version 0.5.4 (#4562)
  test(mobile): assert follow boundary semantics (#4559)
  docs(release): align desktop handoff instructions (#3988)
  fix: report agent usage per provider round, not once per turn (#4545)
  fix(desktop): harden Windows installs against Defender block and orphaned Node (#4382)
  feat(desktop): improve channel template discovery (#4549)
  fix(desktop): save key backups to authorized path (#4022)
  Add channel activity hover menu (#3935)
  feat(desktop): show saved Run on settings when editing an agent (#4539)
  ...

Signed-off-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
joahg added a commit to joahg/buzz-dev-mode that referenced this pull request Aug 4, 2026
…-style

* origin/main: (25 commits)
  Polish mobile inbox and media flows (block#4512)
  feat: ship Buzz Term (block#4347)
  feat(mobile): sync per-group channel sorting (block#4231)
  feat(mobile): add channel scroll navigation (block#4239)
  feat(desktop): redesign the Huddle experience (block#4281)
  feat(mobile): bring channel menus to desktop parity (block#3940)
  feat(agents): model-tuning parity in global Agent Defaults editor (block#4578)
  Polish Share Compute settings (block#3735)
  fix(reactions): wrap long popover names (block#3834)
  fix(desktop): clarify inherited agent parallelism (block#4010)
  feat(desktop): make onboarding model defaults skippable (block#3968)
  ci: add guarded desktop release cache prewarm (block#4575)
  fix(mobile): recover stale relay sessions (block#4372)
  chore(release): release Buzz Desktop version 0.5.4 (block#4562)
  test(mobile): assert follow boundary semantics (block#4559)
  docs(release): align desktop handoff instructions (block#3988)
  fix: report agent usage per provider round, not once per turn (block#4545)
  fix(desktop): harden Windows installs against Defender block and orphaned Node (block#4382)
  feat(desktop): improve channel template discovery (block#4549)
  fix(desktop): save key backups to authorized path (block#4022)
  ...

Amp-Thread-ID: https://ampcode.com/threads/T-019fccee-fa22-7145-95bc-a3aa0c10a174
Co-authored-by: Amp <amp@ampcode.com>
Signed-off-by: Joah Gerstenberg <joah@squareup.com>

# Conflicts:
#	desktop/package.json
#	desktop/src-tauri/src/commands/agent_config.rs
#	desktop/src/app/AppShell.tsx
#	desktop/src/features/channels/hooks.ts
#	desktop/src/features/channels/unreadReadMarker.test.mjs
#	desktop/src/features/channels/useUnreadChannels.ts
#	desktop/src/features/sidebar/ui/CommunityRail.tsx
#	desktop/src/main.tsx
#	pnpm-lock.yaml
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