GemStone Search: rank the selector scan, reset on a session switch, add a refresh - #520
Merged
Conversation
…dd a refresh Three defects in one feature, all of them the search answering out of state that is no longer true. Typing `at:` returned no `Array>>at:`. The Methods scan asks the stone for a bounded number of matching selectors, but the stone collected the FIRST ones it walked into — dictionary-hash order, nothing to do with relevance — and with 1142 selectors containing `at:` on a 3.6.2 base image, the 31 classes that implement `at:` itself sat far past a cutoff of 80 and were never sent. The scan now sorts matches into tiers (the selector IS the term, STARTS WITH it, CONTAINS it) and returns them best-first, so the cut-off drops the least relevant tail. The walk no longer short-circuits on a match count, since a better-tier hit can be anywhere in the image; the one early exit left is a full exact tier, where nothing later can displace a row. Measured cost for `at:`: ~27 ms against ~2 ms, and a full walk was already the price of every precise term. The tier test deliberately uses no string `=`. GciLibrary.execute compiles our source as Utf8, so a literal is a Utf8 while `sel asString` is a String, and comparing them raises ArgumentError 2718 and takes the whole search down — found by the live-stone integration test, not by reading. Making another session active did nothing to the search. The docked panel rebuilt its engine only when something else happened to ask it to; an open Spotter never rebuilt at all. Until then both kept answering, and opening rows, out of the session the user had left. Both hosts now listen to onDidChangeSelection: the engine is rebound and the WEBVIEW is wiped, because those rows still look live and activating one would open a document against the session that is now current. The wipe is not deferred while hidden — a reveal cannot un-show stale rows — though the costly re-prime still is. The Spotter is re-pointed in place rather than closed, which also replaced show()'s old dispose-and-recreate branch. Nothing could pick up code created by EXECUTING it — a workspace `subclass:`, a `compileMethod:` doit, a global assigned in a doit announce nothing the panel can listen for, so short of a commit or abort the cached corpora stayed stale with no upper bound. A refresh control (⟳ in the webview chrome, the panel title bar, and `gemstone.search.refresh` in the palette) reloads every corpus and re-runs the current term. It ignores the visibility gate the sync hooks use — the user asked for it now — and clears any deferred sync rather than paying for both. Closes #517 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two provider doc-comments listed a session sync as the only thing that drops their cache. The refresh added in the previous commit does the same, through the same reprime path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… reconfigure a reopened panel Two things found while testing the refresh. Wiring the ⟳ to `resync` made it a dead button for anyone reading a senders list. `resync` deliberately leaves a pivot alone — a commit is not a request to disturb what you are reading — so the click reloaded every corpus silently and left the stale senders on screen, with nothing to show it had done anything. The engine now has a separate `refresh` for the explicit gesture: it re-asks the stone who references the row the pivot was taken from, keeping whatever filter is typed into it, and if that row is gone (its method or class deleted) it leaves the pivot rather than keep showing senders of nothing. `resync` keeps its old behaviour for the automatic commit/abort path. Collapsing the panel disposes the view, and reopening it hands us a brand-new webview with an empty tab row, no case flag and a zero debounce. The config push lived in `ensureEngine`, which had nothing to rebuild on a reopen (the engine outlived the view and still matched the session) and so pushed nothing. The `ready` handler now takes responsibility when the engine did not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MatiasFernandez
approved these changes
Aug 28, 2026
…spinner Review follow-ups on the session-reset and refresh work. The Spotter's logout path wiped the webview but kept its engine, so the tab still held the departed session's primed corpora and an `activate` closed over its GCI handle — the next keystroke answered with rows out of a session that was gone. It now drops the engine as well, and `onMessage` shows the "log in" notice instead of searching, mirroring the docked host's `ensureEngine` gate. Rebinding compares the engine as well as the session id, so logging back in under the same id is not mistaken for a no-op. A refresh that rejects no longer leaves the panel faded: both hosts catch around the engine call and take the spinner off. Neither entry point (the palette command, the view title-bar button) had a catch of its own. `gemstone.search.refresh` on a COLLAPSED docked panel no longer pays three image-wide GCI executes to post results to a disposed webview; it is remembered and paid on the next reveal, where it also subsumes any deferred sync. Tests: the panel's single lifecycle block becomes seven named cases with an afterEach that disposes the singleton; the relevance thresholds are named constants with the reasoning written down; the macrotask `settle()` helper is hoisted to module scope. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…refresh # Conflicts: # CHANGELOG.md # package.json
The comments read as though the Spotter is normally still on screen when the last session goes. It is not: an unpinned Spotter disposes on focus-out, so clicking away to log out closes it first, and the docked view has a `gemstone.hasActiveSession` when-clause that takes it out of the panel entirely. Only a PINNED Spotter tab survives to take this path — say so, rather than leaving the next reader to work out why the code exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…refresh # Conflicts: # CHANGELOG.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #517. Three defects in GemStone Search, all of them the panel answering out of state that is no longer true.
1. Typing
at:never returnedArray>>at:The Methods scope asks the stone for a bounded number of matching selectors so searching-as-you-type stays fast. The stone collected the first
limitmatches it walked into — dictionary-hash order, nothing to do with relevance. Measured on a 3.6.2 base image: 1142 selectors containat:and only 31 areat:, so with a slice of 80 the exact implementors sat far past the cutoff and were never sent. The slice filled withinstVarAt:put:andfloatAt:put:from a couple of incidental classes.buildSelectorSearchCodenow sorts matches into tiers — the selector is the term, starts with it, merely contains it — and returns them best-first, each tier capped. What a cut-off drops is now the least relevant tail. The walk no longer short-circuits on a match count (a better-tier hit can be anywhere in the image); the one early exit left is a full exact tier, where nothing later can displace a row. Cost forat:: ~27 ms against ~2 ms — and a full walk was already the price of every precise term, which never reached the old cutoff at all.One trap worth knowing about if you touch that query.
GciLibrary.executecompiles our source withUtf8as its string class, so every literal in it is aUtf8whilesel asStringanswers aString. Comparing the two raisesArgumentError 2718, Unicode argument disallowed in String comparisonand takes the whole search down. The tier test therefore uses integer sizes plusincludesString:and no string=anywhere; a unit test guards against a "clearer" rewrite. The live-stone integration test caught this — the unit tests could not.2. Making another session active did nothing to the search
The docked panel rebuilt its engine only when something else happened to ask it to (a reveal, a settings change, the next keystroke); an open Spotter never rebuilt at all. Until then both kept answering — and opening rows — out of the session the user had left.
Both hosts now listen to
SessionManager.onDidChangeSelection. Two decisions worth recording:visiblegate exists to keep image-wide GCI executes off a background path, and that still holds for re-priming. It does not hold for clearing the screen: a reveal cannot un-show stale rows.The Spotter is re-pointed in place rather than closed and reopened — it is an editor tab the user put there, possibly pinned, and the tab, its pin and its HTML are all session-independent.
show()for a different session takes the same path, which replaced its old dispose-and-recreate branch. Logging out of the last session is the same event with nothing to bind to: both hosts reset and say Log in to a GemStone session to search.3. Nothing could pick up code created by executing it
A
subclass:evaluated in a workspace, a method compiled by acompileMethod:doit, a global assigned in a doit — none of them announce anything the panel can listen for, so short of a commit or abort the cached class/dictionary/global/category corpora stayed stale with no upper bound.A ⟳ in the search bar (both surfaces), in the panel title bar, and
gemstone.search.refreshin the palette reloads every corpus and re-runs the current term. It ignores the visibility gate the sync hooks use — the user asked for it now — and clears any deferred sync so one click is one walk.It is a separate engine call (
refresh) rather than the button wired toresync, and the difference is the pivot:resyncdeliberately leaves a references list alone, which made the ⟳ a dead button for anyone reading one.refreshre-asks the stone who references the row the pivot was taken from, keeps whatever filter is typed into it, and leaves the pivot if that row has been deleted.Also fixed, found while testing: collapsing the panel disposes the view, and the fresh webview a reopen creates was never sent its
configmessage (empty tab row, zero debounce until the first search refilled it).Testing
npm testgreen: 6641 client + 322 server + 92 mcp. New coverage:methodsRelevance.integration.test.ts— live stone, both releases: the bounded scan returns the exact implementors ofat:includingArray>>at:, they lead the slice, and a substring-only term still comes through (the tiers rank, they must not filter).searchSelectors.test.ts— tier caps, best-first order, the exact-tier-only early exit, and the no-string-=guard.omniSearchViewProvider.test.ts— session switch wipes + rebinds (visible and hidden), no-op on re-selecting the same session, logout resets, refresh reloads once when a hidden sync was also owed, refresh builds nothing for a view never instantiated, config re-sent on reopen.omniSearchPanel.test.ts— the Spotter rebinds in place, ignores a re-selection of its own session, resets on logout, and builds nothing when closed.omniEngine.test.ts—refreshvsresyncover a pivot, pivot filter preserved across a re-fetch, pivot left when its target is gone.omniRefreshAndReset.test.ts— the ⟳ postsrefreshand keeps the typed term;resetclears query, rows, preview, breadcrumb, error, references chip, and returns the scope to All.Manually verified on a live 3.7.5 stone against a fixture (a class with exact/prefix targets plus 250 substring decoys, above the server ceiling): ranking A1–A5, refresh B1–B6, session switching C1–C5, and no-regression checks D1–D5.
🤖 Generated with Claude Code