diff --git a/web/android/app/src/main/java/ai/omnigent/android/OmnigentWebViewClient.kt b/web/android/app/src/main/java/ai/omnigent/android/OmnigentWebViewClient.kt index d1b5722fd4..f1b155559a 100644 --- a/web/android/app/src/main/java/ai/omnigent/android/OmnigentWebViewClient.kt +++ b/web/android/app/src/main/java/ai/omnigent/android/OmnigentWebViewClient.kt @@ -19,6 +19,9 @@ import android.webkit.WebViewClient * The facade is normally registered with `addDocumentStartJavaScript` in * `MainActivity`. Older WebViews that support the message listener but not * document-start scripts inject it after the pinned page finishes. + * + * Also injects [WorkspaceChromeScript] once each pinned-origin document finishes, + * so a workspace-hosted server's nav chrome stays hidden. */ class OmnigentWebViewClient( private val pinnedOrigin: () -> String?, @@ -101,6 +104,16 @@ class OmnigentWebViewClient( // An app page loaded, so the mount works: re-arm the bounce budget for // the next time the user lands back on the workspace root. if (onPinnedOrigin && databricksWorkspaceUiUrl(url) == null) rootBounces = 0 + // Databricks workspace-hosted Omnigent renders inside the workspace's + // top-nav chrome (the SPA is a workspace page). Hide it by overlaying + // Omnigent's own root — see [WorkspaceChromeScript], which also explains why + // this is keyed on the pinned origin and never on the URL's path. Re-applied + // on every full load (a server switch is a fresh document); the SPA's + // client-side routing keeps the same document, so the injected stylesheet + // persists across in-app navigation. + if (onPinnedOrigin) { + view.evaluateJavascript(WorkspaceChromeScript.source, null) + } if (onPinnedOrigin && shouldInjectBridgeAtPageReady()) { view.evaluateJavascript(NativeBridgeScript.source) { onPageReady(url) } return diff --git a/web/android/app/src/main/java/ai/omnigent/android/WorkspaceChromeScript.kt b/web/android/app/src/main/java/ai/omnigent/android/WorkspaceChromeScript.kt new file mode 100644 index 0000000000..2ea68bfd92 --- /dev/null +++ b/web/android/app/src/main/java/ai/omnigent/android/WorkspaceChromeScript.kt @@ -0,0 +1,57 @@ +package ai.omnigent.android + +/** + * Hiding the Databricks workspace navigation chrome around a workspace-hosted + * Omnigent SPA. Kept in its own `WebView`-free object so the script is + * unit-testable without a live WebView, matching [NativeBridgeScript] and + * [BlobDownloadScript]. + * + * Ported from `web/electron/src/workspace-chrome.js`; the iOS shell carries the + * same logic in `WorkspaceChromeScript.swift`. Keep the CSS identical in all + * three so a fix in one shell isn't silently missing from the others. + */ +object WorkspaceChromeScript { + /** + * CSS that hides the Databricks workspace navigation chrome. + * + * On a workspace the SPA is mounted as a workspace *page*, so Databricks wraps + * it in its top-nav shell (the dark bar with the workspace switcher). In a + * dedicated app window that chrome is just noise. We promote Omnigent's own + * root — `.omnigent-app`, which the embed entry sets (`web/src/embed.tsx`) — to + * a full-viewport overlay so it paints over the workspace bar. Keying on + * Omnigent's wrapper (defined in THIS repo) rather than the monolith-owned, + * unstable workspace nav markup keeps this from silently breaking when + * Databricks reshuffles its chrome; on a standalone (non-embed) build there is + * no `.omnigent-app`, so the rule is a harmless no-op. + */ + val css: String = + """ + .omnigent-app { + position: fixed !important; + inset: 0 !important; + z-index: 2147483647 !important; + } + """.trimIndent() + + /** + * JS that installs [css] into the current document, at most once. + * + * The caller injects this on every finished main-frame load of the pinned + * origin, and must NOT gate it on the URL's path. The workspace serves the SPA + * on more than one mount (`/ml/omnigents` for the desktop shells, `/omnigent` + * in `omnigent/conversation_browser.py`) and an auth redirect can land on + * neither, so a path guard leaves the workspace switcher visible — from there a + * user navigates into another workspace app with no way back. Do not + * reintroduce a path guard. + */ + val source: String = + """ + (() => { + if (document.querySelector("style[data-omnigent-workspace-chrome]")) return; + const style = document.createElement("style"); + style.dataset.omnigentWorkspaceChrome = "true"; + style.textContent = ${jsString(css)}; + document.documentElement.appendChild(style); + })(); + """.trimIndent() +} diff --git a/web/android/app/src/test/java/ai/omnigent/android/OmnigentWebViewClientTest.kt b/web/android/app/src/test/java/ai/omnigent/android/OmnigentWebViewClientTest.kt index 676e739b4f..329f0d5a63 100644 --- a/web/android/app/src/test/java/ai/omnigent/android/OmnigentWebViewClientTest.kt +++ b/web/android/app/src/test/java/ai/omnigent/android/OmnigentWebViewClientTest.kt @@ -25,7 +25,7 @@ class OmnigentWebViewClientTest { client.onPageStarted(webView, PINNED_URL, null) - assertNull(webView.evaluatedScript) + assertTrue(webView.evaluatedScripts.isEmpty()) } @Test @@ -39,13 +39,55 @@ class OmnigentWebViewClientTest { client.onPageFinished(webView, PINNED_URL) - assertEquals(NativeBridgeScript.source, webView.evaluatedScript) + // Chrome-hide CSS first, then the facade — the facade's callback is what + // declares the page ready, so it has to be the last script evaluated. + assertEquals( + listOf(WorkspaceChromeScript.source, NativeBridgeScript.source), + webView.evaluatedScripts, + ) assertNull(readyUrl) webView.completeEvaluation() assertEquals(PINNED_URL, readyUrl) } + @Test + fun `pinned page finish hides the workspace chrome without the facade fallback`() { + val webView = RecordingWebView(ApplicationProvider.getApplicationContext()) + val client = client(shouldInjectBridgeAtPageReady = false) + + client.onPageFinished(webView, PINNED_URL) + + assertEquals(listOf(WorkspaceChromeScript.source), webView.evaluatedScripts) + } + + @Test + fun `workspace chrome hide is not gated on the ui mount path`() { + val webView = RecordingWebView(ApplicationProvider.getApplicationContext()) + val client = client(shouldInjectBridgeAtPageReady = false) + + // A post-login landing on the pinned server's root, and the `/omnigent` + // mount the CLI records — neither starts with `/ml/omnigents`. Both must + // still get the CSS or the workspace switcher stays visible. + client.onPageFinished(webView, "$PINNED_ORIGIN/") + client.onPageFinished(webView, "$PINNED_ORIGIN/omnigent/c/abc") + + assertEquals( + listOf(WorkspaceChromeScript.source, WorkspaceChromeScript.source), + webView.evaluatedScripts, + ) + } + + @Test + fun `off-origin page finish injects nothing`() { + val webView = RecordingWebView(ApplicationProvider.getApplicationContext()) + val client = client(shouldInjectBridgeAtPageReady = true) + + client.onPageFinished(webView, IDP_URL) + + assertTrue(webView.evaluatedScripts.isEmpty()) + } + @Test fun `idp redirect loads inline when the server authenticates in the webview`() { val webView = RecordingWebView(ApplicationProvider.getApplicationContext()) @@ -282,7 +324,7 @@ class OmnigentWebViewClientTest { private class RecordingWebView( context: Context, ) : WebView(context) { - var evaluatedScript: String? = null + val evaluatedScripts = mutableListOf() var stopLoadingCalled = false var currentUrl: String? = null var loadedUrl: String? = null @@ -302,7 +344,7 @@ class OmnigentWebViewClientTest { script: String, resultCallback: ValueCallback?, ) { - evaluatedScript = script + evaluatedScripts += script callback = resultCallback } diff --git a/web/android/app/src/test/java/ai/omnigent/android/WorkspaceChromeScriptTest.kt b/web/android/app/src/test/java/ai/omnigent/android/WorkspaceChromeScriptTest.kt new file mode 100644 index 0000000000..fe28166229 --- /dev/null +++ b/web/android/app/src/test/java/ai/omnigent/android/WorkspaceChromeScriptTest.kt @@ -0,0 +1,51 @@ +package ai.omnigent.android + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WorkspaceChromeScriptTest { + /** + * The rule must key on Omnigent's own embed root, not on the monolith-owned + * workspace nav markup, and must win over it (fixed + full-inset + top layer). + */ + @Test + fun `css promotes the embed root to a full-viewport overlay`() { + assertEquals( + """ + .omnigent-app { + position: fixed !important; + inset: 0 !important; + z-index: 2147483647 !important; + } + """.trimIndent(), + WorkspaceChromeScript.css, + ) + } + + /** + * The script runs on every finished load, so re-running it on a document that + * already carries the stylesheet must not stack duplicate `