Skip to content

fix(search): hold an early ⌘K Enter until results land - #1910

Merged
matt2e merged 6 commits into
mainfrom
paging-10
Nov 2, 2026
Merged

matt2e merged 6 commits into
mainfrom
paging-10

Conversation

@matt2e

@matt2e matt2e commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

This only changes the no-results case in ⌘K: if you press Enter while the palette has no real rows yet (it is empty, or shows only "Search in this conversation"), the Enter is held and opens the first result once it lands. Enter on any visible row works exactly as before. Editing, moving the highlight, choosing a row, Esc or closing the palette drops a held Enter.

  • (a) Held Enter, no false empty state. An early Enter opens the top result when the channel, people and message lookups settle, instead of being dropped or narrowing the search to the current conversation. While those lookups are pending, the palette no longer says "No matching messages in accessible conversations."
  • (c) Newest choice wins. Choosing another person, channel or row while a DM is still opening cancels that DM, so the earlier DM can't open over your later choice.
  • (d) "Show more" for unscoped search. Overall search now offers "Show more messages" like scoped search does. Before, if every hit on page 1 was filtered out, it showed a false empty state.

Not changed: (b), the sticky highlight, is left as is. When a plugin/settings row matches the text, it can still take Enter. That behaviour already exists on main.

Before / after

Each video shows Before (main a3a6223) on the left and After (this branch) on the right. Both ran against a local relay with a ~1 s public-directory load, and the overlay logs each key press and result.

1a: old unjoined channel, from Inbox. Enter is pressed before any row appears. Before, the Enter is dropped and "No matching messages" shows. After, #finance opens about 0.1 s after its row appears.

old-inbox-before-after.mp4

1b: same, from inside a conversation. Before, Enter chooses "Search in general". After, Enter is held on that row and #finance opens once its row appears.

old-conv-before-after.mp4

3a: Enter on Alice, then on Bob, while Alice's DM is still opening. Before, Alice's DM opens anyway. After, Alice's open is cancelled and Bob's DM opens.

dm-bob-before-after.mp4

4: unscoped search where every hit on page 1 is filtered out. Before, the palette shows a false empty state and gives no way forward. After, "Show more messages" loads page 2, and the hit opens.

more-before-after.mp4

🤖 Generated with Claude Code

@matt2e
matt2e marked this pull request as ready for review October 30, 2026 06:03
@matt2e
matt2e requested a review from a team as a code owner October 30, 2026 06:03

@kalvinnchau kalvinnchau 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.

🤖 Two remaining command-palette edge cases: early Enter with a filtered message page, and cancellation across a quick close/reopen.

Comment thread src/app/shell/SearchResults.tsx Resolved
Comment thread src/app/shell/PageSearch.tsx Resolved

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Star Lord’s automated source review via Wes’s account, integrating independent Rocket, Mantis and Groot coverage. One actionable cancellation finding is attached inline.

Reviewed head 857ecd618232b96abc9514ed50970bab240647c7 against base a65463acb6b1a5b6b2e1727a223becdfd7f67f3d (merge-base a3a6223d7eb47d38d6c41b2ed6e6d688619cd100). Checked the changed search owners, callers, cancellation and retry/focus paths, added tests, public description, commit text and sampled frames from all four attached videos. No concrete disclosure was found in those inspected publication surfaces.

Source-only: no tests, builds or app workflows were executed. The one hosted-check snapshot shows CI required passing; Windows validation was skipped. The attached recordings and author-reported checks are not independent validation by this review. Native behavior, live relay ordering, assistive technology and rendered focus remain unverified. No browser test cases or test-infrastructure changes were added by this patch. This is a COMMENT review, not approval or merge authorization.

Comment thread src/app/shell/SearchResults.tsx Resolved
matt2e added a commit that referenced this pull request Oct 30, 2026
…r paging

Addresses the PR #1910 review of 857ecd6.

Show more messages: a held Enter now treats the "Show more messages" row
as an action, like the scope action. If a full page of message hits is
all filtered out while the open-channel directory is still reading, that
row is the only result. Enter then chose it, or "Search in <conversation>"
above it, at once, and the open channel that landed later never opened.
Now Enter waits for the lookups and opens the open channel, or the first
row once they settle. The predicate is renamed isSearchAction. Choosing
the row still runs it at once once the user has highlighted it.

Paging cancels a DM: choosing "Show more messages" now cancels a DM that
is still opening, like every other choice. Before, the DM's completion
closed the palette while the user was paging.

Closing cancels a DM: SearchResults cancels a DM that is still opening
when `open` becomes false, and clears its status line. Before, closing
with Escape and reopening during the close animation kept the retained
attempt, and isOpen() was true again, so the old DM navigated out of the
fresh palette. PageSearch's isOpen() check stays.

Tests (each fails with its fix reverted):
- SearchResults.test.tsx: new "holds an early Enter for an open channel
  over Show more messages %s", alone and with the scope action (without
  the fix, page 2 is read or the search is scoped). "lets the newest
  choice win over a DM that is still opening" serves a full first page
  and adds a pending DM, then Show more, then late resolution.
- PageSearch.test.tsx: the close-animation test is now "does not open a
  DM that finishes starting after search closed and reopened". Escape
  aborts the open, the palette reopens before the animation ends, and
  the DM resolving does not navigate. The old assertion that the status
  line stays during the close animation is gone, since closing now
  clears it.

Validation: vitest src/app/shell, src/features/search,
src/features/direct-messages, src/bundled/mentions, src/bundled/repos
and ChannelMembersDialog (28 files, 637 tests) pass. `pnpm typecheck`,
`pnpm lint`, biome check and `git diff --check` pass. Not exercised in
the running app.

Line counts: this commit production +15/-5 (SearchResults.tsx), tests
+124/-7.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Star Lord’s automated source follow-up review via Wes’s account, integrating independent Rocket, Mantis and Groot coverage. No new actionable findings. The previously reported pending-DM cancellation issue is repaired: Show more messages cancels the old attempt before paging. The close/reopen cancellation and early-Enter handling for action-only results also look consistent with their regression tests.

Reviewed head d1fc696d4c27034626cc39b02948cc4f997bf07a against base a65463acb6b1a5b6b2e1727a223becdfd7f67f3d (merge-base a3a6223d7eb47d38d6c41b2ed6e6d688619cd100), focusing on the three-file delta from 857ecd618232b96abc9514ed50970bab240647c7 and its callers/lifecycle. Inspected the public description, commit text and sampled frames from all four attached videos; no concrete disclosure found in those surfaces. The recordings show the previous head, so they do not validate this follow-up.

