Skip to content

feat(omnibox): Phase 5 — Stability, Polish & Flicker-Free Preloading#226

Open
iamEvanYT wants to merge 14 commits intoevan/omnibox-refactor/mainfrom
evan/omnibox-refactor/phase-5
Open

feat(omnibox): Phase 5 — Stability, Polish & Flicker-Free Preloading#226
iamEvanYT wants to merge 14 commits intoevan/omnibox-refactor/mainfrom
evan/omnibox-refactor/phase-5

Conversation

@iamEvanYT
Copy link
Member

Summary

Phase 5 of the omnibox redesign, implementing stability & polish features from the design doc plus two major architectural improvements: eliminating omnibox flickering and creating a cleaner IPC-based API.

Changes

Scoring Engine (design doc §6.3-6.4)

  • New scoring-engine.ts with centralized scoreMatchQuality() and computeRelevance()
  • Base relevance ranges per match type, frecency normalization, host/path/title match quality
  • Input-length weighting, bonus/penalty adjustments (bookmarks, open tabs, URL length)

Default Match Stability (design doc §9)

  • shouldUpdateDefault() logic: new top must exceed current default by >100 points
  • Short input caution: 1-2 char inputs require >1300 relevance for non-verbatim default
  • Default preservation in updateResults() — moves existing default to top when stable

Arrow-Key Navigation Lock (design doc §9.3)

  • onUserArrowKey() suppresses result list updates while user navigates
  • onUserKeystroke() clears lock and applies buffered pending matches
  • Wired through Omnibox class and OmniboxMain UI component

URL Normalization Enhancement (design doc §8)

  • Trailing slash normalization on all paths (not just root)
  • Default port removal (80/443)
  • Percent-encoding normalization (decode unreserved chars, uppercase hex)
  • Fragment stripping for dedup, stable query parameter sorting

IMUI Cache/Restore (design doc §4.5)

  • Serialize index to localStorage with version stamp
  • Restore from cache on next open (1-hour max age), then background refresh
  • Diagnostic getters: wordCount, prefixCount, lastRefresh

Flicker-Free Omnibox (new)

  • Root cause: every show called loadInterface() which reloaded the URL, remounting React
  • Fix: omnibox renderer loads once, then receives omnibox:do-show / omnibox:do-hide IPC messages
  • Main process tracks initialLoadComplete, routes subsequent shows through sendShowEvent()
  • All 4 entry points updated: IPC handler, Cmd+T, Cmd+L, address bar click

IPC-Based API (new)

  • Added OmniboxShowParams type and onShow/onHide to FlowOmniboxAPI
  • Preload implements listeners via listenOnIPCChannel
  • OmniboxMain is now a persistent component — no remount on show/hide

Debug Page Updates

  • Expanded from 2 to 4 tabs: Details, Scoring, Timings, Debug
  • Scoring tab: all ScoringSignals fields with visual layout
  • Timings tab: provider duration and match count table
  • Debug tab: IMUI state panel (word count, prefix count, last refresh)

Files Changed

  • New: src/renderer/src/lib/omnibox/scoring-engine.ts
  • Modified: autocomplete-controller.ts, url-normalizer.ts, in-memory-url-index.ts, omnibox.ts (renderer)
  • Modified: omnibox.ts (main process), omnibox.ts (IPC), new-tab.ts, file.ts (menu)
  • Modified: omnibox.ts (shared interface), index.ts (preload)
  • Modified: main.tsx (omnibox UI), page.tsx (debug)

iamEvanYT added 10 commits March 1, 2026 18:54
- Add omnibox_shortcuts + bookmarks table schemas and migration
- Implement OmniboxShortcutsService with recordUsage, search, cleanup
- Wire IPC handlers, FlowOmniboxShortcutsAPI interface, preload bridge
- Build ShortcutsProvider (async, max 3, range 1000-1450, 7-day decay)
- Record shortcut on omnibox selection in Omnibox.openMatch()
- Add BookmarkProvider stub with TODOs (pending bookmarks system)
- Add bookmarks data-provider stub (searchBookmarks, isUrlBookmarked)
- Enhance dedup with cross-provider merge (bookmark+history, inline)
- Update UI with bookmark/shortcut icons, labels, and descriptions
…ainst current input

