Skip to content

UTI: top nav bar with text-driven visibility#9159

Merged
malmstein merged 19 commits into
developfrom
07-09-uti_top_nav_bar
Jul 13, 2026
Merged

UTI: top nav bar with text-driven visibility#9159
malmstein merged 19 commits into
developfrom
07-09-uti_top_nav_bar

Conversation

@malmstein

@malmstein malmstein commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Task/Issue URL: https://app.asana.com/1/137249556945/project/1174433894299346/task/1216390319388562?focus=true
Tech Design URL (if applicable):

Description

Adds a persistent top nav bar to the Unified Text Input (UTI) and wires the complementary visibility behaviours so the chrome around the input feels consistent.

Feature flag / guardrail

  • Gated behind a new nativeInputNavBar sub-feature on DuckChatFeature (default internal), exposed through the DuckChat API as observeNativeInputNavBarEnabled().
  • The nav bar is created only when the flag is enabled and the address bar is in Search & Duck.ai mode. Users in Search Only, or with the flag disabled, never get it — regardless of anything else below.

Nav bar visibility

  • The nav bar (back / fire / tab switcher / browser menu) is shown only for browser input (InputContext.BROWSER) and only while the input field is empty. Whitespace counts as text.
  • Once the user types, the bar slides up and away; in top mode the input widget rides up with it to fill the space. Clearing the field reverses it.
  • It is never shown in an active Duck.ai conversation or contextual input. It is shown for both omnibar positions (top and bottom).
  • Duck.ai tab (bottom omnibar): when the Duck.ai tab is selected in the browser unified input, its suggestions span the top of the screen. The suggestions list is inset below the nav bar and the bar is floated above it (bottom omnibar only), so the bar stays visible instead of being covered.

Toggle-row back arrow

  • The back arrow next to the Search/Duck.ai selector is the inverse of the nav bar: hidden while the field is empty (the nav bar shows its own back then), shown once there is text — so exactly one back arrow is ever visible, never two.

NTP return hatch

  • When the nav bar flag is on (and Search & Duck.ai), the return hatch hides its own tabs button — those users get the tabs affordance from the nav bar, so the hatch would otherwise duplicate it. The hatch's fire and options (⋮) are unchanged.

Notable internals

  • configureContentOffset registered its layout listeners additively; recomputing the offset per toggle would have stacked competing listeners and silently looped layout in bottom mode. Listeners now register once, with a cheap updateNavBarInset(px) for subsequent changes.
  • The nav bar slide uses its own ValueAnimator (independent of the widget↔omnibar morph) with a cancellation guard.
  • The nav bar back routes through the widget's onBackPressed() so it fires the same back-button pixel as the toggle-row back (consistent telemetry).
  • The nav bar's fire/tabs/menu views use distinct inputModeWidgetNav* ids so the two views attached to the same parent don't share ids.
  • Autocomplete/content offsets are computed from the widget's on-screen (translation-aware) bottom so the nav-bar ride-up in top mode, and prefilled reopens, don't leave a gap.

Steps to test this PR

Enable the feature

  • Ensure the nativeInputNavBar flag is enabled (internal builds default to on).
  • Settings → AI Features → set the address bar mode to "Toggle between Search and Duck.ai".

Browser, top omnibar

  • Focus the address bar with an empty field → the top nav bar (back / fire / tabs / menu) is shown; there is no back arrow next to the Search/Duck.ai selector.
  • Type any text → the nav bar slides up and the widget rises to the top; the back arrow next to the selector now appears.
  • Clear the field → the nav bar slides back down and the selector back arrow disappears.
  • Type only spaces → nav bar stays hidden (whitespace counts as text).
  • Re-open with a prefilled field → the nav bar starts hidden and the widget sits at the top with no gap, no flash.

Browser, bottom omnibar

  • Nav bar shown at the top, input widget at the bottom; typing hides the bar, clearing shows it. Confirm no layout churn over an NTP with favourites/RMF.
  • Select the Duck.ai tab → the nav bar stays visible and its suggestions sit below it (not covered).

Search Only / flag off

  • Set the address bar to Search Only (or disable the nativeInputNavBar flag) → no nav bar in any state.

Duck.ai conversation / contextual

  • No nav bar is shown in an active Duck.ai conversation or contextual input.