Source-only: no tests, builds or app workflows executed. Hosted checks were still running in the single snapshot; Windows validation was skipped. Native behavior, live relay ordering, rendered focus, assistive technology and human acceptance remain unverified. No browser cases or test-infrastructure changes were introduced. This is a COMMENT review, not approval or merge authorization.

@kazemek21 kazemek21 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.

Kyle reviewed this PR and the Nexus review-tracker notes and approved with no comments.

Posted by Codex (gpt-6.1-sol) on behalf of kylez

matt2e added a commit that referenced this pull request Oct 31, 2026
…r paging

Addresses the PR #1910 review of 857ecd6.

Show more messages: a held Enter now treats the "Show more messages" row
as an action, like the scope action. If a full page of message hits is
all filtered out while the open-channel directory is still reading, that
row is the only result. Enter then chose it, or "Search in <conversation>"
above it, at once, and the open channel that landed later never opened.
Now Enter waits for the lookups and opens the open channel, or the first
row once they settle. The predicate is renamed isSearchAction. Choosing
the row still runs it at once once the user has highlighted it.

Paging cancels a DM: choosing "Show more messages" now cancels a DM that
is still opening, like every other choice. Before, the DM's completion
closed the palette while the user was paging.

Closing cancels a DM: SearchResults cancels a DM that is still opening
when `open` becomes false, and clears its status line. Before, closing
with Escape and reopening during the close animation kept the retained
attempt, and isOpen() was true again, so the old DM navigated out of the
fresh palette. PageSearch's isOpen() check stays.

Tests (each fails with its fix reverted):
- SearchResults.test.tsx: new "holds an early Enter for an open channel
  over Show more messages %s", alone and with the scope action (without
  the fix, page 2 is read or the search is scoped). "lets the newest
  choice win over a DM that is still opening" serves a full first page
  and adds a pending DM, then Show more, then late resolution.
- PageSearch.test.tsx: the close-animation test is now "does not open a
  DM that finishes starting after search closed and reopened". Escape
  aborts the open, the palette reopens before the animation ends, and
  the DM resolving does not navigate. The old assertion that the status
  line stays during the close animation is gone, since closing now
  clears it.

Validation: vitest src/app/shell, src/features/search,
src/features/direct-messages, src/bundled/mentions, src/bundled/repos
and ChannelMembersDialog (28 files, 637 tests) pass. `pnpm typecheck`,
`pnpm lint`, biome check and `git diff --check` pass. Not exercised in
the running app.

Line counts: this commit production +15/-5 (SearchResults.tsx), tests
+124/-7.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Star Lord’s automated source follow-up via Wes’s account, integrating Rocket, Mantis and Groot coverage. One P3 integration finding is attached inline. The prior fixes remain present: Show more cancels a pending DM, closing aborts the attempt, and pagination counts as an action when holding early Enter.

Reviewed head 6f1629efff827c190f42a0c94b4987efd4070e29 against base fb78d21c52a156e973c0b681b6070ff1cfa56b79, with comparison to previously reviewed d1fc696d4c27034626cc39b02948cc4f997bf07a. Focused on the rebase’s shared highlight integration, supported callers, cancellation and error/retry focus. Inspected the public description, commit messages and sampled frames from all four videos; no concrete disclosure found in those inspected surfaces. The videos show an older revision and do not validate this head.

Source-only: no tests, builds or app workflows executed. The single hosted-check snapshot reports JavaScript 2/3 failed; its cause was not investigated. Windows validation was skipped. Native behavior, rendered focus, assistive technology, performance and human acceptance remain unverified. No browser cases or test infrastructure changed in this feature patch. This is a COMMENT review, not approval or merge authorization.

if (
event.key === "Enter" &&
pending &&
!(valid && (selection.moves || selection.pointer)) &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [P3] Distinguish a restored automatic highlight from a user-selected row

With a pending lookup and only the scope action plus Show more visible, hover Show more, leave the palette, and then press Enter for the first time. The leave handler (180–189) restores the automatically highlighted scope row but preserves pointer: true to suppress scrolling. This condition consequently treats that restored row as an explicit user choice and scopes immediately, instead of holding Enter for the incoming channel. The user never selected the scope row; this is separate from cancelling an already-held Enter by moving the highlight.

Preserve the leave handler’s no-scroll behavior while tracking whether the restored row was actually selected by keyboard/focus/pointer. Add the action-only, pending hover → leave → Enter regression, including settlement to a channel result. This is source-derived; not exercised at runtime.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Star Lord’s automated source follow-up via Wes’s account. No new actionable findings in the four-line status-editor test change. The existing P3 search hover/leave finding remains applicable: the search implementation is unchanged.

Reviewed head 51160b97724ae62ede148986df1e05078b77ece5 against base fb78d21c52a156e973c0b681b6070ff1cfa56b79, focusing on the endpoint delta from previously reviewed 6f1629efff827c190f42a0c94b4987efd4070e29. That delta changes only StatusEditor.test.tsx: pin Date to mid-month while leaving timers real, and restore real timers in teardown. The previously reviewed feature patches are unchanged, so this uses the small-follow-up solo exception; no new independent team lanes were run.

Checked the test, calendar/date owner, save validation and error/retry focus paths, public description, new commit text and sampled frames from all four unchanged videos. No new disclosure finding in those inspected surfaces. No browser cases or production code were added by this follow-up.

Source-only: no local tests, builds or app workflows executed. Hosted CI run 41152501148 was still running in the single check snapshot; Windows validation was skipped. The run’s merge commit cd68a53c61abe2bb9610570480b25d3ebf3536d5 includes this head plus newer main (0da12546), so its results are integration evidence, not an isolated test of the pinned base/head pair. Its downloaded advisory Vitest artifacts contain ten passing repetitions of the 20-case StatusEditor file, including the custom-deadline case. This does not establish complete CI success, elimination of all flakes, runtime performance, rendered/native focus or human acceptance. No before/after test-cost comparison was established. COMMENT only, not approval or merge authorization.

matt2e and others added 6 commits November 2, 2026 10:12
Fixes parts (a), (c) and (d) of finding #10, re-checked against the
current code. (b), the sticky highlight, is a product decision and is
unchanged with its locked tests; the "smaller items" table is out of
scope.

(a) Enter before any row arrives, while the open-channel directory or
people directory is still reading, now waits and opens the first row
that lands. An edit, an arrow key, Escape or a pointer move cancels the
wait; it lapses if the lookups settle with no rows. While those lookups
are pending, the messages group says "Searching…" instead of "No
matching messages in accessible conversations." (useSearchHighlight
gains a `pending` option, passed through SearchChoices.)

(c) The newest choice wins. Choosing another person, channel, page,
message or agent chat aborts a DM that is still opening and clears its
status line; choosing the same person again still keeps the open under
way rather than starting a second strong-consistency read. PageSearch
also gates openConversation and openAgentSession on openRef, so an open
that resolves during the close animation does not navigate.

(d) Overall search renders the same "Show more messages" row as scoped
search (the expression is lifted into `messageRows`). When page 1's hits
are all filtered out but the relay has more, that row shows instead of
the false empty text.

No new relay reads or per-keystroke work.

Tests (each fails with its fix reverted):
- use-search-highlight.test.tsx (new): Enter waits for the first row;
  edit/arrow/Escape/pointer cancel it; it lapses when lookups settle
  empty; Enter with nothing pending is not handled.
- SearchResults.test.tsx: early Enter opens the late open channel and
  the false empty text does not show; the newest choice wins over a DM
  still opening (another person, then a channel); overall search offers
  page 2 when page 1 shows nothing.
- PageSearch.test.tsx: a DM that resolves while the dialog's close
  animation is held does not navigate.

Validation: vitest src/app/shell, src/features/search,
src/features/direct-messages, src/bundled/mentions, src/bundled/repos
and ChannelMembersDialog (28 files, 625 tests) pass; `pnpm typecheck`,
`pnpm lint` and `biome check` on the changed files pass. Not exercised
in the running app.

Line counts: production +74 / -22 (SearchResults.tsx +38/-20,
use-search-highlight.ts +25/-2, PageSearch.tsx +7, SearchChoices.tsx
+4); tests +413 (SearchResults.test.tsx +201, PageSearch.test.tsx +109,
use-search-highlight.test.tsx +103).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
Addresses the review of 901a6d1.

Held Enter: useSearchHighlight no longer fires on the first row to land
while `pending`; it waits until `pending` is false, then chooses keys[0],
or lapses if there are no rows. SearchResults now reports `pending` while
a lookup that could still produce a top row is outstanding (the open
channel or people directory, or message search) and no channel, person,
page or other non-message row exists yet. A channel row from an early
directory page still fires at once, while a message or "Show more
messages" row that lands first waits for the lookups to settle. With no
directory match, Enter waits for message search and opens its first hit.
Enter pressed after a row is already showing still chooses that row, as
before.

Scope action: "Search in this conversation" cancels a DM that is still
opening, like the other choices.

Closing: PageSearch passes its `open` state through CommunitySearch,
SearchResults and SearchChoices to the hook's existing `open` option, so a
held Enter records no usage and starts no DM read during the close
animation.

Messages empty state: the "Searching…" string is gone. While the
directories are pending, the messages group shows no empty text, and
only the existing LoadingRegion notice appears.

Tests (each fails with its change reverted):
- use-search-highlight.test.tsx: "chooses the first row once results
  stop pending after Enter" replaces the first-to-land test; new "drops
  a waiting Enter when the picker closes".
- SearchResults.test.tsx: new "holds an early Enter for an open channel
  over a message that lands first, and for a message once channels
  settle empty" (fails without search.loading in `pending`); new "opens
  an open channel from the first directory page after an early Enter,
  while older pages load" (fails without the non-message-row check); the
  newest-choice test also picks the scope action over a DM still opening.

Validation: vitest src/app/shell, src/features/search,
src/features/direct-messages, src/bundled/mentions, src/bundled/repos and
ChannelMembersDialog (28 files, 628 tests) pass; `pnpm typecheck`, `pnpm
lint`, biome check and `git diff --check` pass. Not exercised in the
running app. The PageSearch -> hook `open` wiring is covered only at hook
level; the existing PageSearch close test closes with Escape, which
already cancels a held Enter.

Line counts: this commit production +35/-11, tests +160/-6. Branch vs
merge-base with main: production +101/-25 (SearchResults.tsx +57/-23,
use-search-highlight.ts +25/-2, PageSearch.tsx +10, SearchChoices.tsx
+9); tests +567.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
…er firing twice

Addresses the review of ba38a5a.

Scope action: when Cmd-K opens from a conversation, typed text always
shows "Search in <conversation>", so a held Enter never applied there.
useSearchHighlight gains an optional `isAction` key predicate for rows
that act on the search rather than being results. While `pending`, Enter
on the default highlight is held when every row is an action, as it is
when there are no rows, and once settled it chooses keys[0] as before.
If the user highlights the row by arrow key, pointer or Tab, Enter still
chooses it at once. Message rows stay directly choosable. SearchResults
passes a module-level `isScopeAction` (`scope:` keys) through
SearchChoices, and its leading-row check for `pending` now skips the
scope group, whose missing rank previously counted it as a leading row.

Double fire: Enter that chooses a row now clears a held Enter. Before,
choosing "Show more messages" after a held Enter left it armed, and it
opened keys[0] once the lookups settled.

Operator lookups: `pending` includes operatorLookupPending, matching the
messages empty state. An early Enter on `text in:#unjoined` is no longer
dropped while the joined list or the in: channel lookup is still loading
and message search has not started.

Tests (each fails with its change reverted):
- SearchResults.test.tsx: new "holds an early Enter on the scope action
  for an open channel, and scopes once chosen or settled" (with
  currentChannelId; fails without isAction, without the scope-group
  exclusion, or without the highlighted-row check); new "holds an early
  Enter while an in: channel is still being found" (fails without
  operatorLookupPending).
- use-search-highlight.test.tsx: new "drops a waiting Enter once Enter
  chooses a row".

Validation: vitest src/app/shell, src/features/search,
src/features/direct-messages, src/bundled/mentions, src/bundled/repos
and ChannelMembersDialog (28 files, 631 tests) pass; `pnpm typecheck`,
`pnpm lint`, biome check and `git diff --check` pass. Not exercised in
the running app.

Line counts: this commit production +34/-10, tests +137. Branch vs
merge-base with main: production +125/-25 (SearchResults.tsx +65/-23,
use-search-highlight.ts +36/-2, SearchChoices.tsx +14, PageSearch.tsx
+10); tests +704.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
…light

Addresses the review of 0594a0a.

Highlight moves: select() is now the one place, apart from Escape and
Enter choosing a row, that clears a held Enter. Hovering a row and a row
gaining focus both go through select(key, true), as arrow keys already
did. Before, hovering a row never cancelled. The row handler recorded
the event's coordinates first, so the list handler compared the event
with itself. Tab focus never cancelled either, so a row activated from
focus could fire along with the held Enter once lookups settled. The
list's pointer handler is back to the plain pre-branch assignment, and
keyDown's own clear covers only Escape. A move over a gap or header, or
an arrow key with no rows, no longer cancels, since neither moves the
highlight. onFocus has lost its `active !== key` guard, so focusing the
already-highlighted row also cancels. That row is now marked as
pointer-highlighted, which only skips scrolling a row that has focus and
is already in view.

