Skip to content

fix(mention): stable picker keys, request lifecycle, failure states and a local candidate index - #723

Open
appflowy wants to merge 9 commits into
mainfrom
feat/mention-picker-wave1
Open

appflowy wants to merge 9 commits into
mainfrom
feat/mention-picker-wave1

Conversation

@appflowy

@appflowy appflowy commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

These are the four Web tasks from the first batch of the recency-aware @-mention plan. The batch's server tasks are in AppFlowy-Cloud-Premium#1581, which now holds all three server batches (it replaced #1571 there).

  • Enter inserts the item that is highlighted.
  • Requests are cancelled and time out as the user types.
  • Failed searches show Retry instead of "No result".
  • The client keeps a local index of mention candidates.

None of these changes needs a server change, so the client works with older self-hosted servers as well as the current hosted one.

Task What changes
MEN-WEB-02 Option keys no longer depend on position. Each key is the section kind plus a stable ID: person:, page:, database:<db>:<view>, row:<db>:<row>, link:<url>. The highlight follows its item when typed rows merge in, and Enter resolves the option by its key, so it never inserts a shifted result.
MEN-WEB-01 Each panel opening gets a request session with five lanes (bare, primary, rows, refresh, refresh-rows), each on its own x-request-stream-id, with increasing x-request-time values.
Deadlines are 2.5 s for typed queries and 5 s for bare @. Changing the query, closing the panel or unmounting it aborts the typed lanes.
An answer is cached only while it is still current. Bare @ no longer asks for database_row.
MEN-WEB-03 Each request ends with an explicit outcome (ok, failed, timeout, offline, retry_later or cancelled):
- A failed request keeps the sections the other requests returned.
- The panel shows "Suggestions unavailable · Retry" or "You're offline · Retry", and a countdown when the server says to retry later.
- The rows request is retried once, only when its section failed.
- The cache has a 10 s freshness window, is marked stale on workspace events, and drops revoked views.
MEN-WEB-07 A per-workspace local index of mention candidates.
- Sources: the outline, recent views, trash, the member roster, and this tab's own page creations, renames, moves to trash and opens.
- It excludes what the server excludes: spaces, a database's own views, embedded and dashboard-owned views, row pages, templates and trashed subtrees.
- 5,000 views take about 3.6 ms per snapshot.
Nothing renders it yet: MEN-WEB-09 will merge it into the picker.

Compatibility

  • Older self-hosted servers: the client reads only fields that released servers already send, and an answer without section statuses counts as complete.
  • Cross-origin self-hosts: a self-host on a separate origin whose nginx.conf predates the server's CORS fix rejects the two new headers in preflight. The request is then retried once without them, and the tab stops sending them. Same-origin deployments send no preflight.
  • Desktop and the shared crates: untouched.

Validation

  • Jest: 18 suites and 439 tests pass, covering the mention panel, request lanes, cache, fetch, local index and its hooks, and the rich-text cell editor that hosts the panel.
  • Each new behavior test was checked to fail against the code before the change.
  • pnpm type-check passes. ESLint is clean with the repo's ignore list. error-catalog:check passes.
  • Each task had an independent review, and the findings were fixed. MEN-WEB-03 and MEN-WEB-07 needed two review rounds.

This PR stays a draft until two things are done:

  • Run the new Playwright scenario, playwright/bdd/features/editor/mention-search.feature. It is written and type-checked but has not run yet, and .github/playwright-groups.json has no group for it yet.
  • Copy review and translations for the 5 new strings under document.mention.*: suggestionsUnavailable, offline, retry, retrying, busyRetryingIn.

Product questions left open, with the conservative behavior shipped:

  • A highlighted item that moves behind "N more" resets the highlight to the first option, as the plan says. Auto-expanding instead belongs with MEN-WEB-08.
  • A server without a keyword index fails object sections on every keystroke, so it shows Retry each time. A quieter hint may be better.

🤖 Generated with Claude Code

appflowy and others added 9 commits November 9, 2026 21:29
Option keys embedded the section and item positions. Search returns typed
rows inside the Pages section, so when they merged in after the primary
results, every folded-in database got a new key, the highlight kept its
old index, and Enter inserted whatever had moved there, or expanded
"N more" instead of inserting the highlighted link.

- Key each option by its picker section kind plus a section-free navKey:
  person:<id>, page:<id>, database:<db>:<view|->, row:<db>:<row>,
  link:<url>, and kind plus title for date and reminder quick picks.
  Only a real duplicate inside one section gets #2, #3. mentionItemNavKey
  is exported for the later merge lanes.
- Hold the highlight by key. When its key leaves the list, move it to the
  same object in another section, else to the first option.
- Enter resolves the highlighted option by key, and the keydown handler is
  refreshed before paint. Enter on "N more" still highlights the first
  page it reveals.
- The delivery fixture returns typed rows inside Pages, as Search does.
  New tests cover a database highlight across a rows merge, "N more", the
  reset when the item collapses away, a cross-section refresh, the create
  options and key stability under insertions.

Implements MEN-WEB-02 of the recency-aware mention search plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011r7tx2DTWWjAV5oYkz3pnQ
…s and current-only caching

Web sent mention searches with no stream id, request time or abort signal. The typed primary and
rows-only requests left in one tick and could cancel each other in Search, superseded keystrokes
were never aborted and had no time limit, the cache-hit refresh wrote the cache before checking
that its answer was still current, and a bare @ asked for database rows that it then dropped.

- SearchMentions takes optional { signal, streamId, requestTime }, sent as x-request-stream-id
  and x-request-time with the signal. A request with the headers that fails without a response,
  as a CORS preflight rejected by an older self-hosted nginx does, is retried once without them.
  If that retry is answered, the tab stops sending them.
- Each opening of the panel is a session with five lanes, each its own stream
  web-mention-<session>-<lane>: bare, primary, rows, refresh and refresh-rows. Request times
  strictly increase per lane, and issuing a request on a lane aborts the lane's previous one.
- A query change, closing the panel or unmounting it aborts the typed lanes. Bare and refresh
  requests run to their deadline, so a complete answer still reaches the cache. Deadlines are
  2.5 s typed and 5 s bare. An aborted request is not reported; one past its deadline fails as
  a timeout through the existing failure state.
- The foreground and the refresh path cache an answer only while every request it came from is
  current on its lane and was not aborted.
- A bare query no longer asks for database_row.
- Tests cover the lanes, the transport through the real axios chain, the fetch and the panel
  lifecycle, including an emulated Search that supersedes by stream. A Playwright scenario
  checks both headers and the bare include.

Implements MEN-WEB-01 of the recency-aware mention search plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011r7tx2DTWWjAV5oYkz3pnQ
…ailed rows retries

Follow-up to f8d55c9 from its review.

- Header fallback: a same-origin search sends no CORS preflight, so it never retries without the
  headers. A cross-origin search turns them off only after two separate rejections in a row
  whose retries without them were answered, and only for 10 minutes. Requests in flight
  together count once, and any answered search with the headers resets the count.
- A failed rows retry no longer leaves the rows ticket it replaced in the answer, so the answer
  stays current: a deferred search settles instead of loading forever, and a cache refresh
  shows its answer again. The panel gates caching on lane currency and display on the effect.
- Tests: transport cases for same origin, a transient failure, the token refresh, requests in
  flight together and the off period; panel cases for reopening a mounted panel, a late answer
  to aborted typed requests and both failed-retry cases; fetch tickets after a failed retry.
- Playwright: record only answered mention requests, assert that the bare and typed streams
  share one session, and make the scenario opt-in (RUN_CLOUD_MENTION_STREAM_HEADERS) until the
  Cloud nginx that CI runs allows both headers in CORS preflights.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011r7tx2DTWWjAV5oYkz3pnQ
…ons, keep a fresh cache

Web showed every failure as "No result": a primary error discarded the rows that answered, failed
sections vanished, and a request past its deadline looked like an empty answer. The rows retry
and the no-cache decision keyed on `partial`, which load-shed breadcrumb paths alone set. The
cache revalidated on every hit, never expired, and outlived workspace events and sign-out.

- A search never throws for one of its requests. Each ends as ok, failed, timeout, offline,
  retry_later or cancelled, and the answer merges what answered, with a state: complete,
  unavailable, busy (RetryLater on the primary or bare request) or offline.
- The rows request is retried once, 500 ms later, only when its answer has a failed section with
  partial, or has the superseded shape. Path-shed partial answers, a disabled keyword index
  (failed without partial) and rejected rows requests are not retried.
- Only a complete answer is cached: every request answered, no section failed, none superseded
  (partial without a section of a requested object kind), and every ticket current.
- The panel offers "Suggestions unavailable · Retry", or "You're offline · Retry", as a
  keyboard-selectable option after the results, and counts RetryLater down in a busy row before
  sending again. "No result" shows only for a complete answer. Sections that answered stay on
  screen, a failed refresh keeps the cached answer, and Retry sends again at once.
- Cache: an entry younger than 10 s is shown without a request. Folder, recent-view, roster and
  permission events mark entries stale, a revoked view is dropped from them, and sign-out or a
  panel on another workspace clears them. An answer to a request sent before a clear is
  dropped, and one sent before a stale mark is stored stale.
- English strings for the new rows. Tests cover the outcomes and the retry rule, the cache's
  freshness, events and stamps, and every panel state, including the plan's cases.

Implements MEN-WEB-03 of the recency-aware mention search plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011r7tx2DTWWjAV5oYkz3pnQ
…er and filter cache writes

Follow-up to the review of ff6b47c..15d779d (MEN-WEB-03).

- The rows retry also needs every other request to have answered completely. Behind a failed,
  stalled, busy or offline primary the answer cannot complete, and the retry kept the skeleton
  past the 2.5 s deadline: a stalled primary with failed rows turned unavailable at 3.2 s, or
  5.6 s when the retry stalled too. It now does at 2.62 s, after two requests.
- The superseded shape (partial, without a section of a requested object kind) makes only a
  rows-only answer unavailable. A primary or bare answer of that shape is almost always a People
  annotation that degraded over objects that matched nothing, because the panel aborts its own
  superseded requests, so it is shown without the Retry row. No answer of that shape is cached.
- Sections that arrive while the rest of a search is pending are shown only when they have
  items, so a retry no longer blanks the rows of the previous attempt.
- Cache writes carry a send sequence. An answer to a request sent before the stored one is
  ignored, as when the bare request of an earlier panel opening answers late.
- A revoked view is left out of answers to requests sent before the revoke, in the cache and on
  screen, and an open panel drops it at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011r7tx2DTWWjAV5oYkz3pnQ
…fresh cooldowns

Follow-up to the review of ff6b47c..e9ca903 (MEN-WEB-03).

- A panel that reopens on a stale cached query while the refresh an earlier opening sent is
  still in flight joins that refresh. It now shows the refresh's outcome as its own: the fresh
  results, or the Retry, busy or offline row under the cached answer. Before, it only re-read the
  cache, so a refresh that failed, timed out, went offline or was asked to wait left the stale
  answer on screen with no row and no further refresh. startMentionSearchRefresh now resolves
  with the refresh's result for every caller that joins it.
- Tests for the RetryLater paths of a cached answer, which no test reached: a refresh asked to
  wait shows the busy row under the cached answer and refreshes once when the cooldown ends;
  returning to a query whose cooldown still runs, with or without a cached answer, shows the
  seconds left and searches when it ends; and a reopened panel shows the answer, the failure or
  the cooldown of the refresh it joined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011r7tx2DTWWjAV5oYkz3pnQ
…tline and this tab's edits

Web holds the outline, the recent views, the trash and the roster, and knows what this tab
created, renamed, deleted and opened, but the mention picker could reach none of it. A page made
seconds ago or not yet indexed was missing from its results.

- mentionLocalIndex.ts, a module store keyed by workspace. AppBusinessLayer gives it the outline,
  recent views and trash as references (useMentionLocalIndexSources); usePageOperations records
  creations, renames, moves to trash and restores; useViewOperations records opens; the roster
  cache gives every roster it loads.
- A snapshot is built on the first read after a change and memoized: 5,000 views take about 4 ms
  in Jest. It applies the server's exclusions (spaces, a database's own views, embedded and
  dashboard-owned views, row pages and templates, the trash with its subtree) and carries server
  times, last_viewed_at, this tab's times and the navKey of the server item for each view.
- A creation is listed in the same tick, until the outline lists it. A rename shows until the
  outline catches up or moves past it, a deletion until the trash list shows it, and an outline
  refresh keeps the session times. Opening a database's view counts for the database.
- The store forgets a workspace on a switch, another account or unmount, and everything when the
  session ends. useMentionLocalSnapshot reads it without any context, so it works outside an
  AppProvider. Nothing renders it yet: MEN-WEB-09 merges it into the picker's results.
- mentionUtils exports the page and database navKey formats that mentionItemNavKey uses.

Implements MEN-WEB-07 of the recency-aware mention search plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011r7tx2DTWWjAV5oYkz3pnQ
…btrees in every lane

A review of the local mention index (MEN-WEB-07) found that what it lists depended on when a
snapshot was read, and that the subtree of a trashed page leaked through the recent and session
lanes.

- A deletion recorded in this tab was retired only by a read while the outline still listed the
  page and the trash list showed it. In the usual order the outline drops the page first, so a
  restore in another tab never showed the page again. The trash list showing the page now clears
  the deletion, and a deletion is not recorded when the trash list already shows the page.
- A creation was marked as listed by the sources only during a read, so one moved to the trash
  elsewhere stayed listed from the session. Creations are now settled when the sources change or
  the creation is recorded, and a creation the trash list shows is never listed.
- Rename overrides were dropped while a snapshot was built, so the name shown depended on reads.
  They are now settled when the outline or recent views change. Building a snapshot no longer
  writes to the session.
- The outline walk now marks the whole subtree of a trashed or deleted page, and recent views and
  creations are checked through the parents that recent views and creations give, not only the
  direct parent. A deletion in this tab keeps the subtree the outline showed, so stale recent
  views below the page stay hidden after the outline drops it, until it is restored.
- The trash list carries no subtree: a recent view two or more levels below a page trashed
  elsewhere still shows once the outline has dropped that page, unless the parents of recent views
  lead to it. The module header lists this limit.

Settling walks the outline only while a creation or rename waits for it: a 0.7 ms median for 5,000
views in Jest. The snapshot build stays near 4 ms.

Follow-up to MEN-WEB-07 of the recency-aware mention search plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011r7tx2DTWWjAV5oYkz3pnQ
…rces out of the local index

A second review of the local mention index (MEN-WEB-07) found four problems.

- A page deleted for good came back. The recent views and this tab's creations hid a view only
  while the trash list showed it, and the app reloads recent views only when Search or the Recent
  section opens. A view the trash list drops is now gone for those lanes, with its subtree, until
  the outline lists it again, this tab restores it, or reloaded recent views list it or a view
  below it (the server leaves trashed and deleted views out of them). The outline lane is
  unchanged, so a restore elsewhere still shows the page. At most 500 such views are kept per
  workspace.
- A workspace switch gave the new workspace the previous one's outline and trash for a commit,
  because useWorkspaceData resets them in an effect. The sources hook now gives a new workspace
  none of the references the previous one left until they change. A test drives this through
  AppBusinessLayer.
- deletePage read the subtree from the index's outline after the move resolved, when a folder
  notification may already have dropped it. It now also passes the view it found before the move.
- The outline showing an earlier name this tab sent dropped a newer pending rename. A pending
  rename now keeps up to five names the sources may show before it: the one it replaced and the
  earlier ones this tab sent.

Settling walks the outline once per change while a rename, creation or gone view waits for it:
0.7 ms median for 5,000 views in Jest. The snapshot build stays near 3.6 ms.

Follow-up to MEN-WEB-07 of the recency-aware mention search plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011r7tx2DTWWjAV5oYkz3pnQ
@sourcery-ai

sourcery-ai Bot commented Nov 11, 2026

Copy link
Copy Markdown

Sorry @appflowy, your pull request is larger than the review limit of 150,000 diff characters

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.

1 participant