Return hatch

  • With the nav bar flag on and Search & Duck.ai, on an NTP showing the "Return to…" hatch → the hatch shows fire and options (⋮) but no tabs button.

UI changes

Before After
(no top nav bar) (empty) nav bar shown, no selector back arrow; (typing) nav bar hidden, selector back arrow shown

Note

Medium Risk
Touches browser chrome layout, animations, and NTP/hatch visibility with many edge cases (top vs bottom omnibar, prefilled text, mode/flag toggles); risk is UI regressions rather than security, mitigated by feature flag and extensive unit tests.

Overview
Adds a persistent top nav bar (back, fire, tabs, menu) to unified native browser input, behind the new nativeInputNavBar flag and only in Search & Duck.ai mode—not in search-only, Duck.ai chat, or contextual input.

The bar is visible only while the input field is empty; typing slides it away (in top omnibar mode the widget rides up with it). Clearing text brings it back. The toggle-row back arrow is now the inverse: hidden when empty (nav bar back shows) and shown when there is text, so only one back control appears.

BrowserTabFragment wires tab switcher and browser menu into native input callbacks. NativeInputManager creates/binds the nav bar, observes the flag and input-mode changes (tearing down the widget on search-only), and drives visibility via animateNavBarVisibility. NativeInputLayoutCoordinator offsets widget, NTP/browser content, and autocomplete using nav-bar inset and translation-aware widget bounds; updateNavBarInset avoids stacking layout listeners.

The NTP return hatch hides its tabs button when the nav bar flag is on, avoiding duplicate tabs affordances. DuckChat exposes observeNativeInputNavBarEnabled(). Unit tests cover visibility rules, offsets, animator snap behavior, and nav bar wiring.

Reviewed by Cursor Bugbot for commit 900b9eb. Bugbot is set up for automated code reviews on this repo. Configure here.

malmstein and others added 12 commits July 10, 2026 14:11
Cover the pieces extracted for testability: the contentTopInset pure
function (per-mode content offset), the nav bar layout params and widget
top offset in NativeInputLayoutCoordinator, and the bindInputModeNavBar
click wiring including null-safety when buttons are absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
shouldShowNavBar encodes the browser-context + empty-field rule;
shouldAnimateNavBar guards idempotent visibility toggles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
translationY slide with a dedicated animator so a visibility toggle
never cancels the widget morph. Top mode rides the widget with the bar;
bottom mode animates the bar alone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
bindInputEvents gains an onInputTextEmptyChanged callback fired on every
text change from the existing length-based emptiness check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Attach the nav bar only for browser input, then show it while the field
is empty and slide it away once there is text; top mode rides the widget
with the bar. Duck.ai no longer shows the bar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
configureContentOffset registered a fresh set of layout listeners
on every call, closing over that call's navBarInsetPx and never
removed except on widget detach. The manager called it twice at
attach (inset 0, then inset H) plus once per empty/text toggle, so
in bottom mode the leftover listener sets fought each other every
frame via requestLayout, causing a silent sustained layout loop;
in top mode the sets simply accumulated per session.

Listeners now register once, and the live inset lives in a mutable
field they read directly. updateNavBarInset updates the field and
replays the stored re-apply hook instead of re-registering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The toggle-row back arrow now mirrors the inverse of the top nav bar:
hidden while the field is empty (the nav bar shows its own back then)
and shown once there is text, so the two are never visible together.
Browser only; the arrow never renders in Duck.ai / contextual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Route the nav bar back through the widget's onBackPressed so it fires
the same back-button pixel as the toggle-row back (the two are
complementary, so otherwise empty-state back presses go uncounted).

Give the nav bar's fire/tabs/menu views distinct inputModeWidgetNav*
ids instead of reusing the widget's, so the two views attached to the
same parent no longer share ids (view-state / a11y hazard).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
malmstein and others added 4 commits July 13, 2026 11:41
In top-omnibar mode the nav bar hides by riding the widget up via
translationY, a render transform that leaves the widget's layout
bottom unchanged. The autocomplete offset read that layout bottom, so
the suggestions kept padding for the widget's old nav-bar-shown
position, leaving a gap the size of the nav bar between the widget and
the first result.