Cold joined list: `pending` also holds while the joined list is loading
with no channels, for typed text in overall search (the "Loading joined
conversations…" condition). Before, if message search and the people
lookup settled empty first, a held Enter lapsed. Now it waits and opens
the joined channel once the list loads. Narrower than the review's
scenario: a message hit can't show first on a cold list, because
resolving its channel (applyDiscovery) sets the list "ready" with just
that channel before the roster read finishes. After that, only the
open-channel lookup holds Enter. The roster read itself doesn't. That
store behaviour is unchanged here.

Tests (each fails with its change reverted):
- use-search-highlight.test.tsx: the cancel table now cancels after rows
  land, still pending. It adds "a row hover" (row pointer moves, which
  replaces the move over the empty list) and "Tab focusing a row" (focus
  on the already-highlighted first row). Rows are buttons so focus is
  real.
- SearchResults.test.tsx: new "holds an early Enter while the joined list
  is still loading".

Validation: vitest src/app/shell, src/features/search,
src/features/direct-messages, src/bundled/mentions, src/bundled/repos
and ChannelMembersDialog (28 files, 633 tests) pass. `pnpm typecheck`,
`pnpm lint`, biome check and `git diff --check` pass. Not exercised in
the running app.

Line counts: this commit production +21/-23 (use-search-highlight.ts
+12/-21, SearchResults.tsx +9/-2), tests +92/-12. Branch vs merge-base
with main: production +138/-40 (SearchResults.tsx +72/-23,
use-search-highlight.ts +42/-17, SearchChoices.tsx +14, PageSearch.tsx
+10); tests +784.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
Addresses the review of c85348a.

A held Enter was cleared only when Enter chose a row, so choosing one
another way left it armed. Shift+Cmd+N called the row's run() directly,
and so did a click that does not focus the row, as in WebKit. With the
scope action, which keeps the dialog open with the same query, the held
Enter then opened the first result once the lookups settled, without
the user choosing it.

useSearchHighlight now returns choose(key), which drops a held Enter and
then calls onChoose. Enter, the held-Enter effect, the Shift+Cmd+N
shortcut and every row click and row-key path in SearchChoices go
through it. The held Enter now has three exits: choose(), select() (the
user moves the highlight), and Escape, an edit or closing. The separate
clears in the Enter branch and the effect are gone, and the `waiting`
and `first` locals are inlined into the effect.

Test (fails with the production change reverted, where the held Enter
opens the open channel):
- SearchResults.test.tsx: new "drops a held Enter when the scope action
  is chosen by %s", for a fireEvent.click that leaves the row unfocused
  and for the Shift+Ctrl+1 shortcut. Enter is held, the scope action is
  chosen, then the lookups settle, and nothing else is chosen.

Validation: vitest src/app/shell, src/features/search,
src/features/direct-messages, src/bundled/mentions, src/bundled/repos
and ChannelMembersDialog (28 files, 635 tests) pass. In one earlier run
under load, a NewMessage.test.tsx case failed; it passes alone and in
the full rerun. `pnpm typecheck`, `pnpm lint`, biome check and
`git diff --check` pass. Not exercised in the running app.

Line counts: this commit production +17/-17 (use-search-highlight.ts
+12/-11, SearchChoices.tsx +5/-6), tests +78. Branch vs merge-base with
main: production +145/-47, net +98 as before (SearchResults.tsx
+72/-23, use-search-highlight.ts +44/-18, SearchChoices.tsx +19/-6,
PageSearch.tsx +10); tests +862.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
…r paging

Addresses the PR #1910 review of 857ecd6.

Show more messages: a held Enter now treats the "Show more messages" row
as an action, like the scope action. If a full page of message hits is
all filtered out while the open-channel directory is still reading, that
row is the only result. Enter then chose it, or "Search in <conversation>"
above it, at once, and the open channel that landed later never opened.
Now Enter waits for the lookups and opens the open channel, or the first
row once they settle. The predicate is renamed isSearchAction. Choosing
the row still runs it at once once the user has highlighted it.

Paging cancels a DM: choosing "Show more messages" now cancels a DM that
is still opening, like every other choice. Before, the DM's completion
closed the palette while the user was paging.

Closing cancels a DM: SearchResults cancels a DM that is still opening
when `open` becomes false, and clears its status line. Before, closing
with Escape and reopening during the close animation kept the retained
attempt, and isOpen() was true again, so the old DM navigated out of the
fresh palette. PageSearch's isOpen() check stays.

Tests (each fails with its fix reverted):
- SearchResults.test.tsx: new "holds an early Enter for an open channel
  over Show more messages %s", alone and with the scope action (without
  the fix, page 2 is read or the search is scoped). "lets the newest
  choice win over a DM that is still opening" serves a full first page
  and adds a pending DM, then Show more, then late resolution.
- PageSearch.test.tsx: the close-animation test is now "does not open a
  DM that finishes starting after search closed and reopened". Escape
  aborts the open, the palette reopens before the animation ends, and
  the DM resolving does not navigate. The old assertion that the status
  line stays during the close animation is gone, since closing now
  clears it.

Validation: vitest src/app/shell, src/features/search,
src/features/direct-messages, src/bundled/mentions, src/bundled/repos
and ChannelMembersDialog (28 files, 637 tests) pass. `pnpm typecheck`,
`pnpm lint`, biome check and `git diff --check` pass. Not exercised in
the running app.

Line counts: this commit production +15/-5 (SearchResults.tsx), tests
+124/-7.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
@matt2e
matt2e merged commit e7ae7b1 into main Nov 2, 2026
40 checks passed
@matt2e
matt2e deleted the paging-10 branch November 2, 2026 00:07
morgmart added a commit that referenced this pull request Nov 2, 2026
…isibility

