mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
feat: view your machine's screen from the iOS and Android apps (#123097)
* feat(ui): add mobile desktop document mode Add a shell-free mobile desktop route that reuses the dock panel controller and lazy noVNC client, with source preselection, touch controls, keyboard input, and retryable inventory failures. * feat(ios): add desktop viewer entry points * feat(android): add desktop viewer * fix(android): keep System Back inside the desktop viewer The per-session viewer replaces SessionDashboardScreen in place instead of pushing a shell tab, so System Back fell through to the shell-level handler and popped the whole Dashboard tab, stranding the operator on Chat. Claim Back while the viewer is showing. Also carry over TerminalSettingsScreen's imePadding: the viewer's own touch toolbar hosts the keyboard affordance, so the soft keyboard would cover it. Proof (emulator, Medium_Phone_API_36.0, stub control UI on 18789): pre-fix Back from the viewer lands on Chat; post-fix it returns to Dashboard. No Robolectric regression test — no existing screen test constructs MainViewModel, and BackHandler under Robolectric would need new scaffolding for weaker evidence than the live repro. * test(ui): stop the pairing views leaking dialogs into the shared document `ui/vitest.config.ts` runs the unit project with `isolate: false`, so test files share one jsdom document inside a worker. `view.pairing.test.ts` appends a container to `document.body` for every case and never tears down, unlike its sibling `channels-page.test.ts`, so whichever suite the worker scheduled next inherited a mounted pairing dialog. That surfaced on this PR's first CI run as ten failures in the untouched `input-dialog.test.ts`, which found "Approve DM access" where it expected "Rename session". A rerun went green, so the ordering is scheduler-dependent rather than deterministic; this removes the contamination source rather than leaving the next suite to lose the race. Not a proven fix for that specific run — the leak reproduces only under CI's file scheduling, and the full suite passes locally either way — but the missing teardown is a real violation of the shared-environment contract. * test(ui): stop the background-tasks rail asserting on a ticking clock The rail e2e captured the main transcript's text before opening a task detail and required it to be byte-identical afterwards. A running task renders a live elapsed label, so the assertion failed whenever a second ticked over between the two reads — twice while landing this PR, both times "12s" against "13s" with no other difference. Normalize elapsed labels on both sides instead of weakening the assertion. The invariant it protects, that opening a detail leaves the main transcript alone, still holds: a real content change is still caught, and only complete duration tokens collapse, so diffstat figures like +14/-3 and phrases like "5 messages" are untouched.
This commit is contained in:
committed by
GitHub
parent
d5d069a09e
commit
2a8b322ebf
+734
-622
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ internal object AndroidScreenshotFixture {
|
||||
}
|
||||
|
||||
const val gatewayId = "android-screenshot-gateway"
|
||||
const val controlUiBaseUrl = "http://127.0.0.1:18789"
|
||||
const val mainSessionKey = "agent:main:node-screenshot"
|
||||
const val primarySessionTitle = "Android release planning"
|
||||
const val cronJobId = "android-release-digest"
|
||||
|
||||
@@ -17,6 +17,7 @@ enum class AndroidScreenshotScene(
|
||||
Settings("settings", HomeDestination.Settings),
|
||||
Gateway("gateway", HomeDestination.Settings, SettingsRoute.Gateway),
|
||||
OpenClaw("openclaw", HomeDestination.Settings, SettingsRoute.SystemAgent),
|
||||
Desktop("desktop", HomeDestination.Settings, SettingsRoute.Desktop),
|
||||
VoiceWake("voice-wake", HomeDestination.Settings, SettingsRoute.Voice),
|
||||
;
|
||||
|
||||
|
||||
@@ -517,6 +517,8 @@ class MainViewModel private constructor(
|
||||
val isConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.isConnected }
|
||||
val gatewayControlPage: StateFlow<NodeRuntime.GatewayControlPage?> =
|
||||
runtimeState(initial = null) { it.gatewayControlPage }
|
||||
val desktopObserveAvailable: StateFlow<Boolean> =
|
||||
runtimeState(initial = false) { it.desktopObserveAvailable }
|
||||
val isNodeConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.nodeConnected }
|
||||
val nodeCapabilityApproval: StateFlow<GatewayNodeCapabilityApproval> =
|
||||
runtimeState(initial = GatewayNodeCapabilityApproval.Loading) { it.nodeCapabilityApproval }
|
||||
|
||||
@@ -1119,6 +1119,8 @@ class NodeRuntime private constructor(
|
||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
private val _gatewayControlPage = MutableStateFlow<GatewayControlPage?>(null)
|
||||
val gatewayControlPage: StateFlow<GatewayControlPage?> = _gatewayControlPage.asStateFlow()
|
||||
private val _desktopObserveAvailable = MutableStateFlow(false)
|
||||
val desktopObserveAvailable: StateFlow<Boolean> = _desktopObserveAvailable.asStateFlow()
|
||||
private val _nodeConnected = MutableStateFlow(false)
|
||||
val nodeConnected: StateFlow<Boolean> = _nodeConnected.asStateFlow()
|
||||
private val _nodeCapabilityApproval = MutableStateFlow<GatewayNodeCapabilityApproval>(GatewayNodeCapabilityApproval.Loading)
|
||||
@@ -2914,6 +2916,14 @@ class NodeRuntime private constructor(
|
||||
_serverName.value = "OpenClaw Gateway"
|
||||
_remoteAddress.value = "Mac Studio on local network"
|
||||
_gatewayVersion.value = BuildConfig.VERSION_NAME
|
||||
replaceGatewayMethods(setOf(GatewayMethod.DesktopObserve.rawValue))
|
||||
_gatewayControlPage.value =
|
||||
GatewayControlPage(
|
||||
baseUrl = AndroidScreenshotFixture.controlUiBaseUrl,
|
||||
token = null,
|
||||
password = null,
|
||||
tlsFingerprintSha256 = null,
|
||||
)
|
||||
updateGatewayDefaultAgentId("main")
|
||||
_gatewayAgents.value = AndroidScreenshotFixture.agents
|
||||
_modelCatalog.value = AndroidScreenshotFixture.models
|
||||
@@ -7523,6 +7533,7 @@ class NodeRuntime private constructor(
|
||||
synchronized(gatewayMethodsLock) {
|
||||
gatewayApprovalRpcFamily = selectGatewayApprovalRpcFamily(methods)
|
||||
_clawHubSkillMethodsAvailable.value = supportsClawHubSkillManagement(methods)
|
||||
_desktopObserveAvailable.value = GatewayMethod.DesktopObserve.rawValue in methods
|
||||
systemAgentChatSupported.value = GatewayMethod.OpenclawChat.rawValue in methods
|
||||
gatewayMethodsEpoch += 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.i18n.nativeString
|
||||
import ai.openclaw.app.ui.design.ClawPlainIconButton
|
||||
import ai.openclaw.app.ui.design.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.DesktopWindows
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
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 viewer for a gateway-observable desktop source. */
|
||||
@Composable
|
||||
internal fun DesktopScreen(
|
||||
viewModel: MainViewModel,
|
||||
source: String? = null,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val controlPage by viewModel.gatewayControlPage.collectAsState()
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = ClawTheme.spacing.lg, top = 14.dp, end = ClawTheme.spacing.lg, bottom = 6.dp),
|
||||
) {
|
||||
// The viewer's keyboard affordance opens the soft keyboard over the canvas; without
|
||||
// imePadding it would also cover the viewer's own touch toolbar.
|
||||
Column(modifier = Modifier.fillMaxSize().imePadding(), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
ClawPlainIconButton(
|
||||
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = nativeString("Back"),
|
||||
onClick = onBack,
|
||||
)
|
||||
Text(
|
||||
text = nativeString("Desktop"),
|
||||
style = ClawTheme.type.title,
|
||||
color = ClawTheme.colors.text,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.DesktopWindows,
|
||||
contentDescription = null,
|
||||
tint = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||
val page = controlPage
|
||||
if (isConnected && page != null) {
|
||||
// GatewayControlPage equality includes credentials and the accepted TLS pin.
|
||||
key(page, source) {
|
||||
ControlUiWebView(
|
||||
page = page,
|
||||
url = desktopUrl(baseUrl = page.baseUrl, source = source),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 48.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = nativeString("Desktop needs a connected gateway"),
|
||||
style = ClawTheme.type.section,
|
||||
color = ClawTheme.colors.text,
|
||||
)
|
||||
Text(
|
||||
text = nativeString("Connect to your gateway to view a machine screen."),
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the desktop document route; credentials stay in ControlUiWebView's startup script. */
|
||||
internal fun desktopUrl(
|
||||
baseUrl: String,
|
||||
source: String? = null,
|
||||
): String {
|
||||
val baseUri = baseUrl.trimEnd('/').toUri()
|
||||
val routePath = "${baseUri.encodedPath.orEmpty().trimEnd('/')}/"
|
||||
val builder =
|
||||
baseUri
|
||||
.buildUpon()
|
||||
.encodedPath(routePath)
|
||||
.clearQuery()
|
||||
.fragment(null)
|
||||
.appendQueryParameter("view", "desktop")
|
||||
source?.let { builder.appendQueryParameter("source", it) }
|
||||
return builder.build().toString()
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import ai.openclaw.app.i18n.nativeString
|
||||
import ai.openclaw.app.ui.design.ClawPlainIconButton
|
||||
import ai.openclaw.app.ui.design.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -16,12 +17,16 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.Dashboard
|
||||
import androidx.compose.material.icons.outlined.DesktopWindows
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -37,6 +42,17 @@ internal fun SessionDashboardScreen(
|
||||
) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val controlPage by viewModel.gatewayControlPage.collectAsState()
|
||||
val desktopObserveAvailable by viewModel.desktopObserveAvailable.collectAsState()
|
||||
var showingDesktop by rememberSaveable(sessionKey) { mutableStateOf(false) }
|
||||
if (showingDesktop) {
|
||||
// The viewer replaces this screen in place rather than pushing a shell tab, so it must
|
||||
// claim System Back itself; the shell handler would otherwise pop the whole dashboard.
|
||||
BackHandler { showingDesktop = false }
|
||||
// Session summaries do not advertise an environment id, so the viewer opens
|
||||
// its source picker instead of guessing a gateway or node association.
|
||||
DesktopScreen(viewModel = viewModel, source = null, onBack = { showingDesktop = false })
|
||||
return
|
||||
}
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = ClawTheme.spacing.lg, top = 14.dp, end = ClawTheme.spacing.lg, bottom = 6.dp),
|
||||
) {
|
||||
@@ -59,6 +75,13 @@ internal fun SessionDashboardScreen(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (desktopObserveAvailable) {
|
||||
ClawPlainIconButton(
|
||||
icon = Icons.Outlined.DesktopWindows,
|
||||
contentDescription = nativeString("Open desktop"),
|
||||
onClick = { showingDesktop = true },
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Dashboard,
|
||||
contentDescription = null,
|
||||
|
||||
@@ -189,6 +189,7 @@ internal enum class SettingsRoute {
|
||||
Dreaming,
|
||||
Canvas,
|
||||
Terminal,
|
||||
Desktop,
|
||||
Notifications,
|
||||
PhoneCapabilities,
|
||||
Gateway,
|
||||
@@ -224,6 +225,7 @@ internal fun SettingsDetailScreen(
|
||||
SettingsRoute.Dreaming -> DreamingSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.Canvas -> CanvasSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.Terminal -> TerminalSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.Desktop -> DesktopScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.Notifications -> NotificationSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.PhoneCapabilities -> PhoneCapabilitiesScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.Gateway -> GatewaySettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
|
||||
@@ -93,6 +93,7 @@ import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.outlined.AccessTime
|
||||
import androidx.compose.material.icons.outlined.ChatBubbleOutline
|
||||
import androidx.compose.material.icons.outlined.Dashboard
|
||||
import androidx.compose.material.icons.outlined.DesktopWindows
|
||||
import androidx.compose.material.icons.outlined.Folder
|
||||
import androidx.compose.material.icons.outlined.Inventory2
|
||||
import androidx.compose.material.icons.outlined.MicNone
|
||||
@@ -1684,6 +1685,7 @@ private fun SettingsShellScreen(
|
||||
val nodesDevicesSummary by viewModel.nodesDevicesSummary.collectAsState()
|
||||
val channelsSummary by viewModel.channelsSummary.collectAsState()
|
||||
val dreamingSummary by viewModel.dreamingSummary.collectAsState()
|
||||
val desktopObserveAvailable by viewModel.desktopObserveAvailable.collectAsState()
|
||||
val appearanceThemeMode by viewModel.appearanceThemeMode.collectAsState()
|
||||
val providerRows = providerRows(providers = providers, models = models)
|
||||
val readyProviderCount = providerRows.count { it.ready }
|
||||
@@ -1748,7 +1750,7 @@ private fun SettingsShellScreen(
|
||||
}
|
||||
|
||||
val settingsRows =
|
||||
listOf(
|
||||
listOfNotNull(
|
||||
SettingsRow(
|
||||
nativeText("Gateway"),
|
||||
verbatimText(gatewaySummary(gatewayConnectionDisplay)),
|
||||
@@ -1801,6 +1803,11 @@ private fun SettingsShellScreen(
|
||||
),
|
||||
SettingsRow(nativeText("Dreaming"), verbatimText(dreamingSummaryText(dreamingSummary)), Icons.Default.Storage, status = dreamingStatus(dreamingSummary), route = SettingsRoute.Dreaming),
|
||||
SettingsRow(nativeText("Terminal"), nativeText("Shell in the agent workspace"), Icons.Outlined.Terminal, status = isConnected, route = SettingsRoute.Terminal),
|
||||
if (desktopObserveAvailable) {
|
||||
SettingsRow(nativeText("Desktop"), nativeText("View a machine screen"), Icons.Outlined.DesktopWindows, status = isConnected, route = SettingsRoute.Desktop)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
SettingsRow(nativeText("Voice"), if (speakerEnabled) nativeText("Speaker on") else nativeText("Speaker muted"), Icons.Default.Mic, route = SettingsRoute.Voice),
|
||||
SettingsRow(nativeText("Canvas"), nativeText("Screen surface"), Icons.AutoMirrored.Filled.ScreenShare, status = isConnected, route = SettingsRoute.Canvas),
|
||||
SettingsRow(nativeText("Notifications"), if (notificationForwardingEnabled) nativeText("Smart delivery") else nativeText("Off"), Icons.Default.Notifications, route = SettingsRoute.Notifications),
|
||||
@@ -2053,6 +2060,7 @@ internal fun settingsSectionTitleForRoute(route: SettingsRoute): NativeText =
|
||||
SettingsRoute.SkillWorkshop,
|
||||
SettingsRoute.Dreaming,
|
||||
SettingsRoute.Terminal,
|
||||
SettingsRoute.Desktop,
|
||||
-> nativeText("Agents & automation")
|
||||
|
||||
SettingsRoute.Voice,
|
||||
|
||||
@@ -47,6 +47,7 @@ class AndroidScreenshotModeTest {
|
||||
assertEquals(HomeDestination.Chat, AndroidScreenshotScene.Chat.homeDestination)
|
||||
assertEquals(HomeDestination.Chat, AndroidScreenshotScene.Swarm.homeDestination)
|
||||
assertEquals(HomeDestination.Settings, AndroidScreenshotScene.Settings.homeDestination)
|
||||
assertEquals(HomeDestination.Settings, AndroidScreenshotScene.Desktop.homeDestination)
|
||||
assertEquals(HomeDestination.Settings, AndroidScreenshotScene.VoiceWake.homeDestination)
|
||||
}
|
||||
|
||||
@@ -81,4 +82,12 @@ class AndroidScreenshotModeTest {
|
||||
assertEquals(AndroidScreenshotScene.VoiceWake, scene)
|
||||
assertEquals(SettingsRoute.Voice, scene.settingsRoute)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun desktopSceneTargetsDesktopSettings() {
|
||||
val scene = AndroidScreenshotScene.fromRawValue("desktop")
|
||||
|
||||
assertEquals(AndroidScreenshotScene.Desktop, scene)
|
||||
assertEquals(SettingsRoute.Desktop, scene.settingsRoute)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@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",
|
||||
)
|
||||
|
||||
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="))
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import SwiftUI
|
||||
struct SessionDashboardScreen: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var showsDesktop = false
|
||||
let sessionKey: String
|
||||
|
||||
var body: some View {
|
||||
@@ -31,6 +32,18 @@ struct SessionDashboardScreen: View {
|
||||
.navigationTitle("Dashboard")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if self.appModel.isDesktopObserveAvailable {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
self.showsDesktop = true
|
||||
} label: {
|
||||
Image(systemName: "display")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
.accessibilityLabel("Open Desktop")
|
||||
.accessibilityIdentifier("SessionDashboard.Desktop")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
self.dismiss()
|
||||
@@ -40,6 +53,11 @@ struct SessionDashboardScreen: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(isPresented: self.$showsDesktop) {
|
||||
// Session dashboard presentation currently carries only the session key,
|
||||
// so the desktop document mode owns source selection.
|
||||
DesktopHubScreen(source: nil, usesNativeNavigationChrome: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var unavailableCard: some View {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
/// Control-hub Desktop destination: embeds the gateway-served desktop page in
|
||||
/// the same authenticated, origin-locked WKWebView used by other Control UI pages.
|
||||
struct DesktopHubScreen: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
let source: String?
|
||||
let headerSidebarAction: OpenClawSidebarHeaderAction?
|
||||
let usesNativeNavigationChrome: Bool
|
||||
let gatewayAction: (() -> Void)?
|
||||
|
||||
init(
|
||||
source: String? = nil,
|
||||
headerSidebarAction: OpenClawSidebarHeaderAction? = nil,
|
||||
usesNativeNavigationChrome: Bool = false,
|
||||
gatewayAction: (() -> Void)? = nil)
|
||||
{
|
||||
self.source = source
|
||||
self.headerSidebarAction = headerSidebarAction
|
||||
self.usesNativeNavigationChrome = usesNativeNavigationChrome
|
||||
self.gatewayAction = gatewayAction
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let config = self.appModel.activeGatewayConnectConfig
|
||||
let storedOperatorToken = AuthenticatedControlUI.storedOperatorToken(config: config)
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
if let url = Self.desktopURL(config: config, source: self.source) {
|
||||
AuthenticatedControlUIWebView(
|
||||
url: url,
|
||||
authScript: Self.desktopAuthUserScript(
|
||||
config: config,
|
||||
source: self.source,
|
||||
storedOperatorToken: storedOperatorToken),
|
||||
tls: config?.tls)
|
||||
.id(Self.webContentIdentity(
|
||||
config: config,
|
||||
source: self.source,
|
||||
storedOperatorToken: storedOperatorToken))
|
||||
.ignoresSafeArea(.container, edges: .bottom)
|
||||
} else {
|
||||
self.unavailableCard
|
||||
}
|
||||
}
|
||||
.navigationTitle("Desktop")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar(
|
||||
self.usesNativeNavigationChrome || self.headerSidebarAction != nil ? .visible : .hidden,
|
||||
for: .navigationBar)
|
||||
.toolbar {
|
||||
if self.usesNativeNavigationChrome, let gatewayAction {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(action: gatewayAction) {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
.accessibilityLabel("Gateway settings")
|
||||
}
|
||||
}
|
||||
if let headerSidebarAction {
|
||||
OpenClawSidebarToolbarItem(
|
||||
action: headerSidebarAction,
|
||||
placement: .topBarLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var unavailableCard: some View {
|
||||
VStack(spacing: 12) {
|
||||
ProIconBadge(systemName: "display", color: OpenClawBrand.accent)
|
||||
Text("Desktop needs a connected gateway")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
Text("Connect to your gateway to view an observable machine.")
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
if let gatewayAction {
|
||||
Button(action: gatewayAction) {
|
||||
Text("Open Gateway Settings")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(OpenClawBrand.accent)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
|
||||
/// Credentials never enter this URL; the document-start user script carries
|
||||
/// them through the Control UI's native-auth contract.
|
||||
static func desktopURL(config: GatewayConnectConfig?, source: String?) -> URL? {
|
||||
var queryItems = [URLQueryItem(name: "view", value: "desktop")]
|
||||
if let source = self.normalizedSource(source) {
|
||||
queryItems.append(URLQueryItem(name: "source", value: source))
|
||||
}
|
||||
return AuthenticatedControlUI.pageURL(
|
||||
config: config,
|
||||
path: "/",
|
||||
queryItems: queryItems)
|
||||
}
|
||||
|
||||
static func desktopAuthUserScript(config: GatewayConnectConfig?, source: String?) -> String? {
|
||||
self.desktopAuthUserScript(
|
||||
config: config,
|
||||
source: source,
|
||||
storedOperatorToken: AuthenticatedControlUI.storedOperatorToken(config: config))
|
||||
}
|
||||
|
||||
static func desktopAuthUserScript(
|
||||
config: GatewayConnectConfig?,
|
||||
source: String?,
|
||||
storedOperatorToken: String?) -> String?
|
||||
{
|
||||
AuthenticatedControlUI.authUserScript(
|
||||
config: config,
|
||||
pageURL: self.desktopURL(config: config, source: source),
|
||||
storedOperatorToken: storedOperatorToken)
|
||||
}
|
||||
|
||||
static func webContentIdentity(
|
||||
config: GatewayConnectConfig?,
|
||||
source: String?,
|
||||
storedOperatorToken: String?) -> Int
|
||||
{
|
||||
var hasher = Hasher()
|
||||
hasher.combine(AuthenticatedControlUI.webContentIdentity(
|
||||
config: config,
|
||||
storedOperatorToken: storedOperatorToken))
|
||||
hasher.combine(self.normalizedSource(source))
|
||||
return hasher.finalize()
|
||||
}
|
||||
|
||||
private static func normalizedSource(_ source: String?) -> String? {
|
||||
let trimmed = source?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
@@ -376,6 +376,8 @@ final class NodeAppModel {
|
||||
self.operatorConnected
|
||||
}
|
||||
|
||||
private(set) var isDesktopObserveAvailable: Bool = false
|
||||
|
||||
private(set) var hasOperatorAdminScope: Bool = false
|
||||
|
||||
var gatewayServerName: String?
|
||||
@@ -4491,6 +4493,10 @@ extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
self.setOperatorConnected(true)
|
||||
await self.refreshDesktopObserveAvailability(
|
||||
stableID: stableID,
|
||||
routeGeneration: routeGeneration)
|
||||
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return }
|
||||
self.clearOperatorGatewayConnectionProblemIfCurrent()
|
||||
GatewayDiagnostics.log(
|
||||
"operator gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
|
||||
@@ -5245,6 +5251,9 @@ extension NodeAppModel {
|
||||
func setOperatorConnected(_ connected: Bool) {
|
||||
let changed = self.operatorConnected != connected
|
||||
self.operatorConnected = connected
|
||||
if !connected {
|
||||
self.isDesktopObserveAvailable = false
|
||||
}
|
||||
self.operatorStatusText = connected ? "Connected" : "Offline"
|
||||
self.refreshOperatorAdminScopeFromStore()
|
||||
guard connected else {
|
||||
@@ -5276,6 +5285,24 @@ extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshDesktopObserveAvailability(stableID: String, routeGeneration: UInt64) async {
|
||||
// Advertised methods belong to one admitted operator route. Never let a
|
||||
// reconnect publish support learned from the socket it replaced.
|
||||
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID),
|
||||
let route = await self.operatorGateway.currentRoute(ifGatewayID: stableID)
|
||||
else {
|
||||
self.isDesktopObserveAvailable = false
|
||||
return
|
||||
}
|
||||
let supported = await self.operatorGateway.supportsServerMethod(
|
||||
"desktop.observe",
|
||||
ifCurrentRoute: route) == true
|
||||
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID),
|
||||
await self.operatorGateway.currentRoute(ifGatewayID: stableID) == route
|
||||
else { return }
|
||||
self.isDesktopObserveAvailable = supported
|
||||
}
|
||||
|
||||
func refreshOperatorAdminScopeFromStore() {
|
||||
guard let config = activeGatewayConnectConfig else {
|
||||
self.hasOperatorAdminScope = false
|
||||
|
||||
@@ -43,7 +43,8 @@ struct RootSidebar: View {
|
||||
.background(OpenClawSidebarPalette.background)
|
||||
.sheet(isPresented: self.$showsPagesEditor) {
|
||||
RootSidebarPagesEditor(
|
||||
pinnedPages: self.pinnedPages,
|
||||
destinations: RootTabs.pinnableSidebarPages.filter(self.isDestinationAvailable),
|
||||
pinnedPages: self.storedPinnedPages,
|
||||
onSelect: { destination in
|
||||
self.showsPagesEditor = false
|
||||
self.selectSidebarDestination(destination)
|
||||
@@ -52,12 +53,20 @@ struct RootSidebar: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var pinnedPages: [RootTabs.SidebarDestination] {
|
||||
private var storedPinnedPages: [RootTabs.SidebarDestination] {
|
||||
RootTabs.pinnedSidebarPages(from: self.pinnedPagesStorage)
|
||||
}
|
||||
|
||||
private var pinnedPages: [RootTabs.SidebarDestination] {
|
||||
self.storedPinnedPages.filter(self.isDestinationAvailable)
|
||||
}
|
||||
|
||||
private func isDestinationAvailable(_ destination: RootTabs.SidebarDestination) -> Bool {
|
||||
destination != .desktop || self.appModel.isDesktopObserveAvailable
|
||||
}
|
||||
|
||||
private func togglePinnedPage(_ destination: RootTabs.SidebarDestination) {
|
||||
var pages = self.pinnedPages
|
||||
var pages = self.storedPinnedPages
|
||||
if let index = pages.firstIndex(of: destination) {
|
||||
pages.remove(at: index)
|
||||
} else {
|
||||
@@ -864,6 +873,7 @@ struct RootSidebar: View {
|
||||
/// Web-parity Pages editor (the pen menu): navigate to any page, pin/unpin
|
||||
/// which ones stay in the sidebar. Home is fixed and not listed.
|
||||
struct RootSidebarPagesEditor: View {
|
||||
let destinations: [RootTabs.SidebarDestination]
|
||||
let pinnedPages: [RootTabs.SidebarDestination]
|
||||
let onSelect: (RootTabs.SidebarDestination) -> Void
|
||||
let onTogglePin: (RootTabs.SidebarDestination) -> Void
|
||||
@@ -874,7 +884,7 @@ struct RootSidebarPagesEditor: View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
ForEach(RootTabs.pinnableSidebarPages) { destination in
|
||||
ForEach(self.destinations) { destination in
|
||||
self.pageRow(destination)
|
||||
}
|
||||
} footer: {
|
||||
|
||||
@@ -394,6 +394,10 @@ struct RootTabs: View {
|
||||
headerTitle: "Automations",
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
.id(self.selectedSidebarDestination.id)
|
||||
case .desktop:
|
||||
DesktopHubScreen(
|
||||
headerSidebarAction: self.sidebarHeaderAction,
|
||||
gatewayAction: { self.selectSidebarDestination(.gateway) })
|
||||
case .terminal:
|
||||
TerminalHubScreen(
|
||||
headerSidebarAction: self.sidebarHeaderAction,
|
||||
|
||||
@@ -27,6 +27,7 @@ extension RootTabs {
|
||||
case dreaming
|
||||
case usage
|
||||
case cron
|
||||
case desktop
|
||||
case terminal
|
||||
case docs
|
||||
case settings
|
||||
@@ -50,6 +51,7 @@ extension RootTabs {
|
||||
case .dreaming: String(localized: "Dreaming")
|
||||
case .usage: String(localized: "Usage")
|
||||
case .cron: String(localized: "Automations")
|
||||
case .desktop: String(localized: "Desktop")
|
||||
case .terminal: String(localized: "Terminal")
|
||||
case .docs: String(localized: "Docs")
|
||||
case .settings: String(localized: "Settings")
|
||||
@@ -78,6 +80,7 @@ extension RootTabs {
|
||||
case .dreaming: "moon.stars"
|
||||
case .usage: "chart.bar.xaxis"
|
||||
case .cron: "timer"
|
||||
case .desktop: "display"
|
||||
case .terminal: "terminal"
|
||||
case .docs: "book"
|
||||
case .settings: "gearshape"
|
||||
@@ -92,7 +95,7 @@ extension RootTabs {
|
||||
case .chat, .overview, .activity, .agents, .workboard, .skillWorkshop, .instances, .sessions,
|
||||
.files,
|
||||
.dreaming,
|
||||
.usage, .cron, .terminal, .settings, .docs:
|
||||
.usage, .cron, .desktop, .terminal, .settings, .docs:
|
||||
nil
|
||||
}
|
||||
}
|
||||
@@ -238,6 +241,7 @@ extension RootTabs {
|
||||
.instances,
|
||||
.files,
|
||||
.dreaming,
|
||||
.desktop,
|
||||
.terminal,
|
||||
.docs,
|
||||
]
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
@testable import OpenClawKit
|
||||
|
||||
@MainActor
|
||||
struct DesktopHubScreenTests {
|
||||
private static func makeConfig(
|
||||
url: URL,
|
||||
token: String? = nil,
|
||||
password: String? = nil) -> GatewayConnectConfig
|
||||
{
|
||||
GatewayConnectConfig(
|
||||
url: url,
|
||||
stableID: "manual|gateway.example.com|443",
|
||||
tls: nil,
|
||||
token: token,
|
||||
bootstrapToken: nil,
|
||||
password: password,
|
||||
nodeOptions: GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: "ios",
|
||||
clientMode: "node",
|
||||
clientDisplayName: "Phone"))
|
||||
}
|
||||
|
||||
@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")),
|
||||
token: "secret-token",
|
||||
password: "secret-password")
|
||||
|
||||
let url = DesktopHubScreen.desktopURL(config: config, source: nil)
|
||||
|
||||
#expect(url?.absoluteString == "https://gateway.example.com:8443/openclaw/?view=desktop")
|
||||
#expect(url?.absoluteString.contains("secret-token") == false)
|
||||
#expect(url?.absoluteString.contains("secret-password") == false)
|
||||
}
|
||||
|
||||
@Test func `session desktop URL includes the selected source`() throws {
|
||||
let config = try Self.makeConfig(
|
||||
url: #require(URL(string: "ws://192.168.1.10:18789")),
|
||||
token: "secret-token")
|
||||
|
||||
let url = DesktopHubScreen.desktopURL(config: config, source: "node:worker-1")
|
||||
|
||||
#expect(url?.absoluteString == "http://192.168.1.10:18789/?view=desktop&source=node%3Aworker-1")
|
||||
#expect(url?.absoluteString.contains("secret-token") == false)
|
||||
}
|
||||
|
||||
@Test func `empty desktop source is omitted`() throws {
|
||||
let config = try Self.makeConfig(url: #require(URL(string: "wss://gateway.example.com")))
|
||||
|
||||
let url = DesktopHubScreen.desktopURL(config: config, source: " ")
|
||||
|
||||
#expect(url?.absoluteString == "https://gateway.example.com/?view=desktop")
|
||||
}
|
||||
|
||||
@Test func `desktop auth script carries credentials outside the URL`() throws {
|
||||
let config = try Self.makeConfig(
|
||||
url: #require(URL(string: "wss://gateway.example.com")),
|
||||
token: " secret-token ",
|
||||
password: "secret-password")
|
||||
|
||||
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.contains("secret-token") == false)
|
||||
#expect(url?.absoluteString.contains("secret-password") == false)
|
||||
#expect(script?.contains("__OPENCLAW_NATIVE_CONTROL_AUTH__") == true)
|
||||
#expect(script?.contains("\"token\":\"secret-token\"") == true)
|
||||
#expect(script?.contains("\"password\":\"secret-password\"") == true)
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,7 @@ struct RootTabsPresentationTests {
|
||||
.instances,
|
||||
.files,
|
||||
.dreaming,
|
||||
.desktop,
|
||||
.terminal,
|
||||
.docs,
|
||||
])
|
||||
@@ -193,6 +194,7 @@ struct RootTabsPresentationTests {
|
||||
"dreaming",
|
||||
"usage",
|
||||
"cron",
|
||||
"desktop",
|
||||
"terminal",
|
||||
"docs",
|
||||
"settings",
|
||||
|
||||
@@ -161,7 +161,8 @@ struct RootTabsSourceGuardTests {
|
||||
|
||||
#expect(rootSource.contains("RootSidebar("))
|
||||
#expect(source.contains("ForEach(self.pinnedPages)"))
|
||||
#expect(source.contains("ForEach(RootTabs.pinnableSidebarPages)"))
|
||||
#expect(source.contains("destinations: RootTabs.pinnableSidebarPages.filter(self.isDestinationAvailable)"))
|
||||
#expect(source.contains("ForEach(self.destinations)"))
|
||||
#expect(source.contains("private var brandHeader: some View"))
|
||||
#expect(source.contains("private var agentsSection: some View"))
|
||||
#expect(source.contains("static func shownAgentCount(configured: Int, total: Int) -> Int"))
|
||||
@@ -770,7 +771,7 @@ extension RootTabsSourceGuardTests {
|
||||
|
||||
#expect(rootSource.matches(of: /openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count >= 2)
|
||||
#expect(!rootSource.contains("openVoiceSettings:"))
|
||||
#expect(rootSource.matches(of: /gatewayAction: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count == 2)
|
||||
#expect(rootSource.matches(of: /gatewayAction: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count == 3)
|
||||
#expect(!rootSource.contains("showGatewayActions"))
|
||||
#expect(!rootSource.contains("gatewayActionsDialog"))
|
||||
#expect(overviewSource.contains("Button(action: self.openSettings)"))
|
||||
|
||||
@@ -12,11 +12,14 @@ import { t } from "../i18n/index.ts";
|
||||
import { isTerminalAvailable } from "../lib/terminal-availability.ts";
|
||||
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
|
||||
import { SubscriptionsController } from "../lit/subscriptions-controller.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 { desktopDocumentOptions, isDesktopOnlyView } from "./desktop-document-mode.ts";
|
||||
import {
|
||||
APPROVAL_PAGE_ELEMENT,
|
||||
DESKTOP_PANEL_ELEMENT,
|
||||
isOptionalElementDefined,
|
||||
preloadOptionalElement,
|
||||
TERMINAL_PANEL_ELEMENT,
|
||||
@@ -72,6 +75,11 @@ export class OpenClawApp extends OpenClawLightDomElement {
|
||||
globalThis.location,
|
||||
resolveControlUiBasePath(globalThis.location?.pathname ?? "/"),
|
||||
);
|
||||
private readonly desktopOnly = isDesktopOnlyView(
|
||||
globalThis.location,
|
||||
resolveControlUiBasePath(globalThis.location?.pathname ?? "/"),
|
||||
);
|
||||
private readonly desktopOptions = desktopDocumentOptions(globalThis.location);
|
||||
private runtime: ApplicationRuntime | undefined;
|
||||
private readonly contextProvider = new ContextProvider(this, {
|
||||
context: applicationContext,
|
||||
@@ -106,6 +114,9 @@ export class OpenClawApp extends OpenClawLightDomElement {
|
||||
if (this.terminalOnly) {
|
||||
preloadOptionalElement(this, TERMINAL_PANEL_ELEMENT);
|
||||
}
|
||||
if (this.desktopOnly) {
|
||||
preloadOptionalElement(this, DESKTOP_PANEL_ELEMENT);
|
||||
}
|
||||
if (this.runtime.documentMode?.kind === "approval") {
|
||||
preloadOptionalElement(this, APPROVAL_PAGE_ELEMENT);
|
||||
}
|
||||
@@ -219,6 +230,37 @@ export class OpenClawApp extends OpenClawLightDomElement {
|
||||
: 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) {
|
||||
const desktopAvailable = isDesktopPanelAvailable(gatewaySnapshot);
|
||||
return html`
|
||||
<openclaw-desktop-panel
|
||||
.client=${gatewayConnected ? gatewaySnapshot.client : null}
|
||||
.available=${desktopAvailable}
|
||||
.documentMode=${true}
|
||||
.documentSource=${this.desktopOptions.source}
|
||||
.documentControl=${this.desktopOptions.control}
|
||||
.onDocumentClose=${() => {
|
||||
if (globalThis.history.length > 1) {
|
||||
globalThis.history.back();
|
||||
} else {
|
||||
globalThis.location.assign(context.basePath || "/");
|
||||
}
|
||||
}}
|
||||
></openclaw-desktop-panel>
|
||||
${!gatewayConnected && gatewaySnapshot.lastError === null
|
||||
? renderConnectingSplash()
|
||||
: nothing}
|
||||
${!isOptionalElementDefined(DESKTOP_PANEL_ELEMENT) && desktopAvailable
|
||||
? renderConnectingSplash()
|
||||
: nothing}
|
||||
${!desktopAvailable && (gatewayConnected || gatewaySnapshot.lastError)
|
||||
? html`<div class="desktop-view-unavailable">${t("desktop.unavailable")}</div>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
// In the normal Control UI document, the Gateway lifecycle owns unresolved
|
||||
// first-connect state across every auth mode. Failures publish lastError
|
||||
// before the gate returns; reconnects keep the shell mounted, and
|
||||
|
||||
+17
-7
@@ -38,6 +38,7 @@ import type {
|
||||
ApplicationThemeServerSelection,
|
||||
} from "./context.ts";
|
||||
import { syncCustomThemeStyleTag } from "./custom-theme.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";
|
||||
@@ -55,7 +56,7 @@ import {
|
||||
import { createSkillWorkshopRevisionHandoff } from "./skill-workshop-revision-handoff.ts";
|
||||
import { createStartupLifecycle, type StartupStep } from "./startup-lifecycle.ts";
|
||||
import { resolveApplicationStartupSettings } from "./startup-settings.ts";
|
||||
import { isTerminalDocumentPath } from "./terminal-document-mode.ts";
|
||||
import { isTerminalDocumentPath, isTerminalOnlyView } from "./terminal-document-mode.ts";
|
||||
import { startThemeTransition } from "./theme-transition.ts";
|
||||
import { resolveTheme, type ThemeMode } from "./theme.ts";
|
||||
import { createWebPushCapability } from "./web-push.ts";
|
||||
@@ -270,9 +271,18 @@ export function bootstrapApplication(
|
||||
const basePath = resolveControlUiBasePath(
|
||||
startup.location.pathname || globalThis.location?.pathname || "/",
|
||||
);
|
||||
const terminalDocument = isTerminalDocumentPath(startup.location.pathname, basePath);
|
||||
const standaloneDocument =
|
||||
isTerminalDocumentPath(startup.location.pathname, basePath) ||
|
||||
isDesktopDocumentPath(startup.location.pathname, 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);
|
||||
const sessionPathBuilderReady =
|
||||
dependencies.sessionPathBuilderReady ??
|
||||
(documentMode
|
||||
@@ -366,9 +376,9 @@ export function bootstrapApplication(
|
||||
const chatAttachmentHandoff = createChatAttachmentHandoff();
|
||||
applyThemePresentation(settings);
|
||||
const router = createApplicationRouter();
|
||||
// /terminal is served by the Gateway's SPA fallback but renders before the
|
||||
// shell; starting the page router would rewrite this special document to /chat.
|
||||
const startsApplicationRouter = documentMode === null && !terminalDocument;
|
||||
// Standalone terminal and desktop paths render before the shell; starting
|
||||
// the page router would rewrite these special documents to /chat.
|
||||
const startsApplicationRouter = documentMode === null && !standaloneDocument;
|
||||
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.
|
||||
@@ -526,7 +536,7 @@ export function bootstrapApplication(
|
||||
steps.push(() =>
|
||||
startModelSetupFirstRunRedirectAfterLocation({
|
||||
context,
|
||||
enabled: firstRunDefaultLanding,
|
||||
enabled: firstRunRedirectEnabled,
|
||||
history,
|
||||
initialLocationReady,
|
||||
}),
|
||||
@@ -554,7 +564,7 @@ export function bootstrapApplication(
|
||||
startupLifecycle.trackDisposer(
|
||||
startModelSetupFirstRunRedirectAfterLocation({
|
||||
context,
|
||||
enabled: firstRunDefaultLanding,
|
||||
enabled: firstRunRedirectEnabled,
|
||||
history,
|
||||
initialLocationReady,
|
||||
installLocation: async (location) => {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { normalizeRouteBasePath, normalizeRoutePath } from "@openclaw/uirouter";
|
||||
|
||||
const DESKTOP_DOCUMENT_PATH = "/desktop";
|
||||
|
||||
type DesktopDocumentLocation = Pick<Location, "pathname" | "search">;
|
||||
|
||||
type DesktopDocumentOptions = {
|
||||
source: 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<DesktopDocumentLocation, "search"> | undefined = globalThis.location,
|
||||
): DesktopDocumentOptions {
|
||||
const search = new URLSearchParams(location?.search ?? "");
|
||||
return {
|
||||
source: search.get("source"),
|
||||
control: search.get("control") === "1",
|
||||
};
|
||||
}
|
||||
@@ -66,6 +66,11 @@ describe("DesktopClient", () => {
|
||||
const { Rfb, instances } = createFakeRfb();
|
||||
const socket = new FakeSocket("ws://control.example.test/desktop/observe");
|
||||
const client = new DesktopClient(Rfb, () => socket as unknown as WebSocket);
|
||||
const target = document.createElement("div");
|
||||
const canvas = document.createElement("canvas");
|
||||
const onKeyDown = vi.fn();
|
||||
canvas.addEventListener("keydown", onKeyDown);
|
||||
target.append(canvas);
|
||||
|
||||
const handle = await client.connect({
|
||||
gatewayUrl: "ws://control.example.test",
|
||||
@@ -73,16 +78,28 @@ describe("DesktopClient", () => {
|
||||
credentials: { username: "operator", password: "secret" },
|
||||
background: "rgb(8, 8, 8)",
|
||||
viewOnly: false,
|
||||
target: document.createElement("div"),
|
||||
scaleViewport: false,
|
||||
target,
|
||||
});
|
||||
|
||||
expect(instances[0]?.background).toBe("rgb(8, 8, 8)");
|
||||
expect(instances[0]?.viewOnly).toBe(false);
|
||||
expect(instances[0]?.scaleViewport).toBe(true);
|
||||
expect(instances[0]?.scaleViewport).toBe(false);
|
||||
expect(instances[0]?.options).toEqual({
|
||||
credentials: { username: "operator", password: "secret" },
|
||||
});
|
||||
|
||||
handle.setScaleViewport?.(true);
|
||||
expect(instances[0]?.scaleViewport).toBe(true);
|
||||
handle.sendKeyboardEvent?.(new KeyboardEvent("keydown", { key: "k", code: "KeyK" }));
|
||||
expect(onKeyDown).toHaveBeenCalledOnce();
|
||||
expect((onKeyDown.mock.calls[0]?.[0] as KeyboardEvent | undefined)?.key).toBe("k");
|
||||
handle.sendText?.("m");
|
||||
handle.sendBackspace?.();
|
||||
expect(onKeyDown.mock.calls.map((call) => (call[0] as KeyboardEvent | undefined)?.key)).toEqual(
|
||||
["k", "m", "Backspace"],
|
||||
);
|
||||
|
||||
handle.disconnect();
|
||||
expect(instances[0]?.disconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ type DesktopConnectOptions = {
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: (detail: DesktopDisconnectDetail) => void;
|
||||
onSecurityFailure?: (detail: DesktopSecurityFailureDetail) => void;
|
||||
scaleViewport?: boolean;
|
||||
target: HTMLElement;
|
||||
viewOnly: boolean;
|
||||
wsUrl: string;
|
||||
@@ -22,6 +23,10 @@ type DesktopConnectOptions = {
|
||||
|
||||
export type DesktopConnectionHandle = {
|
||||
disconnect(): void;
|
||||
sendBackspace?(): void;
|
||||
sendKeyboardEvent?(event: KeyboardEvent): void;
|
||||
sendText?(text: string): void;
|
||||
setScaleViewport?(enabled: boolean): void;
|
||||
};
|
||||
|
||||
type RfbClient = EventTarget & {
|
||||
@@ -89,15 +94,66 @@ export class DesktopClient {
|
||||
);
|
||||
rfb.background = options.background ?? getComputedStyle(options.target).backgroundColor;
|
||||
rfb.viewOnly = options.viewOnly;
|
||||
rfb.scaleViewport = true;
|
||||
rfb.scaleViewport = options.scaleViewport ?? true;
|
||||
rfb.addEventListener("connect", () => options.onConnect?.());
|
||||
rfb.addEventListener("disconnect", () => options.onDisconnect?.(closeDetail));
|
||||
rfb.addEventListener("securityfailure", (event) => {
|
||||
const detail = (event as CustomEvent<DesktopSecurityFailureDetail>).detail ?? {};
|
||||
options.onSecurityFailure?.(detail);
|
||||
});
|
||||
const dispatchKeyboardEvent = (event: KeyboardEvent) => {
|
||||
// noVNC owns keyboard translation and attaches its listeners to the
|
||||
// canvas. Forward the offscreen mobile input's event to that same
|
||||
// boundary so virtual-keyboard input follows the canonical RFB path.
|
||||
options.target.querySelector("canvas")?.dispatchEvent(event);
|
||||
};
|
||||
const cloneKeyboardEvent = (event: KeyboardEvent) =>
|
||||
new KeyboardEvent(event.type, {
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
location: event.location,
|
||||
ctrlKey: event.ctrlKey,
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey,
|
||||
metaKey: event.metaKey,
|
||||
repeat: event.repeat,
|
||||
isComposing: event.isComposing,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
return {
|
||||
disconnect: () => rfb.disconnect(),
|
||||
setScaleViewport: (enabled) => {
|
||||
rfb.scaleViewport = enabled;
|
||||
},
|
||||
sendKeyboardEvent: (event) => dispatchKeyboardEvent(cloneKeyboardEvent(event)),
|
||||
sendText: (text) => {
|
||||
// Mobile IMEs can omit keydown/keyup. "Unidentified" asks noVNC's
|
||||
// keyboard owner to translate each inserted character and emit a
|
||||
// balanced press/release, matching its built-in mobile UI fallback.
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
dispatchKeyboardEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: text.charAt(index),
|
||||
code: "Unidentified",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
sendBackspace: () => {
|
||||
for (const type of ["keydown", "keyup"]) {
|
||||
dispatchKeyboardEvent(
|
||||
new KeyboardEvent(type, {
|
||||
key: "Backspace",
|
||||
code: "Backspace",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { desktopDocumentStyles } from "./desktop-document-styles.ts";
|
||||
|
||||
describe("desktop document styles", () => {
|
||||
it("uses fixed inset sizing without viewport height units", () => {
|
||||
expect(desktopDocumentStyles.cssText).toContain("position: fixed");
|
||||
expect(desktopDocumentStyles.cssText).toContain("inset: 0");
|
||||
expect(desktopDocumentStyles.cssText).not.toMatch(/\d(?:dvh|svh|lvh|vh)\b/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { css } from "lit";
|
||||
|
||||
export const desktopDocumentStyles = css`
|
||||
/* The inset sizes this to the viewport on its own. Do not reintroduce viewport
|
||||
height units: Android WebView hosts the Control UI in a container that
|
||||
resolves dvh/vh/svh/lvh to 0, which collapses the viewer to a blank page. */
|
||||
.desktop-document {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg);
|
||||
}
|
||||
.desktop-document .desktop-content {
|
||||
width: 100%;
|
||||
}
|
||||
.desktop-document .desktop-stage {
|
||||
width: 100%;
|
||||
}
|
||||
.desktop-touch-toolbar {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
right: 12px;
|
||||
bottom: max(12px, env(safe-area-inset-bottom));
|
||||
left: 12px;
|
||||
display: flex;
|
||||
width: max-content;
|
||||
max-width: calc(100% - 24px);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
margin: 0 auto;
|
||||
padding: 5px;
|
||||
border: 1px solid color-mix(in srgb, var(--text) 16%, transparent);
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--bg) 84%, transparent);
|
||||
box-shadow: 0 8px 28px rgb(0 0 0 / 35%);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
.desktop-touch-action {
|
||||
display: inline-flex;
|
||||
min-width: 48px;
|
||||
height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 0 9px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
}
|
||||
.desktop-touch-action[aria-pressed="true"] {
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
}
|
||||
.desktop-touch-action:focus-visible {
|
||||
outline: 2px solid var(--focus, var(--accent));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.desktop-touch-action__icon {
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.desktop-touch-action__icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
stroke-width: 1.8;
|
||||
}
|
||||
.desktop-keyboard-input {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (max-width: 430px) {
|
||||
.desktop-touch-action {
|
||||
min-width: 44px;
|
||||
padding: 0 7px;
|
||||
}
|
||||
.desktop-touch-action__label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,123 @@
|
||||
import { html, nothing, svg } from "lit";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { strokeIcon } from "../icons-tools.ts";
|
||||
import { icons } from "../icons.ts";
|
||||
import type { DesktopPanelState } from "./desktop-panel-state.ts";
|
||||
|
||||
const KEYBOARD_GLYPH = strokeIcon(svg`
|
||||
<rect width="20" height="14" x="2" y="5" rx="2" />
|
||||
<path d="M6 9h.01" />
|
||||
<path d="M10 9h.01" />
|
||||
<path d="M14 9h.01" />
|
||||
<path d="M18 9h.01" />
|
||||
<path d="M6 13h.01" />
|
||||
<path d="M10 13h.01" />
|
||||
<path d="M14 13h.01" />
|
||||
<path d="M18 13h.01" />
|
||||
<path d="M8 17h8" />
|
||||
`);
|
||||
|
||||
type DesktopDocumentViewOptions = {
|
||||
state: DesktopPanelState;
|
||||
controlling: boolean;
|
||||
scaleViewport: boolean;
|
||||
notice: unknown;
|
||||
picker: unknown;
|
||||
credentials: unknown;
|
||||
recovery: unknown;
|
||||
keyboardInputValue: string;
|
||||
onControlToggle: () => void;
|
||||
onKeyboardFocus: () => void;
|
||||
onKeyboardEvent: (event: KeyboardEvent) => void;
|
||||
onKeyboardInput: (event: InputEvent) => void;
|
||||
onScaleToggle: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function renderDesktopDocumentView(options: DesktopDocumentViewOptions) {
|
||||
const connection = html`
|
||||
<div class="desktop-stage">
|
||||
<div class="desktop-surface"></div>
|
||||
${options.state === "connecting"
|
||||
? html`<div class="desktop-connecting" role="status" aria-live="polite">
|
||||
<span class="desktop-connecting__monitor" aria-hidden="true">${icons.monitor}</span>
|
||||
<span>${t("desktop.connecting")}</span>
|
||||
</div>`
|
||||
: nothing}
|
||||
<textarea
|
||||
class="desktop-keyboard-input"
|
||||
inputmode="text"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
tabindex="-1"
|
||||
aria-label=${t("desktop.keyboardInput")}
|
||||
.value=${options.keyboardInputValue}
|
||||
@keydown=${options.onKeyboardEvent}
|
||||
@keyup=${options.onKeyboardEvent}
|
||||
@input=${options.onKeyboardInput}
|
||||
></textarea>
|
||||
<nav class="desktop-touch-toolbar" aria-label=${t("desktop.touchControls")}>
|
||||
<button
|
||||
class="desktop-touch-action"
|
||||
type="button"
|
||||
aria-label=${t(options.controlling ? "desktop.switchToViewOnly" : "desktop.takeControl")}
|
||||
aria-pressed=${options.controlling ? "true" : "false"}
|
||||
@click=${options.onControlToggle}
|
||||
>
|
||||
<span class="desktop-touch-action__icon" aria-hidden="true">
|
||||
${options.controlling ? icons.hand : icons.eye}
|
||||
</span>
|
||||
<span class="desktop-touch-action__label">
|
||||
${t(options.controlling ? "desktop.control" : "desktop.viewOnly")}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="desktop-touch-action"
|
||||
type="button"
|
||||
aria-label=${t("desktop.keyboard")}
|
||||
@click=${options.onKeyboardFocus}
|
||||
>
|
||||
<span class="desktop-touch-action__icon" aria-hidden="true">${KEYBOARD_GLYPH}</span>
|
||||
<span class="desktop-touch-action__label">${t("desktop.keyboard")}</span>
|
||||
</button>
|
||||
<button
|
||||
class="desktop-touch-action"
|
||||
type="button"
|
||||
aria-label=${t(options.scaleViewport ? "desktop.actualSize" : "desktop.fitScreen")}
|
||||
aria-pressed=${options.scaleViewport ? "true" : "false"}
|
||||
@click=${options.onScaleToggle}
|
||||
>
|
||||
<span class="desktop-touch-action__icon" aria-hidden="true">
|
||||
${options.scaleViewport ? icons.minimize : icons.maximize}
|
||||
</span>
|
||||
<span class="desktop-touch-action__label">${t("desktop.fit")}</span>
|
||||
</button>
|
||||
<button
|
||||
class="desktop-touch-action"
|
||||
type="button"
|
||||
aria-label=${t("desktop.back")}
|
||||
@click=${options.onClose}
|
||||
>
|
||||
<span class="desktop-touch-action__icon" aria-hidden="true">${icons.arrowLeft}</span>
|
||||
<span class="desktop-touch-action__label">${t("desktop.back")}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<section class="desktop-document" aria-label=${t("desktop.title")}>
|
||||
<div class="desktop-content">
|
||||
${options.notice}
|
||||
${options.state === "picker"
|
||||
? options.picker
|
||||
: options.state === "inventory-error" || options.state === "disconnected"
|
||||
? options.recovery
|
||||
: options.state === "credentials"
|
||||
? options.credentials
|
||||
: connection}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import type { EnvironmentSummary, WorkerDesktopAppId } from "@openclaw/gateway-protocol";
|
||||
import { html, nothing, svg } from "lit";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { icons } from "../icons.ts";
|
||||
import { desktopAppIcon, desktopAppLabel } from "./desktop-app-presentation.ts";
|
||||
import type { DesktopPanelState } from "./desktop-panel-state.ts";
|
||||
import { desktopSourceForEnvironment } from "./desktop-source.ts";
|
||||
|
||||
const CLOSE_GLYPH = svg`<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>`;
|
||||
const DOCK_BOTTOM_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M2 10h12" /></svg>`;
|
||||
const DOCK_RIGHT_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M10 2.5v11" /></svg>`;
|
||||
|
||||
export function renderDesktopPanelHeader(options: {
|
||||
dock: "bottom" | "right";
|
||||
onClose: () => void;
|
||||
onDock: (dock: "bottom" | "right") => void;
|
||||
}) {
|
||||
return html`
|
||||
<header class="bp-header">
|
||||
<div class="bp-title">${t("desktop.title")}</div>
|
||||
<div class="bp-actions">
|
||||
<button
|
||||
class="bp-icon ${options.dock === "bottom" ? "is-active" : ""}"
|
||||
type="button"
|
||||
title=${t("desktop.dockBottom")}
|
||||
aria-label=${t("desktop.dockBottom")}
|
||||
@click=${() => options.onDock("bottom")}
|
||||
>
|
||||
${DOCK_BOTTOM_GLYPH}
|
||||
</button>
|
||||
<button
|
||||
class="bp-icon ${options.dock === "right" ? "is-active" : ""}"
|
||||
type="button"
|
||||
title=${t("desktop.dockRight")}
|
||||
aria-label=${t("desktop.dockRight")}
|
||||
@click=${() => options.onDock("right")}
|
||||
>
|
||||
${DOCK_RIGHT_GLYPH}
|
||||
</button>
|
||||
<button
|
||||
class="bp-icon"
|
||||
type="button"
|
||||
title=${t("desktop.hide")}
|
||||
aria-label=${t("desktop.hide")}
|
||||
@click=${options.onClose}
|
||||
>
|
||||
${CLOSE_GLYPH}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderDesktopPicker(options: {
|
||||
environments: EnvironmentSummary[];
|
||||
loading: boolean;
|
||||
onConnect: (environmentId: string) => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
return html`
|
||||
<div class="desktop-toolbar">
|
||||
<span>${t("desktop.pickerTitle")}</span>
|
||||
<span class="desktop-toolbar__spacer"></span>
|
||||
<button
|
||||
class="desktop-button"
|
||||
type="button"
|
||||
?disabled=${options.loading}
|
||||
@click=${options.onRefresh}
|
||||
>
|
||||
${options.loading ? t("desktop.refreshing") : t("desktop.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
<div class="desktop-picker">
|
||||
${options.loading && options.environments.length === 0
|
||||
? html`<div class="desktop-status">${t("desktop.loading")}</div>`
|
||||
: options.environments.length === 0
|
||||
? html`<div class="desktop-status">${t("desktop.empty")}</div>`
|
||||
: options.environments.map((environment) =>
|
||||
renderDesktopEnvironment(environment, options.onConnect),
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDesktopEnvironment(
|
||||
environment: EnvironmentSummary,
|
||||
onConnect: (environmentId: string) => void,
|
||||
) {
|
||||
const worker = environment.worker;
|
||||
const source = desktopSourceForEnvironment(environment);
|
||||
return html`
|
||||
<div class="desktop-environment">
|
||||
<div class="desktop-environment__details">
|
||||
<div class="desktop-environment__id">
|
||||
${source.kind === "host" ? t("desktop.thisMachine") : environment.id}
|
||||
</div>
|
||||
<div class="desktop-environment__meta">
|
||||
<span>${worker?.state ?? environment.status}</span>
|
||||
</div>
|
||||
${worker && worker.attachedSessionIds.length > 0
|
||||
? html`<div class="desktop-environment__sessions">
|
||||
${worker.attachedSessionIds.map(
|
||||
(sessionId) => html`<span class="desktop-session">${sessionId}</span>`,
|
||||
)}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
<button
|
||||
class="desktop-button desktop-button--primary"
|
||||
type="button"
|
||||
@click=${() => onConnect(environment.id)}
|
||||
>
|
||||
${t("desktop.connect")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderDesktopCredentials(options: {
|
||||
ardAccount: boolean;
|
||||
username: string;
|
||||
onSubmit: (event: SubmitEvent) => void;
|
||||
}) {
|
||||
return html`
|
||||
<div class="desktop-status">
|
||||
<form class="desktop-credentials" @submit=${options.onSubmit}>
|
||||
<div>${t(options.ardAccount ? "desktop.accountPrompt" : "desktop.passwordPrompt")}</div>
|
||||
${options.ardAccount
|
||||
? html`<label class="desktop-credentials__label">
|
||||
${t("desktop.usernameLabel")}
|
||||
<input
|
||||
class="desktop-credentials__input"
|
||||
name="username"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
.value=${options.username}
|
||||
required
|
||||
/>
|
||||
</label>`
|
||||
: nothing}
|
||||
<label class="desktop-credentials__label">
|
||||
${t(options.ardAccount ? "desktop.accountPasswordLabel" : "desktop.passwordLabel")}
|
||||
<input
|
||||
class="desktop-credentials__input"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button class="desktop-button desktop-button--primary" type="submit">
|
||||
${t("desktop.connect")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderDesktopConnection(options: {
|
||||
state: DesktopPanelState;
|
||||
controlling: boolean;
|
||||
desktopApps: WorkerDesktopAppId[];
|
||||
environmentSelected: boolean;
|
||||
launchingApp: WorkerDesktopAppId | null;
|
||||
showApps: boolean;
|
||||
onDisconnect: () => void;
|
||||
onLaunch: (app: WorkerDesktopAppId) => void;
|
||||
onTakeControl: () => void;
|
||||
}) {
|
||||
return html`
|
||||
<div class="desktop-toolbar desktop-toolbar--connection">
|
||||
${options.showApps && options.desktopApps.length > 0
|
||||
? html`<div class="desktop-apps">
|
||||
${options.desktopApps.map((app) => {
|
||||
const launching = options.launchingApp === app;
|
||||
const label = desktopAppLabel(app);
|
||||
return html`<button
|
||||
class="desktop-app-button"
|
||||
type="button"
|
||||
title=${label}
|
||||
aria-label=${label}
|
||||
aria-busy=${launching ? "true" : "false"}
|
||||
?disabled=${!options.environmentSelected || launching}
|
||||
@click=${() => options.onLaunch(app)}
|
||||
>
|
||||
<span
|
||||
class="desktop-app-button__icon ${launching
|
||||
? "desktop-app-button__icon--launching"
|
||||
: ""}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
${desktopAppIcon(app)}
|
||||
</span>
|
||||
<span>${label}</span>
|
||||
</button>`;
|
||||
})}
|
||||
</div>`
|
||||
: nothing}
|
||||
<span class="desktop-toolbar__spacer"></span>
|
||||
<button
|
||||
class="desktop-toolbar-action"
|
||||
type="button"
|
||||
title=${t("desktop.disconnect")}
|
||||
aria-label=${t("desktop.disconnect")}
|
||||
@click=${options.onDisconnect}
|
||||
>
|
||||
${t("desktop.disconnect")}
|
||||
</button>
|
||||
</div>
|
||||
<div class="desktop-stage">
|
||||
<div class="desktop-surface"></div>
|
||||
${!options.controlling
|
||||
? html`<button
|
||||
class="desktop-stage__take-control"
|
||||
type="button"
|
||||
title=${t("desktop.takeControl")}
|
||||
aria-label=${t("desktop.takeControl")}
|
||||
@click=${options.onTakeControl}
|
||||
></button>`
|
||||
: nothing}
|
||||
${options.state === "connecting"
|
||||
? html`<div class="desktop-connecting" role="status" aria-live="polite">
|
||||
<span class="desktop-connecting__monitor" aria-hidden="true">${icons.monitor}</span>
|
||||
<span class="desktop-connecting__copy">
|
||||
${t("desktop.connecting")}
|
||||
<span class="desktop-connecting__dots" aria-hidden="true">
|
||||
<span class="desktop-connecting__dot"></span>
|
||||
<span class="desktop-connecting__dot"></span>
|
||||
<span class="desktop-connecting__dot"></span>
|
||||
</span>
|
||||
</span>
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderDesktopNotice(errorText: string | null, noticeText: string | null) {
|
||||
return errorText
|
||||
? html`<div class="desktop-note desktop-note--error" role="alert">${errorText}</div>`
|
||||
: noticeText
|
||||
? html`<div class="desktop-note" role="status">${noticeText}</div>`
|
||||
: nothing;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
WorkerDesktopAppId,
|
||||
WorkerDesktopLaunchResult,
|
||||
} from "@openclaw/gateway-protocol";
|
||||
import { html, nothing, svg } from "lit";
|
||||
import { html, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
@@ -14,23 +14,26 @@ import { formatUiError } from "../../lib/format-error.ts";
|
||||
import { OpenClawLitElement } from "../../lit/openclaw-element.ts";
|
||||
import { DockLayoutController, dockPanelStyles } from "../dock-layout-controller.ts";
|
||||
import { createDockPanelLayout } from "../dock-panel-layout.ts";
|
||||
import { icons } from "../icons.ts";
|
||||
import {
|
||||
DESKTOP_PANEL_TOGGLE_EVENT,
|
||||
type DesktopPanelToggleDetail,
|
||||
} from "../panel-toggle-contract.ts";
|
||||
import { desktopAppIcon, desktopAppLabel } from "./desktop-app-presentation.ts";
|
||||
import { DesktopClient, type DesktopConnectionHandle } from "./desktop-client.ts";
|
||||
import { desktopDocumentStyles } from "./desktop-document-styles.ts";
|
||||
import { renderDesktopDocumentView } from "./desktop-document-view.ts";
|
||||
import { desktopCredentialRequirement } from "./desktop-panel-credentials.ts";
|
||||
import { desktopPanelLauncherStyles } from "./desktop-panel-launcher-styles.ts";
|
||||
import { type DesktopPanelState, renderDesktopPanelRecovery } from "./desktop-panel-state.ts";
|
||||
import { desktopPanelStyles } from "./desktop-panel-styles.ts";
|
||||
import {
|
||||
renderDesktopConnection,
|
||||
renderDesktopCredentials,
|
||||
renderDesktopNotice,
|
||||
renderDesktopPanelHeader,
|
||||
renderDesktopPicker,
|
||||
} from "./desktop-panel-view.ts";
|
||||
import { desktopSourceForEnvironment } from "./desktop-source.ts";
|
||||
|
||||
const CLOSE_GLYPH = svg`<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>`;
|
||||
const DOCK_BOTTOM_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M2 10h12" /></svg>`;
|
||||
const DOCK_RIGHT_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M10 2.5v11" /></svg>`;
|
||||
|
||||
const panelLayout = createDockPanelLayout({
|
||||
storageKey: "openclaw.desktopPanel",
|
||||
minHeight: 240,
|
||||
@@ -49,12 +52,17 @@ type PendingDesktopConnection = {
|
||||
operationId: number;
|
||||
};
|
||||
type ObservedDesktopConnection = PendingDesktopConnection & { observed: DesktopObserveResult };
|
||||
const MOBILE_KEYBOARD_SENTINEL = "________________";
|
||||
|
||||
/** `<openclaw-desktop-panel>` — dockable RFB access to Gateway desktop sources. */
|
||||
class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
@property({ attribute: false }) client: GatewayBrowserClient | null = null;
|
||||
@property({ type: Boolean }) available = false;
|
||||
@property({ type: Boolean }) suppressed = false;
|
||||
@property({ type: Boolean }) documentMode = false;
|
||||
@property({ attribute: false }) documentSource: string | null = null;
|
||||
@property({ type: Boolean }) documentControl = false;
|
||||
@property({ attribute: false }) onDocumentClose: (() => void) | null = null;
|
||||
|
||||
/** Browser tests replace the transport without opening a real RFB socket. */
|
||||
desktopClientFactory: () => Pick<DesktopClient, "connect"> = () => new DesktopClient();
|
||||
@@ -71,6 +79,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
@state() private launchingApp: DesktopAppId | null = null;
|
||||
@state() private launchErrorText: string | null = null;
|
||||
@state() private desktopApps: DesktopAppId[] = [];
|
||||
@state() private scaleViewport = true;
|
||||
|
||||
private connection: DesktopConnectionHandle | null = null;
|
||||
private credentials: DesktopCredentials | undefined;
|
||||
@@ -79,6 +88,8 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
private operationId = 0;
|
||||
private launchOperationId = 0;
|
||||
private controlTakeoverRecoveryUsed = false;
|
||||
private documentSourceResolved = false;
|
||||
private keyboardInputValue = MOBILE_KEYBOARD_SENTINEL;
|
||||
private readonly dockLayout = new DockLayoutController(this, {
|
||||
layout: panelLayout,
|
||||
reservationPrefix: "desktop",
|
||||
@@ -86,13 +97,20 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
});
|
||||
private readonly onToggleRequest = (event: Event) => this.handleToggleRequest(event);
|
||||
|
||||
static override styles = [dockPanelStyles, desktopPanelLauncherStyles, desktopPanelStyles];
|
||||
static override styles = [
|
||||
dockPanelStyles,
|
||||
desktopPanelLauncherStyles,
|
||||
desktopPanelStyles,
|
||||
desktopDocumentStyles,
|
||||
];
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener(DESKTOP_PANEL_TOGGLE_EVENT, this.onToggleRequest);
|
||||
this.dockLayout.setSuppressed(this.suppressed);
|
||||
if (this.dockLayout.open) {
|
||||
if (this.documentMode && this.available) {
|
||||
void this.refreshEnvironments();
|
||||
} else if (this.dockLayout.open) {
|
||||
void this.refreshEnvironments();
|
||||
}
|
||||
}
|
||||
@@ -113,7 +131,22 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
void this.refreshEnvironments();
|
||||
}
|
||||
}
|
||||
if (changed.has("client") || changed.has("available")) {
|
||||
if (changed.has("documentSource")) {
|
||||
this.documentSourceResolved = false;
|
||||
}
|
||||
const gatewayAvailabilityChanged = changed.has("client") || changed.has("available");
|
||||
const documentPresentationChanged =
|
||||
changed.has("documentMode") ||
|
||||
changed.has("documentSource") ||
|
||||
changed.has("documentControl");
|
||||
if (this.documentMode && (gatewayAvailabilityChanged || documentPresentationChanged)) {
|
||||
if (!this.available) {
|
||||
this.documentSourceResolved = false;
|
||||
this.returnToPicker();
|
||||
} else {
|
||||
void this.refreshEnvironments();
|
||||
}
|
||||
} else if (gatewayAvailabilityChanged) {
|
||||
if (!this.available && this.dockLayout.open) {
|
||||
this.dockLayout.hideWithoutPersisting();
|
||||
this.returnToPicker();
|
||||
@@ -125,6 +158,9 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
}
|
||||
|
||||
handleToggleRequest(event: Event): void {
|
||||
if (this.documentMode) {
|
||||
return;
|
||||
}
|
||||
const detail =
|
||||
event instanceof CustomEvent && typeof event.detail === "object" && event.detail !== null
|
||||
? (event.detail as DesktopPanelToggleDetail)
|
||||
@@ -174,6 +210,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
const connection = this.connection;
|
||||
this.connection = null;
|
||||
connection?.disconnect();
|
||||
this.resetDocumentKeyboardInput();
|
||||
}
|
||||
|
||||
private clearLaunchState(): void {
|
||||
@@ -190,23 +227,54 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
const operationId = expectedOperationId ?? ++this.operationId;
|
||||
this.loading = true;
|
||||
this.errorText = null;
|
||||
let refreshed = false;
|
||||
try {
|
||||
const result = await client.request<EnvironmentsListResult>("environments.list", {});
|
||||
if (operationId !== this.operationId) {
|
||||
return false;
|
||||
}
|
||||
this.environments = result.environments.filter((environment) => environment.desktop === true);
|
||||
return true;
|
||||
refreshed = true;
|
||||
} catch (error) {
|
||||
if (operationId === this.operationId) {
|
||||
this.errorText = t("desktop.errors.listFailed", { error: formatUiError(error) });
|
||||
if (this.documentMode && this.documentSource !== null) {
|
||||
this.environmentId = this.documentSource;
|
||||
this.state = "inventory-error";
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (operationId === this.operationId) {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
if (refreshed) {
|
||||
await this.resolveDocumentSource(operationId);
|
||||
}
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
private async resolveDocumentSource(operationId: number): Promise<void> {
|
||||
if (!this.documentMode || this.documentSourceResolved || operationId !== this.operationId) {
|
||||
return;
|
||||
}
|
||||
this.documentSourceResolved = true;
|
||||
const requestedSource = this.documentSource;
|
||||
if (requestedSource === null) {
|
||||
return;
|
||||
}
|
||||
if (!this.environments.some((environment) => environment.id === requestedSource)) {
|
||||
this.state = "picker";
|
||||
this.noticeText = t("desktop.sourceUnavailable");
|
||||
return;
|
||||
}
|
||||
await this.connectEnvironment(requestedSource, this.documentControl);
|
||||
}
|
||||
|
||||
private retryDocumentInventory(): void {
|
||||
this.documentSourceResolved = false;
|
||||
this.state = "connecting";
|
||||
void this.refreshEnvironments();
|
||||
}
|
||||
|
||||
private async connectRequestedEnvironment(environmentId: string): Promise<void> {
|
||||
@@ -275,6 +343,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
if (operationId !== this.operationId) {
|
||||
return;
|
||||
}
|
||||
this.controlling = observed.control;
|
||||
const credentials = observed.preauthenticated
|
||||
? undefined
|
||||
: observed.vncPassword
|
||||
@@ -334,6 +403,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
gatewayUrl: client.gatewayUrl,
|
||||
credentials,
|
||||
viewOnly: !pending.observed.control,
|
||||
scaleViewport: this.scaleViewport,
|
||||
target,
|
||||
onConnect: () => {
|
||||
if (pending.operationId === this.operationId) {
|
||||
@@ -472,243 +542,156 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private renderHeader() {
|
||||
const dock = this.dockLayout.dock;
|
||||
return html`
|
||||
<header class="bp-header">
|
||||
<div class="bp-title">${t("desktop.title")}</div>
|
||||
<div class="bp-actions">
|
||||
<button
|
||||
class="bp-icon ${dock === "bottom" ? "is-active" : ""}"
|
||||
type="button"
|
||||
title=${t("desktop.dockBottom")}
|
||||
aria-label=${t("desktop.dockBottom")}
|
||||
@click=${() => this.dockLayout.setDock("bottom")}
|
||||
>
|
||||
${DOCK_BOTTOM_GLYPH}
|
||||
</button>
|
||||
<button
|
||||
class="bp-icon ${dock === "right" ? "is-active" : ""}"
|
||||
type="button"
|
||||
title=${t("desktop.dockRight")}
|
||||
aria-label=${t("desktop.dockRight")}
|
||||
@click=${() => this.dockLayout.setDock("right")}
|
||||
>
|
||||
${DOCK_RIGHT_GLYPH}
|
||||
</button>
|
||||
<button
|
||||
class="bp-icon"
|
||||
type="button"
|
||||
title=${t("desktop.hide")}
|
||||
aria-label=${t("desktop.hide")}
|
||||
@click=${() => this.closePanel()}
|
||||
>
|
||||
${CLOSE_GLYPH}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
`;
|
||||
private handleDocumentKeyboardEvent(event: KeyboardEvent): void {
|
||||
if (!this.controlling || !this.connection?.sendKeyboardEvent) {
|
||||
return;
|
||||
}
|
||||
this.connection.sendKeyboardEvent(event);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
private renderPicker() {
|
||||
return html`
|
||||
<div class="desktop-toolbar">
|
||||
<span>${t("desktop.pickerTitle")}</span>
|
||||
<span class="desktop-toolbar__spacer"></span>
|
||||
<button
|
||||
class="desktop-button"
|
||||
type="button"
|
||||
?disabled=${this.loading}
|
||||
@click=${() => void this.refreshEnvironments()}
|
||||
>
|
||||
${this.loading ? t("desktop.refreshing") : t("desktop.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
<div class="desktop-picker">
|
||||
${this.loading && this.environments.length === 0
|
||||
? html`<div class="desktop-status">${t("desktop.loading")}</div>`
|
||||
: this.environments.length === 0
|
||||
? html`<div class="desktop-status">${t("desktop.empty")}</div>`
|
||||
: this.environments.map((environment) => this.renderEnvironment(environment))}
|
||||
</div>
|
||||
`;
|
||||
private handleDocumentKeyboardInput(event: InputEvent): void {
|
||||
const input = event.currentTarget as HTMLTextAreaElement;
|
||||
if (!this.controlling) {
|
||||
this.resetDocumentKeyboardInput(input);
|
||||
return;
|
||||
}
|
||||
const previousValue = this.keyboardInputValue;
|
||||
const nextValue = input.value;
|
||||
let prefixLength = 0;
|
||||
const comparableLength = Math.min(previousValue.length, nextValue.length);
|
||||
while (
|
||||
prefixLength < comparableLength &&
|
||||
previousValue.charAt(prefixLength) === nextValue.charAt(prefixLength)
|
||||
) {
|
||||
prefixLength += 1;
|
||||
}
|
||||
const removedCount = previousValue.length - prefixLength;
|
||||
for (let index = 0; index < removedCount; index += 1) {
|
||||
this.connection?.sendBackspace?.();
|
||||
}
|
||||
this.connection?.sendText?.(nextValue.slice(prefixLength));
|
||||
if (nextValue.length < 1 || nextValue.length > MOBILE_KEYBOARD_SENTINEL.length * 2) {
|
||||
this.resetDocumentKeyboardInput(input);
|
||||
return;
|
||||
}
|
||||
this.keyboardInputValue = nextValue;
|
||||
}
|
||||
|
||||
private renderEnvironment(environment: EnvironmentSummary) {
|
||||
const worker = environment.worker;
|
||||
const source = desktopSourceForEnvironment(environment);
|
||||
return html`
|
||||
<div class="desktop-environment">
|
||||
<div class="desktop-environment__details">
|
||||
<div class="desktop-environment__id">
|
||||
${source.kind === "host" ? t("desktop.thisMachine") : environment.id}
|
||||
</div>
|
||||
<div class="desktop-environment__meta">
|
||||
<span>${worker?.state ?? environment.status}</span>
|
||||
</div>
|
||||
${worker && worker.attachedSessionIds.length > 0
|
||||
? html`<div class="desktop-environment__sessions">
|
||||
${worker.attachedSessionIds.map(
|
||||
(sessionId) => html`<span class="desktop-session">${sessionId}</span>`,
|
||||
)}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
<button
|
||||
class="desktop-button desktop-button--primary"
|
||||
type="button"
|
||||
@click=${() => void this.connectEnvironment(environment.id, false)}
|
||||
>
|
||||
${t("desktop.connect")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
private resetDocumentKeyboardInput(input?: HTMLTextAreaElement): void {
|
||||
this.keyboardInputValue = MOBILE_KEYBOARD_SENTINEL;
|
||||
const target =
|
||||
input ?? this.shadowRoot?.querySelector<HTMLTextAreaElement>(".desktop-keyboard-input");
|
||||
if (target) {
|
||||
target.value = MOBILE_KEYBOARD_SENTINEL;
|
||||
}
|
||||
}
|
||||
|
||||
private renderConnection() {
|
||||
return html`
|
||||
<div class="desktop-toolbar desktop-toolbar--connection">
|
||||
${this.source?.kind === "environment" && this.desktopApps.length > 0
|
||||
? html`<div class="desktop-apps">
|
||||
${this.desktopApps.map((app) => {
|
||||
const launching = this.launchingApp === app;
|
||||
const label = desktopAppLabel(app);
|
||||
return html`<button
|
||||
class="desktop-app-button"
|
||||
type="button"
|
||||
title=${label}
|
||||
aria-label=${label}
|
||||
aria-busy=${launching ? "true" : "false"}
|
||||
?disabled=${!this.environmentId || launching}
|
||||
@click=${() => void this.launchApp(app)}
|
||||
>
|
||||
<span
|
||||
class="desktop-app-button__icon ${launching
|
||||
? "desktop-app-button__icon--launching"
|
||||
: ""}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
${desktopAppIcon(app)}
|
||||
</span>
|
||||
<span>${label}</span>
|
||||
</button>`;
|
||||
})}
|
||||
</div>`
|
||||
: nothing}
|
||||
<span class="desktop-toolbar__spacer"></span>
|
||||
<button
|
||||
class="desktop-toolbar-action"
|
||||
type="button"
|
||||
title=${t("desktop.disconnect")}
|
||||
aria-label=${t("desktop.disconnect")}
|
||||
@click=${() => this.returnToPicker()}
|
||||
>
|
||||
${t("desktop.disconnect")}
|
||||
</button>
|
||||
</div>
|
||||
<div class="desktop-stage">
|
||||
<div class="desktop-surface"></div>
|
||||
${!this.controlling
|
||||
? html`<button
|
||||
class="desktop-stage__take-control"
|
||||
type="button"
|
||||
title=${t("desktop.takeControl")}
|
||||
aria-label=${t("desktop.takeControl")}
|
||||
@click=${() =>
|
||||
this.environmentId && void this.connectEnvironment(this.environmentId, true)}
|
||||
></button>`
|
||||
: nothing}
|
||||
${this.state === "connecting"
|
||||
? html`<div class="desktop-connecting" role="status" aria-live="polite">
|
||||
<span class="desktop-connecting__monitor" aria-hidden="true">${icons.monitor}</span>
|
||||
<span class="desktop-connecting__copy">
|
||||
${t("desktop.connecting")}
|
||||
<span class="desktop-connecting__dots" aria-hidden="true">
|
||||
<span class="desktop-connecting__dot"></span>
|
||||
<span class="desktop-connecting__dot"></span>
|
||||
<span class="desktop-connecting__dot"></span>
|
||||
</span>
|
||||
</span>
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
private focusDocumentKeyboard(): void {
|
||||
const input = this.shadowRoot?.querySelector<HTMLTextAreaElement>(".desktop-keyboard-input");
|
||||
input?.focus({ preventScroll: true });
|
||||
input?.setSelectionRange(input.value.length, input.value.length);
|
||||
}
|
||||
|
||||
private renderCredentials() {
|
||||
const ardAccount = this.credentialAuth === "ard-account";
|
||||
return html`
|
||||
<div class="desktop-status">
|
||||
<form
|
||||
class="desktop-credentials"
|
||||
@submit=${(event: SubmitEvent) => this.handleCredentialsSubmit(event)}
|
||||
>
|
||||
<div>${t(ardAccount ? "desktop.accountPrompt" : "desktop.passwordPrompt")}</div>
|
||||
${ardAccount
|
||||
? html`<label class="desktop-credentials__label">
|
||||
${t("desktop.usernameLabel")}
|
||||
<input
|
||||
class="desktop-credentials__input"
|
||||
name="username"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
.value=${this.credentials?.username ?? ""}
|
||||
required
|
||||
/>
|
||||
</label>`
|
||||
: nothing}
|
||||
<label class="desktop-credentials__label">
|
||||
${t(ardAccount ? "desktop.accountPasswordLabel" : "desktop.passwordLabel")}
|
||||
<input
|
||||
class="desktop-credentials__input"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button class="desktop-button desktop-button--primary" type="submit">
|
||||
${t("desktop.connect")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
private toggleDocumentScale(): void {
|
||||
this.scaleViewport = !this.scaleViewport;
|
||||
this.connection?.setScaleViewport?.(this.scaleViewport);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.available || !this.dockLayout.open) {
|
||||
if (!this.available) {
|
||||
return nothing;
|
||||
}
|
||||
const notice = renderDesktopNotice(this.launchErrorText ?? this.errorText, this.noticeText);
|
||||
const picker = renderDesktopPicker({
|
||||
environments: this.environments,
|
||||
loading: this.loading,
|
||||
onRefresh: () => void this.refreshEnvironments(),
|
||||
onConnect: (environmentId) => void this.connectEnvironment(environmentId, false),
|
||||
});
|
||||
const credentials = renderDesktopCredentials({
|
||||
ardAccount: this.credentialAuth === "ard-account",
|
||||
username: this.credentials?.username ?? "",
|
||||
onSubmit: (event) => this.handleCredentialsSubmit(event),
|
||||
});
|
||||
const recovery = renderDesktopPanelRecovery({
|
||||
inventoryError: this.state === "inventory-error",
|
||||
reason: this.disconnectedReason,
|
||||
onRetry: () => {
|
||||
if (!this.environmentId) {
|
||||
return;
|
||||
}
|
||||
if (this.state === "inventory-error") {
|
||||
if (this.documentMode) {
|
||||
this.retryDocumentInventory();
|
||||
} else {
|
||||
void this.connectRequestedEnvironment(this.environmentId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
void this.connectEnvironment(this.environmentId, this.controlling);
|
||||
},
|
||||
});
|
||||
const connection = renderDesktopConnection({
|
||||
state: this.state,
|
||||
controlling: this.controlling,
|
||||
desktopApps: this.desktopApps,
|
||||
environmentSelected: this.environmentId !== null,
|
||||
launchingApp: this.launchingApp,
|
||||
showApps: this.source?.kind === "environment",
|
||||
onLaunch: (app) => void this.launchApp(app),
|
||||
onTakeControl: () => {
|
||||
if (this.environmentId) {
|
||||
void this.connectEnvironment(this.environmentId, true);
|
||||
}
|
||||
},
|
||||
onDisconnect: () => this.returnToPicker(),
|
||||
});
|
||||
if (this.documentMode) {
|
||||
return renderDesktopDocumentView({
|
||||
state: this.state,
|
||||
controlling: this.controlling,
|
||||
scaleViewport: this.scaleViewport,
|
||||
keyboardInputValue: this.keyboardInputValue,
|
||||
notice,
|
||||
picker,
|
||||
credentials,
|
||||
recovery,
|
||||
onControlToggle: () => {
|
||||
if (this.environmentId) {
|
||||
void this.connectEnvironment(this.environmentId, !this.controlling);
|
||||
}
|
||||
},
|
||||
onKeyboardFocus: () => this.focusDocumentKeyboard(),
|
||||
onKeyboardEvent: (event) => this.handleDocumentKeyboardEvent(event),
|
||||
onKeyboardInput: (event) => this.handleDocumentKeyboardInput(event),
|
||||
onScaleToggle: () => this.toggleDocumentScale(),
|
||||
onClose: () => this.onDocumentClose?.(),
|
||||
});
|
||||
}
|
||||
if (!this.dockLayout.open) {
|
||||
return nothing;
|
||||
}
|
||||
const dock = this.dockLayout.dock;
|
||||
const style =
|
||||
dock === "bottom" ? `height:${this.dockLayout.height}px` : `width:${this.dockLayout.width}px`;
|
||||
const visibleErrorText = this.launchErrorText ?? this.errorText;
|
||||
return html`
|
||||
<section class="bp bp--${dock}" style=${style} aria-label=${t("desktop.title")}>
|
||||
${this.dockLayout.renderResizer("bp", t("desktop.resize"))} ${this.renderHeader()}
|
||||
${this.dockLayout.renderResizer("bp", t("desktop.resize"))}
|
||||
${renderDesktopPanelHeader({
|
||||
dock,
|
||||
onDock: (nextDock) => this.dockLayout.setDock(nextDock),
|
||||
onClose: () => this.closePanel(),
|
||||
})}
|
||||
<div class="desktop-content">
|
||||
${visibleErrorText
|
||||
? html`<div class="desktop-note desktop-note--error" role="alert">
|
||||
${visibleErrorText}
|
||||
</div>`
|
||||
: this.noticeText
|
||||
? html`<div class="desktop-note" role="status">${this.noticeText}</div>`
|
||||
: nothing}
|
||||
${notice}
|
||||
${this.state === "picker"
|
||||
? this.renderPicker()
|
||||
? picker
|
||||
: this.state === "inventory-error" || this.state === "disconnected"
|
||||
? renderDesktopPanelRecovery({
|
||||
inventoryError: this.state === "inventory-error",
|
||||
reason: this.disconnectedReason,
|
||||
onRetry: () =>
|
||||
this.environmentId &&
|
||||
void (this.state === "inventory-error"
|
||||
? this.connectRequestedEnvironment(this.environmentId)
|
||||
: this.connectEnvironment(this.environmentId, this.controlling)),
|
||||
})
|
||||
? recovery
|
||||
: this.state === "credentials"
|
||||
? this.renderCredentials()
|
||||
: this.renderConnection()}
|
||||
? credentials
|
||||
: connection}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
|
||||
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
||||
|
||||
const suite = createControlUiE2eSuite({
|
||||
name: "desktop document mode",
|
||||
startServerBeforeBrowser: true,
|
||||
unavailableMessage: (executablePath) =>
|
||||
`Playwright Chromium is not installed or cannot start at ${executablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`.`,
|
||||
});
|
||||
|
||||
const artifactDirectory = path.resolve(".artifacts/mobile-desktop");
|
||||
const gatewayEnvironment = {
|
||||
id: "gateway",
|
||||
type: "local",
|
||||
status: "available",
|
||||
desktop: true,
|
||||
};
|
||||
|
||||
type FakeDesktopConnectOptions = {
|
||||
onConnect?: () => void;
|
||||
scaleViewport?: boolean;
|
||||
target: HTMLElement;
|
||||
viewOnly: boolean;
|
||||
};
|
||||
|
||||
async function installDesktopClientFake(panel: import("playwright").Locator) {
|
||||
await panel.evaluate((element) => {
|
||||
(
|
||||
element as HTMLElement & {
|
||||
desktopClientFactory: () => {
|
||||
connect(options: FakeDesktopConnectOptions): Promise<{
|
||||
disconnect(): void;
|
||||
sendBackspace(): void;
|
||||
sendKeyboardEvent(event: KeyboardEvent): void;
|
||||
sendText(text: string): void;
|
||||
setScaleViewport(enabled: boolean): void;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
).desktopClientFactory = () => ({
|
||||
async connect(options) {
|
||||
element.dataset.viewOnly = String(options.viewOnly);
|
||||
element.dataset.scaleViewport = String(options.scaleViewport ?? true);
|
||||
const remote = document.createElement("div");
|
||||
remote.dataset.testRemoteDesktop = "true";
|
||||
remote.textContent = "Remote desktop";
|
||||
remote.style.cssText =
|
||||
"display:grid;place-items:center;width:100%;height:100%;color:#e8edf5;background:linear-gradient(145deg,#26364d,#111823);font:600 18px system-ui";
|
||||
options.target.replaceChildren(remote);
|
||||
options.onConnect?.();
|
||||
return {
|
||||
disconnect() {
|
||||
remote.remove();
|
||||
},
|
||||
sendKeyboardEvent(event) {
|
||||
element.dataset.lastKeyboardEvent = `${event.type}:${event.key}`;
|
||||
},
|
||||
sendText(text) {
|
||||
element.dataset.lastKeyboardText = text;
|
||||
},
|
||||
sendBackspace() {
|
||||
element.dataset.lastKeyboardText = "Backspace";
|
||||
},
|
||||
setScaleViewport(enabled) {
|
||||
element.dataset.scaleViewport = String(enabled);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startDesktopDocument(
|
||||
page: import("playwright").Page,
|
||||
route: string,
|
||||
desktopObserve: unknown = {
|
||||
transport: "rfb",
|
||||
wsPath: "/desktop/observe?token=document",
|
||||
expiresAtMs: 60_000,
|
||||
control: false,
|
||||
},
|
||||
) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const gateway = await installMockGateway(page, {
|
||||
deferredMethods: ["environments.list"],
|
||||
featureMethods: ["desktop.observe", "environments.list", "openclaw.setup.detect"],
|
||||
methodResponses: {
|
||||
"desktop.observe": desktopObserve,
|
||||
"openclaw.setup.detect": {
|
||||
candidates: [],
|
||||
manualProviders: [],
|
||||
workspace: "/tmp/openclaw-desktop-document",
|
||||
setupComplete: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
await page.goto(`${suite.server.baseUrl}${route}`);
|
||||
const panel = page.locator("openclaw-desktop-panel");
|
||||
await panel.waitFor({ state: "attached" });
|
||||
await gateway.waitForRequest("environments.list");
|
||||
await installDesktopClientFake(panel);
|
||||
return { gateway, panel };
|
||||
}
|
||||
|
||||
async function openDesktopDocument(
|
||||
page: import("playwright").Page,
|
||||
route: string,
|
||||
environments: unknown[],
|
||||
desktopObserve?: unknown,
|
||||
) {
|
||||
const document = await startDesktopDocument(page, route, desktopObserve);
|
||||
await document.gateway.resolveDeferred("environments.list", { environments });
|
||||
return document;
|
||||
}
|
||||
|
||||
suite.define(() => {
|
||||
it.each([
|
||||
["the query route", "?view=desktop"],
|
||||
["the path route", "desktop"],
|
||||
])("renders a full-bleed shell-free picker from %s", async (_label, route) => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const { panel } = await openDesktopDocument(page, route, [gatewayEnvironment]);
|
||||
const viewer = panel.locator("section.desktop-document");
|
||||
await viewer.waitFor();
|
||||
await panel.getByText("Desktop sources", { exact: true }).waitFor();
|
||||
|
||||
expect(await page.locator("openclaw-app-shell").count()).toBe(0);
|
||||
expect(page.url()).not.toContain("model-setup");
|
||||
const bounds = await viewer.boundingBox();
|
||||
expect(bounds?.width).toBeGreaterThanOrEqual(389);
|
||||
expect(bounds?.height).toBeGreaterThanOrEqual(843);
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
|
||||
).toBe(true);
|
||||
|
||||
if (route === "?view=desktop") {
|
||||
await mkdir(artifactDirectory, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: path.join(artifactDirectory, "picker-390x844.png"),
|
||||
fullPage: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the picker with a notice for an unobservable source", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const { gateway, panel } = await openDesktopDocument(
|
||||
page,
|
||||
"?view=desktop&source=missing-machine",
|
||||
[gatewayEnvironment],
|
||||
);
|
||||
|
||||
await panel
|
||||
.getByText("The requested desktop source is unavailable. Choose another source.", {
|
||||
exact: true,
|
||||
})
|
||||
.waitFor();
|
||||
await panel.getByText("Desktop sources", { exact: true }).waitFor();
|
||||
expect(await gateway.getRequests("desktop.observe")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders inventory failure recovery and retries the preselected source", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const { gateway, panel } = await startDesktopDocument(page, "?view=desktop&source=gateway");
|
||||
await gateway.rejectDeferred("environments.list", {
|
||||
code: "UNAVAILABLE",
|
||||
message: "desktop inventory is temporarily unavailable",
|
||||
});
|
||||
|
||||
await panel.getByRole("alert").filter({ hasText: "inventory" }).waitFor();
|
||||
const retry = panel.getByRole("button", { name: "Retry", exact: true });
|
||||
await retry.waitFor();
|
||||
expect(await gateway.getRequests("desktop.observe")).toHaveLength(0);
|
||||
|
||||
await gateway.setMethodResponse("environments.list", {
|
||||
environments: [gatewayEnvironment],
|
||||
});
|
||||
await retry.click();
|
||||
const observeRequest = await gateway.waitForRequest("desktop.observe");
|
||||
expect(observeRequest.params).toEqual({ source: { kind: "host" }, control: false });
|
||||
await panel.locator("[data-test-remote-desktop='true']").waitFor();
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-connects view-only and provides four working touch actions", async () => {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const { gateway, panel } = await openDesktopDocument(
|
||||
page,
|
||||
"?view=desktop&source=gateway",
|
||||
[gatewayEnvironment],
|
||||
{
|
||||
sequence: [
|
||||
{
|
||||
transport: "rfb",
|
||||
wsPath: "/desktop/observe?token=view",
|
||||
expiresAtMs: 60_000,
|
||||
control: false,
|
||||
},
|
||||
{
|
||||
transport: "rfb",
|
||||
wsPath: "/desktop/observe?token=control",
|
||||
expiresAtMs: 60_000,
|
||||
control: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const viewRequest = await gateway.waitForRequest("desktop.observe");
|
||||
expect(viewRequest.params).toEqual({ source: { kind: "host" }, control: false });
|
||||
await expect.poll(() => panel.getAttribute("data-view-only")).toBe("true");
|
||||
const touchActions = panel.locator(".desktop-touch-action");
|
||||
await expect.poll(() => touchActions.count()).toBe(4);
|
||||
await panel.getByRole("button", { name: "Back", exact: true }).waitFor();
|
||||
|
||||
await panel.getByRole("button", { name: "Take control", exact: true }).click();
|
||||
await expect.poll(async () => (await gateway.getRequests("desktop.observe")).length).toBe(2);
|
||||
expect((await gateway.getRequests("desktop.observe"))[1]?.params).toEqual({
|
||||
source: { kind: "host" },
|
||||
control: true,
|
||||
});
|
||||
await expect.poll(() => panel.getAttribute("data-view-only")).toBe("false");
|
||||
|
||||
await panel.getByRole("button", { name: "Use actual size", exact: true }).click();
|
||||
await expect.poll(() => panel.getAttribute("data-scale-viewport")).toBe("false");
|
||||
|
||||
await panel.getByRole("button", { name: "Keyboard", exact: true }).click();
|
||||
expect(
|
||||
await panel.evaluate((element) =>
|
||||
element.shadowRoot?.activeElement?.classList.contains("desktop-keyboard-input"),
|
||||
),
|
||||
).toBe(true);
|
||||
await page.keyboard.type("k");
|
||||
await expect.poll(() => panel.getAttribute("data-last-keyboard-event")).toBe("keyup:k");
|
||||
await panel.locator(".desktop-keyboard-input").evaluate((element) => {
|
||||
const input = element as HTMLTextAreaElement;
|
||||
input.value += "m";
|
||||
input.dispatchEvent(
|
||||
new InputEvent("input", { data: "m", inputType: "insertText", bubbles: true }),
|
||||
);
|
||||
});
|
||||
await expect.poll(() => panel.getAttribute("data-last-keyboard-text")).toBe("m");
|
||||
|
||||
await mkdir(artifactDirectory, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: path.join(artifactDirectory, "connected-toolbar-390x844.png"),
|
||||
fullPage: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("applies only control=1 as the initial control request", async () => {
|
||||
for (const [value, expected] of [
|
||||
["1", true],
|
||||
["true", false],
|
||||
] as const) {
|
||||
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
|
||||
const { gateway } = await openDesktopDocument(
|
||||
page,
|
||||
`?view=desktop&source=gateway&control=${value}`,
|
||||
[gatewayEnvironment],
|
||||
{
|
||||
transport: "rfb",
|
||||
wsPath: `/desktop/observe?token=control-${value}`,
|
||||
expiresAtMs: 60_000,
|
||||
control: expected,
|
||||
},
|
||||
);
|
||||
const request = await gateway.waitForRequest("desktop.observe");
|
||||
expect(request.params).toEqual({ source: { kind: "host" }, control: expected });
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2006,6 +2006,7 @@ export const en: TranslationMap = {
|
||||
},
|
||||
desktop: {
|
||||
title: "Desktop",
|
||||
unavailable: "Desktop viewing is unavailable for this connection.",
|
||||
toggle: "Toggle desktop panel",
|
||||
hide: "Hide desktop panel",
|
||||
resize: "Resize desktop panel",
|
||||
@@ -2017,9 +2018,20 @@ export const en: TranslationMap = {
|
||||
refreshing: "Refreshing…",
|
||||
loading: "Loading desktop sources…",
|
||||
empty: "No desktop-capable sources are available.",
|
||||
sourceUnavailable: "The requested desktop source is unavailable. Choose another source.",
|
||||
connect: "Connect",
|
||||
connecting: "Connecting to desktop…",
|
||||
takeControl: "Take control",
|
||||
switchToViewOnly: "Switch to view only",
|
||||
viewOnly: "View only",
|
||||
control: "Control",
|
||||
keyboard: "Keyboard",
|
||||
keyboardInput: "Remote desktop keyboard input",
|
||||
touchControls: "Remote desktop controls",
|
||||
fit: "Fit",
|
||||
fitScreen: "Fit screen",
|
||||
actualSize: "Use actual size",
|
||||
back: "Back",
|
||||
disconnect: "Disconnect",
|
||||
reconnect: "Reconnect",
|
||||
passwordPrompt: "Enter the VNC password for this machine.",
|
||||
|
||||
@@ -126,6 +126,12 @@ function renderInto(template: unknown): HTMLDivElement {
|
||||
return container;
|
||||
}
|
||||
|
||||
// These render into the shared document, so a missing teardown leaks pairing
|
||||
// dialogs into whichever suite the worker runs next.
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe("channel DM access request views", () => {
|
||||
it("renders pending senders without exposing the pairing code", () => {
|
||||
const onApprove = vi.fn();
|
||||
|
||||
@@ -15,6 +15,12 @@ const artifactDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/chat-
|
||||
const baseTime = Date.now();
|
||||
const chatSessionKey = "agent:main:main";
|
||||
|
||||
// Running tasks render a live elapsed label, so comparing raw transcript text makes the
|
||||
// assertion fail whenever a second ticks over mid-check. Only the durations may move here.
|
||||
function withoutElapsedLabels(text: string | null): string {
|
||||
return (text ?? "").replaceAll(/\d+(?:\.\d+)?\s*(?:ms|[smhd])\b/g, "<elapsed>");
|
||||
}
|
||||
|
||||
function requestSessionKey(request: MockGatewayRequest): string | undefined {
|
||||
const { params } = request;
|
||||
if (
|
||||
@@ -180,7 +186,7 @@ suite.define(() => {
|
||||
|
||||
const chatUrl = page.url();
|
||||
const mainTranscript = page.locator(".chat-main .chat-thread");
|
||||
const mainTranscriptBefore = await mainTranscript.textContent();
|
||||
const mainTranscriptBefore = withoutElapsedLabels(await mainTranscript.textContent());
|
||||
const openRow = rail.locator('[data-task-id="task-subagent"]');
|
||||
await openRow.click();
|
||||
const detailPanel = page.locator("[data-task-detail-panel]");
|
||||
@@ -209,7 +215,7 @@ suite.define(() => {
|
||||
limit: 100,
|
||||
});
|
||||
expect(page.url()).toBe(chatUrl);
|
||||
expect(await mainTranscript.textContent()).toBe(mainTranscriptBefore);
|
||||
expect(withoutElapsedLabels(await mainTranscript.textContent())).toBe(mainTranscriptBefore);
|
||||
await page.screenshot({
|
||||
path: path.join(railFlowDir, "02-task-detail-sidebar.png"),
|
||||
fullPage: true,
|
||||
|
||||
@@ -3912,9 +3912,10 @@ html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Terminal-only document (`?view=terminal`, mobile WebViews): centered notice
|
||||
when the gateway refuses or lacks the terminal surface. */
|
||||
.terminal-view-unavailable {
|
||||
/* Standalone terminal/desktop documents: centered notice when the gateway
|
||||
refuses or lacks the requested surface. */
|
||||
.terminal-view-unavailable,
|
||||
.desktop-view-unavailable {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user