Skip to content

Persist search sheet state across close/reopen#5534

Open
FadhlanR wants to merge 8 commits into
mainfrom
cs-12109-host-persist-search-sheet-state-query-filters-view-results
Open

Persist search sheet state across close/reopen#5534
FadhlanR wants to merge 8 commits into
mainfrom
cs-12109-host-persist-search-sheet-state-query-filters-view-results

Conversation

@FadhlanR

Copy link
Copy Markdown
Contributor

Resolves CS-12109.

Problem

Closing the operator-mode search sheet discards the entire search — reopening starts from a blank slate. The @mode gate at search-sheet/index.gts destroys the whole SearchPanel → PanelContent → SearchResults → SearchEntriesResource subtree on close, dropping the query, filters, sort, view, pagination, and results.

Approach

A new in-memory, session-scoped SearchSheetState service holds the sheet's state (query text, selected types/realms, sort, view toggle, pagination) plus a snapshot of the last results.

  • The @mode-gated subtree is still destroyed on close, so there is no background work while the sheet is closed (the search resource and its realm subscriptions tear down).
  • On reopen the subtree remounts, rehydrates from the service, redisplays the cached results snapshot immediately (no blank flash), and re-runs the query to refresh — so newly-created cards show up. Stale-while-revalidate.
  • Persistence is opt-in via a @persist flag passed only by the search sheet, so the card choosers (which render their own SearchPanel instances) keep their own clean, per-invocation state.
  • The service registers with the reset service, so logout / realm reset clears it. A page reload also clears it (in-memory only, no localStorage).
  • resetState now runs only on explicit Cancel / Escape — removed from plain close/blur and from result-select, so those keep the search. Reopening a persisted search opens straight to the results view.

Files

  • services/search-sheet-state.ts (new) — the in-memory store + results snapshot, reset-registered.
  • search-sheet/index.gts — state backed by the service; reset only on explicit cancel.
  • card-search/panel.gts — additive initialActiveSort / onSortChange / persist args (choosers unaffected).
  • card-search/panel-content.gts — view + pagination behind @persist; main-results snapshot capture/redisplay.
  • operator-mode/submode-layout.gts — reopen to the results view when a search is persisted.

Verification

lint:types, lint:js, and lint:hbs pass in packages/host (with boxel-icons + boxel-ui built).

Remaining

  • Host acceptance tests: persist across close/reopen; result-select keeps the search; Cancel/Escape resets; choosers stay clean; in-memory (reset/reload) clears. Draft opened ahead of these per request.
  • Manual verification in the dev stack (snapshot → live handoff on reopen).

🤖 Generated with Claude Code

@FadhlanR
FadhlanR force-pushed the cs-12109-host-persist-search-sheet-state-query-filters-view-results branch from 6474fb0 to 48ab0d6 Compare July 17, 2026 07:31
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files      1 suites   2h 58m 43s ⏱️
3 584 tests 3 569 ✅ 15 💤 0 ❌
3 603 runs  3 588 ✅ 15 💤 0 ❌

Results for commit a423790.

Realm Server Test Results

    1 files  ±0      1 suites  ±0   12m 22s ⏱️ +19s
1 913 tests ±0  1 913 ✅ ±0  0 💤 ±0  0 ❌ ±0 
1 992 runs  ±0  1 992 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit a423790. ± Comparison against earlier commit e42d46b.

FadhlanR and others added 4 commits July 20, 2026 19:49
Add an in-memory, session-scoped SearchSheetState service that holds the
operator-mode search sheet's query, selected types/realms, sort, view, and
pagination, plus a snapshot of the last results. The sheet's @mode-gated
subtree is still destroyed on close (no background work while closed); on
reopen it rehydrates from the service, redisplays the cached results snapshot
immediately, and re-runs the query to refresh.

Persistence is opt-in via a `@persist` flag passed only by the search sheet,
so the card choosers keep their own ephemeral state. The service registers
with the reset service, so logout/realm reset clears it; a page reload also
clears it (in-memory, no localStorage).

resetState now runs only on explicit Cancel/Escape (removed from plain
close/blur and result-select), and reopening a persisted search opens straight
to the results view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The search sheet's result snapshot and the search resource each wrote
tracked state during the render that consumed it, tripping Glimmer's
mutate-after-consume assertion and failing host tests as a global error.

- Capture the main-results snapshot in a microtask instead of during the
  modifier's render, and key it to the current wire query so a reopen only
  redisplays results for the same search (never stale rows under a different
  term, and an idle sheet never clobbers a snapshot). Extract the
  snapshot-vs-live decision into a pure, unit-tested helper.
- Start SearchEntriesResource's search a microtask after modify so the
  task's isRunning write lands outside the consuming render — the case that
  bites when a SearchResults mounts with a query already set (the sheet
  reopening restored, or a chooser rendering recents). Load tracking stays
  synchronous via a Deferred so prerender readiness is unaffected.
- Update the reopen expectation in the interact-submode acceptance test to
  the persisted behavior, and add acceptance + unit coverage for persist,
  Cancel/Escape reset, empty-then-close, and service reset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reopening the operator-mode search sheet with an unchanged query now
seeds the freshly-mounted search resource from the retained snapshot and
skips the fetch, so the prior results show immediately with no re-run and
no "Searching…" flash. A one-shot guard keeps the seed from re-applying,
so any query change still runs a fresh search. The results-list scroll
offset is captured on scroll and restored on reopen (session-only).

The seeded resource holds the rows directly, so the display-time
resolveMainResults snapshot path is removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The search sheet now keeps its query and filters on a plain close (click
outside); only an explicit Cancel or Escape clears. Update the two
operator-mode UI tests that asserted the old reset-on-blur behavior:

- realm filter: selecting a realm then clicking outside and reopening
  keeps the realm checked.
- type filter: a search term plus a type selection survive click-outside,
  so reopening returns to the results view with both still applied.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@FadhlanR
FadhlanR force-pushed the cs-12109-host-persist-search-sheet-state-query-filters-view-results branch from 7a7cbc7 to 04c73c6 Compare July 20, 2026 12:50
The code-mode playground autogenerates a blank instance when it sees a
search that has settled with no results. Starting the search resource's
task a microtask after modify() (needed so a SearchResults mounting with a
query already set doesn't flip isRunning mid-render and trip Glimmer's
backtracking assertion) left a window where a just-set query read as
settled-with-no-results — so the playground autogenerated "Untitled"
instances, breaking the playground / spec / AI-assistant suites.

Keep the deferred start, but have isLoading also report loading across
that gap: it's true whenever a query is set and no run has completed yet,
read from the untracked #previousQuery / hasCompletedFullRun fields so
nothing tracked is mutated mid-render. The seed branch marks the run
complete synchronously, so a seeded reopen still presents as settled with
no flash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@FadhlanR
FadhlanR force-pushed the cs-12109-host-persist-search-sheet-state-query-filters-view-results branch from 4b75873 to 37a7867 Compare July 20, 2026 13:58
FadhlanR and others added 3 commits July 21, 2026 20:34
The session-scoped service now owns the search query and the
SearchEntriesResource itself, so the results survive a sheet
close/reopen without being snapshotted, re-seeded, or revalidated —
the resource simply outlives the sheet's subtree, and its realm
subscriptions stay live while closed.

- Service derives mainQuery from its own inputs (idle when no term,
  type, or realm), so a type-filtered reopen never runs unfiltered and
  resetState() collapses the resource to idle.
- SearchResults takes a @resource arg instead of @seed/@onSnapshot;
  PanelContent passes the service resource when persisting and keeps
  its own query derivation for the card choosers.
- An externally-triggered search resets state and remounts the panel
  via a trigger epoch, so a trigger while the sheet is open re-reads
  the freshly-reset filter display.
- isLoading settles after a failed run (no permanent "Searching…"),
  and a pending debounce is cancelled on Cancel/Escape.
- Delete the snapshot/seed machinery and its tests; add coverage for
  the query derivation, failed-run settling, and clean triggered search.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Search-panel components no longer know about the search-sheet service.
PanelContent is now controlled/uncontrolled via plain args
(mainSearchResource, viewId/onViewIdChange, pagination, scrollTop/
onScrollTopChange); the sheet — which owns the service — passes them in.
SearchPanel drops the `persist` flag.

Replace the bespoke rAF-countdown scroll restore with a reusable
persist-scroll-position modifier that re-applies the offset on every
geometry change (MutationObserver + ResizeObserver) and only records
gesture-driven scrolls, so restore no longer races the sheet's open
transition and late-hydrating rows (flash-to-top / stuck-at-top). Adds
deterministic integration tests for the modifier.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Restoring the persisted offset on reopen painted its intermediate
states: the list appeared at the top mid-animation, then jumped or
drifted as the offset re-applied against still-hydrating rows.

The modifier now tags the container `data-scroll-restore-pending`
while a non-zero saved offset is in flight, and the search sheet's
content hides behind the tag and fades in on removal — the list
reveals already in place. The tag comes off when an applied offset
survives read-back and the container box has been quiet ~100ms (a
stick against the mid-transition tiny viewport is trivial and must
not count), on the first user gesture, or after a 1s cap when the
offset is unreachable because the list got shorter.

Restore runs once per element: recording writes the live offset back
into the tracked state feeding the `scrollTop` arg, which re-runs the
modifier on every recorded scroll — a re-install is recording-only
(no tag, no observers, no re-applies), so the user's own scrolling is
never hidden or fought. Close/reopen mounts a new element, so each
reopen still restores exactly once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@FadhlanR
FadhlanR marked this pull request as ready for review July 22, 2026 12:41
@FadhlanR
FadhlanR requested a review from a team July 22, 2026 12:41

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a423790714

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +354 to +356
this.searchSheetMode = this.searchSheetState.searchKey.trim()
? SearchSheetModes.SearchResults
: SearchSheetModes.SearchPrompt;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Treat filter-only searches as persisted on reopen

This reopen gate only checks the saved text, but the new state service also treats selected types and selected realms as active search criteria. For filter-only searches such as Code mode’s “Find instances” (openSearch('', ref)), closing and reopening via the search button sends the sheet to the compact prompt even though mainSearch still has a type-filtered query; compact mode renders only recents, so the saved results are hidden until the user changes the search again.

Useful? React with 👍 / 👎.

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

[Claude Code 🤖] Review — reactivity & lifetime of the service-owned search resource

This review focused on the two things most likely to bite in this design: the reactivity/lifetime contract of the new session-scoped SearchEntriesResource, and whether "reopen restores the search" is complete across every way a search becomes active (term, URL, type, realm).

