Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6f4d49a
fix(web): keep the Workspace rail clear of the OS status and nav bars
btli Jul 30, 2026
b5b210c
fix(web): give the execution-logs panel the native safe-area insets
btli Jul 30, 2026
8658f29
test(web): reject nested Workspace rail rule
btli Jul 30, 2026
4fc6e9e
test(web): inspect every Workspace rail margin rule
btli Jul 30, 2026
b0a9e85
test(web): check the inset vars across every Workspace margin rule
btli Jul 30, 2026
0ad0001
test(web): treat shorthand margin as a Workspace rail override
btli Jul 30, 2026
71a6e20
test(web): allow only one Workspace rail margin rule
btli Jul 30, 2026
67cdaf0
test(web): count the logical margin properties as overrides
btli Jul 30, 2026
271a92f
test(web): match any spelling of the Workspace attribute selector
btli Jul 30, 2026
ae32d6a
test(web): derive the rail's at-rule check from the matched rule
btli Jul 31, 2026
3ee615c
test(web): assert the rail rule's nesting depth rather than its ances…
btli Jul 31, 2026
0b97484
fix(native): apply four-edge safe areas to the workspace rail
btli Aug 5, 2026
b322739
refactor(web): share the safe-area assertions in the CSS layout test
btli Aug 5, 2026
60cdaed
fix(android): scope the injected panel inset rule to phone widths
btli Aug 5, 2026
3f194e7
chore: restore uv.lock registry URLs to PyPI
btli Aug 5, 2026
019da2b
test(e2e): cover the Workspace rail four-edge safe-area fold on Android
btli Aug 6, 2026
369882b
fix(web): give docked md+ panels the rail's safe-area treatment
btli Aug 6, 2026
de5bf09
test(web): pin the insetStyles template-literal invariant
btli Aug 6, 2026
64380c5
fix(web): drop the stale rail margin from the workspace panel offset
btli Aug 5, 2026
a2cb59a
fix(android): keep the md+ conversations sidebar clear of the system …
btli Aug 6, 2026
881bb46
Merge branch 'main' into fix/android-right-rail
btli Aug 11, 2026
ac9df2b
fix(android): unify native safe-area rule, guard collapsed panels, re…
btli Aug 12, 2026
3136fe1
fix(android): exclude peeking sidebar from native safe-area fold
btli Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions tests/e2e_ui/mobile/test_android_shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@

from playwright.sync_api import Page, ViewportSize, expect

from tests.e2e_ui.conftest import open_right_rail

# A phone-sized viewport: the Android shell is a mobile surface, and the narrow
# width is where the sidebar behaves as an overlay drawer (the
# ``[data-android-native]`` drawer rules this change adds). The
Expand Down Expand Up @@ -134,6 +136,184 @@ def test_no_android_tag_or_fold_in_plain_browser(
assert page.evaluate(_READ_SAFE_TOP_PX) == "0px"


# Inject all four OS insets the way the shell does (distinct per edge, so an
# edge-for-edge mapping error can't cancel out): status bar top, gesture-nav
# bottom, display-cutout left/right.
_INJECT_FOUR_EDGE_INSETS = """
() => {
const s = document.documentElement.style;
s.setProperty('--omnigent-android-safe-area-top', '17px');
s.setProperty('--omnigent-android-safe-area-bottom', '23px');
s.setProperty('--omnigent-android-safe-area-left', '11px');
s.setProperty('--omnigent-android-safe-area-right', '13px');
}
"""

_READ_RAIL_LAYOUT = """
() => {
const rail = document.querySelector('aside[aria-label="Workspace"]');
if (!rail) return null;
const cs = getComputedStyle(rail);
const rect = rail.getBoundingClientRect();
const tabs = rail.querySelector('[role="tablist"]');
const tabsRect = tabs ? tabs.getBoundingClientRect() : null;
return {
padding: {
top: cs.paddingTop,
bottom: cs.paddingBottom,
left: cs.paddingLeft,
right: cs.paddingRight,
},
margin: {
top: cs.marginTop,
bottom: cs.marginBottom,
left: cs.marginLeft,
right: cs.marginRight,
},
rect: { top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left },
tabs: tabsRect
? { top: tabsRect.top, left: tabsRect.left, right: tabsRect.right }
: null,
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
};
}
"""

# The four injected insets, edge for edge: status bar top, gesture-nav bottom,
# display-cutout left/right.
_EXPECTED_FOUR_EDGE_PADDING = {
"top": "17px",
"bottom": "23px",
"left": "11px",
"right": "13px",
}


def test_workspace_rail_folds_inset_on_all_four_edges(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""The md+ Workspace rail pads itself by the injected OS inset, edge for edge.

The rail only exists at desktop width (hidden below 48rem), outside the
mobile drawer rules, so it needs its own safe-area rule: on a landscape
phone / tablet with a display cutout its content would otherwise start
under the cutout and the gesture-nav bar. Asserts the full chain: injected
bridge -> ``data-android-native`` -> the ``--omnigent-android-safe-area-*``
fold -> computed four-edge padding on the real rail — and that the inset
arrives as PADDING only: zero margin, the rail box flush with the window
edges (a second displacement via margin, position, or the app-owned
``--omnigent-inset-*``/``--omnigent-native-*`` vars would double-count),
with the tab strip landing inside the padded content box.

:param page: Playwright page fixture (fresh context per test).
:param seeded_session: ``(base_url, session_id)`` of a runner-bound session.
"""
base_url, session_id = seeded_session

page.set_viewport_size({"width": 1280, "height": 800})
page.add_init_script(_ANDROID_SHELL_INIT_SCRIPT)
page.goto(f"{base_url}/c/{session_id}")
open_right_rail(page)

page.evaluate(_INJECT_FOUR_EDGE_INSETS)
layout = page.evaluate(_READ_RAIL_LAYOUT)
assert layout["padding"] == _EXPECTED_FOUR_EDGE_PADDING

# The fold displaces nothing: no margin, and the box stays flush with the
# window's top/right/bottom edges whatever channel a future rule might use.
assert layout["margin"] == {
"top": "0px",
"bottom": "0px",
"left": "0px",
"right": "0px",
}
assert abs(layout["rect"]["top"]) <= 0.5
assert layout["rect"]["right"] >= layout["innerWidth"] - 1
assert layout["rect"]["bottom"] >= layout["innerHeight"] - 1

# The padding genuinely insets content: the rail's tab strip sits inside
# the padded content box on every folded edge.
tabs = layout["tabs"]
assert tabs is not None
assert tabs["top"] >= 17
assert tabs["left"] >= layout["rect"]["left"] + 11
assert tabs["right"] <= layout["rect"]["right"] - 13


_READ_EXEC_PANEL_LAYOUT = """
() => {
const panel = document.querySelector('[data-testid="execution-logs-panel"]');
if (!panel) return null;
const cs = getComputedStyle(panel);
const rect = panel.getBoundingClientRect();
return {
padding: {
top: cs.paddingTop,
bottom: cs.paddingBottom,
left: cs.paddingLeft,
right: cs.paddingRight,
},
width: rect.width,
right: rect.right,
innerWidth: window.innerWidth,
};
}
"""


def test_execution_logs_panel_folds_inset_below_and_above_md(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""The execution-logs panel folds the OS inset as an overlay AND docked.

Below 48rem the panel is a full-screen overlay drawer; above it a docked
right-edge push panel. Both incarnations touch screen edges, so both must
fold the injected OS inset into their padding — a phone-width-gated rule
covers only the overlay. Between the two, asserts the CLOSED panel stays
zero-width at md+ with insets injected: it remains mounted (``w-0``), and
with border-box sizing any padding on it would render as a cutout-sized
gap in the layout.

:param page: Playwright page fixture (fresh context per test).
:param seeded_session: ``(base_url, session_id)`` of a runner-bound session.
"""
base_url, session_id = seeded_session

page.set_viewport_size({"width": 390, "height": 844})
page.add_init_script(_ANDROID_SHELL_INIT_SCRIPT)
page.goto(f"{base_url}/c/{session_id}?debug=1")

# Below 48rem: open the full-screen overlay from the session-menu FAB
# (its Logs entry is debug-mode only).
page.get_by_role("button", name="Open session menu").click()
page.get_by_role("menuitem", name="Logs").click()
panel = page.locator('[data-testid="execution-logs-panel"]')
expect(panel).to_be_visible()

page.evaluate(_INJECT_FOUR_EDGE_INSETS)
layout = page.evaluate(_READ_EXEC_PANEL_LAYOUT)
assert layout["padding"] == _EXPECTED_FOUR_EDGE_PADDING

# Above 48rem the closed panel stays mounted at w-0 — with insets
# injected its layout width must remain zero.
panel.get_by_role("button", name="Close").click()
page.set_viewport_size({"width": 1280, "height": 800})
expect(panel).to_have_attribute("data-collapsed", "true")
assert page.evaluate(_READ_EXEC_PANEL_LAYOUT)["width"] == 0

# Open it docked from the debug SessionRail and re-assert the same fold.
page.get_by_test_id("execution-log-row-main").click()
expect(panel).not_to_have_attribute("data-collapsed", "true")
layout = page.evaluate(_READ_EXEC_PANEL_LAYOUT)
assert layout["padding"] == _EXPECTED_FOUR_EDGE_PADDING
# Docked flush against the right screen edge: the inset is padding, not
# displacement.
assert layout["right"] >= layout["innerWidth"] - 1


# Bridge stub that also CAPTURES the notification-activation callback the SPA
# registers (``useIdleNotifications`` -> ``onNativeNotificationActivated``), so a
# test can fire it the way the Android shell does when its badge notification is
Expand Down
51 changes: 34 additions & 17 deletions web/android/app/src/main/java/ai/omnigent/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,33 @@ import androidx.webkit.ScriptHandler
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature

internal fun systemSafeAreaInsets(insets: WindowInsetsCompat): Insets =
insets.getInsets(
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
)

internal fun androidSafeAreaScript(
insets: Insets,
density: Float,
): String =
"""
(() => {
const s = document.documentElement.style;
const top = '${insets.top / density}px';
const bottom = '${insets.bottom / density}px';
const left = '${insets.left / density}px';
const right = '${insets.right / density}px';
s.setProperty('--omnigent-safe-top', top);
s.setProperty('--omnigent-safe-bottom', bottom);
s.setProperty('--omnigent-safe-left', left);
s.setProperty('--omnigent-safe-right', right);
s.setProperty('--omnigent-android-safe-area-top', top);
s.setProperty('--omnigent-android-safe-area-bottom', bottom);
s.setProperty('--omnigent-android-safe-area-left', left);
s.setProperty('--omnigent-android-safe-area-right', right);
})();
""".trimIndent()

/**
* The single WebView host. Mirrors the iOS `WebShellView` + `OmnigentWebView`:
* loads the server-served SPA, installs the `window.omnigentNative` bridge, and
Expand Down Expand Up @@ -194,7 +221,7 @@ class MainActivity : AppCompatActivity() {
// alone (unreliable < API 30 and across OEM builds). Cached so the first
// post-load emit (in onPageReady) isn't lost to the pre-load race.
ViewCompat.setOnApplyWindowInsetsListener(webView) { view, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val bars = systemSafeAreaInsets(insets)
val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
// Edge-to-edge (setDecorFitsSystemWindows=false, above) neutralizes the
// manifest's adjustResize: the window no longer shrinks when the IME
Expand Down Expand Up @@ -640,11 +667,15 @@ class MainActivity : AppCompatActivity() {
}

private fun emitInsets() {
// lastInsets unions systemBars() with displayCutout() (per-edge max — see
// systemSafeAreaInsets), so a landscape cutout reaches the left/right
// vars; any cutout share of top/bottom also widens the existing
// chat-surface padding on cutout devices.
// Feed the OS safe area into the web layer two ways, because the shell
// pins to a user-supplied server whose web build may PRE-DATE the Android
// shell's CSS — it can't be assumed to carry the `[data-android-native]`
// fold:
// 1. `--omnigent-safe-top/bottom` — the app's OWN base inset vars. Every
// 1. `--omnigent-safe-*` — the app's OWN base inset vars. Every
// build already derives `--omnigent-inset-*` and its layout from
// these, defaulting them to `env(safe-area-inset-*)`, which Android
// WebView reports as 0. Setting them inline (highest priority)
Expand All @@ -658,21 +689,7 @@ class MainActivity : AppCompatActivity() {
// the safe area there would mis-assign it to a bar-footprint variable.
val bars = lastInsets ?: return
val d = resources.displayMetrics.density
val js =
"""
(() => {
const s = document.documentElement.style;
const top = '${bars.top / d}px';
const bottom = '${bars.bottom / d}px';
s.setProperty('--omnigent-safe-top', top);
s.setProperty('--omnigent-safe-bottom', bottom);
s.setProperty('--omnigent-android-safe-area-top', top);
s.setProperty('--omnigent-android-safe-area-bottom', bottom);
s.setProperty('--omnigent-android-safe-area-left', '${bars.left / d}px');
s.setProperty('--omnigent-android-safe-area-right', '${bars.right / d}px');
})();
""".trimIndent()
webView.evaluateJavascript(js, null)
webView.evaluateJavascript(androidSafeAreaScript(bars, d), null)
}

private fun hasPermission(permission: String): Boolean =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ package ai.omnigent.android
* `evaluateJavascript` into the `window.__omnigentNativeEmit*` functions here.
*/
object NativeBridgeScript {
// Interpolated into a JS template literal in [source], so this CSS must stay
// free of backticks and of the `${` sequence.
internal val insetStyles: String =
"""
.chat-header{top:max(0px, calc(var(--omnigent-safe-top, 0px) - 0.5rem)) !important}
.chat-conversation-content{padding-top:calc(var(--omnigent-header-height, 3.5rem) + 1.5rem + var(--omnigent-safe-top, 0px)) !important}
.main-terminal-view{padding-top:calc(3.25rem + var(--omnigent-safe-top, 0px)) !important}
.chat-composer-form{padding-bottom:calc(0.75rem + var(--omnigent-safe-bottom, 0px)) !important}
.chat-composer-form.terminal-first-composer-form{padding-bottom:0.25rem !important}
.terminal-first-switcher-container{padding-bottom:calc(0.35rem + var(--omnigent-safe-bottom, 0px)) !important}
:is(aside[aria-label="Workspace"],.conversations-sidebar,[data-testid="execution-logs-panel"],[data-testid="file-viewer"],[data-testid="files-panel-drawer"],[data-testid="terminals-panel"],[data-testid="subagents-panel-drawer"],[data-testid="todos-panel-drawer"],[data-testid="shells-panel-drawer"]):not(aside[aria-label="Workspace"] *):not([data-collapsed]):not(.is-peek){padding-top:var(--omnigent-safe-top, 0px) !important;padding-bottom:var(--omnigent-safe-bottom, 0px) !important;padding-left:var(--omnigent-safe-left, 0px) !important;padding-right:var(--omnigent-safe-right, 0px) !important}
""".trimIndent()

val source: String =
"""
(() => {
Expand Down Expand Up @@ -48,7 +61,7 @@ object NativeBridgeScript {
else document.addEventListener("DOMContentLoaded", ensureViewportFit, { once: true });

// Apply the OS safe area to the layout from the native side. emitInsets
// feeds --omnigent-safe-top/bottom (the app's own inset vars), but on a
// feeds the app's own --omnigent-safe-* vars, but on a
// server whose web build predates the Android shell the inset-aware rules
// lose the cascade: their semantic selectors (.chat-conversation-content
// etc., specificity 0,1,0) tie with the Tailwind utility classes on the
Expand All @@ -60,23 +73,9 @@ object NativeBridgeScript {
// Android WebView reports as 0, so it needs the override even pre-Tailwind.
const ensureInsetStyles = () => {
if (document.getElementById("omnigent-android-insets")) return;
const T = "var(--omnigent-safe-top, 0px)";
const B = "var(--omnigent-safe-bottom, 0px)";
const style = document.createElement("style");
style.id = "omnigent-android-insets";
style.textContent = [
".chat-header{top:max(0px, calc(" + T + " - 0.5rem)) !important}",
".chat-conversation-content{padding-top:calc(var(--omnigent-header-height, 3.5rem) + 1.5rem + " + T + ") !important}",
".main-terminal-view{padding-top:calc(3.25rem + " + T + ") !important}",
// Bottom inset belongs on whichever element is bottom-most per mode:
// the composer in regular chat, the switcher pill in terminal-first
// (its composer sits above the pill, so it must NOT also add it).
".chat-composer-form{padding-bottom:calc(0.75rem + " + B + ") !important}",
".chat-composer-form.terminal-first-composer-form{padding-bottom:0.25rem !important}",
".terminal-first-switcher-container{padding-bottom:calc(0.35rem + " + B + ") !important}",
// Drawers/panels span full height — clear both bars.
":is(.conversations-sidebar,[data-testid=\"file-viewer\"],[data-testid=\"files-panel-drawer\"],[data-testid=\"terminals-panel\"],[data-testid=\"subagents-panel-drawer\"],[data-testid=\"todos-panel-drawer\"]){padding-top:" + T + " !important;padding-bottom:" + B + " !important}",
].join("");
style.textContent = `$insetStyles`;
(document.head || document.documentElement).appendChild(style);
};
if (document.head) ensureInsetStyles();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import android.content.RestrictionsManager
import android.content.res.Configuration
import android.os.Bundle
import android.webkit.WebView
import androidx.core.graphics.Insets
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
Expand All @@ -22,6 +24,49 @@ import org.robolectric.shadows.ShadowRestrictionsManager
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class MainActivityTest {
@Test
fun `cutout-only safe area is published on every edge`() {
val cutout = Insets.of(11, 23, 31, 0)
val insets =
WindowInsetsCompat
.Builder()
.setInsets(WindowInsetsCompat.Type.displayCutout(), cutout)
.build()

val safeArea = systemSafeAreaInsets(insets)
assertEquals(cutout, safeArea)

val script = androidSafeAreaScript(safeArea, 1f)
assertTrue(script.contains("const top = '23.0px'"))
assertTrue(script.contains("const left = '11.0px'"))
assertTrue(script.contains("const right = '31.0px'"))
assertTrue(script.contains("setProperty('--omnigent-safe-left', left)"))
assertTrue(script.contains("setProperty('--omnigent-safe-right', right)"))
}

@Test
fun `landscape cutout unions with the system bars per edge`() {
// Landscape phone: the gesture nav bar keeps the bottom inset while the
// camera cutout eats the left edge — a systemBars()-only source would
// report left as 0 and let the rail/drawers slide under the cutout.
val bars = Insets.of(0, 24, 0, 16)
val cutout = Insets.of(31, 0, 0, 0)
val insets =
WindowInsetsCompat
.Builder()
.setInsets(WindowInsetsCompat.Type.systemBars(), bars)
.setInsets(WindowInsetsCompat.Type.displayCutout(), cutout)
.build()

val safeArea = systemSafeAreaInsets(insets)
assertEquals(Insets.of(31, 24, 0, 16), safeArea)

val script = androidSafeAreaScript(safeArea, 1f)
assertTrue(script.contains("const top = '24.0px'"))
assertTrue(script.contains("const bottom = '16.0px'"))
assertTrue(script.contains("const left = '31.0px'"))
}

@Test
fun `webview leaves algorithmic darkening disabled`() {
ServerStore(ApplicationProvider.getApplicationContext()).connect("https://example.com")
Expand Down
Loading
Loading