The SQL LIKE pattern was checking if the stored inputText started with
the current input (inputText LIKE 'gith%'), when it should check if the
current input starts with the stored inputText ('gith' LIKE inputText || '%').

This fixes core shortcut matching: typing 'gith' now correctly matches
shortcuts stored for 'gi', 'git', 'gith' rather than 'gith', 'github'.
Implement scoreMatchQuality() and computeRelevance() per design doc
sections 6.3 and 6.4. Provides base relevance ranges per match type,
frecency normalization, host/path/title match quality scoring, input
length weighting, and bonus/penalty adjustments.
…der timing diagnostics

Rewrite autocomplete controller with:
- Default match tracking with shouldUpdateDefault() logic (design doc §9)
- Short input caution: 1-2 char inputs require >1300 relevance for non-verbatim default
- Arrow-key navigation lock: onUserArrowKey() suppresses result updates,
  onUserKeystroke() clears lock and applies buffered matches
- Provider timing diagnostics with ProviderTiming interface for debug page
Add trailing slash normalization on all paths, default port removal
(80/443), percent-encoding normalization (decode unreserved, uppercase
hex digits), fragment stripping for dedup, and stable query parameter
sorting per design doc §8.
Serialize IMUI index to localStorage on populate with version stamp,
restore from cache on next open (1-hour max age), then refresh in
background. Add diagnostic getters: wordCount, prefixCount, lastRefresh
per design doc §4.5.
…ssing

Fix omnibox flickering by eliminating loadInterface() calls on show.
The omnibox renderer is now loaded once and stays alive, receiving
show/hide params via IPC messages instead of URL reloads.

- Add OmniboxShowParams type and onShow/onHide to FlowOmniboxAPI
- Add omnibox:do-show and omnibox:do-hide IPC channels in preload
- Main process Omnibox class: track initialLoadComplete, add
  sendShowEvent() and sendHideEvent(), hide() notifies renderer
- Update all 4 entry points (IPC handler, new-tab, command palette,
  address bar) to use sendShowEvent() instead of loadInterface()
Convert OmniboxMain from reload-on-show to a persistent component:
- Listen for flow.omnibox.onShow/onHide IPC events to control visibility
- Reset state (input, matches, selection) on each show event
- Use openInRef instead of URL params for current/new_tab mode
- Wire up arrow key events to controller.onUserArrowKey()
- Route keystroke input through controller.onUserKeystroke()
- Expose diagnostic getters (providerTimings, IMUI stats) on Omnibox class
- Maintain backward compat with URL params for initial load
Expand debug page from 2 to 4 tabs:
- Scoring tab: displays all ScoringSignals fields and dedup key
- Timings tab: provider name, duration, match count per provider
- Debug tab: add IMUI state panel (word count, prefix count, last refresh)
- Fix inline completion display to use template literal
@github-actions
Copy link
Contributor

github-actions bot commented Mar 3, 2026

Build artifacts for all platforms are ready! 🚀

Download the artifacts for:

One-line installer (Unstable):
bunx flow-debug-build --open 22647803307

(execution 22647803307 / attempt 1)

show() called this.hide() internally to reset state before showing,
but hide() now sends 'omnibox:do-hide' to the renderer via IPC. Since
callers invoke sendShowEvent() before show(), the hide event was
undoing the show event, leaving isVisible=false and the omnibox
invisible (opacity 0). Replace this.hide() with a direct
view.setVisible(false) to reset the native view without notifying
the renderer.
@greptile-apps
Copy link

greptile-apps bot commented Mar 3, 2026

Greptile Summary

This PR implements Phase 5 of the omnibox redesign, delivering two major architectural improvements — flicker-free preloading and an IPC-based show/hide API — alongside a new scoring engine, default match stability logic, arrow-key navigation lock, URL normalization enhancements, IMUI localStorage caching, and a new omnibox shortcuts persistence layer (SQLite + service + IPC). The changes are well-scoped and represent a meaningful step toward a production-quality omnibox.

Key concerns found during review:

  • Race condition across three entry points (new-tab.ts, file.ts, window/omnibox.ts): These now call sendShowEvent() directly instead of routing through loadInterface(). sendShowEvent unconditionally fires an IPC message without checking initialLoadComplete, so if Cmd+T is triggered during the ~200–500 ms initial renderer load window, the message is dropped and the omnibox appears blank. The original loadInterface already had the correct guard — these callers should route through it.
  • Stale pendingMatches across sessions in AutocompleteController: stop() (called on omnibox hide) does not clear pendingMatches or reset _userIsNavigating. After the user navigates with arrow keys and closes the omnibox, stale buffered results persist and can briefly flash when the user opens the omnibox again and types a character.
  • Scheme-unaware port normalization in url-normalizer.ts: The check port !== "80" && port !== "443" incorrectly strips port 443 from HTTP URLs (non-default for HTTP), causing http://example.com:443 to dedup-collide with http://example.com. Since new URL() already strips scheme-default ports, the check can simply be port !== "".
  • Shortcut search ordering in shortcuts-service.ts: The SQL query orders by lastAccessTime only, so the limit cap can exclude high-hit-count older shortcuts before the JS relevance scorer ever sees them.

Confidence Score: 2/5

  • Not safe to merge yet — two logic bugs (race condition on show, stale pending matches) could cause visible blank omnibox or result flashing in real usage.
  • The flicker-free architecture is sound in principle, but the refactoring introduced a regression: sendShowEvent is called directly from three entry points without the initialLoadComplete guard that loadInterface previously provided. Additionally, AutocompleteController.stop() does not clear pendingMatches, leaving stale state across show/hide cycles. Both issues are reproducible under normal browser startup conditions. The port normalization bug is an edge case but still incorrect. The overall PR quality is high — the issues are localized and fixable with small changes.
  • src/main/ipc/app/new-tab.ts, src/main/controllers/app-menu-controller/menu/items/file.ts, and src/main/ipc/window/omnibox.ts (race condition); src/renderer/src/lib/omnibox/autocomplete-controller.ts (stale pending matches); src/renderer/src/lib/omnibox/url-normalizer.ts (port normalization).

Important Files Changed

Filename Overview
src/main/controllers/windows-controller/utils/browser/omnibox.ts Core flicker-free architecture: adds initialLoadComplete flag and IPC-based show/hide. The loadInterface method correctly gates on initialLoadComplete, but new direct callers of sendShowEvent (in file.ts, new-tab.ts, window/omnibox.ts) bypass this guard, introducing a race condition on early Cmd+T.
src/renderer/src/lib/omnibox/autocomplete-controller.ts Adds arrow-key navigation lock, default match stability, and provider timing diagnostics. pendingMatches is not cleared in stop() or start(), allowing stale buffered results from a previous session to briefly appear on the next keystroke after hide/show.
src/renderer/src/lib/omnibox/scoring-engine.ts New centralized scoring engine implementing frecency normalization, match quality, input-length weighting, and bonus/penalty adjustments. Well-structured and correctly clamped to provider ranges.
src/renderer/src/lib/omnibox/url-normalizer.ts Enhanced URL normalization with trailing slash, percent-encoding, fragment stripping, and stable query sort. Port handling has a scheme-unaware bug: port !== "443" incorrectly strips port 443 from HTTP URLs (which is non-default for HTTP), producing wrong dedup keys.
src/main/saving/omnibox-shortcuts/shortcuts-service.ts New service for learned shortcut mappings. Uses drizzle ORM with parameterized queries (safe). Search query orders by lastAccessTime only, not relevance, potentially missing high-frequency older shortcuts before the JS scorer can rank them.
src/renderer/src/lib/omnibox/providers/shortcut.ts New ShortcutsProvider with exponential decay scoring and inline completion. AbortController correctly cancels in-flight IPC calls on stop(). Scoring formula is well-reasoned with log-scaled hit count and specificity bonus.
src/renderer/src/components/omnibox/main.tsx Converts OmniboxMain from a remount-on-show component to a persistent one driven by IPC events. Clean separation of onShow/onHide lifecycle, backward-compat URL param handling, and proper useCallback/useRef usage.
src/main/ipc/window/omnibox.ts IPC handler for renderer-initiated omnibox show: switched from loadInterface to sendShowEvent, which bypasses the initialLoadComplete guard — same race condition as in new-tab.ts and file.ts.

