feat(web): scroll to the matched message on a palette search hit - #4
Merged
Conversation
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.
xiaogwu
marked this pull request as ready for review
August 17, 2026 19:25
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
OrchestrationThreadSearchMatchnow carriesmessageId. The ranked CTE inProjectionSnapshotQuerywas already readingmessages.message_idfor itsROW_NUMBERtiebreaker, 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 therevealLinepattern inrightPanelStore. 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 afternavigate()resolves, because requesting earlier means a thread switch is still in flight andChatView's thread-change reset would wipe the anchor out from under it.ChatViewconsumes 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 sameanchoredEndSpace/onAnchorReady/scrollToIndexflow 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 inonTimelineAnchorReady, 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 theisAtEndhandler 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
positionedTimelineAnchorRefandsettledTimelineAnchorRef, 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 tofollowing-endif the row never resolves, which happens when the message was deleted or compacted away between the search and the Enter. Without that timerpendingTimelineAnchorRefstays 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-shadowring, mirroring the settings search target pulse, and goes quiet underprefers-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
(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 runon the five touched test files: 73 passed.@t3tools/contracts,@t3tools/client-runtime,@t3tools/web, andt3: zero errors.vp linton the touched source files andvp fmt --checkboth clean.Design notes:
docs/specs/2026-08-16-command-palette-message-reveal-design.md.