Skip to content

feat(web): scroll to the matched message on a palette search hit - #4

Merged
xiaogwu merged 1 commit into
integrationfrom
cmdk-search-scroll-to-match
Aug 17, 2026
Merged

feat(web): scroll to the matched message on a palette search hit#4
xiaogwu merged 1 commit into
integrationfrom
cmdk-search-scroll-to-match

Conversation

@xiaogwu

@xiaogwu xiaogwu commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Summary

Pressing Enter on a command palette content match used to open the thread at the live edge. On a long thread that lands nowhere near the text you searched for, so you have to scroll and re-read to find it. This makes the thread scroll to the matched message and flash it briefly.

The search result carried no message identity, so the first part is plumbing: OrchestrationThreadSearchMatch now carries messageId. The ranked CTE in ProjectionSnapshotQuery was already reading messages.message_id for its ROW_NUMBER tiebreaker, so this is one extra column in the result set and no new index. The field is required rather than optional, since the server is the only producer and always has the id.

On the client, a small zustand store holds one nullable reveal request with a monotonic requestId, mirroring the revealLine pattern in rightPanelStore. The monotonic id is what makes repeat requests work: searching inside the thread you are already viewing produces no route change and no other state diff, so without it the second search would be a no-op. The palette fires the request after navigate() resolves, because requesting earlier means a thread switch is still in flight and ChatView's thread-change reset would wipe the anchor out from under it.

ChatView consumes the request in one effect that sits immediately after that reset effect. Effects run in declaration order, so an earlier declaration would have its anchor cleared whenever a reveal and a thread switch land in the same commit. The effect does not add a second scroll path: it reuses the existing timeline anchor, so the matched message is pinned near the top through the same anchoredEndSpace / onAnchorReady / scrollToIndex flow the send flow already uses.

The part that took the most iteration is the scroll mode. The reveal runs under a new TimelineScrollMode, "anchoring-reveal", because none of the three existing modes work. "following-end" lets live-follow drag the view back to the bottom. "free-scrolling" fails the mode guard in onTimelineAnchorReady, so the positioning silently never runs and the thread just opens (this was the actual bug during testing). "anchoring-new-turn" would pass the guard but also pull in the streaming turn-metrics adjustments, which assume a response is arriving below the anchor. The new mode passes the anchor-ready guard, is ignored by the turn-metrics pass, suppresses the isAtEnd handler while the scroll is in flight, and hands the list back to "free-scrolling" once the anchor settles.

Two guards are worth pointing out. The effect clears positionedTimelineAnchorRef and settledTimelineAnchorRef, so revealing the same message twice re-runs the positioning instead of returning early on the "already positioned" check. And a 5s timer drops the pending anchor back to following-end if the row never resolves, which happens when the message was deleted or compacted away between the search and the Enter. Without that timer pendingTimelineAnchorRef stays set and live-follow is suppressed for the rest of that thread's life, which reads as "the chat stopped auto-scrolling".

The matched row pulses twice with a box-shadow ring, mirroring the settings search target pulse, and goes quiet under prefers-reduced-motion.

Scope

The server returns at most one match per thread (WHERE thread_match_rank = 1), so this reveals that single best match. It is not find-next / find-prev; cycling matches would mean changing the query shape and the palette result model.

Mobile renders search matches through its own list and navigation and keeps today's behavior. The contract change is additive, so it still compiles.

Verification

  • Manually verified in a local (test) desktop build off this branch: jump to an early user message, jump to an early agent message, plain thread row still opens at the live edge, repeat the same search twice, and live-follow resumes normally after a reveal.
  • vp test run on the five touched test files: 73 passed.
  • Typecheck across @t3tools/contracts, @t3tools/client-runtime, @t3tools/web, and t3: zero errors.
  • vp lint on the touched source files and vp fmt --check both clean.

Design notes: docs/specs/2026-08-16-command-palette-message-reveal-design.md.

Pressing Enter on a command palette content match opened the thread at the
live edge. On a long thread that lands nowhere near the text that was
searched for, so the user has to scroll and re-read to find it. The search
result also carried no message identity, so the client had nothing to scroll
to even if it wanted to.

Server and contract: OrchestrationThreadSearchMatch now carries messageId.
The ranked CTE in ProjectionSnapshotQuery already read messages.message_id
for its ROW_NUMBER tiebreaker, so this is one extra column in the result set,
no new index. The field is required rather than optional: the server is the
only producer and it always has the id.

Client: a small zustand store (threadMessageRevealStore) holds a single
nullable reveal request with a monotonic requestId, mirroring the revealLine
pattern in rightPanelStore. The monotonic id is what makes repeat requests
work, since searching the thread you are already viewing produces no route
change and no other state diff. The palette requests the reveal after
navigate() resolves, because requesting earlier means a thread switch is in
flight and ChatView's thread-change reset would wipe the anchor.

ChatView consumes the request in one effect declared after that reset effect
(effects run in declaration order, so an earlier declaration would have its
anchor cleared when a reveal and a thread switch land in the same commit).
The effect reuses the existing timeline anchor rather than adding a second
scroll path: it pins the matched message near the top through the same
anchoredEndSpace / onAnchorReady / scrollToIndex flow the send flow uses.

The reveal runs under a new TimelineScrollMode, "anchoring-reveal", instead
of reusing either existing mode. "following-end" would let live-follow drag
the view back to the bottom, "free-scrolling" fails the mode guard in
onTimelineAnchorReady so the positioning never runs at all, and
"anchoring-new-turn" would pull in the streaming turn-metrics adjustments
that assume a response is arriving below the anchor. The new mode passes the
anchor-ready guard, is ignored by the turn-metrics pass, suppresses the
isAtEnd handler while the scroll is in flight, and hands the list back to
"free-scrolling" once the anchor settles.

Two guards worth calling out. The effect clears positionedTimelineAnchorRef
and settledTimelineAnchorRef so revealing the same message twice re-runs the
positioning instead of returning early. And a 5s timer drops the pending
anchor back to following-end if the row never resolves, which happens when
the message was deleted or compacted away between the search and the Enter;
without it pendingTimelineAnchorRef stays set and live-follow is suppressed
for the rest of the thread's life.

The matched row pulses twice with a box-shadow ring, mirroring the settings
search target pulse, and respects prefers-reduced-motion.

The server returns at most one match per thread (WHERE thread_match_rank = 1),
so this reveals that single best match. It is not find-next/find-prev.
Mobile renders search matches through its own list and navigation and keeps
today's behavior; the contract change is additive.

Tests: store request-id and stale-clear semantics, the server messageId for a
user hit and an assistant hit, and the palette wiring that requests a reveal
for a content match and not for a plain thread row.
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XL labels Aug 17, 2026
@xiaogwu
xiaogwu marked this pull request as ready for review August 17, 2026 19:25
@xiaogwu
xiaogwu merged commit 4568c14 into integration Aug 17, 2026
11 of 16 checks passed
@xiaogwu
xiaogwu deleted the cmdk-search-scroll-to-match branch August 17, 2026 19:45
xiaogwu pushed a commit that referenced this pull request Aug 17, 2026
….1116

origin/integration carried the two fork PRs (#3 Stop hard-stop, #4 palette
search scroll); local integration carried the nightly 1116 merge. Neither
had both. No conflicts: the sides touch different files.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant