Skip to content

fix(threads): read an already-open thread when Activity opens it - #1524

Merged
jrobotham-square merged 8 commits into
block:mainfrom
joelrobotham:fix/activity-thread-focus
Oct 25, 2026
Merged

jrobotham-square merged 8 commits into
block:mainfrom
joelrobotham:fix/activity-thread-focus

Conversation

@joelrobotham

@joelrobotham joelrobotham commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Problem

With a thread already open and selected, hovering its mention in sidebar Activity and clicking it did not clear the unread dot. With the thread closed, the same click did.

Cause

A thread is marked read only by the reading observer (use-reading.ts), which requires focus inside the thread's reading surface. Opening a closed thread adds and selects a tab, and PanelWorkspace focuses it. Opening an already-selected thread changes no selection, so nothing hands focus in, and the click leaves focus on the sidebar/popover.

Fix

ThreadMessages focuses its scroller when it receives a new navigation request that addresses its own thread root, but only when nothing holds focus (the opener's control has unmounted). It does nothing if the opener asked to preserve focus, the panel is inactive or not yet positioned, or the person has focused anything else in the meantime. That covers both a pending retarget (review feedback) and a first opening whose routed request arrives after the panel opened.

The Activity popover no longer returns focus to its sidebar trigger after an unread-thread row opens a thread. Base UI returns focus in a microtask after close, and when the popover unmounted in the same commit as the navigation, that microtask stole focus from the thread. This uses the same finalFocus={() => !handoff.current} pattern as ProfileButton.

Production diff: ThreadPanel.tsx and ChannelActivityPopover.tsx.

Tests

  • New browser journey in thread-unread.spec.mjs: open a thread with reading focus held, click the same thread from Activity, expect observed unread to reach 0. Fails on main (stays 1), passes with the fix, twice each way.
  • Chromium: thread-unread, activity-navigation-focus, channel-tabs — 13 passed. Vitest for ThreadPanel / ChannelsPage.tabs / ConversationTab / ChannelActivityPopover — 128 passed. tsc --noEmit and biome lint/format clean on touched files.

Manually verified with a local run of the app.

Additional tests and CI notes

  • thread-unread.spec.mjs: new case "an Activity reopening leaves focus the person moved meanwhile". It holds relay reads, reopens the thread from Activity, and types in the channel composer while the reads are held. Fails 6/6 on 1bec7851 (Chromium + WebKit) and passes 6/6 with the fix.
  • Before the fix, the Activity test failed 2 of 100 locally in Chromium (popover return-focus race), and navigation-thread-history:20 failed 26 of 40 in WebKit with 4 workers. With the fix: Activity test 150/150 Chromium (instrumented), 50/50 Chromium, 49/50 WebKit (the one failure was a setup timeout in Search Buzz). navigation-thread-history:20: 40/40 WebKit. Related suites in both engines: 160/160.
  • Unrelated test change: github-body.spec.mjs:35 failed on every CI run of this PR in Linux WebKit. Its fixture clip is 2 s long, and the "Pause video" click waited about 2 s for the button to stop moving. By then the clip had ended, the button had become "Play video", and the click restarted playback, so the next "Play video" button looked up by position didn't exist. Each video now uses its own player's controls and plays to the end. Pause clicks remain covered by the video player tests and messages.spec.mjs. Not reproducible on macOS. I have not found why it only failed on this PR.

@joelrobotham
joelrobotham force-pushed the fix/activity-thread-focus branch from 2a3a116 to 563536d Compare October 22, 2026 01:36
@jrobotham-square
jrobotham-square marked this pull request as ready for review October 22, 2026 01:38
@jrobotham-square
jrobotham-square requested a review from a team as a code owner October 22, 2026 01:38
Opening a thread from sidebar Activity that is already selected changes no
tab selection, so nothing handed focus into it and the reading observer
never started; the unread dot stayed. A new navigation request for the
shown thread now focuses its scroller unless focus is already inside or
the opener asked to preserve it.

Signed-off-by: joelrobotham <joelrobotham@gmail.com>
@joelrobotham
joelrobotham force-pushed the fix/activity-thread-focus branch from 563536d to 4757cc7 Compare October 22, 2026 03:19
Opening the already-shown thread moved focus into its scroller even when
the person was steering the tab strip (keyboard tab moves), which broke
"consecutive native Close requests close each main-pane thread". Only focus
left behind by the opener moves in now.

Signed-off-by: joelrobotham <joelrobotham@gmail.com>
The focus handoff for an already-open thread also ran when a thread opened
for the first time, taking focus from the tab that is meant to receive it
and breaking the focus order Back restores. Wait until the panel has been
positioned, so only a thread that is already open is refocused.

Signed-off-by: joelrobotham <joelrobotham@gmail.com>
The thread is already on screen when Activity opens it, so waiting for it
to be visible proved nothing. Wait for the click's own navigation to open,
then report where focus was if the thread is still unread.

Signed-off-by: joelrobotham <joelrobotham@gmail.com>

@salman1993 salman1993 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Reviewed commit 1bec785. One P2 correctness finding below. Validation: 34 existing browser checks across Chromium and WebKit and 100 focused unit tests passed. A separate gated focus-ordering reproduction fails in both engines with this change and passes with only the new effect removed. Native desktop acceptance was not exercised.

!element.closest("[data-reading-surface]")?.contains(focused) &&
!focused?.closest('[role="tab"]')
)
element.focus({ preventScroll: true });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 [P2] Preserve newer focus intent while the retained thread retargets

Reopening an already-open thread from Activity waits for RetainedThreadPanel's async view.retarget() before this effect receives the new navigation. If the user clicks into the channel composer and starts typing while the root/overlay reads are pending, this guard accepts that newer focus and moves it into the thread scroller when the reads finish, interrupting typing. I reproduced this with an explicitly gated root read in both Chromium and WebKit; the same scenario passes with only this new effect removed. Capture the opening focus intent before awaiting retarget and expire the handoff on subsequent pointer/keyboard input, following the existing usePendingRequest policy.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks, reproduced and fixed in 5809530, with a regression test in 89bf94c.

Test (thread-unread.spec.mjs, "an Activity reopening leaves focus the person moved meanwhile"): it holds relay reads, opens the already-open thread from Activity, and types in the channel composer while the reads are held. After the reads are released and navigation reports opened, it checks that the composer still has focus and still contains the text.

  • On 1bec785: fails 6 of 6 (Chromium and WebKit, "Received: inactive").
  • On 89bf94c: passes 6 of 6.

Fix: the handoff now moves focus into the thread only when nothing holds focus (activeElement is body). That is the state an Activity row leaves behind when the popover unmounts it. Focus the person placed anywhere else is no longer taken, whether during a pending retarget like here or just after a first opening whose routed request arrives late. The late-request case was also breaking navigation-thread-history:20 in WebKit under load.

Same commit: the Activity popover no longer returns focus to its sidebar trigger after a thread row opens a thread. That uses the finalFocus={() => !handoff.current} pattern from ProfileButton. Base UI's return-focus microtask could run after the thread took focus and steal it, which caused the intermittent CI failure of the Activity test.

Not done: expiring the handoff on pointer/keyboard input the way usePendingRequest does. With the body-only rule, that would add just one case: a click on a non-focusable area during the reads. That leaves nothing focused, so the thread that was asked for takes focus. Covering it properly means starting the listener before retarget and passing the result down. Happy to add it if you think that case matters.

joelrobotham and others added 3 commits October 23, 2026 09:02
Two races let the already-open-thread focus handoff misbehave under load.

The Activity popover returned focus to its sidebar trigger in a Base UI
microtask after closing. When the popover unmounted in the same commit
as the navigation, that microtask ran after the thread took focus and
stole it, so the thread stayed unread. A thread row now hands focus off
instead, as ProfileButton does for Settings.

On a first opening the panel can position before its routed request
arrives. The handoff then mistook that request for a reopening and took
focus from a control the person had focused since. It now moves focus
only when nothing holds it, which is what the opener leaves behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Joel Robotham <joelrobotham@gmail.com>
Review on block#1524 found that focus the person moved while an already-open
thread retargets could be pulled back into the thread. Hold the reads,
type in the channel composer meanwhile, and check the composer keeps
focus and text after the reopening completes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Joel Robotham <joelrobotham@gmail.com>
The fixture clip lasts two seconds. On CI's Linux WebKit the Pause
button took about two seconds to settle, so the clip ended first, the
button became Play, and the click restarted it. The next lookup by
position then found no second Play button. Each video now uses its own
player's controls and plays to the end. Pause clicks remain covered by
the video player tests and messages.spec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Joel Robotham <joelrobotham@gmail.com>
…ocus

* origin/main: (35 commits)
  fix(goose): say a provider isn't set up instead of offering placeholder models (block#1543)
  fix(agent-activity): note under a message when its agent was stopped (block#1544)
  feat(messages): show whose agent wrote a message (block#1502)
  feat(agents): put main agent choice in the card's actions menu (block#1530)
  fix(relay): let live dev connections save config artifacts (block#1583)
  feat(builderlab): let local agents create, deploy, share and open Builderlab apps (block#1340)
  Unify chat and channel access, tidy the Solo chat menu (block#1446)
  fix(todos): render nested TODOs with collapsible hierarchy and Markdown (block#1483)
  fix(agents): Allow agents to propose updates to agents (block#1478)
  fix(composer): prevent plugins from updating the recipient on a WIP draft message (block#1497)
  fix(agents): can't delete an agent belonging to an archived channel (block#1560)
  Bump tokio to 1.53.3 for RUSTSEC-2026-0386 (block#1581)
  Unify attachment cards with neutral icon tiles (block#1548)
  Polish edit context, channel join and profile actions (block#1545)
  fix(claude-code): keep context across channel threads (block#1516)
  fix(builderlab): let Create agent cancel a pending browser sign-in (block#1526)
  test(sidebar-filters): start the focus journey on General (block#1555)
  Add opt-in native MCP for macOS tests (block#1528)
  Show unreads for channels after access changes (block#1550)
  Deliver artifact changes through shared live routes (block#1480)
  ...

Signed-off-by: user-kWN3CIVowEvEo3RM <joelrobotham+bbprod@gmail.com>

# Conflicts:
#	tests/browser/github-body.spec.mjs
@jrobotham-square
jrobotham-square merged commit d2ddd7a into block:main Oct 25, 2026
39 checks passed
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.

3 participants