Bottom line: the architecture is sound and the reactivity handling is careful and correct. One confirmed gap (filter-only searches don't reopen to their results) is worth closing in this PR, and the PR description's "Approach" section now contradicts the shipped behavior and should be reconciled. No crashers; host + realm CI is green.

What lands right

  • Moving the query and the SearchEntriesResource into the session-scoped service is the right call over the earlier snapshot/re-seed approach — the results survive close/reopen because the resource literally outlives the @mode-gated subtree, with no snapshot-vs-live handoff to keep correct. Deriving mainQuery from the service's own inputs (not the panel's async-restored filter state) is what makes a type-filtered reopen never transiently run unfiltered.
  • The deferred task start + two-flag isLoading (#hasSettled vs hasCompletedFullRun) is subtle but correct — details in the inline confirmation on search-entries.ts. It's a genuine guard rail worth keeping intact.
  • The controlled/uncontrolled split on PanelContent (choosers pass none of the service args and keep their ephemeral local state; SearchResults branches once at construction on whether @resource is present) keeps the blast radius off every other consumer of the search components.

Recommendations

  1. [should-fix, this PR] Reopen gate misses filter-only searches — see the inline thread on openSearchSheetToPrompt. Confirms the Codex flag with a verified path ("Find instances" → close → reopen); gate on the service's hasActiveSearch rather than searchKey.trim().
  2. [non-blocking, please reconcile] The description's Approach still reads: "there is no background work while the sheet is closed (the search resource and its realm subscriptions tear down)." The shipped service does the opposite, and documents it so in search-sheet-state.ts: once a search is active, the resource and its realm subscriptions stay live for the whole session, and a plain close does not stop them. Because mainQuery is always page-capped (page: { size: SECTION_DISPLAY_LIMIT_FOCUSED }), the resource never qualifies for the selective or partial-refresh paths (query.page === undefined is required for both), so every incremental-index / prerender_html event in any searched realm triggers a full re-run — ongoing while the sheet is closed, until Cancel/Escape or a page reload. That's a defensible, deliberate tradeoff, but it's a real ongoing-cost change from the old "close tears everything down" model, and a plain click-outside close leaving a live re-running search is easy for a user not to realize. Please align the description with the code and confirm the tradeoff is intended.
  3. [non-blocking, coverage] Nothing pins the filter-only reopen from (1): the mainQuery unit test exercises only term-based derivation (not type-only), and no acceptance test reopens after a type-only "Find instances". A regression test there would lock the fix and the intended results-view reopen.

Adjacent, out of scope: I traced the ScrollToFocusedSection (scroll-to-0 on a restored focusedSection) vs persistScrollPosition (restore saved offset) interaction on reopen — the restore's per-frame re-apply burst outlasts the one-shot scroll-to-0, and the scroll-to-0 fires during restoring === true so it isn't recorded — so no visible conflict. Noting it only so it's on record; nothing to change.

Submitted as a comment — approve / request-changes is the maintainer's call.


Generated by Claude Code

Comment on lines +352 to +356
// Reopen straight to the results view when a search is persisted, so the
// restored results are shown immediately rather than the compact prompt.
this.searchSheetMode = this.searchSheetState.searchKey.trim()
? SearchSheetModes.SearchResults
: SearchSheetModes.SearchPrompt;

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.

[Claude Code 🤖] Reopen gate misses filter-only searches — confirms the Codex bot's flag on this line, verified against the code path.

The claim. Reopening a filter-only persisted search (a type or realm selected, empty term) sends the sheet to the compact prompt instead of the results view, so the restored results stay hidden until the user changes the search.

The mechanism. This gate keys on this.searchSheetState.searchKey.trim(), but the service treats a search as active when there's a term or a type or a realm — see hasActiveSearch in services/search-sheet-state.ts, which is exactly what drives whether mainQuery (and so the resource) produces results. Concrete path: code mode's "Find instances" calls openSearch('', ref) (detail-panel.gtssearchForInstances) → doExternallyTriggeredSearch('', ref) in search-sheet/index.gts sets searchKey = '' and selectedTypes = [ref]. A plain click-outside close keeps that state; reopening via the search button lands here → ''.trim() is falsy → SearchSheetModes.SearchPrompt. Prompt ⇒ isCompact is true, and in sheet-results.gts the main-query sections render only in the non-compact {{else}} branch — the {{#if @isCompact}} branch shows recents only. The type-filtered rows are live in the resource but never shown.

The fix. Gate on the service's own notion of an active search — it already covers term / URL / type / realm in one place, so the check can't drift the way a term-only re-derivation here already has. Make hasActiveSearch public (drop private on the getter) and:

Suggested change
// Reopen straight to the results view when a search is persisted, so the
// restored results are shown immediately rather than the compact prompt.
this.searchSheetMode = this.searchSheetState.searchKey.trim()
? SearchSheetModes.SearchResults
: SearchSheetModes.SearchPrompt;
// Reopen straight to the results view when a search is persisted, so the
// restored results are shown immediately rather than the compact prompt.
this.searchSheetMode = this.searchSheetState.hasActiveSearch
? SearchSheetModes.SearchResults
: SearchSheetModes.SearchPrompt;

hasActiveSearch is also true for a URL key (it's non-empty), so the URL-paste reopen — which likewise only renders its section in the results view — keeps working; the existing acceptance test for that stays green.

Scope. Regression in this PR's new reopen behavior; should-fix here (small, and the single-source gate stops the term-vs-active-search drift from recurring). Not a crasher — blocking is your call.


Generated by Claude Code

Comment on lines +341 to +360
// reads the task's `isRunning` (through `isLoading`) during render, so a
// synchronous `perform()` here — which flips `isRunning` — would mutate a
// value already consumed in the same computation, tripping Glimmer's
// backtracking assertion (a `<SearchResults>` mounting with a query already
// set — a chooser or the playground — hits this). Register the load
// synchronously (via a Deferred) so a prerender still waits for the search,
// but defer the task start a microtask so its `isRunning` write lands after
// the render. `isLoading` below still reports loading across that gap.
let loaded = new Deferred<void>();
this.#trackSearchLoad(loaded.promise);
void Promise.resolve().then(() => {
if (isDestroyed(this) || isDestroying(this)) {
loaded.fulfill();
return;
}
this.search.perform().then(
() => loaded.fulfill(),
() => loaded.fulfill(),
);
});

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.

[Claude Code 🤖] Confirmation — nothing to change; a guard rail for the next editor.

This deferred task start is load-bearing and correct, and it's the kind of thing a later "just call perform() directly" simplification would silently break, so it's worth calling out that it's intentional:

  • Starting the task a microtask after modify() (instead of a synchronous perform()) keeps a <SearchResults> that mounts with a query already set — a chooser, the playground, or the sheet reopening restored — from flipping search.isRunning inside the render that just consumed it (Glimmer's mutate-after-consume backtracking assertion).
  • The paired isLoading change (reporting loading across the gap via the untracked #previousQuery / #hasSettled) is what stops a just-set query from reading momentarily as settled-with-no-results — the failure that made the code-mode playground autogenerate a blank "Untitled" instance.

Verified the settle path is right: #hasSettled flips in the task's finally, so a failed run also settles (no permanent "Searching…"), while hasCompletedFullRun stays gated to a successful full run for the partial-refresh precondition — the two-flag split is deliberate. The Deferred keeps the prerender load-tracking synchronous so render readiness is unaffected. Good as is.


Generated by Claude Code

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.

2 participants