diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/DesktopScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/DesktopScreen.kt index 0662a71e9abb..461ede29f504 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/DesktopScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/DesktopScreen.kt @@ -103,22 +103,26 @@ internal fun DesktopScreen( } } -/** Builds the desktop document route; credentials stay in ControlUiWebView's startup script. */ +/** Builds the desktop focus route; credentials stay in ControlUiWebView's startup script. */ internal fun desktopUrl( baseUrl: String, source: String? = null, session: String? = null, ): String { - val baseUri = baseUrl.trimEnd('/').toUri() - val routePath = "${baseUri.encodedPath.orEmpty().trimEnd('/')}/" + val normalizedSource = source?.trim()?.takeIf(String::isNotEmpty) + val normalizedSession = session?.trim()?.takeIf(String::isNotEmpty) val builder = - baseUri + baseUrl + .trimEnd('/') + .toUri() .buildUpon() - .encodedPath(routePath) .clearQuery() .fragment(null) - .appendQueryParameter("view", "desktop") - source?.let { builder.appendQueryParameter("source", it) } - session?.let { builder.appendQueryParameter("session", it) } + .appendPath("focus") + .appendPath("desktop") + when { + normalizedSource != null -> builder.appendPath("source").appendPath(normalizedSource) + normalizedSession != null -> builder.appendPath("session").appendPath(normalizedSession) + } return builder.build().toString() } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/TerminalSettingsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/TerminalSettingsScreen.kt index 5282e63df9c8..85e73afe4ded 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/TerminalSettingsScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/TerminalSettingsScreen.kt @@ -27,10 +27,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.core.net.toUri /** * Full-height terminal surface: embeds the gateway-served terminal-only - * Control UI document (`/?view=terminal`, the same ghostty-web surface the + * Control UI focus document (`/focus/terminal`, the same ghostty-web surface the * desktop Control UI uses) for the currently connected gateway. */ @Composable @@ -61,7 +62,7 @@ internal fun TerminalSettingsScreen( key(page) { ControlUiWebView( page = page, - url = "${page.baseUrl}/?view=terminal", + url = terminalUrl(page.baseUrl), modifier = Modifier.fillMaxSize(), ) } @@ -75,3 +76,16 @@ internal fun TerminalSettingsScreen( } } } + +/** Builds the terminal focus route without putting gateway credentials in the URL. */ +internal fun terminalUrl(baseUrl: String): String = + baseUrl + .trimEnd('/') + .toUri() + .buildUpon() + .clearQuery() + .fragment(null) + .appendPath("focus") + .appendPath("terminal") + .build() + .toString() diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt index 428547a03367..84ffc3c282b5 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.async import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking @@ -438,37 +439,26 @@ class GatewayBootstrapAuthTest { fun connect_ignoresStaleTlsProbeAfterDisconnect() = runBlocking { val fingerprint = "aa".repeat(32) - val probeStarted = CompletableDeferred() + val probeJob = CompletableDeferred() val probeResult = CompletableDeferred() val (_, prefs, runtime) = gatewayFixture { _, _ -> - probeStarted.complete(Unit) + probeJob.complete(checkNotNull(currentCoroutineContext()[Job])) probeResult.await() } val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789) prefs.saveGatewayTlsFingerprint(endpoint.stableId, fingerprint) - val runtimeScope = readField(runtime, "scope") - val existingJobs = - runtimeScope.coroutineContext[Job] - ?.children - ?.toSet() - .orEmpty() runtime.connect( endpoint, auth(token = "shared-token"), ) - probeStarted.await() - val probeJob = - runtimeScope.coroutineContext[Job] - ?.children - ?.singleOrNull { it !in existingJobs } - ?: error("Expected one TLS probe job") + val tlsProbeJob = probeJob.await() runtime.disconnect() probeResult.complete(GatewayTlsProbeResult(fingerprintSha256 = fingerprint)) // Join the owning coroutine so assertions run after its stale-attempt guard. - probeJob.join() + tlsProbeJob.join() assertNull(runtime.pendingGatewayTrust.value) assertNull(desiredBootstrapToken(runtime, "nodeSession")) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/DesktopScreenTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/DesktopScreenTest.kt index 799d66380c22..3b25131acfab 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/DesktopScreenTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/DesktopScreenTest.kt @@ -1,7 +1,6 @@ package ai.openclaw.app.ui import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -11,56 +10,70 @@ import org.robolectric.annotation.Config @Config(sdk = [34]) class DesktopScreenTest { @Test - fun desktopUrlUsesDocumentModeWithoutSource() { - val url = desktopUrl(baseUrl = "https://gateway.example.com:8443/openclaw/") - - assertEquals("https://gateway.example.com:8443/openclaw/?view=desktop", url) - assertFalse(url.contains("token=")) - assertFalse(url.contains("password=")) - } - - @Test - fun desktopUrlEncodesProvidedSource() { - val url = - desktopUrl( - baseUrl = "https://gateway.example.com:8443", - source = "environment:Mac Studio/QA & demo", + fun desktopUrlBuildsCanonicalFocusPaths() { + val cases = + listOf( + DesktopUrlCase( + name = "root base", + baseUrl = "https://gateway.example.com:8443", + expected = "https://gateway.example.com:8443/focus/desktop", + ), + DesktopUrlCase( + name = "configured base path", + baseUrl = "https://gateway.example.com:8443/openclaw/", + expected = "https://gateway.example.com:8443/openclaw/focus/desktop", + ), + DesktopUrlCase( + name = "encoded source", + baseUrl = "https://gateway.example.com:8443", + source = "environment:Mac Studio/QA & demo", + expected = + "https://gateway.example.com:8443/focus/desktop/source/environment%3AMac%20Studio%2FQA%20%26%20demo", + ), + DesktopUrlCase( + name = "encoded session under configured base path", + baseUrl = "https://gateway.example.com:8443/openclaw/", + session = "agent:main:mobile session", + expected = + "https://gateway.example.com:8443/openclaw/focus/desktop/session/agent%3Amain%3Amobile%20session", + ), + DesktopUrlCase( + name = "source wins over session", + baseUrl = "https://gateway.example.com:8443", + source = "node:worker-1", + session = "agent:main:mobile", + expected = "https://gateway.example.com:8443/focus/desktop/source/node%3Aworker-1", + ), + DesktopUrlCase( + name = "empty source falls through to session", + baseUrl = "https://gateway.example.com:8443", + source = " ", + session = "agent:main:mobile", + expected = "https://gateway.example.com:8443/focus/desktop/session/agent%3Amain%3Amobile", + ), + DesktopUrlCase( + name = "empty values are omitted", + baseUrl = "https://gateway.example.com:8443/openclaw/", + source = " ", + session = "\n", + expected = "https://gateway.example.com:8443/openclaw/focus/desktop", + ), ) - assertEquals( - "https://gateway.example.com:8443/?view=desktop&source=environment%3AMac%20Studio%2FQA%20%26%20demo", - url, - ) - assertFalse(url.contains("token=")) - assertFalse(url.contains("password=")) - } - - @Test - fun desktopUrlEncodesProvidedSession() { - val url = - desktopUrl( - baseUrl = "https://gateway.example.com:8443/openclaw/", - session = "agent:main:mobile session", + cases.forEach { case -> + assertEquals( + case.name, + case.expected, + desktopUrl(baseUrl = case.baseUrl, source = case.source, session = case.session), ) - - assertEquals( - "https://gateway.example.com:8443/openclaw/?view=desktop&session=agent%3Amain%3Amobile%20session", - url, - ) + } } - @Test - fun desktopUrlRetainsExplicitSourceAlongsideSession() { - val url = - desktopUrl( - baseUrl = "https://gateway.example.com:8443", - source = "node:worker-1", - session = "agent:main:mobile", - ) - - assertEquals( - "https://gateway.example.com:8443/?view=desktop&source=node%3Aworker-1&session=agent%3Amain%3Amobile", - url, - ) - } + private data class DesktopUrlCase( + val name: String, + val baseUrl: String, + val source: String? = null, + val session: String? = null, + val expected: String, + ) } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/TerminalSettingsScreenTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/TerminalSettingsScreenTest.kt new file mode 100644 index 000000000000..ea19eaa95f19 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/TerminalSettingsScreenTest.kt @@ -0,0 +1,26 @@ +package ai.openclaw.app.ui + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class TerminalSettingsScreenTest { + @Test + fun terminalUrlBuildsCanonicalFocusPath() { + val cases = + listOf( + "https://gateway.example.com:8443" to + "https://gateway.example.com:8443/focus/terminal", + "https://gateway.example.com:8443/openclaw/" to + "https://gateway.example.com:8443/openclaw/focus/terminal", + ) + + cases.forEach { (baseUrl, expected) -> + assertEquals(baseUrl, expected, terminalUrl(baseUrl)) + } + } +} diff --git a/apps/ios/Sources/Desktop/DesktopHubScreen.swift b/apps/ios/Sources/Desktop/DesktopHubScreen.swift index bb2eca1787d1..e5295bc126d6 100644 --- a/apps/ios/Sources/Desktop/DesktopHubScreen.swift +++ b/apps/ios/Sources/Desktop/DesktopHubScreen.swift @@ -100,17 +100,11 @@ struct DesktopHubScreen: View { source: String?, session: String? = nil) -> URL? { - var queryItems = [URLQueryItem(name: "view", value: "desktop")] - if let source = self.normalizedSource(source) { - queryItems.append(URLQueryItem(name: "source", value: source)) - } - if let session = self.normalizedSource(session) { - queryItems.append(URLQueryItem(name: "session", value: session)) - } + guard let path = self.desktopPath(source: source, session: session) else { return nil } return AuthenticatedControlUI.pageURL( config: config, - path: "/", - queryItems: queryItems) + path: path, + queryItems: []) } static func desktopAuthUserScript( @@ -147,13 +141,25 @@ struct DesktopHubScreen: View { hasher.combine(AuthenticatedControlUI.webContentIdentity( config: config, storedOperatorToken: storedOperatorToken)) - hasher.combine(self.normalizedSource(source)) - hasher.combine(self.normalizedSource(session)) + hasher.combine(self.desktopPath(source: source, session: session)) return hasher.finalize() } - private static func normalizedSource(_ source: String?) -> String? { - let trimmed = source?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + private static func desktopPath(source: String?, session: String?) -> String? { + let basePath = "/focus/desktop" + if let source = self.normalizedValue(source) { + guard let encoded = AuthenticatedControlUI.percentEncodedPathSegment(source) else { return nil } + return "\(basePath)/source/\(encoded)" + } + if let session = self.normalizedValue(session) { + guard let encoded = AuthenticatedControlUI.percentEncodedPathSegment(session) else { return nil } + return "\(basePath)/session/\(encoded)" + } + return basePath + } + + private static func normalizedValue(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? nil : trimmed } } diff --git a/apps/ios/Sources/Terminal/TerminalHubScreen.swift b/apps/ios/Sources/Terminal/TerminalHubScreen.swift index 5195752f6ca8..c108e964f4c1 100644 --- a/apps/ios/Sources/Terminal/TerminalHubScreen.swift +++ b/apps/ios/Sources/Terminal/TerminalHubScreen.swift @@ -2,7 +2,7 @@ import OpenClawKit import SwiftUI /// Control-hub Terminal destination: embeds the gateway-served terminal page -/// (`/?view=terminal`, the ghostty-web surface shared with the Control UI) in a +/// (`/focus/terminal`, the ghostty-web surface shared with the Control UI) in a /// WKWebView, authenticated with the stored gateway credentials. struct TerminalHubScreen: View { @Environment(NodeAppModel.self) private var appModel @@ -87,14 +87,14 @@ struct TerminalHubScreen: View { } /// Derives the terminal page URL from the active gateway connection: the - /// WS endpoint flips to HTTP(S) and only `view=terminal` rides in the URL. + /// WS endpoint flips to HTTP(S) and the configured Control UI base path is preserved. /// Credentials never enter the URL — they are injected as a document-start /// user script (see `terminalAuthUserScript`), matching the macOS Dashboard. static func terminalURL(config: GatewayConnectConfig?) -> URL? { AuthenticatedControlUI.pageURL( config: config, - path: "/", - queryItems: [URLQueryItem(name: "view", value: "terminal")]) + path: "/focus/terminal", + queryItems: []) } /// Origin-gated document-start script that hands the gateway credentials to diff --git a/apps/ios/Sources/Web/AuthenticatedControlUIWebView.swift b/apps/ios/Sources/Web/AuthenticatedControlUIWebView.swift index 4ab690e1181a..23f3d9af76e6 100644 --- a/apps/ios/Sources/Web/AuthenticatedControlUIWebView.swift +++ b/apps/ios/Sources/Web/AuthenticatedControlUIWebView.swift @@ -7,6 +7,8 @@ import WebKit enum AuthenticatedControlUI { private static let queryComponentAllowed = CharacterSet( charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") + private static let pathSegmentAllowed = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!'()*") static func pageURL( config: GatewayConnectConfig?, @@ -33,10 +35,16 @@ enum AuthenticatedControlUI { return "\(name)=\(encodedValue)" } guard encodedItems.count == queryItems.count else { return nil } - components.percentEncodedQuery = encodedItems.joined(separator: "&") + components.percentEncodedQuery = encodedItems.isEmpty + ? nil + : encodedItems.joined(separator: "&") return components.url } + static func percentEncodedPathSegment(_ value: String) -> String? { + value.addingPercentEncoding(withAllowedCharacters: self.pathSegmentAllowed) + } + /// Origin-gated document-start script for the Control UI native-auth contract. static func authUserScript( config: GatewayConnectConfig?, diff --git a/apps/ios/Tests/DesktopHubScreenTests.swift b/apps/ios/Tests/DesktopHubScreenTests.swift index 9dfde13862e0..04ec327a3db1 100644 --- a/apps/ios/Tests/DesktopHubScreenTests.swift +++ b/apps/ios/Tests/DesktopHubScreenTests.swift @@ -30,13 +30,13 @@ struct DesktopHubScreenTests { @Test func `standalone desktop URL uses document mode without credentials`() throws { let config = try Self.makeConfig( - url: #require(URL(string: "wss://gateway.example.com:8443/openclaw")), + url: #require(URL(string: "wss://gateway.example.com:8443/openclaw/")), token: "secret-token", password: "secret-password") let url = DesktopHubScreen.desktopURL(config: config, source: nil, session: nil) - #expect(url?.absoluteString == "https://gateway.example.com:8443/openclaw/?view=desktop") + #expect(url?.absoluteString == "https://gateway.example.com:8443/openclaw/focus/desktop") #expect(url?.absoluteString.contains("secret-token") == false) #expect(url?.absoluteString.contains("secret-password") == false) } @@ -49,29 +49,33 @@ struct DesktopHubScreenTests { let url = DesktopHubScreen.desktopURL( config: config, source: nil, - session: "agent:main:mobile session") + session: "agent:main/mobile session") - #expect(url?.absoluteString == "http://192.168.1.10:18789/?view=desktop&session=agent%3Amain%3Amobile%20session") + #expect( + url?.absoluteString == + "http://192.168.1.10:18789/focus/desktop/session/agent%3Amain%2Fmobile%20session") #expect(url?.absoluteString.contains("secret-token") == false) } - @Test func `explicit desktop source is retained alongside the session`() throws { + @Test func `explicit desktop source wins over the session`() throws { let config = try Self.makeConfig(url: #require(URL(string: "wss://gateway.example.com"))) let url = DesktopHubScreen.desktopURL( config: config, - source: "node:worker-1", + source: "node:worker-1/primary?mode=qa", session: "agent:main:mobile") - #expect(url?.absoluteString == "https://gateway.example.com/?view=desktop&source=node%3Aworker-1&session=agent%3Amain%3Amobile") + #expect( + url?.absoluteString == + "https://gateway.example.com/focus/desktop/source/node%3Aworker-1%2Fprimary%3Fmode%3Dqa") } - @Test func `empty desktop source is omitted`() throws { + @Test func `empty desktop source and session are normalized away`() throws { let config = try Self.makeConfig(url: #require(URL(string: "wss://gateway.example.com"))) let url = DesktopHubScreen.desktopURL(config: config, source: " ", session: " ") - #expect(url?.absoluteString == "https://gateway.example.com/?view=desktop") + #expect(url?.absoluteString == "https://gateway.example.com/focus/desktop") } @Test func `desktop auth script carries credentials outside the URL`() throws { @@ -83,7 +87,7 @@ struct DesktopHubScreenTests { let url = DesktopHubScreen.desktopURL(config: config, source: "gateway") let script = DesktopHubScreen.desktopAuthUserScript(config: config, source: "gateway") - #expect(url?.absoluteString == "https://gateway.example.com/?view=desktop&source=gateway") + #expect(url?.absoluteString == "https://gateway.example.com/focus/desktop/source/gateway") #expect(url?.absoluteString.contains("secret-token") == false) #expect(url?.absoluteString.contains("secret-password") == false) #expect(script?.contains("__OPENCLAW_NATIVE_CONTROL_AUTH__") == true) diff --git a/apps/ios/Tests/TerminalHubScreenTests.swift b/apps/ios/Tests/TerminalHubScreenTests.swift index 0f9a37b53b71..96e7c3f91478 100644 --- a/apps/ios/Tests/TerminalHubScreenTests.swift +++ b/apps/ios/Tests/TerminalHubScreenTests.swift @@ -37,12 +37,12 @@ struct TerminalHubScreenTests { @Test func `terminal URL flips scheme and preserves the Control UI base path`() throws { let config = try Self.makeConfig( - url: #require(URL(string: "wss://gateway.example.com:8443/openclaw")), + url: #require(URL(string: "wss://gateway.example.com:8443/openclaw/")), token: "secret-token") let url = TerminalHubScreen.terminalURL(config: config) - #expect(url?.absoluteString == "https://gateway.example.com:8443/openclaw/?view=terminal") + #expect(url?.absoluteString == "https://gateway.example.com:8443/openclaw/focus/terminal") // Credentials must never ride in the page URL; they travel via the // document-start auth user script instead. #expect(url?.absoluteString.contains("secret-token") == false) @@ -53,7 +53,7 @@ struct TerminalHubScreenTests { let url = TerminalHubScreen.terminalURL(config: config) - #expect(url?.absoluteString == "http://192.168.1.10:18789/?view=terminal") + #expect(url?.absoluteString == "http://192.168.1.10:18789/focus/terminal") } @Test func `auth user script carries credentials gated to the page origin`() throws { diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 07764f4f3325..b8e20e89d21b 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -441,14 +441,14 @@ Codex and Claude Code sessions discovered in the sessions sidebar can open in th Eligibility is per session and per host. Gateway-local sessions start the provider-owned resume command on the Gateway host. Paired-node sessions start an allowlisted provider command on the owning node and relay only that PTY's output, input, and resize events; this does not expose a general node shell or accept browser-supplied commands. File uploads use the separate, size-bounded `terminal.upload` node command and remain bound to the already-open terminal session. Approve the node pairing upgrade when that command first appears. Nodes that do not advertise the matching terminal-resume command, including embedded worker bridges without duplex streaming, keep the viewer available and show terminal opening as unavailable; older nodes can still run a terminal but cannot receive dragged files. -Standalone operator sessions, including the full-screen terminal document, are connection-owned. A page reload, laptop sleep, or network blip detaches one on the Gateway instead of killing it, and the same browser tab reattaches on reconnect with recent output replayed. Detached connection-owned sessions are killed after `gateway.terminal.detachedSessionTimeoutSeconds` (default 300 seconds; `0` restores kill-on-disconnect). Attaching one of these sessions remains tmux-style take-over. +Standalone operator sessions, including the terminal focus presentation, are connection-owned. A page reload, laptop sleep, or network blip detaches one on the Gateway instead of killing it, and the same browser tab reattaches on reconnect with recent output replayed. Detached connection-owned sessions are killed after `gateway.terminal.detachedSessionTimeoutSeconds` (default 300 seconds; `0` restores kill-on-disconnect). Attaching one of these sessions remains tmux-style take-over. Conversation-owned sessions, whether opened by the agent tool or from that Chat session's Terminal panel, are not bound to a browser connection. `terminal.attach` adds each browser as a viewer without taking ownership, and closing a viewer tab detaches only that browser. Conversation-owned PTYs remain until the agent closes them, their process exits, policy disables them, or the Gateway shuts down. PTYs opened by a detached task close automatically when that task succeeds, fails, times out, is cancelled, or is lost. `terminal.list` marks each entry as connection- or agent-owned. All Gateway terminal PTYs are process-local. A Gateway restart ends them; the PTY sessions and their scrollback are not recovered after the new process starts. -The terminal is also available as a [full-screen terminal document](/web/urls#special-documents-and-startup-modes). The iOS and Android apps embed this page in their Terminal screens, reusing the stored gateway credentials; availability follows the same `gateway.terminal.enabled` and `operator.admin` gate, and the page shows a notice when the connected Gateway does not offer the terminal. +The terminal is also available as a [focus presentation](/web/urls#focus-presentation-routes). The iOS and Android apps embed this page in their Terminal screens, reusing the stored gateway credentials; availability follows the same `gateway.terminal.enabled` and `operator.admin` gate, and the page shows a notice when the connected Gateway does not offer the terminal. Focus presentation removes the application chrome; it does not invoke browser fullscreen. ## Browser panel diff --git a/docs/web/dashboards.md b/docs/web/dashboards.md index 5724b5a671a7..77b4a4df6a6f 100644 --- a/docs/web/dashboards.md +++ b/docs/web/dashboards.md @@ -25,11 +25,12 @@ thread's `/dashboard//` URL. An open Dashboards page updates as threads are renamed, archived, deleted, or switched between Chat and Dashboard, including after a Gateway reconnect. -Use **Open full-screen dashboard** on a row to open its board as a standalone -browser document with no sidebar, top bar, or chat. The close button returns to -the previous page. Inside a session, use the fullscreen button beside the -Chat / Split / Dashboard switch to enter or leave browser fullscreen while the -board is visible. +Use **Open dashboard in focus mode** on a row to open its board as a standalone +browser document at `/focus/dashboard//`, with no sidebar, +top bar, or chat. This focus presentation does not invoke browser fullscreen; +the close button returns to the previous page. Inside a session, use the +fullscreen button beside the Chat / Split / Dashboard switch to enter or leave +browser fullscreen while the board is visible. The Chat or Dashboard face preference is stored server-side per thread. It therefore follows you when you connect to the same gateway from another device. diff --git a/docs/web/urls.md b/docs/web/urls.md index 19744c815513..52c2a911eb7d 100644 --- a/docs/web/urls.md +++ b/docs/web/urls.md @@ -1,9 +1,9 @@ --- -summary: "Control UI URL routes, stable session-link grammar, and connection handoff parameters" +summary: "Control UI routes, focus presentations, stable session links, and connection handoff parameters" read_when: - You need to bookmark or share a Control UI session - You are adding or changing a Control UI route - - You need a terminal, approval, onboarding, or remote Gateway URL + - You need a terminal, desktop, approval, onboarding, or remote Gateway URL title: "Control UI URLs" --- @@ -123,10 +123,70 @@ own `?session=` parameter because that parameter expands a row; it is not a session deep link. The one-shot composer value `?draft=` remains supported on chat and dashboard session paths. -This canonical-link restriction applies to application routes. The standalone -dashboard document described below intentionally uses -`/?view=dashboard&session=` because it is a special document, not a -session route. +## Focus presentation routes + +A focus route renders one supported content surface without the normal Control +UI application chrome. Focus presentation is separate from browser fullscreen: +opening a focus route does not invoke the browser Fullscreen API. + +Insert `/focus` immediately after the configured Control UI base path. Removing +it returns the corresponding normal route when one exists: + +```text +/dashboard/roboclaw/the-daily-claw-6d7c9ccb +/focus/dashboard/roboclaw/the-daily-claw-6d7c9ccb + +/openclaw/dashboard/roboclaw/the-daily-claw-6d7c9ccb +/openclaw/focus/dashboard/roboclaw/the-daily-claw-6d7c9ccb +``` + +Dashboard focus routes use the complete canonical `/dashboard` grammar above: + +```text +/focus/dashboard/ +/focus/dashboard// +``` + +The Control UI removes the focus modifier before passing the dashboard route to +the canonical session resolver. Canonical address replacement and ambiguity +candidate links preserve `/focus`. Missing, ambiguous, and unavailable sessions +remain visible, and the dashboard is not read until the session resolves to a +canonical key. + +The other focus targets are: + +```text +/focus/terminal + +/focus/desktop +/focus/desktop/source/ +/focus/desktop/session/ +/focus/desktop/control +/focus/desktop/control/source/ +/focus/desktop/control/session/ +``` + +Encode desktop source and exact-session-key values with `encodeURIComponent` so +each occupies one path segment. Empty source and session values are omitted. If +a native caller supplies both non-empty values, the source form wins. The +optional `control` segment requests initial control; it does not grant control +or authorize the connection. + +The focus target and desktop identity or options are path-only. Credentials do +not belong in these URLs. Each target keeps the startup, authentication, +permission, and capability checks of its normal or embedded surface. In +particular, the terminal still requires `gateway.terminal.enabled` and an +`operator.admin` connection. + +Stable releases previously emitted `/?view=terminal`. The Control UI accepts +that form only at the application root (or `/?view=terminal`) and +immediately replaces it in browser history with `/focus/terminal` under the +same base path, removing the legacy `view` parameter. New links must use +`/focus/terminal`. The query form is not recognized on other application +paths, and the removed desktop and dashboard query forms are not accepted. + +`/focus` and unsupported `/focus/*` targets show an error without the ordinary +application shell. They do not open a normal application route. ## Route table @@ -200,24 +260,22 @@ Agent selection and its `overview|files|tools|skills|channels|cron|memory` panels use paths. Older links with `?agent=` are replaced once with the agent path while keeping other query parameters and the fragment. -## Special documents and startup modes +## Other special documents and startup modes These Gateway-served documents sit outside the application route table: - `/?onboarding=1` opens the first-run onboarding presentation. -- `/terminal` opens the user-facing full-screen terminal. With a base path, use - `/terminal`. -- `/?view=terminal` opens the same terminal-only document in the WebView/embed - form used by the mobile apps. Terminal availability in either form still - requires `gateway.terminal.enabled` and `operator.admin`. -- `/?view=dashboard&session=` opens that session's interactive - dashboard full-window without application or chat chrome. The document stays - connected to live board updates and shows a visible empty state when the - session or board is unavailable. - `/approve/` opens a standalone approval document. With a base path, use `/approve/`. The id identifies an approval but never authorizes it; normal Gateway authentication still applies. +Registered exact and prefix plugin HTTP routes can own `/focus` and +`/focus/*`. After plugin authentication and dispatch decline a request, the +Gateway uses those paths as the Control UI focus fallback: unclaimed `GET` and +`HEAD` requests serve the Control UI document, while other methods return +`404`. Every unclaimed method returns `404` when Control UI serving is +disabled. Lookalikes such as `/focused` are not part of the focus fallback. + The approval namespace is reserved ahead of plugin HTTP routes for all HTTP methods. When Control UI serving is disabled, it returns `404` instead of falling through to a plugin route. diff --git a/extensions/qa-lab/src/gateway-child.ts b/extensions/qa-lab/src/gateway-child.ts index b632f7f06d6c..32bedc3352e2 100644 --- a/extensions/qa-lab/src/gateway-child.ts +++ b/extensions/qa-lab/src/gateway-child.ts @@ -51,6 +51,7 @@ import { waitForQaGatewayRestartBoundary, } from "./gateway-child-readiness.js"; import { redactQaGatewayDebugText } from "./gateway-log-redaction.js"; +import { reserveQaGatewayPort } from "./gateway-port-reservation.js"; import { createQaGatewayProcessBoundaryController, type QaGatewayVerifiedProcessIdentity, @@ -102,21 +103,6 @@ function createQaGatewayEmptyTransport() { } satisfies Pick; } -async function getFreePort() { - return await new Promise((resolve, reject) => { - const server = net.createServer(); - server.once("error", (error) => reject(error)); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") { - reject(new Error("failed to allocate port")); - return; - } - server.close((error) => (error ? reject(error) : resolve(address.port))); - }); - }); -} - function appendQaGatewayTempRoot(details: string, tempRoot: string) { return details.includes(tempRoot) ? details @@ -261,6 +247,7 @@ export async function startQaGatewayChild(params: { ReturnType > | null = null; let rpcClient: Awaited> | null = null; + let gatewayPortReservation: Awaited> | null = null; let stagedBundledPluginsRoot: string | null = null; const tempRoot = await fs.mkdtemp(path.join(tempParentDir, "openclaw-qa-suite-")); // The startup owner must release its temp root even when launcher or staging @@ -514,7 +501,8 @@ export async function startQaGatewayChild(params: { }; for (let attempt = 1; attempt <= QA_GATEWAY_CHILD_STARTUP_MAX_ATTEMPTS; attempt += 1) { if (!reuseStartupLaunchState) { - gatewayPort = await getFreePort(); + gatewayPortReservation = await reserveQaGatewayPort(() => net.createServer()); + gatewayPort = gatewayPortReservation.port; baseUrl = `http://127.0.0.1:${gatewayPort}`; wsUrl = `ws://127.0.0.1:${gatewayPort}`; cfg = await buildStagedGatewayConfig(gatewayPort); @@ -603,6 +591,10 @@ export async function startQaGatewayChild(params: { reuseStartupLaunchState = false; const attemptLogMark = output.mark(); + // Hold the selected port through plugin/config staging so parallel QA workers + // cannot satisfy readiness against one another. Release only for the child bind. + await gatewayPortReservation?.release(); + gatewayPortReservation = null; const spawnedAttempt = await spawnGatewayProcess(env); const attemptChild = spawnedAttempt.child; child = attemptChild; @@ -1027,6 +1019,13 @@ export async function startQaGatewayChild(params: { }; } catch (error) { const cleanupErrors: unknown[] = []; + if (gatewayPortReservation) { + try { + await gatewayPortReservation.release(); + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } + } await rpcClient?.stop().catch(() => {}); let processStopped = child === null; if (child) { diff --git a/extensions/qa-lab/src/gateway-port-reservation.test.ts b/extensions/qa-lab/src/gateway-port-reservation.test.ts new file mode 100644 index 000000000000..28e5b2dc002a --- /dev/null +++ b/extensions/qa-lab/src/gateway-port-reservation.test.ts @@ -0,0 +1,40 @@ +// Qa Lab tests cover Gateway port reservation behavior. +import net from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { reserveQaGatewayPort } from "./gateway-port-reservation.js"; + +const servers = new Set(); + +afterEach(async () => { + await Promise.all( + [...servers].map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()); + }), + ), + ); + servers.clear(); +}); + +async function bindReservedPort(port: number) { + const server = net.createServer(); + servers.add(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", resolve); + }); + return server; +} + +describe("reserveQaGatewayPort", () => { + it("keeps the selected port unavailable until release", async () => { + const reservation = await reserveQaGatewayPort(() => net.createServer()); + + await expect(bindReservedPort(reservation.port)).rejects.toMatchObject({ code: "EADDRINUSE" }); + await reservation.release(); + + await expect(bindReservedPort(reservation.port)).resolves.toBeInstanceOf(net.Server); + await expect(reservation.release()).resolves.toBeUndefined(); + }); +}); diff --git a/extensions/qa-lab/src/gateway-port-reservation.ts b/extensions/qa-lab/src/gateway-port-reservation.ts new file mode 100644 index 000000000000..de7e3cdc6d4d --- /dev/null +++ b/extensions/qa-lab/src/gateway-port-reservation.ts @@ -0,0 +1,39 @@ +// Qa Lab plugin module reserves Gateway ports across pre-spawn setup. +type QaGatewayPortServer = { + once(event: "error", listener: (error: Error) => void): void; + off(event: "error", listener: (error: Error) => void): void; + listen(port: number, host: string, listener: () => void): void; + address(): { port: number } | string | null; + close(callback?: (error?: Error) => void): void; +}; + +export async function reserveQaGatewayPort(createServer: () => QaGatewayPortServer) { + const server = createServer(); + const port = await new Promise((resolve, reject) => { + const handleError = (error: Error) => { + server.close(() => {}); + reject(error); + }; + server.once("error", handleError); + server.listen(0, "127.0.0.1", () => { + server.off("error", handleError); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("failed to reserve gateway port")); + return; + } + resolve(address.port); + }); + }); + let releasePromise: Promise | undefined; + return { + port, + release() { + releasePromise ??= new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + return releasePromise; + }, + }; +} diff --git a/extensions/qa-lab/src/harness-runtime.integration.test.ts b/extensions/qa-lab/src/harness-runtime.integration.test.ts index 0f87923213de..0e2ce9690869 100644 --- a/extensions/qa-lab/src/harness-runtime.integration.test.ts +++ b/extensions/qa-lab/src/harness-runtime.integration.test.ts @@ -5,7 +5,7 @@ import { resetPluginRuntimeStateForTest, setActivePluginRegistry, } from "openclaw/plugin-sdk/plugin-test-runtime"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { startQaBusServer } from "./bus-server.js"; import { createQaBusState } from "./bus-state.js"; import { createQaRunnerRuntime } from "./harness-runtime.js"; @@ -80,19 +80,17 @@ describe("QA runner runtime integration", () => { text: "ping", }); - await Promise.race([ - vi.waitFor( - () => { - expect(harness.state.getSnapshot().messages).toContainEqual( - expect.objectContaining({ direction: "outbound", text: "qa-echo: ping" }), - ); - }, - { interval: 25, timeout: 2_000 }, - ), + const outbound = await Promise.race([ + harness.state.waitFor({ + kind: "message-text", + direction: "outbound", + textIncludes: "qa-echo: ping", + }), harness.gatewayTask.then(() => { throw new Error("QA Channel gateway stopped before delivering the turn"); }), ]); + expect(outbound).toMatchObject({ direction: "outbound", text: "qa-echo: ping" }); } finally { await harness.stop(); } diff --git a/packages/session-url-contract/src/focus.test.ts b/packages/session-url-contract/src/focus.test.ts new file mode 100644 index 000000000000..0ec9d0cccc70 --- /dev/null +++ b/packages/session-url-contract/src/focus.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest"; +import { + buildControlUiFocusPath, + inferControlUiFocusBasePath, + parseControlUiFocusLocation, +} from "./focus.js"; + +describe("Control UI focus locations", () => { + it.each([ + ["dashboard main", "/focus/dashboard/roboclaw", undefined, "/dashboard/roboclaw"], + [ + "dashboard short reference", + "/focus/dashboard/roboclaw/the-daily-claw-6d7c9ccb", + undefined, + "/dashboard/roboclaw/the-daily-claw-6d7c9ccb", + ], + [ + "dashboard literal key", + "/focus/dashboard/roboclaw/~key/12345678", + undefined, + "/dashboard/roboclaw/~key/12345678", + ], + [ + "base-path dashboard", + "/openclaw/focus/dashboard/roboclaw/the-daily-claw-6d7c9ccb/", + "/openclaw", + "/openclaw/dashboard/roboclaw/the-daily-claw-6d7c9ccb", + ], + ])("parses %s through the underlying dashboard route", (_name, pathname, basePath, routePath) => { + expect(parseControlUiFocusLocation(pathname, basePath)).toEqual({ + status: "valid", + basePath: basePath ?? "", + target: { + kind: "dashboard", + route: { pathname: routePath, search: "", hash: "" }, + }, + }); + }); + + it.each([ + ["terminal", "/focus/terminal", { kind: "terminal" }], + ["desktop", "/focus/desktop/", { kind: "desktop", control: false, selector: null }], + [ + "desktop source", + "/focus/desktop/source/environment%3AMac%20Studio%2FQA%20%26%20demo", + { + kind: "desktop", + control: false, + selector: { kind: "source", value: "environment:Mac Studio/QA & demo" }, + }, + ], + [ + "desktop session", + "/focus/desktop/session/agent%3Amain%3Amobile%20session", + { + kind: "desktop", + control: false, + selector: { kind: "session", value: "agent:main:mobile session" }, + }, + ], + [ + "controlled desktop", + "/focus/desktop/control", + { kind: "desktop", control: true, selector: null }, + ], + [ + "controlled source", + "/focus/desktop/control/source/node%3Aworker-1", + { + kind: "desktop", + control: true, + selector: { kind: "source", value: "node:worker-1" }, + }, + ], + [ + "controlled session", + "/focus/desktop/control/session/agent%3Amain%3Amobile", + { + kind: "desktop", + control: true, + selector: { kind: "session", value: "agent:main:mobile" }, + }, + ], + ] as const)("parses %s", (_name, pathname, target) => { + expect(parseControlUiFocusLocation(pathname, "")).toEqual({ + status: "valid", + basePath: "", + target, + }); + }); + + it.each([ + "/focus", + "/focus/unknown", + "/focus/terminal/extra", + "/focus/desktop/source", + "/focus/desktop/session/%", + "/focus/desktop/control/unknown/value", + "/focus/dashboard", + ])("rejects malformed or unsupported target %s", (pathname) => { + expect(parseControlUiFocusLocation(pathname, "")).toEqual({ + status: "unsupported", + basePath: "", + }); + }); + + it.each([ + "/?view=dashboard&session=agent%3Amain%3Awork", + "/?view=terminal", + "/?view=desktop", + "/terminal", + "/desktop", + "/focused/terminal", + ])("does not parse query aliases or lookalike location %s", (pathname) => { + expect(parseControlUiFocusLocation(pathname, "")).toBeNull(); + }); + + it("infers focus-aware base paths without overriding an explicit base", () => { + expect(inferControlUiFocusBasePath("/focus/terminal")).toBe(""); + expect(inferControlUiFocusBasePath("/openclaw/focus/desktop")).toBe("/openclaw"); + expect(inferControlUiFocusBasePath("/company/focus/focus/terminal")).toBe("/company/focus"); + expect(inferControlUiFocusBasePath("/focused/terminal")).toBeNull(); + expect(parseControlUiFocusLocation("/openclaw/focus/terminal", "/other")).toBeNull(); + }); + + it("passes dashboard search and hash through to the canonical route loader", () => { + expect( + parseControlUiFocusLocation({ + pathname: "/focus/dashboard/main", + search: "?catalog=beam&host=gateway&thread=one", + hash: "#pane", + }), + ).toEqual({ + status: "valid", + basePath: "", + target: { + kind: "dashboard", + route: { + pathname: "/dashboard/main", + search: "?catalog=beam&host=gateway&thread=one", + hash: "#pane", + }, + }, + }); + }); +}); + +describe("buildControlUiFocusPath", () => { + it.each([ + [ + "dashboard", + { kind: "dashboard", path: "/dashboard/roboclaw/the-daily-claw-6d7c9ccb" }, + "", + "/focus/dashboard/roboclaw/the-daily-claw-6d7c9ccb", + ], + [ + "base-path dashboard with suffix", + { kind: "dashboard", path: "/openclaw/dashboard/roboclaw/main?catalog=beam#pane" }, + "/openclaw/", + "/openclaw/focus/dashboard/roboclaw/main?catalog=beam#pane", + ], + ["terminal", { kind: "terminal" }, "/openclaw", "/openclaw/focus/terminal"], + ["desktop", { kind: "desktop" }, "", "/focus/desktop"], + [ + "desktop source", + { kind: "desktop", source: "environment:Mac Studio/QA & demo" }, + "", + "/focus/desktop/source/environment%3AMac%20Studio%2FQA%20%26%20demo", + ], + [ + "desktop session", + { kind: "desktop", session: "agent:main:mobile session" }, + "", + "/focus/desktop/session/agent%3Amain%3Amobile%20session", + ], + [ + "controlled source wins", + { + kind: "desktop", + control: true, + source: "node:worker-1", + session: "agent:main:mobile", + }, + "", + "/focus/desktop/control/source/node%3Aworker-1", + ], + [ + "controlled session", + { kind: "desktop", control: true, session: "agent:main:mobile" }, + "", + "/focus/desktop/control/session/agent%3Amain%3Amobile", + ], + [ + "empty values", + { kind: "desktop", source: " ", session: "" }, + "/openclaw", + "/openclaw/focus/desktop", + ], + ] as const)("builds %s", (_name, target, basePath, expected) => { + expect(buildControlUiFocusPath(target, basePath)).toBe(expected); + }); + + it("rejects a dashboard route outside the configured base path", () => { + expect( + buildControlUiFocusPath({ kind: "dashboard", path: "/dashboard/roboclaw/main" }, "/openclaw"), + ).toBeNull(); + }); +}); diff --git a/packages/session-url-contract/src/focus.ts b/packages/session-url-contract/src/focus.ts new file mode 100644 index 000000000000..f1aee28d9087 --- /dev/null +++ b/packages/session-url-contract/src/focus.ts @@ -0,0 +1,229 @@ +import { normalizeControlUiBasePath } from "./grammar.js"; + +const FOCUS_SEGMENT = "/focus"; + +type ControlUiFocusDashboardTarget = { + kind: "dashboard"; + /** Existing canonical dashboard route, including any search or hash suffix. */ + path: string; +}; + +type ControlUiFocusDesktopBuildTarget = { + kind: "desktop"; + control?: boolean; + source?: string | null; + session?: string | null; +}; + +export type ControlUiFocusBuildTarget = + | ControlUiFocusDashboardTarget + | { kind: "terminal" } + | ControlUiFocusDesktopBuildTarget; + +export type ControlUiFocusTarget = + | { + kind: "dashboard"; + route: { pathname: string; search: string; hash: string }; + } + | { kind: "terminal" } + | { + kind: "desktop"; + control: boolean; + selector: { kind: "source" | "session"; value: string } | null; + }; + +export type ControlUiFocusLocation = + | { status: "valid"; basePath: string; target: ControlUiFocusTarget } + | { status: "unsupported"; basePath: string }; + +type ControlUiFocusLocationInput = string | { pathname: string; search?: string; hash?: string }; + +function normalizePathname(pathname: string): string { + const trimmed = pathname.trim(); + const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + return withSlash.length > 1 ? withSlash.replace(/\/+$/u, "") : withSlash; +} + +function splitPathSuffix(value: string): { pathname: string; suffix: string } { + const queryIndex = value.indexOf("?"); + const hashIndex = value.indexOf("#"); + const suffixIndex = [queryIndex, hashIndex] + .filter((index) => index >= 0) + .reduce((first, index) => Math.min(first, index), value.length); + return { pathname: value.slice(0, suffixIndex), suffix: value.slice(suffixIndex) }; +} + +function nonEmptyValue(value: string | null | undefined): string | null { + return value && value.trim() ? value : null; +} + +function decodeFocusValue(segment: string): { ok: true; value: string | null } | { ok: false } { + try { + return { ok: true, value: nonEmptyValue(decodeURIComponent(segment)) }; + } catch { + return { ok: false }; + } +} + +export function inferControlUiFocusBasePath(pathname: string): string | null { + const normalizedPath = normalizePathname(pathname); + const segments = normalizedPath.split("/").filter(Boolean); + const focusIndexes = segments.flatMap((segment, index) => + segment === FOCUS_SEGMENT.slice(1) ? [index] : [], + ); + if (focusIndexes.length === 0) { + return null; + } + const supportsSuffix = (index: number): boolean => { + const rest = segments.slice(index + 1); + if (rest[0] === "terminal") { + return rest.length === 1; + } + if (rest[0] === "dashboard") { + return rest.length >= 2; + } + if (rest[0] !== "desktop") { + return false; + } + const selectorIndex = rest[1] === "control" ? 2 : 1; + return ( + rest.length === selectorIndex || + (rest.length === selectorIndex + 2 && + (rest[selectorIndex] === "source" || rest[selectorIndex] === "session")) + ); + }; + let focusIndex = focusIndexes.at(-1) ?? 0; + for (let index = focusIndexes.length - 1; index >= 0; index -= 1) { + const candidate = focusIndexes[index]; + if (candidate !== undefined && supportsSuffix(candidate)) { + focusIndex = candidate; + break; + } + } + return normalizeControlUiBasePath(segments.slice(0, focusIndex).join("/")); +} + +export function isControlUiFocusPath(pathname: string, basePath = ""): boolean { + const normalizedPath = normalizePathname(pathname); + const root = `${normalizeControlUiBasePath(basePath)}${FOCUS_SEGMENT}`; + return normalizedPath === root || normalizedPath.startsWith(`${root}/`); +} + +export function buildControlUiFocusPath( + target: Exclude, + basePath?: string, +): string; +export function buildControlUiFocusPath( + target: ControlUiFocusDashboardTarget, + basePath?: string, +): string | null; +export function buildControlUiFocusPath( + target: ControlUiFocusBuildTarget, + basePath?: string, +): string | null; +export function buildControlUiFocusPath( + target: ControlUiFocusBuildTarget, + basePath = "", +): string | null { + const base = normalizeControlUiBasePath(basePath); + const root = `${base}${FOCUS_SEGMENT}`; + if (target.kind === "terminal") { + return `${root}/terminal`; + } + if (target.kind === "desktop") { + const control = target.control === true ? "/control" : ""; + const source = nonEmptyValue(target.source); + const session = nonEmptyValue(target.session); + const selector = source + ? `/source/${encodeURIComponent(source)}` + : session + ? `/session/${encodeURIComponent(session)}` + : ""; + return `${root}/desktop${control}${selector}`; + } + const { pathname, suffix } = splitPathSuffix(target.path); + const normalizedPath = normalizePathname(pathname); + const dashboardRoot = `${base}/dashboard/`; + if (!normalizedPath.startsWith(dashboardRoot)) { + return null; + } + return `${root}${normalizedPath.slice(base.length)}${suffix}`; +} + +export function parseControlUiFocusLocation( + input: ControlUiFocusLocationInput, + basePath?: string, +): ControlUiFocusLocation | null { + const pathname = typeof input === "string" ? input : input.pathname; + const search = typeof input === "string" ? "" : (input.search ?? ""); + const hash = typeof input === "string" ? "" : (input.hash ?? ""); + const normalizedPath = normalizePathname(pathname); + const resolvedBasePath = + basePath === undefined + ? inferControlUiFocusBasePath(normalizedPath) + : normalizeControlUiBasePath(basePath); + if (resolvedBasePath === null || !isControlUiFocusPath(normalizedPath, resolvedBasePath)) { + return null; + } + const root = `${resolvedBasePath}${FOCUS_SEGMENT}`; + const rest = normalizedPath.slice(root.length + 1); + if (rest === "terminal") { + return { status: "valid", basePath: resolvedBasePath, target: { kind: "terminal" } }; + } + if (rest.startsWith("dashboard/") && rest.length > "dashboard/".length) { + return { + status: "valid", + basePath: resolvedBasePath, + target: { + kind: "dashboard", + route: { pathname: `${resolvedBasePath}/${rest}`, search, hash }, + }, + }; + } + + const segments = rest.split("/"); + if (segments[0] !== "desktop") { + return { status: "unsupported", basePath: resolvedBasePath }; + } + let index = 1; + const control = segments[index] === "control"; + if (control) { + index += 1; + } + if (segments.length === index) { + return { + status: "valid", + basePath: resolvedBasePath, + target: { kind: "desktop", control, selector: null }, + }; + } + const selectorKind = segments[index]; + const encodedValue = segments[index + 1]; + if ( + segments.length !== index + 2 || + (selectorKind !== "source" && selectorKind !== "session") || + encodedValue === undefined + ) { + return { status: "unsupported", basePath: resolvedBasePath }; + } + const decoded = decodeFocusValue(encodedValue); + if (!decoded.ok) { + return { status: "unsupported", basePath: resolvedBasePath }; + } + if (!decoded.value) { + return { + status: "valid", + basePath: resolvedBasePath, + target: { kind: "desktop", control, selector: null }, + }; + } + return { + status: "valid", + basePath: resolvedBasePath, + target: { + kind: "desktop", + control, + selector: { kind: selectorKind, value: decoded.value }, + }, + }; +} diff --git a/packages/session-url-contract/src/index.ts b/packages/session-url-contract/src/index.ts index dac38ae7de8b..ce68b50f04bb 100644 --- a/packages/session-url-contract/src/index.ts +++ b/packages/session-url-contract/src/index.ts @@ -8,6 +8,7 @@ import { } from "./grammar.js"; export { normalizeControlUiBasePath }; +export * from "./focus.js"; // Control UI session URL grammar shared by browser and plugin consumers. export type ControlUiSessionNamespace = "chat" | "dashboard"; diff --git a/src/docs/install-cloud-secrets.test.ts b/src/docs/install-cloud-secrets.test.ts index 10d21c652ee0..90279cc42063 100644 --- a/src/docs/install-cloud-secrets.test.ts +++ b/src/docs/install-cloud-secrets.test.ts @@ -25,7 +25,7 @@ async function readInstallDocs(): Promise { - it("does not publish a copy-paste gateway token placeholder", async () => { + it("keeps cloud install secret guidance safe and centralized", async () => { for (const { docName, markdown } of await readInstallDocs()) { for (const token of KNOWN_WEAK_GATEWAY_TOKEN_PLACEHOLDERS) { expect(markdown, docName).not.toContain(`OPENCLAW_GATEWAY_TOKEN=${token}`); diff --git a/src/gateway/control-ui-routing.test.ts b/src/gateway/control-ui-routing.test.ts index b33250887f82..fd38161e676c 100644 --- a/src/gateway/control-ui-routing.test.ts +++ b/src/gateway/control-ui-routing.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { classifyControlUiRequest, isControlUiApprovalDocumentPath, + isControlUiFocusDocumentPath, isControlUiPluginManagerRequest, } from "./control-ui-routing.js"; @@ -44,6 +45,27 @@ describe("isControlUiApprovalDocumentPath", () => { }); }); +describe("isControlUiFocusDocumentPath", () => { + it.each([ + { basePath: "", pathname: "/focus" }, + { basePath: "", pathname: "/focus/" }, + { basePath: "", pathname: "/focus/dashboard/roboclaw/the-daily-claw-6d7c9ccb" }, + { basePath: "", pathname: "/focus/not-supported" }, + { basePath: "/openclaw", pathname: "/openclaw/focus/desktop/control" }, + ])("classifies $pathname", ({ basePath, pathname }) => { + expect(isControlUiFocusDocumentPath({ basePath, pathname })).toBe(true); + }); + + it.each([ + { basePath: "", pathname: "/focused" }, + { basePath: "", pathname: "/focused/terminal" }, + { basePath: "/openclaw", pathname: "/focus/terminal" }, + { basePath: "/openclaw", pathname: "/openclaw/focused" }, + ])("does not classify $pathname", ({ basePath, pathname }) => { + expect(isControlUiFocusDocumentPath({ basePath, pathname })).toBe(false); + }); +}); + describe("Control UI SPA fallback Accept routing", () => { it.each([ { diff --git a/src/gateway/control-ui-routing.ts b/src/gateway/control-ui-routing.ts index 218649f16570..2ade69101f46 100644 --- a/src/gateway/control-ui-routing.ts +++ b/src/gateway/control-ui-routing.ts @@ -1,4 +1,5 @@ // Control UI route classifier for base-path and root-mounted SPA serving. +import { isControlUiFocusPath } from "@openclaw/session-url-contract"; import { acceptsControlUiHtmlResponse, isReadHttpMethod } from "./control-ui-http-utils.js"; import { classifyGatewayProbePath, @@ -45,6 +46,14 @@ export function isControlUiApprovalDocumentPath(params: { return encodedId.length > 0 && !encodedId.includes("/"); } +/** Focused presentation namespace used only after plugin routing declines it. */ +export function isControlUiFocusDocumentPath(params: { + basePath: string; + pathname: string; +}): boolean { + return isControlUiFocusPath(params.pathname, params.basePath); +} + /** Classify an HTTP request as Control UI serving, redirect, 404, or non-Control-UI. */ export function classifyControlUiRequest(params: { basePath: string; diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index efa1bc304b22..312614f59a90 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -1561,33 +1561,56 @@ describe("handleControlUiHttpRequest", () => { it.each([ { - name: "root-mounted nested routes", + name: "root-mounted focus routes", + requestPath: "/focus/dashboard/roboclaw/session-ref", + basePath: undefined, + expectedPrefix: "", + }, + { + name: "base-mounted focus routes", + requestPath: "/openclaw/focus/desktop/control", + basePath: "/openclaw", + expectedPrefix: "/openclaw", + }, + { + name: "root-mounted ordinary deep routes", requestPath: "/settings/approvals", basePath: undefined, expectedPrefix: "", }, { - name: "base-mounted nested routes", + name: "base-mounted ordinary deep routes", requestPath: "/openclaw/settings/approvals", basePath: "/openclaw", expectedPrefix: "/openclaw", }, ])( - "anchors Vite-relative public asset hrefs for $name", + "anchors Vite-relative asset references for $name", async ({ requestPath, basePath, expectedPrefix }) => { - const assets = [ + const emittedAssets = [ + ["index.js", "index-js\n", "application/javascript; charset=utf-8"], + ["runtime.js", "runtime-js\n", "application/javascript; charset=utf-8"], + ["index.css", "index-css\n", "text/css; charset=utf-8"], + ] as const; + const publicAssets = [ "favicon.svg", "favicon-32.png", "apple-touch-icon.png", "manifest.webmanifest", ]; - const html = `${assets + const html = `${publicAssets .map((asset) => ``) - .join("")}\n`; + .join( + "", + )}\n`; await withControlUiRoot({ indexHtml: html, fn: async (tmp) => { + await fs.mkdir(path.join(tmp, "assets")); + for (const [asset, content] of emittedAssets) { + await fs.writeFile(path.join(tmp, "assets", asset), content); + } const { res, end } = makeMockHttpResponse(); const handled = await handleControlUiHttpRequest( { @@ -1604,10 +1627,36 @@ describe("handleControlUiHttpRequest", () => { expect(handled).toBe(true); const body = String(end.mock.calls[0]?.[0] ?? ""); - for (const asset of assets) { + for (const asset of publicAssets) { expect(body).toContain(`href="${expectedPrefix}/${asset}"`); expect(body).not.toContain(`href="./${asset}"`); } + expect(body).toContain(`src="${expectedPrefix}/assets/index.js"`); + expect(body).toContain(`href="${expectedPrefix}/assets/runtime.js"`); + expect(body).toContain(`href="${expectedPrefix}/assets/index.css"`); + expect(body).not.toContain('="./assets/'); + expect(body).not.toContain(`${requestPath}/assets/`); + + const emittedAssetUrls = Array.from( + body.matchAll(/(?:src|href)="([^" ]*\/assets\/[^" ]+)"/g), + ).flatMap((match) => (match[1] ? [match[1]] : [])); + expect(new Set(emittedAssetUrls)).toEqual( + new Set(emittedAssets.map(([asset]) => `${expectedPrefix}/assets/${asset}`)), + ); + for (const url of emittedAssetUrls) { + const emittedAsset = emittedAssets.find(([asset]) => url.endsWith(`/${asset}`)); + expect(emittedAsset).toBeDefined(); + const [, content, contentType] = emittedAsset!; + const response = await runControlUiRequest({ + url, + method: "GET", + rootPath: tmp, + basePath, + }); + expect(response.handled).toBe(true); + expect(responseBody(response.end)).toBe(content); + expect(response.setHeader).toHaveBeenCalledWith("Content-Type", contentType); + } }, }); }, @@ -3505,28 +3554,38 @@ describe("handleControlUiHttpRequest", () => { it.each([ { - name: "root-mounted", + name: "root-mounted approval", basePath: undefined, url: "/approve/Approval%3AMobile%2F%E6%9D%B1%E4%BA%AC%20100%25%20%F0%9F%A6%9E", }, { - name: "configured-base-path", + name: "configured-base-path approval", basePath: "/openclaw", url: "/openclaw/approve/Approval%3AMobile%2F%E6%9D%B1%E4%BA%AC%20100%25%20%F0%9F%A6%9E", }, { - name: "asset-like-id", + name: "asset-like approval id", basePath: undefined, url: "/approve/plugin%3Arequest.json", }, { - name: "configured-base-asset-like-id", + name: "configured-base asset-like approval id", basePath: "/openclaw", url: "/openclaw/approve/plugin%3Arequest.js", }, - ])("serves $name approval deep links through the SPA fallback", async ({ basePath, url }) => { + { + name: "root-mounted focus path", + basePath: undefined, + url: "/focus/dashboard/roboclaw/session.json", + }, + { + name: "configured-base focus path", + basePath: "/openclaw", + url: "/openclaw/focus/desktop/control/session/agent%3Amain%3Amain", + }, + ])("serves $name through the standalone document", async ({ basePath, url }) => { await withControlUiRoot({ - indexHtml: "approval-spa\n", + indexHtml: "standalone-spa\n", fn: async (tmp) => { for (const method of ["GET", "HEAD"] as const) { const { res, end, handled } = await runControlUiRequest({ @@ -3541,7 +3600,7 @@ describe("handleControlUiHttpRequest", () => { if (method === "HEAD") { expect(firstEndCallLength(end)).toBe(0); } else { - expect(responseBody(end)).toContain("approval-spa"); + expect(responseBody(end)).toContain("standalone-spa"); if (basePath) { expect(responseBody(end)).toContain('data-openclaw-control-ui-base-path="/openclaw"'); } @@ -3553,21 +3612,31 @@ describe("handleControlUiHttpRequest", () => { it.each([ { - name: "root-mounted", + name: "root-mounted approval", basePath: undefined, url: "/approve/Approval%3AMobile%2F%E6%9D%B1%E4%BA%AC%20100%25%20%F0%9F%A6%9E", }, { - name: "configured-base-path", + name: "configured-base-path approval", basePath: "/openclaw", url: "/openclaw/approve/Approval%3AMobile%2F%E6%9D%B1%E4%BA%AC%20100%25%20%F0%9F%A6%9E", }, { - name: "asset-like-id", + name: "asset-like approval id", basePath: undefined, url: "/approve/plugin%3Arequest.json", }, - ])("declines POST to $name approval deep links at the UI module", async ({ basePath, url }) => { + { + name: "root-mounted focus path", + basePath: undefined, + url: "/focus/terminal", + }, + { + name: "configured-base focus path", + basePath: "/openclaw", + url: "/openclaw/focus/desktop", + }, + ])("declines POST to $name at the UI module", async ({ basePath, url }) => { await withControlUiRoot({ fn: async (tmp) => { const { handled, end } = await runControlUiRequest({ @@ -3577,9 +3646,8 @@ describe("handleControlUiHttpRequest", () => { basePath, }); - // The UI module only serves reads; the gateway's approval-document - // stage (server-http.ts) owns the terminal 404 for write methods, so - // these requests never reach plugin HTTP handlers in production. + // The UI module serves reads only. The gateway router decides whether a + // write is reserved approval traffic or an unclaimed focus fallback. expect(handled).toBe(false); expect(end).not.toHaveBeenCalled(); }, diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 9ad7ddc2091b..b63d8d9ac300 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -57,7 +57,11 @@ import { respondNotFound as respondControlUiNotFound, respondPlainText, } from "./control-ui-http-utils.js"; -import { classifyControlUiRequest, isControlUiApprovalDocumentPath } from "./control-ui-routing.js"; +import { + classifyControlUiRequest, + isControlUiApprovalDocumentPath, + isControlUiFocusDocumentPath, +} from "./control-ui-routing.js"; import { buildControlUiAvatarUrl, CONTROL_UI_AVATAR_PREFIX, @@ -122,10 +126,12 @@ const CONTROL_UI_ROOT_PUBLIC_ASSETS = new Set([ "sw.js", ]); -/** Anchors bundled public assets before deep-linked documents begin preloading. */ -function rewriteControlUiIndexHtmlPublicAssetHrefs(html: string, basePath: string): string { +/** Anchors bundled assets before deep-linked documents begin preloading. */ +function rewriteControlUiIndexHtmlAssetHrefs(html: string, basePath: string): string { const normalized = normalizeControlUiBasePath(basePath); - let next = html; + let next = html + .replaceAll('src="./assets/', `src="${normalized}/assets/`) + .replaceAll('href="./assets/', `href="${normalized}/assets/`); for (const asset of CONTROL_UI_ROOT_PUBLIC_ASSETS) { const assetHref = `href="${normalized}/${asset}"`; // Vite's portable ./ base emits relative hrefs, which the browser starts @@ -666,7 +672,7 @@ async function serveResolvedIndexHtml( allowWasm?: boolean, ) { const normalizedBasePath = normalizeControlUiBasePath(basePath); - const withBasePath = rewriteControlUiIndexHtmlPublicAssetHrefs(body, normalizedBasePath); + const withBasePath = rewriteControlUiIndexHtmlAssetHrefs(body, normalizedBasePath); const basePathAttribute = normalizedBasePath ? ` ${CONTROL_UI_BASE_PATH_ATTRIBUTE}="${escapeHtmlAttribute(normalizedBasePath)}"` : ""; @@ -956,7 +962,9 @@ export async function handleControlUiHttpRequest( const uiPath = basePath && pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) : pathname; - const approvalDocument = isControlUiApprovalDocumentPath({ basePath, pathname }); + const standaloneDocument = + isControlUiApprovalDocumentPath({ basePath, pathname }) || + isControlUiFocusDocumentPath({ basePath, pathname }); const rel = (() => { if (uiPath === ROOT_PREFIX) { return ""; @@ -973,7 +981,7 @@ export async function handleControlUiHttpRequest( } return uiPath.slice(1); })(); - const requested = approvalDocument + const requested = standaloneDocument ? "index.html" : rel && !rel.endsWith("/") ? rel diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index f4d9891e96b7..75f15082012c 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -31,6 +31,7 @@ import { import { respondNotFound, respondPlainText } from "./control-ui-http-utils.js"; import { isControlUiApprovalDocumentPath, + isControlUiFocusDocumentPath, isControlUiPluginManagerRequest, } from "./control-ui-routing.js"; import type { ControlUiRootState } from "./control-ui.js"; @@ -325,6 +326,17 @@ export function createGatewayHttpServer(opts: { agentId: resolveAssistantIdentity({ cfg: configSnapshot }).agentId, root: controlUiRoot, }); + const handleStandaloneControlUiRequest = async () => { + if (!controlUiEnabled) { + respondNotFound(res); + return true; + } + if (await handleControlUiRequest()) { + return true; + } + respondNotFound(res); + return true; + }; const requestStages: GatewayHttpRequestStage[] = [ { run: () => @@ -460,24 +472,15 @@ export function createGatewayHttpServer(opts: { config: openAiChatCompletionsConfig, }), ); - addRequestStage( - isControlUiApprovalDocumentPath({ - basePath: controlUiBasePath, - pathname: scopedRequestPath, - }), - async () => { - if (!controlUiEnabled) { - respondNotFound(res); - return true; - } - const handled = await handleControlUiRequest(); - if (handled) { - return true; - } - respondNotFound(res); - return true; - }, - ); + const approvalDocument = isControlUiApprovalDocumentPath({ + basePath: controlUiBasePath, + pathname: scopedRequestPath, + }); + const focusDocument = isControlUiFocusDocumentPath({ + basePath: controlUiBasePath, + pathname: scopedRequestPath, + }); + addRequestStage(approvalDocument, handleStandaloneControlUiRequest); addRequestStage(Boolean(nodeCapability), async () => { const { authorizePluginNodeCapabilityRequest } = await getPluginNodeCapabilityAuthModule(); const ok = await authorizePluginNodeCapabilityRequest({ @@ -581,6 +584,8 @@ export function createGatewayHttpServer(opts: { ); } + addRequestStage(focusDocument, handleStandaloneControlUiRequest); + addRequestStage( scopedRequestPath.startsWith("/api/chat/media/outgoing/") || (controlUiRouteBasePath.length > 0 && diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index 7a2f7f7159b4..4a9c2c23cb70 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -163,6 +163,42 @@ function createRuntimeScopeRecorderHandler(params: { }); } +function createPublicPluginRouteHandler(params: { + path: string; + match: "exact" | "prefix"; + method: string; + responseBody: string; +}) { + const routeHandler = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { + if (req.method !== params.method) { + return false; + } + res.statusCode = 200; + res.end(params.responseBody); + return true; + }); + return { + routeHandler, + handlePluginRequest: createGatewayPluginRequestHandler({ + registry: createGatewayTestRegistry({ + httpRoutes: [ + { + pluginId: "focus-owner", + source: "focus-owner", + path: params.path, + auth: "plugin", + match: params.match, + handler: routeHandler, + }, + ], + }), + log: { warn: vi.fn() } as unknown as Parameters< + typeof createGatewayPluginRequestHandler + >[0]["log"], + }), + }; +} + async function expectPluginRequestOk( server: Parameters[0], request: Parameters[0], @@ -754,6 +790,153 @@ describe("gateway plugin HTTP auth boundary", () => { }); }); + test.each([ + { + label: "root-mounted exact GET", + basePath: "", + routePath: "/focus/terminal", + match: "exact" as const, + requestPath: "/focus/terminal", + method: "GET", + }, + { + label: "root-mounted prefix POST", + basePath: "", + routePath: "/focus", + match: "prefix" as const, + requestPath: "/focus/desktop/control", + method: "POST", + }, + { + label: "base-path-mounted prefix PUT", + basePath: "/openclaw", + routePath: "/openclaw/focus", + match: "prefix" as const, + requestPath: "/openclaw/focus/dashboard/roboclaw/session-ref", + method: "PUT", + }, + ])( + "lets a registered $label route own focus requests", + async ({ basePath, routePath, match, requestPath, method }) => { + const { handlePluginRequest, routeHandler } = createPublicPluginRouteHandler({ + path: routePath, + match, + method, + responseBody: "plugin-owned-focus", + }); + + await withGatewayServer({ + prefix: "openclaw-plugin-http-focus-ownership-test-", + resolvedAuth: AUTH_NONE, + overrides: { + controlUiEnabled: true, + controlUiBasePath: basePath, + controlUiRoot: { kind: "missing" }, + handlePluginRequest, + }, + run: async (server) => { + const response = await sendRequest(server, { path: requestPath, method }); + expect(response.res.statusCode).toBe(200); + expect(response.getBody()).toBe("plugin-owned-focus"); + expect(routeHandler).toHaveBeenCalledOnce(); + }, + }); + }, + ); + + test.each([ + { + label: "root-mounted", + basePath: "", + rootPath: "/focus", + descendantPath: "/focus/desktop/control", + lookalikePath: "/focused", + }, + { + label: "base-path-mounted", + basePath: "/openclaw", + rootPath: "/openclaw/focus", + descendantPath: "/openclaw/focus/dashboard/roboclaw/session-ref", + lookalikePath: "/openclaw/focused", + }, + ])( + "uses focus as the $label unclaimed fallback without reserving lookalikes", + async ({ basePath, rootPath, descendantPath, lookalikePath }) => { + const { handlePluginRequest, routeHandler } = createPublicPluginRouteHandler({ + path: lookalikePath, + match: "exact", + method: "GET", + responseBody: "plugin-lookalike", + }); + + await withGatewayServer({ + prefix: "openclaw-plugin-http-focus-fallback-test-", + resolvedAuth: AUTH_NONE, + overrides: { + controlUiEnabled: true, + controlUiBasePath: basePath, + controlUiRoot: { kind: "missing" }, + handlePluginRequest, + }, + run: async (server) => { + const get = await sendRequest(server, { path: rootPath }); + expect(get.res.statusCode).toBe(503); + expect(get.getBody()).toContain("Control UI assets not found"); + + const head = await sendRequest(server, { path: descendantPath, method: "HEAD" }); + expect(head.res.statusCode).toBe(503); + + for (const method of ["POST", "PUT"] as const) { + const write = await sendRequest(server, { path: descendantPath, method }); + expect(write.res.statusCode, method).toBe(404); + expect(write.getBody(), method).toBe("Not Found"); + } + + const lookalike = await sendRequest(server, { path: lookalikePath }); + expect(lookalike.res.statusCode).toBe(200); + expect(lookalike.getBody()).toBe("plugin-lookalike"); + expect(routeHandler).toHaveBeenCalledOnce(); + }, + }); + }, + ); + + test.each([ + { label: "root-mounted", basePath: "", path: "/focus/terminal" }, + { + label: "base-path-mounted", + basePath: "/openclaw", + path: "/openclaw/focus/desktop", + }, + ])( + "returns 404 for an unclaimed $label focus request when control ui serving is disabled", + async ({ basePath, path }) => { + const { handlePluginRequest, routeHandler } = createPublicPluginRouteHandler({ + path: `${basePath}/unrelated`, + match: "exact", + method: "GET", + responseBody: "unrelated", + }); + + await withPluginGatewayServer({ + prefix: "openclaw-plugin-http-disabled-focus-fallback-test-", + resolvedAuth: AUTH_NONE, + overrides: { + controlUiEnabled: false, + controlUiBasePath: basePath, + handlePluginRequest, + }, + run: async (server) => { + const response = await sendRequest(server, { path }); + + expect(response.res.statusCode).toBe(404); + expect(response.getBody()).toBe("Not Found"); + expect(routeHandler).not.toHaveBeenCalled(); + }, + }); + }, + ); + test("passes POST webhook routes through root-mounted control ui to plugins", async () => { const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { const pathname = new URL(req.url ?? "/", "http://localhost").pathname; diff --git a/test/vitest/vitest.extension-codex-app-server-attempt-extra.config.ts b/test/vitest/vitest.extension-codex-app-server-attempt-extra.config.ts index 8faa5484a578..1551278641c6 100644 --- a/test/vitest/vitest.extension-codex-app-server-attempt-extra.config.ts +++ b/test/vitest/vitest.extension-codex-app-server-attempt-extra.config.ts @@ -1,7 +1,7 @@ // Vitest extension codex app server attempt extra config wires the extension codex app server attempt extra test shard. import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; -function createExtensionCodexAppServerAttemptExtraVitestConfig( +export function createExtensionCodexAppServerAttemptExtraVitestConfig( env: Record = process.env, ) { return createScopedVitestConfig( diff --git a/test/vitest/vitest.extension-codex-app-server-attempt-light.config.ts b/test/vitest/vitest.extension-codex-app-server-attempt-light.config.ts index c26bf1769cef..82d2ef8d8828 100644 --- a/test/vitest/vitest.extension-codex-app-server-attempt-light.config.ts +++ b/test/vitest/vitest.extension-codex-app-server-attempt-light.config.ts @@ -1,7 +1,7 @@ // Vitest extension codex app server attempt light config wires the extension codex app server attempt light test shard. import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; -function createExtensionCodexAppServerAttemptLightVitestConfig( +export function createExtensionCodexAppServerAttemptLightVitestConfig( env: Record = process.env, ) { return createScopedVitestConfig( diff --git a/test/vitest/vitest.extension-codex-app-server-tools.config.ts b/test/vitest/vitest.extension-codex-app-server-tools.config.ts index fda4a152d8d2..b6f1f706beef 100644 --- a/test/vitest/vitest.extension-codex-app-server-tools.config.ts +++ b/test/vitest/vitest.extension-codex-app-server-tools.config.ts @@ -1,7 +1,7 @@ // Vitest extension codex app server tools config wires the extension codex app server tools test shard. import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; -function createExtensionCodexAppServerToolsVitestConfig( +export function createExtensionCodexAppServerToolsVitestConfig( env: Record = process.env, ) { return createScopedVitestConfig( diff --git a/ui/src/app-navigation.test.ts b/ui/src/app-navigation.test.ts index 8d3c390c3bd3..e50f80d85685 100644 --- a/ui/src/app-navigation.test.ts +++ b/ui/src/app-navigation.test.ts @@ -494,6 +494,9 @@ describe("inferBasePathFromPathname", () => { // Real mount directories that merely contain a route-suffix keep working. expect(inferBasePathFromPathname("/ui/config")).toBe("/ui"); expect(inferBasePathFromPathname("/ui/settings/appearance")).toBe("/ui"); + expect(inferBasePathFromPathname("/focus/terminal")).toBe(""); + expect(inferBasePathFromPathname("/openclaw/focus/dashboard/main")).toBe("/openclaw"); + expect(inferBasePathFromPathname("/company/focus/focus/terminal")).toBe("/company/focus"); }); }); diff --git a/ui/src/app-route-paths.ts b/ui/src/app-route-paths.ts index 426fec26c105..ec90a8be9527 100644 --- a/ui/src/app-route-paths.ts +++ b/ui/src/app-route-paths.ts @@ -1,3 +1,4 @@ +import { inferControlUiFocusBasePath } from "@openclaw/session-url-contract"; import { normalizeRouteBasePath, normalizeRoutePath } from "@openclaw/uirouter"; import type { RouteLocation } from "@openclaw/uirouter"; import { isValidWorkboardBoardId } from "@openclaw/workboard-contract"; @@ -310,6 +311,10 @@ function isRouteOwnedBasePath(basePath: string): boolean { } export function inferBasePathFromPathname(pathname: string): string { + const focusBasePath = inferControlUiFocusBasePath(pathname); + if (focusBasePath !== null) { + return focusBasePath; + } const isMountRoot = pathname.trim().endsWith("/"); const normalizedPath = normalizePath(pathname); if (normalizedPath.toLowerCase().endsWith("/index.html")) { diff --git a/ui/src/app/app-root.ts b/ui/src/app/app-root.ts index 0415773207ff..c2f76b76d132 100644 --- a/ui/src/app/app-root.ts +++ b/ui/src/app/app-root.ts @@ -1,4 +1,6 @@ import { ContextProvider } from "@lit/context"; +import { buildControlUiFocusPath, type ControlUiFocusTarget } from "@openclaw/session-url-contract"; +import type { RouteLocation, RouteNotFound } from "@openclaw/uirouter"; import { html, nothing } from "lit"; import { state } from "lit/decorators.js"; import type { GatewayBrowserClient } from "../api/gateway.ts"; @@ -9,16 +11,15 @@ import "../components/login-gate.ts"; import "../components/openclaw-mascot.ts"; import { installNativeTitleGuard } from "../components/tooltip.ts"; import { t } from "../i18n/index.ts"; +import { formatUiError } from "../lib/format-error.ts"; import { normalizeAgentId } from "../lib/sessions/session-key.ts"; import { isTerminalAvailable } from "../lib/terminal-availability.ts"; import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; import { SubscriptionsController } from "../lit/subscriptions-controller.ts"; +import type { ChatRouteData } from "../pages/chat/route-loader.ts"; import { isDesktopPanelAvailable } from "./app-shell-chrome.ts"; import { bootstrapApplication, type ApplicationRuntime } from "./bootstrap.ts"; -import { resolveControlUiBasePath } from "./browser.ts"; import { applicationContext, type ApplicationContext } from "./context.ts"; -import { dashboardDocumentSession, isDashboardOnlyView } from "./dashboard-document-mode.ts"; -import { desktopDocumentOptions, isDesktopOnlyView } from "./desktop-document-mode.ts"; import { APPROVAL_PAGE_ELEMENT, DASHBOARD_DOCUMENT_ELEMENT, @@ -29,7 +30,21 @@ import { } from "./lazy-custom-element.ts"; import { resolveOnboardingMode } from "./onboarding-mode.ts"; import { controlUiPublicAssetPath } from "./public-assets.ts"; -import { isTerminalOnlyView } from "./terminal-document-mode.ts"; + +type FocusDashboardRouteState = + | { kind: "loading" } + | { kind: "not-found" } + | { kind: "error"; message: string } + | { kind: "ambiguous"; data: Extract } + | { kind: "session"; data: Extract }; + +function routeLocationHref(location: RouteLocation): string { + return `${location.pathname}${location.search}${location.hash}`; +} + +function isRouteNotFound(result: ChatRouteData | RouteNotFound): result is RouteNotFound { + return "type" in result && result.type === "notFound"; +} export function resolveTerminalThemeMode(): "dark" | "light" { return document.documentElement.dataset.themeMode === "light" ? "light" : "dark"; @@ -79,18 +94,8 @@ export class OpenClawApp extends OpenClawLightDomElement { @state() private loginShowGatewayPassword = false; @state() private pendingGatewayUrl: string | null = null; @state() private onboarding = resolveOnboardingMode(globalThis.location?.search ?? ""); + @state() private focusDashboardRoute: FocusDashboardRouteState = { kind: "loading" }; - private readonly terminalOnly = isTerminalOnlyView( - globalThis.location, - resolveControlUiBasePath(globalThis.location?.pathname ?? "/"), - ); - private readonly desktopOnly = isDesktopOnlyView( - globalThis.location, - resolveControlUiBasePath(globalThis.location?.pathname ?? "/"), - ); - private readonly dashboardOnly = isDashboardOnlyView(globalThis.location); - private readonly dashboardSession = dashboardDocumentSession(globalThis.location); - private readonly desktopOptions = desktopDocumentOptions(globalThis.location); private runtime: ApplicationRuntime | undefined; private readonly contextProvider = new ContextProvider(this, { context: applicationContext, @@ -98,11 +103,21 @@ export class OpenClawApp extends OpenClawLightDomElement { private readonly subscriptions = new SubscriptionsController(this); private loginGatewaySource: ApplicationContext["gateway"] | null = null; private loginConnectionClient: GatewayBrowserClient | null = null; + private focusDashboardAbort: AbortController | null = null; private get context(): ApplicationContext | undefined { return this.runtime?.context; } + private get focusTarget(): ControlUiFocusTarget | null { + const focus = this.runtime?.focusLocation; + return focus?.status === "valid" ? focus.target : null; + } + + private get terminalOnly(): boolean { + return this.focusTarget?.kind === "terminal"; + } + constructor() { super(); this.subscriptions @@ -128,13 +143,14 @@ export class OpenClawApp extends OpenClawLightDomElement { void import("../components/session-progress-hovercard-registration.ts"); this.resetLoginSensitivePresentation(); this.runtime = bootstrapApplication(); - if (this.terminalOnly) { + const focusTarget = this.focusTarget; + if (focusTarget?.kind === "terminal") { preloadOptionalElement(this, TERMINAL_PANEL_ELEMENT); } - if (this.desktopOnly) { + if (focusTarget?.kind === "desktop") { preloadOptionalElement(this, DESKTOP_PANEL_ELEMENT); } - if (this.dashboardOnly) { + if (focusTarget?.kind === "dashboard") { preloadOptionalElement(this, DASHBOARD_DOCUMENT_ELEMENT); } if (this.runtime.documentMode?.kind === "approval") { @@ -149,14 +165,19 @@ export class OpenClawApp extends OpenClawLightDomElement { // The runtime is created after controller hostConnected hooks run. Ensure // their lazy source getters bind on both the initial mount and reconnect. this.requestUpdate(); - void this.runtime.start().catch((error: unknown) => { - console.error("[openclaw] application start failed", error); - }); + void this.runtime + .start() + .then(() => this.resolveFocusDashboard()) + .catch((error: unknown) => { + console.error("[openclaw] application start failed", error); + }); } override disconnectedCallback() { // Stop reactive subscriptions before disposing their application sources. this.subscriptions.clear(); + this.focusDashboardAbort?.abort(); + this.focusDashboardAbort = null; this.runtime?.stop(); this.runtime = undefined; this.loginGatewaySource = null; @@ -216,6 +237,159 @@ export class OpenClawApp extends OpenClawLightDomElement { } } + private renderFocusEscape(label: string) { + return html``; + } + + private replaceFocusDashboardLocation(location: RouteLocation, source: RouteLocation): void { + const basePath = this.context?.basePath ?? ""; + const expected = buildControlUiFocusPath( + { kind: "dashboard", path: routeLocationHref(source) }, + basePath, + ); + const replacement = buildControlUiFocusPath( + { kind: "dashboard", path: routeLocationHref(location) }, + basePath, + ); + const current = `${globalThis.location.pathname}${globalThis.location.search}${globalThis.location.hash}`; + if (!expected || !replacement || current !== expected || replacement === current) { + return; + } + globalThis.history.replaceState(globalThis.history.state, "", replacement); + } + + private async resolveFocusDashboard(): Promise { + const target = this.focusTarget; + const context = this.context; + if (target?.kind !== "dashboard" || !context) { + return; + } + this.focusDashboardAbort?.abort(); + const controller = new AbortController(); + this.focusDashboardAbort = controller; + this.focusDashboardRoute = { kind: "loading" }; + const location = target.route; + try { + const { loadChatRoute } = await import("../pages/chat/route-loader.ts"); + const result = await loadChatRoute(context, location, "dashboard", controller.signal); + if (controller.signal.aborted || this.focusDashboardAbort !== controller) { + return; + } + if (isRouteNotFound(result)) { + this.focusDashboardRoute = { kind: "not-found" }; + return; + } + if (result.kind === "ambiguous") { + this.focusDashboardRoute = { + kind: "ambiguous", + data: { + ...result, + candidates: result.candidates.map((candidate) => ({ + ...candidate, + href: + buildControlUiFocusPath( + { kind: "dashboard", path: candidate.href }, + context.basePath, + ) ?? candidate.href, + })), + }, + }; + return; + } + this.focusDashboardRoute = { kind: "session", data: result }; + if (result.canonicalLocation && result.canonicalLocationSource) { + this.replaceFocusDashboardLocation( + result.canonicalLocation, + result.canonicalLocationSource, + ); + } + const canonicalLocationSource = result.canonicalLocationSource; + if (result.canonicalLocationReady && canonicalLocationSource) { + void result.canonicalLocationReady.then((canonicalLocation) => { + if ( + canonicalLocation && + !controller.signal.aborted && + this.focusDashboardAbort === controller + ) { + this.replaceFocusDashboardLocation(canonicalLocation, canonicalLocationSource); + } + }); + } + } catch (error) { + if (!controller.signal.aborted && this.focusDashboardAbort === controller) { + this.focusDashboardRoute = { kind: "error", message: formatUiError(error) }; + } + } + } + + private renderFocusDashboard( + gatewaySnapshot: ApplicationContext["gateway"]["snapshot"], + gatewayConnected: boolean, + gatewayStartupStatus: string | undefined, + ) { + const route = this.focusDashboardRoute; + if (route.kind === "loading") { + return renderConnectingSplash(gatewayStartupStatus); + } + if (route.kind === "not-found") { + return html`
+
+ ${t("dashboardDocument.notFound")} + ${this.renderFocusEscape(t("dashboardDocument.close"))} +
+
`; + } + if (route.kind === "error") { + return html`
+ +
`; + } + if (route.kind === "ambiguous") { + return html`
+
+

${t("chat.sessionRoute.chooseTitle")}

+

+ ${route.data.candidates.length > 1 + ? t("chat.sessionRoute.multipleMatches", { shortId: route.data.shortId }) + : t("chat.sessionRoute.additionalMatches")} +

+ ${route.data.candidates.map( + (candidate) => html`

+ ${candidate.displayName}
+ ${candidate.agentId} · ${candidate.idPrefix} +

`, + )} + ${route.data.truncated + ? html`

${t("chat.sessionRoute.additionalMatches")}

` + : nothing} + ${this.renderFocusEscape(t("dashboardDocument.close"))} +
+
`; + } + return html` + this.closeDocument(this.context?.basePath ?? "")} + > + ${!gatewayConnected && gatewaySnapshot.lastError === null + ? renderConnectingSplash(gatewayStartupStatus) + : nothing} + ${!isOptionalElementDefined(DASHBOARD_DOCUMENT_ELEMENT) && gatewayConnected + ? renderConnectingSplash(gatewayStartupStatus) + : nothing} + `; + } + override render() { const context = this.context; const runtime = this.runtime; @@ -243,9 +417,18 @@ export class OpenClawApp extends OpenClawLightDomElement { > ` : nothing; - // Full-screen terminals own the whole document. Keep the generic login gate + if (runtime.focusLocation?.status === "unsupported") { + return html`
+
+ ${t("focus.unsupported")} + ${this.renderFocusEscape(t("common.back"))} +
+
`; + } + const focusTarget = this.focusTarget; + // Focused terminals own the whole document. Keep the generic login gate // out of this path or a connecting native session exposes Web UI chrome. - if (this.terminalOnly) { + if (focusTarget?.kind === "terminal") { const terminalAvailable = isTerminalAvailable( gatewaySnapshot, context.config.current.terminalEnabled ?? false, @@ -269,23 +452,30 @@ export class OpenClawApp extends OpenClawLightDomElement { ? renderConnectingSplash(gatewayStartupStatus) : nothing} ${!terminalAvailable && (gatewayConnected || gatewaySnapshot.lastError) - ? html`
${t("terminal.unavailable")}
` + ? html`
+
+ ${t("terminal.unavailable")} + ${this.renderFocusEscape(t("common.back"))} +
+
` : nothing} `; } // Desktop documents share the panel's connection owner but none of its // dock or shell chrome. Native clients can therefore load this route as a // standalone, mobile-shaped surface without changing the observe contract. - if (this.desktopOnly) { + if (focusTarget?.kind === "desktop") { const desktopAvailable = isDesktopPanelAvailable(gatewaySnapshot); + const source = focusTarget.selector?.kind === "source" ? focusTarget.selector.value : null; + const session = focusTarget.selector?.kind === "session" ? focusTarget.selector.value : null; return html` this.closeDocument(context.basePath)} > ${!gatewayConnected && gatewaySnapshot.lastError === null @@ -295,26 +485,17 @@ export class OpenClawApp extends OpenClawLightDomElement { ? renderConnectingSplash(gatewayStartupStatus) : nothing} ${!desktopAvailable && (gatewayConnected || gatewaySnapshot.lastError) - ? html`
${t("desktop.unavailable")}
` + ? html`
+
+ ${t("desktop.unavailable")} + ${this.renderFocusEscape(t("common.back"))} +
+
` : nothing} `; } - // Dashboard documents reuse the live board provider and widget bridge while - // keeping the application shell, transcript, and navigation chrome unmounted. - if (this.dashboardOnly) { - return html` - this.closeDocument(context.basePath)} - > - ${!gatewayConnected && gatewaySnapshot.lastError === null - ? renderConnectingSplash(gatewayStartupStatus) - : nothing} - ${!isOptionalElementDefined(DASHBOARD_DOCUMENT_ELEMENT) && gatewayConnected - ? renderConnectingSplash(gatewayStartupStatus) - : nothing} - `; + if (focusTarget?.kind === "dashboard") { + return this.renderFocusDashboard(gatewaySnapshot, gatewayConnected, gatewayStartupStatus); } // In the normal Control UI document, the Gateway lifecycle owns unresolved // first-connect state across every auth mode. Failures publish lastError diff --git a/ui/src/app/app-shell-view.ts b/ui/src/app/app-shell-view.ts index 9d99fdda3c76..02baed9f7f36 100644 --- a/ui/src/app/app-shell-view.ts +++ b/ui/src/app/app-shell-view.ts @@ -682,6 +682,7 @@ export function renderApplicationShell(host: ShellViewHost) { .client=${gatewayConnected ? gatewaySnapshot.client : null} .available=${desktopPanelAvailable} .suppressed=${settingsTakeover} + .basePath=${context.basePath} > `} () { return { promise, resolve }; } +describe("normalizeLegacyTerminalViewLocation", () => { + it.each([ + { + location: { pathname: "/", search: "?view=terminal&keep=yes", hash: "#pane" }, + basePath: "", + expected: { pathname: "/focus/terminal", search: "?keep=yes", hash: "#pane" }, + }, + { + location: { + pathname: "/openclaw/", + search: "?keep=yes&view=terminal", + hash: "#pane", + }, + basePath: "/openclaw", + expected: { + pathname: "/openclaw/focus/terminal", + search: "?keep=yes", + hash: "#pane", + }, + }, + ])("normalizes the released terminal query at $basePath", ({ location, basePath, expected }) => { + expect(normalizeLegacyTerminalViewLocation(location, basePath)).toEqual(expected); + }); + + it.each([ + { pathname: "/", search: "?view=desktop", hash: "" }, + { pathname: "/", search: "?view=dashboard", hash: "" }, + { pathname: "/settings/appearance", search: "?view=terminal", hash: "" }, + ])("does not normalize an unsupported legacy location $pathname$search", (location) => { + expect(normalizeLegacyTerminalViewLocation(location, "")).toBe(location); + }); +}); + describe("normalizeInitialApplicationLocation", () => { it("routes an opaque persisted key without aborting bootstrap", () => { expect( @@ -521,17 +555,22 @@ describe("normalizeInitialApplicationLocation", () => { } }); - it("keeps the terminal document route outside the application router", async () => { + it("keeps the focused terminal route outside the application router", async () => { const previousSettings = loadSettings(); const previousUrl = window.location.href; - window.history.replaceState({}, "", "/terminal"); + window.history.replaceState({}, "", "/focus/terminal"); const runtime = bootstrapApplication({ sessionPathBuilderReady: Promise.resolve() }); const routerStart = vi.spyOn(runtime.router, "start"); try { await runtime.start(); - expect(window.location.pathname).toBe("/terminal"); + expect(window.location.pathname).toBe("/focus/terminal"); + expect(runtime.focusLocation).toEqual({ + status: "valid", + basePath: "", + target: { kind: "terminal" }, + }); expect(routerStart).not.toHaveBeenCalled(); } finally { runtime.stop(); @@ -540,6 +579,119 @@ describe("normalizeInitialApplicationLocation", () => { } }); + it.each([ + { + initialUrl: "/?view=terminal&keep=yes#pane", + expectedUrl: "/focus/terminal?keep=yes#pane", + basePath: "", + }, + { + initialUrl: "/openclaw/?view=terminal&keep=yes#pane", + expectedUrl: "/openclaw/focus/terminal?keep=yes#pane", + basePath: "/openclaw", + }, + ])( + "rewrites the released terminal query at the $basePath application boundary", + async ({ initialUrl, expectedUrl, basePath }) => { + const previousSettings = loadSettings(); + const previousUrl = window.location.href; + window.history.replaceState({}, "", initialUrl); + const replaceState = vi.spyOn(window.history, "replaceState"); + const runtime = bootstrapApplication({ sessionPathBuilderReady: Promise.resolve() }); + const routerStart = vi.spyOn(runtime.router, "start"); + + try { + expect(`${window.location.pathname}${window.location.search}${window.location.hash}`).toBe( + expectedUrl, + ); + expect(runtime.focusLocation).toEqual({ + status: "valid", + basePath, + target: { kind: "terminal" }, + }); + + await runtime.start(); + + expect(routerStart).not.toHaveBeenCalled(); + expect(replaceState).toHaveBeenCalledTimes(1); + } finally { + runtime.stop(); + replaceState.mockRestore(); + window.history.replaceState({}, "", previousUrl); + saveSettings(previousSettings); + } + }, + ); + + it.each(["desktop", "dashboard"])( + "does not recognize the removed %s query presentation", + (view) => { + const previousSettings = loadSettings(); + const previousUrl = window.location.href; + const initialUrl = `/?view=${view}&keep=yes#pane`; + window.history.replaceState({}, "", initialUrl); + const replaceState = vi.spyOn(window.history, "replaceState"); + const runtime = bootstrapApplication({ sessionPathBuilderReady: Promise.resolve() }); + + try { + expect(runtime.focusLocation).toBeNull(); + expect(`${window.location.pathname}${window.location.search}${window.location.hash}`).toBe( + initialUrl, + ); + expect(replaceState).not.toHaveBeenCalled(); + } finally { + runtime.stop(); + replaceState.mockRestore(); + window.history.replaceState({}, "", previousUrl); + saveSettings(previousSettings); + } + }, + ); + + it("strips startup credentials before rewriting the released terminal query", () => { + const previousSettings = loadSettings(); + const previousUrl = window.location.href; + window.history.replaceState({}, "", "/?view=terminal#token=startup-token&pane=1"); + const replaceState = vi.spyOn(window.history, "replaceState"); + const runtime = bootstrapApplication({ sessionPathBuilderReady: Promise.resolve() }); + + try { + expect(replaceState.mock.calls.map((call) => call[2])).toEqual([ + "/?view=terminal#pane=1", + "/focus/terminal#pane=1", + ]); + expect(runtime.focusLocation).toEqual({ + status: "valid", + basePath: "", + target: { kind: "terminal" }, + }); + } finally { + runtime.stop(); + replaceState.mockRestore(); + window.history.replaceState({}, "", previousUrl); + saveSettings(previousSettings); + } + }); + + it("does not recognize the terminal query outside the application root", () => { + const previousSettings = loadSettings(); + const previousUrl = window.location.href; + const initialUrl = "/settings/appearance?view=terminal&keep=yes#pane"; + window.history.replaceState({}, "", initialUrl); + const runtime = bootstrapApplication({ sessionPathBuilderReady: Promise.resolve() }); + + try { + expect(runtime.focusLocation).toBeNull(); + expect(`${window.location.pathname}${window.location.search}${window.location.hash}`).toBe( + initialUrl, + ); + } finally { + runtime.stop(); + window.history.replaceState({}, "", previousUrl); + saveSettings(previousSettings); + } + }); + it("keeps the latest navigation requested before router start", async () => { const previousSettings = loadSettings(); const previousUrl = window.location.href; diff --git a/ui/src/app/bootstrap.ts b/ui/src/app/bootstrap.ts index 82e8e24aed0c..8652e787ae60 100644 --- a/ui/src/app/bootstrap.ts +++ b/ui/src/app/bootstrap.ts @@ -1,3 +1,7 @@ +import { + parseControlUiFocusLocation, + type ControlUiFocusLocation, +} from "@openclaw/session-url-contract"; import type { RouteLocation } from "@openclaw/uirouter"; import type { GatewayBrowserClient } from "../api/gateway.ts"; import { sessionRouteNamespaceFromPath } from "../app-route-paths.ts"; @@ -39,8 +43,6 @@ import type { ApplicationThemeServerSelection, } from "./context.ts"; import { syncCustomThemeStyleTag } from "./custom-theme.ts"; -import { isDashboardOnlyView } from "./dashboard-document-mode.ts"; -import { isDesktopDocumentPath, isDesktopOnlyView } from "./desktop-document-mode.ts"; import { createApplicationGateway } from "./gateway-store.ts"; import { createInitialUserMessageHandoff } from "./initial-user-message-handoff.ts"; import { createNativeChatDrafts } from "./native-bridge.ts"; @@ -58,8 +60,10 @@ import { } from "./settings.ts"; import { createSkillWorkshopRevisionAdmissions } from "./skill-workshop-revision-admissions.ts"; import { createStartupLifecycle, type StartupStep } from "./startup-lifecycle.ts"; -import { resolveApplicationStartupSettings } from "./startup-settings.ts"; -import { isTerminalDocumentPath, isTerminalOnlyView } from "./terminal-document-mode.ts"; +import { + normalizeLegacyTerminalViewLocation, + resolveApplicationStartupSettings, +} from "./startup-settings.ts"; import { startThemeTransition } from "./theme-transition.ts"; import { resolveTheme, type ThemeMode } from "./theme.ts"; import { createWebPushCapability } from "./web-push.ts"; @@ -222,6 +226,7 @@ export type ApplicationRuntime = { readonly context: ApplicationContext; readonly router: ApplicationRouter; readonly documentMode: ApprovalDocumentMode | null; + readonly focusLocation: ControlUiFocusLocation | null; readonly pendingGatewayConnection: { readonly gatewayUrl: string; readonly token: string; @@ -271,27 +276,26 @@ export function bootstrapApplication( saveSettings(startup.settings); } } - const basePath = resolveControlUiBasePath( - startup.location.pathname || globalThis.location?.pathname || "/", + const applicationLocation = normalizeLegacyTerminalViewLocation( + startup.location, + initialBasePath, ); - const dashboardDocument = isDashboardOnlyView(startup.location); - const standaloneDocument = - isTerminalDocumentPath(startup.location.pathname, basePath) || - isDesktopDocumentPath(startup.location.pathname, basePath) || - dashboardDocument; + if (applicationLocation !== startup.location) { + history.replace(applicationLocation); + } + const basePath = resolveControlUiBasePath( + applicationLocation.pathname || globalThis.location?.pathname || "/", + ); + const focusLocation = parseControlUiFocusLocation(applicationLocation, basePath); const firstRunDefaultLanding = - documentMode === null && isDefaultChatLanding(startup.location, basePath, routeIdFromPath); - // A `?view=` document mode still lands on the chat path, so it counts as the default landing - // for routing, but it is an explicit destination that renders its own surface. Redirecting it - // into model setup strands native app webviews on a blank page, so only gate the redirect. - const firstRunRedirectEnabled = - firstRunDefaultLanding && - !isTerminalOnlyView(startup.location, basePath) && - !isDesktopOnlyView(startup.location, basePath) && - !dashboardDocument; + documentMode === null && + focusLocation === null && + isDefaultChatLanding(applicationLocation, basePath, routeIdFromPath); + const firstRunRedirectEnabled = firstRunDefaultLanding; const sessionPathBuilderReady = dependencies.sessionPathBuilderReady ?? - (documentMode || dashboardDocument + (documentMode || + (focusLocation?.status === "valid" && focusLocation.target.kind !== "dashboard") ? Promise.resolve() : import("@openclaw/session-url-contract").then((contract) => { setSessionPathBuilder(contract.buildControlUiSessionPath); @@ -313,23 +317,23 @@ export function bootstrapApplication( ); const agents = createAgentCapability(gateway); const startupLifecycle = createStartupLifecycle(); - const startupRouteId = routeIdFromPath(startup.location.pathname, basePath); + const startupRouteId = routeIdFromPath(applicationLocation.pathname, basePath); const releasedSessionQuery = (startupRouteId === "chat" || startupRouteId === "dashboard") && - sessionRouteNamespaceFromPath(startup.location.pathname, basePath) === null && - new URLSearchParams(startup.location.search).has("session"); + sessionRouteNamespaceFromPath(applicationLocation.pathname, basePath) === null && + new URLSearchParams(applicationLocation.search).has("session"); const deferInitialLocationUntilGateway = documentMode === null && !releasedSessionQuery && firstRunDefaultLanding && !parseAgentSessionKey(settings.sessionKey); const initialLocationReady = ( - documentMode || dashboardDocument - ? Promise.resolve(startup.location) + documentMode || focusLocation + ? Promise.resolve(applicationLocation) : Promise.all([sessionPathBuilderReady, import("./bootstrap-location.ts")]).then( ([, location]) => location.resolveInitialApplicationLocation({ - location: startup.location, + location: applicationLocation, basePath, sessionKey: settings.sessionKey, gateway, @@ -341,7 +345,7 @@ export function bootstrapApplication( // stop() aborts an eager unscoped-session lookup even when start() returns // at the lazy-chunk guard, so consume that teardown-only rejection here. if (startupLifecycle.signal.aborted) { - return startup.location; + return applicationLocation; } throw error; }); @@ -391,9 +395,9 @@ export function bootstrapApplication( const chatAttachmentHandoff = createChatAttachmentHandoff(); applyThemePresentation(settings); const router = createApplicationRouter(); - // Standalone terminal, desktop, and dashboard documents render before the - // shell; starting the page router would rewrite them to an application route. - const startsApplicationRouter = documentMode === null && !standaloneDocument; + // Focus documents render before the shell; starting the application router + // would rewrite their reserved presentation route into an ordinary page. + const startsApplicationRouter = documentMode === null && focusLocation === null; let routerStarted = false; // Pre-start navigations are invisible to history; retain the latest request so // router.start() cannot resolve the stale browser URL over the user's route. @@ -532,6 +536,7 @@ export function bootstrapApplication( context, router, documentMode, + focusLocation, get pendingGatewayConnection() { return pendingGatewayConnection; }, diff --git a/ui/src/app/dashboard-document-mode.ts b/ui/src/app/dashboard-document-mode.ts deleted file mode 100644 index 5d1a0c42c514..000000000000 --- a/ui/src/app/dashboard-document-mode.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { normalizeRouteBasePath } from "@openclaw/uirouter"; - -type DashboardDocumentLocation = Pick; - -export function isDashboardOnlyView( - location: DashboardDocumentLocation | undefined = globalThis.location, -): boolean { - return new URLSearchParams(location?.search ?? "").get("view") === "dashboard"; -} - -export function dashboardDocumentSession( - location: DashboardDocumentLocation | undefined = globalThis.location, -): string | null { - return new URLSearchParams(location?.search ?? "").get("session"); -} - -export function dashboardDocumentHref(basePath: string, sessionRef: string): string { - const path = normalizeRouteBasePath(basePath) || "/"; - const search = new URLSearchParams({ view: "dashboard", session: sessionRef }); - return `${path}?${search}`; -} diff --git a/ui/src/app/desktop-document-mode.test.ts b/ui/src/app/desktop-document-mode.test.ts deleted file mode 100644 index b1114d5f87a4..000000000000 --- a/ui/src/app/desktop-document-mode.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { GatewaySessionRow } from "../api/types.ts"; -import { resolveDesktopDocumentTarget } from "../components/desktop/desktop-source.ts"; -import { - dashboardDocumentHref, - dashboardDocumentSession, - isDashboardOnlyView, -} from "./dashboard-document-mode.ts"; -import { desktopDocumentOptions } from "./desktop-document-mode.ts"; - -describe("dashboard document mode", () => { - it("parses the dashboard session reference", () => { - const location = { - search: "?view=dashboard&session=agent%3Amain%3Awork", - }; - - expect(isDashboardOnlyView(location)).toBe(true); - expect(dashboardDocumentSession(location)).toBe("agent:main:work"); - }); - - it("keeps a missing session visible to the document empty state", () => { - const location = { search: "?view=dashboard" }; - - expect(isDashboardOnlyView(location)).toBe(true); - expect(dashboardDocumentSession(location)).toBeNull(); - }); - - it("does not treat an ordinary route as a dashboard document", () => { - expect(isDashboardOnlyView({ search: "" })).toBe(false); - }); - - it("builds an encoded base-path-aware document URL", () => { - expect(dashboardDocumentHref("/openclaw/", "agent:main:work item")).toBe( - "/openclaw?view=dashboard&session=agent%3Amain%3Awork+item", - ); - }); -}); - -describe("desktop document mode", () => { - it("parses desktop source, session, and control options", () => { - expect( - desktopDocumentOptions({ - search: "?view=desktop&source=gateway&session=agent%3Amain%3Awork&control=1", - }), - ).toEqual({ - source: "gateway", - session: "agent:main:work", - control: true, - }); - }); - - it("prefers an explicit source over the session placement", () => { - const session = { - key: "agent:main:work", - kind: "direct", - updatedAt: 1, - execNode: "workstation", - } satisfies GatewaySessionRow; - - expect( - resolveDesktopDocumentTarget( - { source: "gateway", session: session.key, control: false }, - session, - ), - ).toBe("gateway"); - }); - - it.each([ - [ - "cloud placement", - { - key: "agent:main:cloud", - kind: "direct", - updatedAt: 1, - placement: { state: "active", environmentId: "worker:cloud-1" }, - } as GatewaySessionRow, - "worker:cloud-1", - ], - [ - "execution node", - { - key: "agent:main:node", - kind: "direct", - updatedAt: 1, - execNode: "workstation", - } satisfies GatewaySessionRow, - "node:workstation", - ], - [ - "gateway fallback", - { - key: "agent:main:gateway", - kind: "direct", - updatedAt: 1, - } satisfies GatewaySessionRow, - "gateway", - ], - ])("resolves a session's %s through the chat placement owner", (_label, session, expected) => { - expect( - resolveDesktopDocumentTarget({ source: null, session: session.key, control: false }, session), - ).toBe(expected); - }); - - it("returns no target for an unknown session", () => { - expect( - resolveDesktopDocumentTarget( - { source: null, session: "agent:main:missing", control: false }, - undefined, - ), - ).toBeNull(); - }); -}); diff --git a/ui/src/app/desktop-document-mode.ts b/ui/src/app/desktop-document-mode.ts deleted file mode 100644 index 9c816f3ebefb..000000000000 --- a/ui/src/app/desktop-document-mode.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { normalizeRouteBasePath, normalizeRoutePath } from "@openclaw/uirouter"; - -const DESKTOP_DOCUMENT_PATH = "/desktop"; - -type DesktopDocumentLocation = Pick; - -export type DesktopDocumentOptions = { - source: string | null; - session: string | null; - control: boolean; -}; - -function desktopDocumentPath(basePath = ""): string { - return `${normalizeRouteBasePath(basePath)}${DESKTOP_DOCUMENT_PATH}`; -} - -export function isDesktopDocumentPath(pathname: string, basePath: string): boolean { - return normalizeRoutePath(pathname) === desktopDocumentPath(basePath); -} - -export function isDesktopOnlyView( - location: DesktopDocumentLocation | undefined = globalThis.location, - basePath = "", -): boolean { - return ( - new URLSearchParams(location?.search ?? "").get("view") === "desktop" || - isDesktopDocumentPath(location?.pathname ?? "/", basePath) - ); -} - -export function desktopDocumentOptions( - location: Pick | undefined = globalThis.location, -): DesktopDocumentOptions { - const search = new URLSearchParams(location?.search ?? ""); - return { - source: search.get("source"), - session: search.get("session"), - control: search.get("control") === "1", - }; -} diff --git a/ui/src/app/startup-settings.ts b/ui/src/app/startup-settings.ts index 73e62c5e4966..55d4a6fa3b87 100644 --- a/ui/src/app/startup-settings.ts +++ b/ui/src/app/startup-settings.ts @@ -1,4 +1,5 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { buildControlUiFocusPath } from "@openclaw/session-url-contract"; // Control UI startup settings resolve native auth handoff and URL parameters. import { CONTROL_UI_BOOTSTRAP_PROFILE_FRAGMENT_PARAM, @@ -38,6 +39,27 @@ declare global { } } +export function normalizeLegacyTerminalViewLocation( + location: ApplicationStartupLocation, + basePath: string, +): ApplicationStartupLocation { + const applicationRoot = basePath ? `${basePath}/` : "/"; + if (location.pathname !== applicationRoot) { + return location; + } + const searchParams = new URLSearchParams(location.search); + if (searchParams.get("view") !== "terminal") { + return location; + } + searchParams.delete("view"); + const search = searchParams.toString(); + return { + pathname: buildControlUiFocusPath({ kind: "terminal" }, basePath), + search: search ? `?${search}` : "", + hash: location.hash, + }; +} + export function resolveApplicationStartupSettings( initialSettings: UiSettings, location: ApplicationStartupLocation, diff --git a/ui/src/app/terminal-document-mode.test.ts b/ui/src/app/terminal-document-mode.test.ts deleted file mode 100644 index 66a8f429c419..000000000000 --- a/ui/src/app/terminal-document-mode.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isTerminalOnlyView, terminalDocumentPath } from "./terminal-document-mode.ts"; - -describe("terminal document mode", () => { - it.each([ - ["the root route", { pathname: "/terminal", search: "" }, ""], - ["a base-mounted route", { pathname: "/openclaw/terminal", search: "" }, "/openclaw"], - ["the embedded query form", { pathname: "/", search: "?view=terminal" }, ""], - ])("recognizes %s", (_label, location, basePath) => { - expect(isTerminalOnlyView(location, basePath)).toBe(true); - }); - - it("does not treat an ordinary route as a terminal document", () => { - expect(isTerminalOnlyView({ pathname: "/chat", search: "" }, "")).toBe(false); - }); - - it("builds a base-path-aware user-facing route", () => { - expect(terminalDocumentPath("/openclaw/")).toBe("/openclaw/terminal"); - }); -}); diff --git a/ui/src/app/terminal-document-mode.ts b/ui/src/app/terminal-document-mode.ts deleted file mode 100644 index 48f72319fd77..000000000000 --- a/ui/src/app/terminal-document-mode.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { normalizeRouteBasePath, normalizeRoutePath } from "@openclaw/uirouter"; - -const TERMINAL_DOCUMENT_PATH = "/terminal"; - -type TerminalDocumentLocation = Pick; - -export function terminalDocumentPath(basePath = ""): string { - return `${normalizeRouteBasePath(basePath)}${TERMINAL_DOCUMENT_PATH}`; -} - -export function isTerminalDocumentPath(pathname: string, basePath: string): boolean { - return normalizeRoutePath(pathname) === terminalDocumentPath(basePath); -} - -export function isTerminalOnlyView( - location: TerminalDocumentLocation | undefined = globalThis.location, - basePath = "", -): boolean { - return ( - new URLSearchParams(location?.search ?? "").get("view") === "terminal" || - isTerminalDocumentPath(location?.pathname ?? "/", basePath) - ); -} diff --git a/ui/src/components/desktop/desktop-document-inventory.ts b/ui/src/components/desktop/desktop-document-inventory.ts index 8bc1c1a3f73a..e1cdba3ec1ff 100644 --- a/ui/src/components/desktop/desktop-document-inventory.ts +++ b/ui/src/components/desktop/desktop-document-inventory.ts @@ -23,7 +23,7 @@ export async function resolveDesktopDocumentInventoryTarget(options: { } catch {} } const requestedSource = resolveDesktopDocumentTarget( - { source: options.source, session: options.sessionKey, control: false }, + { source: options.source, session: options.sessionKey }, session, ); return requestedSource !== null && diff --git a/ui/src/components/desktop/desktop-focus-window.ts b/ui/src/components/desktop/desktop-focus-window.ts new file mode 100644 index 000000000000..a9be17c2cefc --- /dev/null +++ b/ui/src/components/desktop/desktop-focus-window.ts @@ -0,0 +1,14 @@ +import { buildControlUiFocusPath } from "@openclaw/session-url-contract"; +import { openExternalUrlSafe } from "../../lib/open-external-url.ts"; + +export function desktopFocusPath( + basePath: string, + source?: string | null, + control = false, +): string { + return buildControlUiFocusPath({ kind: "desktop", source, control }, basePath); +} + +export function openDesktopFocus(basePath: string, source?: string | null, control = false): void { + openExternalUrlSafe(desktopFocusPath(basePath, source, control)); +} diff --git a/ui/src/components/desktop/desktop-panel-view.ts b/ui/src/components/desktop/desktop-panel-view.ts index 8dd8c66a5860..eb14e52e0367 100644 --- a/ui/src/components/desktop/desktop-panel-view.ts +++ b/ui/src/components/desktop/desktop-panel-view.ts @@ -11,6 +11,7 @@ export function renderDesktopPanelHeader(options: { fullscreenControl: TemplateResult; onClose: () => void; onDock: (dock: "bottom" | "right") => void; + onOpenWindow: () => void; }) { return html`
@@ -34,6 +35,15 @@ export function renderDesktopPanelHeader(options: { > ${icons.panelRightOpen} + ${options.fullscreenControl}