* origin/main:
  feat(identity): first-run setup store, step table and pages (composition follows in the next PR) (#1819)
  perf(identity-names): re-render a names consumer only when a label it resolved changes (#2100)
  fix(search): hold an early ⌘K Enter until results land (#1910)
  ci: 14 WebKit and 4 Vitest shards; retry preview publish and tool fetch (#1974)
  perf(channels): re-render working-agent rows only for their agents' profiles (#2101)
  test(messages): measure coordination text relative to its row (#2096)
  test: serve artwork in reaction preview geometry fixture (#2068)
  feat(desktop): send app version as user-agent on native relay requests (#2111)
  test: stabilize reaction overlay fixture media (#2106)
  [1/5] Keep protected launches on the startup Agents root (#2044)
  fix(mcp): tag community emoji in agent messages and edits (#2102)
  feat(identity): show when the organization sign-in ends in settings (#2097)
  feat(channels): simplify access choices in the members dialog (#2030)
  fix(profiles): show nip-fi verified name read-only in profile settings (#2099)
wesbillman pushed a commit that referenced this pull request Nov 2, 2026
…undetermined WebKit flakes (#2118)

## Summary

**These are diagnostics and unproven mitigations, not fixes.** Three
WebKit journeys failed once on GitHub Actions between 31 Oct and 2 Nov
and passed on re-run. None of their causes is established, and none
reproduces reliably locally. This PR adds assertions and instrumentation
so the next failure reports where it went wrong. It also adds two
readiness waits that may avoid one suspected ordering. No product code
changes.

- `agent-search-chat.spec.mjs` and `global-search.spec.mjs`: assert the
typed query right after `fill()`, and wait for rendered content before
opening the palette.
- `messages.spec.mjs`: when the inline video does not pause, report the
media element's state and recent events. The instrumentation is
temporary and should be removed once the failure is diagnosed.

This is one of three independent PRs split from the
`flakey-tests-monday` branch: #2116 (ProfileAgentIdentity unit test) and
#2117 (browser root-cause fixes). Each is cut from `main` and can merge
in any order; no file is shared between them.

### `agent-search-chat.spec.mjs:19`: assert the typed value; wait for
the shell's Solo chats first

Test: "search opens a new Solo chat with one of the viewer's agents".

- **CI:** [WebKit shard 1/14, run 41286046125 (job
124787613179)](https://github.com/block/buzz-app/actions/runs/41286046125/job/124787613179).
The Agents result was never found.
- **Evidence:** reproduced once locally in 10 runs, with a trace. The
palette input stayed mounted and focused. `fill("honey")` resolved 70 ms
after the Cmd+K press while the palette was still entering, yet the
input value was `""` 14 ms later. The palette kept showing Recent
activity, so no query ran.
- Playwright's `fill()` selects and focuses in the page, then inserts
the text through `Page.insertText` into whatever is focused and selected
at that moment, without checking the value. Something in that window
loses the text.
- Base UI's deferred initial focus targets the already focused input.
There is no composer on this page, and the input is not remounted (same
Base UI id). The trigger is not determined.
- 0/120 with an instrumented copy of the spec, 0/40 with product-side
logging, 30/30 under CPU load and 30/30 in a Linux WebKit container did
not reproduce it.
- **Change:**
- The test asserts `toHaveValue("honey")` right after `fill()`, so the
next failure points at the typed query instead of a missing result. This
is a diagnostic boundary.
- Before opening the palette, it waits for the sidebar's Solo
conversations section to list "Earlier with Honey": the fixture's data
has loaded and rendered in the shell. This is the nearest equivalent to
the rendered-conversation wait that the public search destination test
in `global-search.spec.mjs` already uses. This page opens on Channels
("Select a channel to read it.") and renders no conversation. The code
comment says the mechanism is unproven.
- **Verification:** 5/5 locally on WebKit with `--repeat-each 5`.

### `global-search.spec.mjs:602`: assert the typed operator query; wait
for a rendered message first

Test: "combines author, channel and inclusive/exclusive day boundaries
in the rendered flow".

- **CI:** [WebKit shard 2/14, run 41272526754 (job
124748254345)](https://github.com/block/buzz-app/actions/runs/41272526754/job/124748254345).
The crew-search result never became visible.
- **Evidence:** no trace survived the re-run. The attempt-1 log carries
no detail beyond the timeout and a 429 that belongs to the neighbouring
history-loading quota test. The test fills the operator query
immediately after opening the palette, the same shape as the
agent-search case. The `in:` operand resolves and re-runs once the
channel list arrives, so no product cause was found. 30/30 in a Linux
WebKit container did not reproduce it.
- **Change:**
- The same value assertion follows its first `fill()`, to attribute the
next failure to the typed query or to the search.
- The test waits for a rendered `[data-message-id]` before opening the
palette. This is the same unproven ordering mitigation as the public
search destination test in this file.
- **Verification:** 5/5 locally on WebKit with `--repeat-each 5`.

### `messages.spec.mjs:176`: report media state when the pause assertion
fails

Test: "inline video controls follow playback, hover and keyboard focus".

- **CI:** [WebKit shard 7/14, run 41331319960 (job
124922069284)](https://github.com/block/buzz-app/actions/runs/41331319960/job/124922069284).
`expect(video).toHaveJSProperty("paused", true)` at `:255` received
`false` for 10 s after clicking "Pause video". The click resolved to the
button with no retries, yet the label stayed "Pause video".
- **Trace evidence** (the trace's slider snapshots record the media
clock):
- From the Play click (11.25 s into the test), the clock ran at about
1.0x to 1.167 s, then jumped to 0.288 s at 12.38 s. That is a restart
from zero well short of the 2 s loop boundary.
- It ran to 0.828 s at the Pause click (12.93 s) and read 0.001 s right
after it: a second off-boundary restart instead of a pause.
- Over the following 10 s it advanced only to 0.269 s, while screencast
frames stopped at about 13.9 s. The media pipeline stalled after the
Pause click, and no `pause` event reached the product.
- WebKit's `pause()` sets `paused` synchronously except for early
returns (suspension, interrupted media session, user-gesture policy).
The trace can neither confirm nor rule out any of them.
- The clip is a 2 s, 12 fps, silent h264 file. No path in
`MediaAttachment`, `VideoPlayer` or the media read recovery calls
`play()` after a pause in this fixture.
- **Not reproduced:** 13/13 full runs on WebKit. A probe that paused the
looping clip 12 times at `currentTime` 1.85–2.00 s paused immediately
every time. A stalled media pipeline on Linux WebKit is consistent with
the frozen frames but is not established.
- **Change (diagnostic only; behaviour unchanged):**
- Once the video appears, the test records the element's last 24
`loadstart`, `abort`, `play`, `pause`, `playing`, `waiting`, `stalled`,
`seeking`, `seeked`, `ended`, `emptied` and `error` events.
- It polls the pause assertion against a state object: `paused`,
`currentTime`, `readyState`, `seeking`, `ended`, `networkState`,
`error`, `currentSrc`, the instrumented element's `isConnected`, and the
recent events.
- The instrumented element is held through an `ElementHandle` while the
poll reads the live locator, so a reload, a seek and a remount are
distinguishable.
- Same expect timeout and semantics as `toHaveJSProperty("paused",
true)`. `expect.objectContaining` prints the whole received object on
failure.
- **Remove this instrumentation once the CI failure is diagnosed;** the
code comment says so.
- **Verification:** a must-fail control copy printed the full state and
event list, with `currentSrc` as the fixture URL. WebKit `--repeat-each
5`: 5/5. Chromium `--repeat-each 3`: 3/3.

### Browser coverage

- Browser cases added or removed: none.
- Changed: the two search journeys gain a readiness precondition and a
typed-value assertion. The video journey's pause assertion now polls a
state object with the same pass condition. No assertion is relaxed.

### Verification (local macOS)

- Full files, one single-worker Playwright process per engine, on the
source branch at 5e9fc0db5 (based on 7edaec0). `agent-search-chat`,
`global-search` and `messages` ran in the same run as the other branch
files: Chromium 78 passed, 1 skipped; WebKit 78 passed, 1 skipped. The
Chromium skip is the existing one at `messages.spec.mjs:893`.
- This PR's tree is `origin/main` ac8495a plus these three files. Main
has not changed them since that base, and each is byte-identical to the
source branch. `biome check` on the three files and `git diff --check`
pass.
- Agent review: two review passes on the source branch. The readiness
waits and the extra video state (`loadstart`/`abort`, `currentSrc`,
`isConnected`) came from the first. No blocking findings remain.

### Remaining gaps

- Hosted CI has not run on this head yet.
- None of the three causes is determined. The lost WebKit `fill()`
reproduced once in about 230 local attempts (once in the first 10 runs,
then not in the next 220). The global-search and inline-video failures
did not reproduce locally at all. The readiness waits are unproven
mitigations, and the video change is diagnostic only.
- Since the source branch's base, main gained #1910, which holds an
early ⌘K Enter until results land. These search journeys were not re-run
against it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Matt Toohey <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
kalvinnchau pushed a commit that referenced this pull request Nov 2, 2026
## Problem

⌘K's Settings group indexed only three hard-coded controls (Color mode,
Sound, Notify while viewing). Typing a section name such as `shortcuts`,
`profile`, `identity` or `plugins` found no Settings row; plugin on/off
switches were the only hits, and Enter toggled focus onto one of them.
The palette never read `settingsSections()`.

## Fix

Three commits, each reviewed:

1. **List Settings sections in ⌘K.** `useSearchSettings` builds
`settingsSections()` from the visible settings cards and the selected
membership, the same way `SettingsSidebar` does, and adds one row per
section (label, group as detail, icon). Section rows sort ahead of their
own controls on a tie. The hook retains card visibility while the
palette is open, because the cards snapshot lists only cards whose
visibility has settled visible. `PageSearch` passes `settingsCards` and
`communities` through and opens a section via `onSelect("settings", {
section })`, so navigation's `select()` adds the community scope that
sections like Leave or remove need.
2. **Rank group-label matches behind name matches.** The app group's
label is "Buzz", so `buzz` had put eight exact-rank Settings rows ahead
of a channel that merely starts with "buzz". A group-label match now
ranks one step behind a section's own label (as plugin categories
already do), and the Settings and Plugins inline groups rank by their
best row rather than their own label, which keeps Manage plugins first
for `plugins`.
3. **Drop section matches looser than `MatchRank.wordRuns`** (the
threshold `ChannelCompletion.tsx` already uses). Letters-in-order
matches had made `wes` → Software updates, `ops` → Software updates,
`help` → Channel templates, which mattered because an inline row present
before directory results arrive takes an early Enter (#1910).

Unchanged: the three control rows, the generic Settings page row, plugin
row matching, and the #1910 Enter hold.

## Before / after

Same keys, same timing on both builds (browser-test fixture, headless
Chromium, 1024×720). Left is the base `ac8495a8b`; right is this branch.


https://github.com/user-attachments/assets/da2b52b9-a7cf-43d8-a510-88814ea22e64

| Query | Before | After |
|---|---|---|
| `shortcuts` | Plugins only: Notes reader, Attention | **Shortcuts**
(Buzz) first, then the same switches |
| `identity` | Plugins only | **Identity** (Buzz) first |
| `plugins` | Plugin switches only; no Manage plugins | **Manage
plugins** and the Plugins group's sections, then the switches |
| `notifications` | Sound and Notify while viewing switches |
**Notifications** section first, then the two switches |
| `shortcuts` + Enter | Focus moves to the Notes reader switch; palette
stays open | Settings opens at Shortcuts |

Also checked in the after build: dark mode, 390 px width, and 1.5× text.
Rows fit, nothing overlaps, icons and match underlines render.

## Tests

15 new Vitest cases across `SearchSettings.test.tsx`,
`PageSearch.test.tsx` and `SearchResults.test.tsx`. Fail-then-pass
evidence per commit:

- Commit 1: with only the two production files reverted, all 9 new tests
fail and the 23 existing ones still pass.
- Commit 2: 5 new `SearchResults` cases (`buzz` and `community` with a
channel and with a person; a contributed `Labs` group) run the real hook
against real channel/person ranking. Each fails without the ranking
change.
- Commit 3: 1 new `SearchResults` case with a directory-only person and
Enter pressed before the lookup resolves. Fails on commit 2
(`openSection` called once), passes here. The query is `champ` (→
Channel templates) because plugin descriptions already match almost
every real first name at the letters-in-order rank, so no realistic name
isolates the section change.

`vitest run src/app/shell`: 15 files, 267 tests pass. `pnpm typecheck`
and `pnpm lint` clean. `git diff --check` clean. No browser (Playwright)
cases added; the hook and ranking are covered in jsdom and the behavior
was exercised in the app for the recording.

## Known follow-ups (not in this PR)

- **Early Enter can still land on an inline row.** Since #1910 the hold
applies only while every visible row is an action; inline rows
(controls, plugin switches, section rows) aren't. Plugin descriptions
match nearly every first name, so a fast Enter on a directory-only
person can still pick a plugin switch today and, where a section row
leads, now navigates to Settings instead of focusing a switch. The
complete fix (treat inline rows as actions for the hold) changes #1910's
behavior for every inline row and should be its own PR.
- **Narrow width: opening Settings at a section leaves the section below
the fold.** At 390 px the section list fills the screen; `Dialog.tsx`
scrolls content into view only on a nav click. Pre-existing;
`SoloRegions.tsx` and `CommunityPicker.tsx` open sections the same way.
- Ranking side effects: `settings` no longer scores the inline Settings
group at 0, so a channel like `settings-sync` now leads it (the Settings
page row still wins). On ties, Settings now sorts before Plugins.

## Deferred checks

- Human test (AGENTS.md step 3). Steps: run the browser fixture or
`BUZZ_DEV_VIEWER= just web`, press ⌘K, type `shortcuts`, `identity`,
`plugins`, `notifications`; each should list its section under Settings
first, and Enter should open Settings at that section. In Personal
space, `community` should list no Community sections.
- WebKit and the native desktop app were not exercised.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Matt Toohey <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
tulsi-builder added a commit that referenced this pull request Nov 4, 2026
## What

Search gets **All** and **Channels** tabs (Ctrl+Tab / Ctrl+Shift+Tab
switch from the field). Channels is how someone finds open channels they
haven't joined yet.

- **Nothing typed:** lists the open channels you can join, A–Z.
- **Typed text:** fuzzy-matches those plus your own channels, which are
marked "Joined" so a familiar name is never a dead end.
- **Choosing a row:** a joined channel opens. An unjoined one opens its
read-only preview, where Join lives.
- **Member counts:** each row ends with a person icon and the channel's
member count.

Builds on the fuzzy matching from #1986.

## Screenshots

Taken in the browser test fixture with made-up channels.

| | Light | Dark |
|---|---|---|
| Browsing open channels, A–Z with member counts | ![Channels tab
browsing,
light](https://raw.githubusercontent.com/block/buzz-app/2fa5d92c9a061058125abffd969a34bd22c5b081/pr-2164--channels-browse-light.png)
| ![Channels tab browsing,
dark](https://raw.githubusercontent.com/block/buzz-app/2fa5d92c9a061058125abffd969a34bd22c5b081/pr-2164--channels-browse-dark.png)
|
| Typing "des": matches with matched letters underlined | ![Typed
matches,
light](https://raw.githubusercontent.com/block/buzz-app/2fa5d92c9a061058125abffd969a34bd22c5b081/pr-2164--channels-typed-light.png)
| ![Typed matches,
dark](https://raw.githubusercontent.com/block/buzz-app/2fa5d92c9a061058125abffd969a34bd22c5b081/pr-2164--channels-typed-dark.png)
|
| Typing "al": a joined channel leads, marked Joined | ![Joined match
first,
light](https://raw.githubusercontent.com/block/buzz-app/2fa5d92c9a061058125abffd969a34bd22c5b081/pr-2164--channels-joined-light.png)
| ![Joined match first,
dark](https://raw.githubusercontent.com/block/buzz-app/2fa5d92c9a061058125abffd969a34bd22c5b081/pr-2164--channels-joined-dark.png)
|

## How it feels

- **Fast first paint.** The directory read starts as search opens, so
the Channels tab usually has it by the time it's chosen. Each page shows
as it lands. Later pages join below the rows already on screen rather
than reshuffling them, and the list is fully A–Z the next time it opens.
- **Counts without jumpiness.** Counts are read only for rows in or near
view, joined channels answer from local state, and the first batch goes
out right away. The count's slot (8ch, tabular numbers) is held from the
start, so its arrival doesn't move the row or trim the description
again.
- **Returns where you left it.** For five minutes after closing, search
keeps its tab, text, highlighted row and scroll place; Escape means
done, and the next open starts fresh. Kept text opens selected, so
typing something new replaces it. A highlight is kept only while its row
is in view, so Enter never chooses a row nobody can see. If the order
changed while search was closed (a partial list became the full A–Z), it
reopens on the same rows rather than the same pixel offset.
- Main's "held Enter while search closes chooses nothing" (#1910)
applies to both tabs.

## Notable code

- `relay/store.ts`: progressive naming of the directory during a first
read (`refresh` distinguishes a refresh of a finished read),
`readDirectory`, `directoryComplete`, and `readMemberCounts`. Counts are
batched `#d` reads of kind 39002 at 100 ids each, cached per epoch, and
counted as distinct `p` tags. This is merged with #2023's incremental
public refresh.
- `SearchChoices.tsx`: a `trailing` slot per row, `onRowsShown` (an
IntersectionObserver, with a report-all fallback), and a `SearchPlace`
that holds an anchor-based scroll restore.
- `use-search-highlight.ts`: `initial` (where `""` highlights none) and
`clear()`.
- `docs/buzz-help.md`: describes the Channels tab.
- `switch-tab-request.js`: the macOS Show Next/Previous Tab menu items
now reach a modal's own tabs (search) instead of doing nothing.

## Testing

- **Checks:** `tsc`, `biome check --error-on-warnings` and `pnpm run
design:check` are clean.
- **Vitest, merged with main:** across `src/app/shell`,
`src/features/{search,channels,relay,conversation,panels,direct-messages}`
and `src/bundled`, 7,476 tests passed and 1 failed. The failure was
`codex-sh.test.ts › lets go of the hand-over lock`, a timing case this
change doesn't touch; it passes when that file runs alone.
- **New and updated coverage:**
- `ChannelDirectoryResults.test.tsx`: browse vs. typed, the joined
label, counts with singular and plural, retry, progressive pages
appended in place then A–Z on reopen, and reopening on the highlighted
row.
- `SearchChoices.test.tsx`: scrolling away clears a kept highlight, a
visible highlight is kept, and a reordered list reopens on the same rows
with no selection.
  - `use-search-highlight.test.ts`: `initial` and `clear()`.
  - `store-discovery.test.ts`: count reads.
- **Fail-then-pass:** each review blocker below got a test that failed
before the fix and passes after it. They are a hidden highlighted row
after a scroll restore, a stale highlight after dismissing, and pixel
restore landing on the wrong rows after the order changed.
- **Journey fix:** `navigation-sidebar.spec.mjs` › "keyboard page search
focuses main" failed in both engines because kept text was appended to.
It now passes in Chromium and WebKit locally.
- **Review:** agent review (Crack Catcher) found three blockers, all
fixed and re-approved. A visual pass (Xela) found nothing that must
change.
- **In the app:** the human author tested the feature in the desktop
app, and retested it after the final merge with main: progressive
loading, counts, typed matching, and reopening on the same rows.
- **Agent exercise:** not done. Screen capture of the native app wasn't
available to the agent, so no agent drove the app. The human author
chose to skip this step.

## Deferred

- Visual check: whether the 14px person icon reads heavy at the default
stroke; `stroke={1.75}` is the suggested fallback.
- No browser (Playwright) journey was added; the coverage above is
unit-level with jsdom geometry stubs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: tulsi <[email protected]>
Co-authored-by: BasilBot <399581b6165ab01343e3a1324774b1024d76942aea563468f93c975e5ee5193c@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <[email protected]>
matt2e added a commit that referenced this pull request Nov 5, 2026
…2607)

## Problem

The PR flake check repeated only the tests in files a pull request
edited. If a product change made an unedited test racy, the check
selected nothing. #2000 repeated one emoji journey and skipped the seven
other specs that load `emoji.html`. Five of its flaky journeys were in
those specs.

## What changes

`scripts/flake-check.mjs select` now selects in two tiers:

- **Tier 1, edited tests.** This is exactly the old rule: same
repetitions, both engines. The budget never trims it, even when tier 1
alone goes over.
- **Tier 2, related tests**, run as whole files:
- Browser specs that import a changed helper, or that open a
`tests/fixtures/*.html` page whose module graph holds a changed source
file, stylesheet or patched package. One rolldown build of every spec
and fixture page supplies the graph and writes nothing (1,027 modules in
about 1 s).
- Vitest files related to the changed modules through Vitest's module
graph. This is the graph the pre-push `vitest related` uses.

Picks are ranked by specificity: the smallest route that holds a changed
file comes first, and the cheaper test wins a tie. A test named after a
changed module ranks ahead of that order (`ComposerAttachments.tsx` →
`composer-attachments.spec.mjs`). The step summary marks those rows as a
naming heuristic, not graph evidence. Tier 2 adds picks in rank order
while each lane's predicted wall time (`flake_browser` and
`flake_vitest`) stays within **9 minutes** (`LANE_BUDGET_MINUTES`;
`--budget=<minutes>` overrides it). The 40-journey and 20-Vitest-file
caps still apply, and the pick that crosses a cap still runs.

**Cost table.** `scripts/flake-check-costs.json` is checked in and
written by the new `flake-check costs [run-id…]` subcommand. By default
it reads the latest three green `main` push runs. It reads the required
lanes' Playwright and Vitest reports and stores median passing seconds
per journey title as [Chromium, WebKit], plus a median per Vitest file.
A test missing from the table falls back to its file's median, then to
the engine-wide median or the median of all Vitest files. I checked in
the table instead of fetching timings in CI for three reasons: `select`
stays reproducible from the commit, `flake_select` needs no token or
network, and the artifacts are about 100 MB per run and expire after 7
days. The current table comes from runs 41990511962, 41980968364 and
41979442279 (Nov 5): 1,049 journeys and 964 Vitest files in 181 KB.

**Lane model.** I fit the model to hosted PR runs from Nov 4–5. The
median predicted/actual ratio is 0.95 for the browser lane and 1.02 for
Vitest.

- Browser shard: 105 s of overhead + 1.11 × the shard's summed test time
× 10 repetitions / 2 workers, then × 1.22 for imbalance when there is
more than one shard.
- Vitest batch: 26 s + 5 × (5.7 s + 0.72 × max(sum / 2, slowest file)).

The step summary gains a Tier column. It also shows each lane's
predicted time against the budget, with the edited-only time beside it,
and the related picks dropped for time and for the cap.

`docs/contributing.md` describes the tiers and the budget. It also
corrects "at most eight" shards to sixteen (`MAX_SHARDS`).

## For reviewers: the budget takes the longest fitting prefix

Tier 2 does **not** stop at the first pick that would go over the
budget. It takes the longest rank-order prefix whose predicted lane
fits. Browser shards grow with the selection (one shard per five
journeys), so a longer prefix can predict a *shorter* lane than a
shorter prefix. In the 248-PR replay, stopping at the first overrun
would have trimmed browser picks in 45 PRs. In 44 of those, the cap-only
selection already fit, and 40 predicted a slower lane after trimming.
#2000 would have kept 2 related journeys at 8.6 minutes instead of 46 at
6.2, and lost both of its flaky specs. Ranks are never skipped. The
Vitest lane only grows with each pick, so both rules give the same
result there. A unit test covers the non-monotonic case, and that test
fails under a first-overrun loop.

## Validation

Replays of `select <merge>^1 <merge>` in detached checkouts of each
merge commit. Selection before this PR → after:

| PR | Edited Vitest / journeys | Related journeys | Related Vitest |
Browser lane (min) | Vitest lane (min) | Flaky targets kept |
| --- | --- | --- | --- | --- | --- | --- |
| #2000 0944c1d | 1 / 1 | 0 → 46 (12 specs, incl. reactions-polish,
message-overlays) | 0 → 4 | 6.2 | 1.5 | yes |
| #1361 5992e69 | 2 / 0 | 0 → 40 (incl. composer-attachments, by name)
| 0 → 0 (budget) | 8.0 | 9.4 (edited alone 9.4) | yes |
| #2291 83b72a3 | 2 / 153 | 0 → 0 (budget) | 0 → 18 | 15.3 (edited
alone 15.3) | 6.6 | n/a |
| #1910 e7ae7b1 | 3 / 0 | 0 → 53 | 0 → 10 | 5.7 | 9.0 | n/a |

Lane times are this PR's predictions. Without the budget (the first
commit alone), the Vitest lane would have been 9.8 min for #2000, 16.3
min for #1910 and 6.6 min for #2291, and #2291's browser lane 18.3 min.
`--budget=Infinity` reproduces the first commit's output byte for byte
on all four source PRs. In the first commit's replay, a spec-only PR
(#2557) and a docs-only PR (#1694) selected exactly what the old rule
selected.

- `node --test tests/integration/flake-check.test.mjs`: 20 pass. Biome
is clean, including the table.
- Local `select` time on a laptop: #2000 takes 49 s with a cold Vitest
cache and 6 s warm (13 s before); #1361 takes 63 s cold and 8 s warm (19
s before). CI runs cold, within the select job's 5-minute limit. These
are local measurements, not hosted ones.

## Cost and known gaps

- **Runner-minutes grow about 7.5×.** In a replay of the 248 PRs merged
Nov 2–4, flake runner-minutes per run averaged about 11.4 under the old
rule and about 84 with related selection. Almost every source PR fills
the 40/20 caps, and the caps are what bound the cost. The budget limits
wall time only: against the first commit it moves the mean from 84.6 to
84.0 and the max from 381 to 319 (#2291). Lane wall time stays about 6–7
minutes for most PRs, and the highest predicted lanes fall from 16.7 to
13.7 minutes (browser) and from 16.3 to 10.4 (Vitest). All 11 Vitest
lanes still over 9 minutes are over on edited tests alone. The lanes
stay advisory, and repetitions, timeouts and workers are unchanged.
- **Not yet run in hosted CI.** This PR's own run will be the first.
- **Cost table drift.** Timings go stale until someone reruns
`flake-check costs`.
- **Model error.** Predicted/actual ranges from 0.73 to 1.23 (p10–p90)
for the browser lane and from 0.75 to 1.58 for Vitest. A Vitest
`file:line` filter is charged for its whole file.
- **Full-app journeys.** Journeys that load the full app rather than a
fixture page are reached only through helpers. This misses #1729, #2108
and #1969, which a coverage-map follow-up would address. A patched
package reaches browser specs but not Vitest files.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Matt Toohey <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
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.

4 participants