Sequence Diagram

sequenceDiagram
    participant User
    participant MainProcess as Main Process
    participant OmniboxWindow as Omnibox (Main)
    participant OmniboxRenderer as Omnibox (Renderer)
    participant Controller as AutocompleteController
    participant Providers as Providers (HQP/Search/Shortcuts/...)
    participant DB as SQLite DB

    Note over OmniboxWindow: App startup
    OmniboxWindow->>OmniboxRenderer: loadInterface() → loads URL (first time only)
    OmniboxRenderer-->>OmniboxWindow: did-finish-load → initialLoadComplete=true

    User->>MainProcess: Cmd+T / address bar click
    MainProcess->>OmniboxWindow: sendShowEvent({currentInput, openIn})
    OmniboxWindow->>OmniboxRenderer: IPC: omnibox:do-show (params)
    OmniboxRenderer->>OmniboxRenderer: setInput / setIsVisible(true) / focus

    OmniboxRenderer->>Controller: handleInput(text, "focus")
    Controller->>Providers: start(input) [parallel]
    Providers->>DB: history / shortcuts queries
    DB-->>Providers: results
    Providers-->>Controller: onProviderResults(matches)

    alt User presses arrow key
        User->>OmniboxRenderer: ArrowUp/ArrowDown
        OmniboxRenderer->>Controller: onUserArrowKey()
        Controller->>Controller: _userIsNavigating=true
        Providers-->>Controller: late results → buffered in pendingMatches
    end

    alt User types next character
        User->>OmniboxRenderer: keystroke
        OmniboxRenderer->>Controller: onUserKeystroke(input)
        Controller->>Controller: applyPendingUpdates() → updateResults()
        Controller->>Controller: start(newInput)
    end

    Controller-->>OmniboxRenderer: onUpdate(topMatches)
    OmniboxRenderer->>OmniboxRenderer: setMatches / render suggestions

    User->>OmniboxRenderer: selects suggestion (Enter/click)
    OmniboxRenderer->>Controller: openMatch(match, openIn)
    Controller->>DB: recordShortcutUsage(input, url, title, type)

    User->>MainProcess: closes omnibox (Esc / focus-out)
    MainProcess->>OmniboxWindow: hide()
    OmniboxWindow->>OmniboxRenderer: IPC: omnibox:do-hide
    OmniboxRenderer->>OmniboxRenderer: setIsVisible(false) / stopQuery()
Loading

Last reviewed commit: 68bde38

Comment on lines 113 to 115
}
}

Copy link

Choose a reason for hiding this comment

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

Stale pendingMatches persists across show/hide sessions

stop() (called by Omnibox.stopQuery() when the omnibox is closed) does not clear pendingMatches:

stop(): void {
    this.activeProviders = 0;
    this._providerTimings = [];
    // ← pendingMatches is NOT cleared here
}

Similarly, start() does not clear pendingMatches either. This means:

  1. User opens omnibox → presses arrow keys (_userIsNavigating = true) → some provider results arrive and are buffered into pendingMatches
  2. User closes omnibox → stop() is called, _userIsNavigating stays true, pendingMatches retains stale entries
  3. User opens omnibox again → types a character → onUserKeystroke() is called → applyPendingUpdates() fires → stale results from the previous session are briefly rendered before the new query completes

pendingMatches should be cleared in stop() (and ideally also in start()) to prevent cross-session contamination:

stop(): void {
    this.activeProviders = 0;
    this._providerTimings = [];
    this.pendingMatches = [];
    this._userIsNavigating = false;
}

@greptile-apps
Copy link

greptile-apps bot commented Mar 3, 2026