Compute the top offset from the widget's on-screen bottom (layout
bottom plus translationY) and reapply it when the nav bar toggles,
since a translationY change fires no layout listener. The bottom
offset stays layout-based because the bottom-mode nav bar does not
ride the widget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
applyForcedBottomTranslation runs a frame after attach and reset a
top-anchored widget's translationY to zero. When reopening the unified
input with prefilled text in top-omnibar mode, the nav bar is hidden
and the widget rides up by the nav bar height via translationY; the
reset undid that ride, dropping the widget and leaving a gap under the
top chrome with misaligned content padding.

Only reset bottom-anchored widgets. A top-anchored widget's
translationY is owned by the nav bar visibility, so leave it alone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With a bottom omnibar the autocomplete suggestions list spans the top
of the screen and drew over the nav bar strip, hiding it whenever the
Duck.ai tab was selected (its suggestions show even with an empty
field). Two changes fix it:

- Offset the suggestions list below the nav bar in bottom mode by the
  nav bar inset (was hard-zero), reusing the updateNavBarInset reapply
  hook so it tracks the empty/text toggle.
- Float the nav bar above the suggestions list via translationZ, but
  only with a bottom omnibar. A top omnibar has no such overlap (the
  legacy AppBar already occupies the top), and the elevation shadow
  there would tint the bar so its background no longer matches the
  content.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a nativeInputNavBar sub-feature to DuckChatFeature (default
internal) as an extra guardrail on the input-mode nav bar. Expose it
through the DuckChat API as observeNativeInputNavBarEnabled and have
the manager gate nav bar creation on it.

The bar is now created only when the flag is enabled AND the address
bar is in Search & Duck.ai mode. Search-only users, and users without
the flag, never get it. Duck.ai conversation and contextual input are
unchanged (still no bar). Decision extracted to the pure
shouldCreateNavBar for unit testing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@cursor cursor Bot 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 6a4123e. Configure here.

For users with the nativeInputNavBar flag enabled and the address bar
in Search & Duck.ai mode, the tabs button lives in the input-mode nav
bar. Hide the duplicate tabs button in the return hatch for them by
folding observeNativeInputNavBarEnabled into the hatch's
shouldShowTabsButton. Search-only, flag-off, and Duck.ai-conversation
cases are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@karlenDimla

Copy link
Copy Markdown
Contributor

@malmstein From testing, when I switch from Duck.Ai and Search to Search only, the Native input bar is still visible until I restart the app, Not the case for the other way around.

The nav bar was decided only at showNativeInput. If the address bar
switched from Search & Duck.ai to Search-only (or the flag flipped
off) while the input stayed open, the already-attached bar kept
showing until the widget was torn down — a restart in practice.

Fold the create-time gate into the runtime visibility decision
(navBarShouldBeVisible = shouldCreateNavBar && shouldShowNavBar) and
re-apply it from the inputModeCapability and nav-bar-flag observers,
so a live switch to Search-only now hides the bar. Creation still
happens at show time, so the reverse (becoming Search & Duck.ai
mid-session) is picked up on the next open, as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Changing the address bar from Search & Duck.ai to Search-only while
the unified input was open left the native widget on screen as a
toggle-less variant instead of reverting to the legacy omnibar; it
only corrected on the next close/refocus.

The browser stops using native input in search-only
(isNativeInputActive() is false), so when the input mode changes and
the input is no longer active, hide the widget (restoring the legacy
omnibar) rather than only re-evaluating the nav bar. hideNativeInput
no-ops when nothing is shown, and Duck.ai (still active) is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
private var navBarShown: Boolean? = null
private var navBarHeightPx: Int = 0
private var navBarIsBottom: Boolean = false
private var navBarTabCountLiveData: LiveData<Int>? = null

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.

NIT.: Why live data?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

the tabs source is already handed to us as LiveData<List> by the fragment, and the widget's own bindTabCount takes LiveData too — this just does tabs.map { it.size } and mirrors that same binding so both tab counts update the same way. didn't want to convert this one button to Flow and diverge from the sibling. happy to migrate both together if we're moving that way.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@malmstein

Copy link
Copy Markdown
Contributor Author

Thanks @karlenDimla !

@malmstein malmstein merged commit 544b654 into develop Jul 13, 2026
28 checks passed
@malmstein malmstein deleted the 07-09-uti_top_nav_bar branch July 13, 2026 16:37
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