Additional Comments (3)

src/main/ipc/app/new-tab.ts
Race condition: sendShowEvent bypasses initialLoadComplete guard

sendShowEvent unconditionally calls this.webContents.send("omnibox:do-show", params) without checking initialLoadComplete. The previous loadInterface call was the single routing point that either loaded the URL (first show) or sent via IPC (subsequent shows). By calling sendShowEvent directly here (and in file.ts and window/omnibox.ts), the initialLoadComplete guard is bypassed.

If the user presses Cmd+T during the ~200–500 ms window while the omnibox renderer is still loading its initial URL (between the constructor's setTimeout firing and did-finish-load), the IPC message is sent to a page that has not yet mounted its onShow listener. The message is silently dropped, and the omnibox opens in a blank/uninitialized state.

The fix is to route through loadInterface (which has the guard) or add the same check to sendShowEvent. The same bug exists in src/main/controllers/app-menu-controller/menu/items/file.ts:583 and src/main/ipc/window/omnibox.ts:769.

// Route through loadInterface so it can handle the not-yet-loaded case:
omnibox.loadInterface({ currentInput: null, openIn: "new_tab" });
omnibox.setBounds(null);
omnibox.show();

src/renderer/src/lib/omnibox/url-normalizer.ts
Port normalization is scheme-unaware

The check port !== "80" && port !== "443" treats these port numbers as universally default regardless of scheme. new URL() does strip default ports from parsed.port (returning "" for them), but only when the port matches the scheme's default. For cross-protocol edge cases:

  • new URL("http://example.com:443").port"443" (non-default for HTTP, NOT stripped)
  • Current condition: "443" && "443" !== "80" && "443" !== "443"false → no port suffix
  • Result: http://example.com:443 and https://example.com both produce dedup key example.com, incorrectly treating them as duplicates

The check should be scheme-aware:

const port = parsed.port; // already empty for scheme-default ports
const hasNonDefaultPort = port !== ""; // sufficient — URL already handled defaults
const portSuffix = hasNonDefaultPort ? `:${port}` : "";

Since new URL() already handles the scheme-aware stripping, the secondary !== "80" && !== "443" checks are both redundant and incorrect.


src/main/saving/omnibox-shortcuts/shortcuts-service.ts
Search result ordering doesn't match the stated relevance semantics

The method docstring says results are "ordered by relevance (hit count decayed by recency)", but the SQL query only orders by lastAccessTime DESC. This means the limit is applied to the most-recently-accessed shortcuts, not the most-relevant ones.

Consider a user who typed "gi"github.com 50 times (last accessed 1 month ago) and typed "gi"gitlab.com once (last accessed today). If there are 5+ other recent-but-low-hit shortcuts, github.com (the strongest signal) might never appear in the limit-capped result set sent to the JS scorer.

Either add a secondary sort by hitCount to the SQL, or increase the fetch limit and rely on the JS scorer to pick the best:

.orderBy(desc(schema.omniboxShortcuts.hitCount), desc(schema.omniboxShortcuts.lastAccessTime))

Or if recency is intentionally prioritized, update the docstring to reflect that.

…tion

The blur event handler on the omnibox webcontents fires maybeHide() →
hide() → sendHideEvent() during or shortly after the show() focus
dance. Because sendShowEvent() is called BEFORE show(), the sequence
was: renderer receives show (isVisible=true), then blur triggers
hide (isVisible=false), leaving the omnibox invisible.

The fix: stop sending hide IPC from hide(). Native view visibility
(view.setVisible) is the sole mechanism for main-process-initiated
hides. The renderer already manages its own isVisible state for
user-initiated hides (Escape key, match selection), and the show
handler always resets state on next open.
…L navigation

For URL-classified inputs (e.g. roblox.com), Google's API often returns
high verbatim relevance (>=1300) for popular domains. The server
blending logic was letting this override the input-type-based score,
pushing verbatim from 1100 to 1300 and beating url-what-you-typed's
1200. Now URL inputs always use the base relevance (1100), ensuring
navigation always wins over search for URL-like input.
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