feat(macos): native-feel dashboard hosting — instant reopen, preload, frame autosave, ⌘N/⌘K, route memory (#106997)

* feat(macos): native-feel dashboard hosting: instant reopen, preload, frame autosave, ⌘N/⌘K, route memory

The hosted Control UI dashboard now skips SPA reloads when reopening an
unchanged endpoint (auth-equality gated), preloads after launch when a
credentialed local/direct config exists, autosaves its window frame, maps
⌘N/⌘K menu items onto the shipped native web events, filters browser-tell
context-menu items, restores the last committed route via web-side
localStorage memory, and themes the pre-paint background.

* refactor(ui): satisfy TS LOC ratchet: move optional lazy-element helpers out of app-host

app-host.ts is over the 500-line ratchet ceiling and may not grow; move the
self-contained optional custom-element block into lazy-custom-element.ts (its
natural owner) and fold the route-restore decision into native-route-memory's
considerRouteRestore so the shell only replaces-or-persists.

* fix(macos): recover stuck dashboard command queue via deliverability gate

Replace the failure-page-only fast-path check with canDeliverNativeCommands
(live document or in-flight load); a terminally cancelled reload now falls
through to the reload path instead of queueing ⌘N/⌘K forever.

* fix(ui): let in-flight navigation win over native route restore

The one-shot startup restore now checks the rendered/pending match against
the committed bootstrap route; an explicit navigation already in flight
(replayed native new-session, fast click) is no longer clobbered.

* docs(ui): note idempotent collapse of pending native new-session replays

* fix(macos): drop queued dashboard commands on terminal load failure

Queued ⌘N/⌘K are moment-bound; surviving a failure page meant a later
recovery reload replayed stale commands (double palette toggle nets closed,
surprise navigation). Both failure entry points now clear the queue.

* fix(ui): persist native route memory only for settled router states

Mid-navigation emissions still carry the stale committed bootstrap route; an
interrupted restore could overwrite the remembered destination with it.

* fix(macos): coalesce dashboard opens for queued native commands

One in-flight open drains a manager-level queue in press order; a Task per
key press could race window creation (duplicate windows) and reorder ⌘N/⌘K
delivery on the non-immediate remote path.

* fix(macos): correct dashboard placement after frame-autosave restore

setFrameAutosaveName re-applies the stored frame; run ensureOnScreen after
both autosave-name assignments so a frame saved on a disconnected monitor
cannot restore the window off-screen.

* fix(macos): clear dashboard failure state when history restores a real document

Swipe-back/⌘[ off the failure page commits an http(s) document without
passing through load(); the flag previously stayed set and forced a reload
on the next native command.

* fix(macos): keep in-flight dashboard loads instead of restarting them

An open/update during the launch preload previously cancelled and restarted
the same-URL load; in-flight non-failure documents now count as usable in
the reload decision.

* fix(macos,ui): ⌘K legacy fallback + transient search param filtering

Command palette dispatch now sends a cancelable toggle event and falls back
to the shipped open-search event when no handler acknowledges it, so ⌘K
keeps working against older gateway-served bundles. Route memory strips
one-shot action params (?draft=) before persisting; navigation state like
?session= still restores.

* chore(i18n): register dashboard menu strings + drop unused route-memory export

native:i18n:sync inventory + per-locale artifacts for New Session / Command
Palette…; StoredNativeRoute is module-internal after the considerRouteRestore
refactor (deadcode exports gate).

* chore(i18n): rebaseline raw-copy for relocated lazy-element labels

The lazy-custom-element extraction moved three internal load-failure labels
out of app-host.ts; refresh the keyless raw-copy baseline to match (main's
new ui:i18n:verify gate).
This commit is contained in:
Peter Steinberger
2026-07-14 03:33:25 -07:00
committed by GitHub
parent 5bc101bab2
commit bc4ed5cf61
34 changed files with 998 additions and 104 deletions
+28 -12
View File
@@ -28635,7 +28635,7 @@
},
{
"kind": "ui-named-argument",
"line": 144,
"line": 148,
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
"source": "Dashboard reconnecting",
"surface": "apple",
@@ -28643,7 +28643,7 @@
},
{
"kind": "ui-named-argument",
"line": 145,
"line": 149,
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
"source": "The selected Gateway changed.",
"surface": "apple",
@@ -28651,7 +28651,7 @@
},
{
"kind": "ui-named-argument",
"line": 146,
"line": 150,
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
"source": "Waiting for a fresh authenticated connection.",
"surface": "apple",
@@ -28659,7 +28659,7 @@
},
{
"kind": "ui-named-argument",
"line": 229,
"line": 251,
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
"source": "Dashboard unavailable",
"surface": "apple",
@@ -28667,7 +28667,7 @@
},
{
"kind": "ui-named-argument",
"line": 231,
"line": 253,
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
"source": "Check Settings → Connection or use Debug → Reset Remote Tunnel, then try again.",
"surface": "apple",
@@ -28675,7 +28675,7 @@
},
{
"kind": "conditional-branch",
"line": 763,
"line": 811,
"path": "apps/macos/Sources/OpenClaw/DashboardWindowController.swift",
"source": "[\\(host)]",
"surface": "apple",
@@ -30557,13 +30557,21 @@
"kind": "ui-call",
"line": 103,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "New Session",
"surface": "apple",
"id": "native.apple.75102378a6775180"
},
{
"kind": "ui-call",
"line": 109,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "Settings...",
"surface": "apple",
"id": "native.apple.185a1b27c9859cc0"
},
{
"kind": "ui-call",
"line": 110,
"line": 116,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "Back",
"surface": "apple",
@@ -30571,15 +30579,23 @@
},
{
"kind": "ui-call",
"line": 115,
"line": 121,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "Forward",
"surface": "apple",
"id": "native.apple.c19b0989064c0020"
},
{
"kind": "ui-call",
"line": 128,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "Command Palette…",
"surface": "apple",
"id": "native.apple.ef646bce7927b2f0"
},
{
"kind": "conditional-branch",
"line": 133,
"line": 146,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "OpenClaw - Voice Wake live meter active",
"surface": "apple",
@@ -30587,7 +30603,7 @@
},
{
"kind": "conditional-branch",
"line": 134,
"line": 147,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "OpenClaw",
"surface": "apple",
@@ -30595,7 +30611,7 @@
},
{
"kind": "conditional-branch",
"line": 334,
"line": 347,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "Close Canvas",
"surface": "apple",
@@ -30603,7 +30619,7 @@
},
{
"kind": "conditional-branch",
"line": 334,
"line": 347,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "Open Canvas",
"surface": "apple",
+10
View File
@@ -19098,6 +19098,11 @@
"source": "Critical",
"translated": "حرج"
},
{
"id": "native.apple.75102378a6775180",
"source": "New Session",
"translated": "جلسة جديدة"
},
{
"id": "native.apple.185a1b27c9859cc0",
"source": "Settings...",
@@ -19113,6 +19118,11 @@
"source": "Forward",
"translated": "تقديم"
},
{
"id": "native.apple.ef646bce7927b2f0",
"source": "Command Palette…",
"translated": "لوحة الأوامر…"
},
{
"id": "native.apple.13a7356f48278757",
"source": "OpenClaw - Voice Wake live meter active",
+10
View File
@@ -19098,6 +19098,11 @@
"source": "Critical",
"translated": "Kritisch"
},
{
"id": "native.apple.75102378a6775180",
"source": "New Session",
"translated": "Neue Sitzung"
},
{
"id": "native.apple.185a1b27c9859cc0",
"source": "Settings...",
@@ -19113,6 +19118,11 @@
"source": "Forward",
"translated": "Vorwärts"
},
{
"id": "native.apple.ef646bce7927b2f0",
"source": "Command Palette…",
"translated": "Befehlspalette…"
},
{
"id": "native.apple.13a7356f48278757",
"source": "OpenClaw - Voice Wake live meter active",
+10
View File
@@ -19098,6 +19098,11 @@
"source": "Critical",
"translated": "Crítico"
},
{
"id": "native.apple.75102378a6775180",
"source": "New Session",
"translated": "Nueva sesión"
},
{
"id": "native.apple.185a1b27c9859cc0",
"source": "Settings...",
@@ -19113,6 +19118,11 @@
"source": "Forward",
"translated": "Adelante"
},
{
"id": "native.apple.ef646bce7927b2f0",
"source": "Command Palette…",
"translated": "Paleta de comandos…"
},
{
"id": "native.apple.13a7356f48278757",
"source": "OpenClaw - Voice Wake live meter active",
+10
View File
@@ -19098,6 +19098,11 @@
"source": "Critical",
"translated": "بحرانی"
},
{
"id": "native.apple.75102378a6775180",
"source": "New Session",
"translated": "جلسه جدید"
},
{
"id": "native.apple.185a1b27c9859cc0",
"source": "Settings...",
@@ -19113,6 +19118,11 @@
"source": "Forward",
"translated": "جلو"
},
{
"id": "native.apple.ef646bce7927b2f0",
"source": "Command Palette…",
"translated": "پالت فرمان…"
},
{
"id": "native.apple.13a7356f48278757",
"source": "OpenClaw - Voice Wake live meter active",
+10
View File
@@ -19098,6 +19098,11 @@
"source": "Critical",
"translated": "Critique"
},
{
"id": "native.apple.75102378a6775180",
"source": "New Session",
"translated": "Nouvelle session"
},
{
"id": "native.apple.185a1b27c9859cc0",
"source": "Settings...",
@@ -19113,6 +19118,11 @@
"source": "Forward",
"translated": "Suivant"
},
{
"id": "native.apple.ef646bce7927b2f0",
"source": "Command Palette…",
"translated": "Palette de commandes…"
},
{
"id": "native.apple.13a7356f48278757",
"source": "OpenClaw - Voice Wake live meter active",
+10
View File
@@ -19098,6 +19098,11 @@
"source": "Critical",
"translated": "गंभीर"
},
{
"id": "native.apple.75102378a6775180",
"source": "New Session",
"translated": "नया सत्र"
},
{
"id": "native.apple.185a1b27c9859cc0",
"source": "Settings...",
@@ -19113,6 +19118,11 @@
"source": "Forward",
"translated": "आगे"
},
{
"id": "native.apple.ef646bce7927b2f0",
"source": "Command Palette…",
"translated": "कमांड पैलेट…"
},
{
"id": "native.apple.13a7356f48278757",
"source": "OpenClaw - Voice Wake live meter active",
+10
View File
@@ -19098,6 +19098,11 @@
"source": "Critical",
"translated": "Kritis"
},
{
"id": "native.apple.75102378a6775180",
"source": "New Session",
"translated": "Sesi Baru"
},
{
"id": "native.apple.185a1b27c9859cc0",
"source": "Settings...",
@@ -19113,6 +19118,11 @@
"source": "Forward",
"translated": "Maju"
},
{
"id": "native.apple.ef646bce7927b2f0",
"source": "Command Palette…",
"translated": "Palet Perintah…"
},
{
"id": "native.apple.13a7356f48278757",
"source": "OpenClaw - Voice Wake live meter active",
+10
View File
@@ -19098,6 +19098,11 @@
"source": "Critical",
"translated": "Critico"
},
{
"id": "native.apple.75102378a6775180",
"source": "New Session",
"translated": "Nuova sessione"
},
{
"id": "native.apple.185a1b27c9859cc0",
"source": "Settings...",
@@ -19113,6 +19118,11 @@
"source": "Forward",
"translated": "Avanti"
},
{
"id": "native.apple.ef646bce7927b2f0",
"source": "Command Palette…",
"translated": "Palette dei comandi…"
},
{
"id": "native.apple.13a7356f48278757",
"source": "OpenClaw - Voice Wake live meter active",
+10
View File
@@ -19098,6 +19098,11 @@
"source": "Critical",
"translated": "Critical"
},
{
"id": "native.apple.75102378a6775180",
"source": "New Session",
"translated": "新規セッション"
},
{
"id": "native.apple.185a1b27c9859cc0",
"source": "Settings...",
@@ -19113,6 +19118,11 @@
"source": "Forward",
"translated": "進む"
},
{
"id": "native.apple.ef646bce7927b2f0",
"source": "Command Palette…",
"translated": "コマンドパレット…"
},
{
"id": "native.apple.13a7356f48278757",
"source": "OpenClaw - Voice Wake live meter active",
+10
View File
@@ -19098,6 +19098,11 @@
"source": "Critical",
"translated": "심각"
},
{
"id": "native.apple.75102378a6775180",
"source": "New Session",
"translated": "새 세션"
},
{
"id": "native.apple.185a1b27c9859cc0",
"source": "Settings...",
@@ -19113,6 +19118,11 @@
"source": "Forward",
"translated": "앞으로"
},
{
"id": "native.apple.ef646bce7927b2f0",
"source": "Command Palette…",
"translated": "명령 팔레트…"
},
{
"id": "native.apple.13a7356f48278757",
"source": "OpenClaw - Voice Wake live meter active",
+10
View File
@@ -19098,6 +19098,11 @@
"source": "Critical",
"translated": "Kritiek"
},
{
"id": "native.apple.75102378a6775180",
"source": "New Session",
"translated": "Nieuwe sessie"
},
{
"id": "native.apple.185a1b27c9859cc0",
"source": "Settings...",
@@ -19113,6 +19118,11 @@
"source": "Forward",
"translated": "Vooruit"
},
{
"id": "native.apple.ef646bce7927b2f0",
"source": "Command Palette…",
"translated": "Opdrachtenpalet…"
},
{
"id": "native.apple.13a7356f48278757",
"source": "OpenClaw - Voice Wake live meter active",
+10
View File
@@ -19098,6 +19098,11 @@
"source": "Critical",
"translated": "Krytyczny"
},
{
"id": "native.apple.75102378a6775180",
"source": "New Session",
"translated": "Nowa sesja"
},
{
"id": "native.apple.185a1b27c9859cc0",
"source": "Settings...",
@@ -19113,6 +19118,11 @@
"source": "Forward",
"translated": "Dalej"
},
{
"id": "native.apple.ef646bce7927b2f0",
"source": "Command Palette…",
"translated": "Paleta poleceń…"
},
{
"id": "native.apple.13a7356f48278757",
"source": "OpenClaw - Voice Wake live meter active",
+10
View File
@@ -19098,6 +19098,11 @@
"source": "Critical",
"translated": "Crítico"
},
{
"id": "native.apple.75102378a6775180",
"source": "New Session",
"translated": "Nova sessão"
},
{
"id": "native.apple.185a1b27c9859cc0",
"source": "Settings...",
@@ -19113,6 +19118,11 @@
"source": "Forward",
"translated": "Avançar"
},
{
"id": "native.apple.ef646bce7927b2f0",
"source": "Command Palette…",
"translated": "Paleta de comandos…"
},
{
"id": "native.apple.13a7356f48278757",
"source": "OpenClaw - Voice Wake live meter active",
+10
View File
@@ -19098,6 +19098,11 @@
"source": "Critical",
"translated": "Критическая"
},
{
"id": "native.apple.75102378a6775180",
"source": "New Session",
"translated": "Новый сеанс"
},
{
"id": "native.apple.185a1b27c9859cc0",
"source": "Settings...",
@@ -19113,6 +19118,11 @@
"source": "Forward",
"translated": "Вперёд"
},
{
"id": "native.apple.ef646bce7927b2f0",
"source": "Command Palette…",
"translated": "Палитра команд…"
},
{
"id": "native.apple.13a7356f48278757",
"source": "OpenClaw - Voice Wake live meter active",
+10
View File
@@ -19098,6 +19098,11 @@
"source": "Critical",
"translated": "Kritisk"
},
{
"id": "native.apple.75102378a6775180",
"source": "New Session",
"translated": "Ny session"
},
{
"id": "native.apple.185a1b27c9859cc0",
"source": "Settings...",
@@ -19113,6 +19118,11 @@
"source": "Forward",
"translated": "Framåt"
},
{
"id": "native.apple.ef646bce7927b2f0",
"source": "Command Palette…",
"translated": "Kommandopalett…"
},
{
"id": "native.apple.13a7356f48278757",
"source": "OpenClaw - Voice Wake live meter active",
+10
View File
@@ -19098,6 +19098,11 @@
"source": "Critical",
"translated": "ร้ายแรง"
},
{
"id": "native.apple.75102378a6775180",
"source": "New Session",
"translated": "เซสชันใหม่"
},
{
"id": "native.apple.185a1b27c9859cc0",
"source": "Settings...",
@@ -19113,6 +19118,11 @@
"source": "Forward",
"translated": "ไปข้างหน้า"
},
{
"id": "native.apple.ef646bce7927b2f0",
"source": "Command Palette…",
"translated": "ชุดคำสั่ง…"
},
{
"id": "native.apple.13a7356f48278757",
"source": "OpenClaw - Voice Wake live meter active",
+10
View File
@@ -19098,6 +19098,11 @@
"source": "Critical",
"translated": "Kritik"
},
{
"id": "native.apple.75102378a6775180",
"source": "New Session",
"translated": "Yeni Oturum"
},
{
"id": "native.apple.185a1b27c9859cc0",
"source": "Settings...",
@@ -19113,6 +19118,11 @@
"source": "Forward",
"translated": "İleri"
},
{
"id": "native.apple.ef646bce7927b2f0",
"source": "Command Palette…",
"translated": "Komut Paleti…"
},
{
"id": "native.apple.13a7356f48278757",
"source": "OpenClaw - Voice Wake live meter active",
+10
View File
@@ -19098,6 +19098,11 @@
"source": "Critical",
"translated": "Критично"
},
{
"id": "native.apple.75102378a6775180",
"source": "New Session",
"translated": "Новий сеанс"
},
{
"id": "native.apple.185a1b27c9859cc0",
"source": "Settings...",
@@ -19113,6 +19118,11 @@
"source": "Forward",
"translated": "Вперед"
},
{
"id": "native.apple.ef646bce7927b2f0",
"source": "Command Palette…",
"translated": "Палітра команд…"
},
{
"id": "native.apple.13a7356f48278757",
"source": "OpenClaw - Voice Wake live meter active",
+10
View File
@@ -19098,6 +19098,11 @@
"source": "Critical",
"translated": "Nghiêm trọng"
},
{
"id": "native.apple.75102378a6775180",
"source": "New Session",
"translated": "Phiên mới"
},
{
"id": "native.apple.185a1b27c9859cc0",
"source": "Settings...",
@@ -19113,6 +19118,11 @@
"source": "Forward",
"translated": "Tiến tới"
},
{
"id": "native.apple.ef646bce7927b2f0",
"source": "Command Palette…",
"translated": "Bảng lệnh…"
},
{
"id": "native.apple.13a7356f48278757",
"source": "OpenClaw - Voice Wake live meter active",
+10
View File
@@ -19098,6 +19098,11 @@
"source": "Critical",
"translated": "严重"
},
{
"id": "native.apple.75102378a6775180",
"source": "New Session",
"translated": "新建会话"
},
{
"id": "native.apple.185a1b27c9859cc0",
"source": "Settings...",
@@ -19113,6 +19118,11 @@
"source": "Forward",
"translated": "前进"
},
{
"id": "native.apple.ef646bce7927b2f0",
"source": "Command Palette…",
"translated": "命令面板…"
},
{
"id": "native.apple.13a7356f48278757",
"source": "OpenClaw - Voice Wake live meter active",
+10
View File
@@ -19098,6 +19098,11 @@
"source": "Critical",
"translated": "嚴重"
},
{
"id": "native.apple.75102378a6775180",
"source": "New Session",
"translated": "新增工作階段"
},
{
"id": "native.apple.185a1b27c9859cc0",
"source": "Settings...",
@@ -19113,6 +19118,11 @@
"source": "Forward",
"translated": "前進"
},
{
"id": "native.apple.ef646bce7927b2f0",
"source": "Command Palette…",
"translated": "命令選擇區…"
},
{
"id": "native.apple.13a7356f48278757",
"source": "OpenClaw - Voice Wake live meter active",
@@ -11,6 +11,8 @@ final class DashboardManager {
private var controller: DashboardWindowController?
private var endpointTask: Task<Void, Never>?
private var pendingOpenCommands: [DashboardNativeCommand] = []
private var openForCommandTask: Task<Void, Never>?
private var updater: UpdaterProviding?
private var displayedRouteRevision: UInt64?
private let authTokenProvider: @Sendable (GatewayConnection.Config) async -> String?
@@ -122,6 +124,7 @@ final class DashboardManager {
auth: DashboardWindowAuth,
mode: AppState.ConnectionMode)
{
current.releaseFrameAutosaveForReplacement()
current.closeDashboard()
let replacement = DashboardWindowController(
url: url,
@@ -133,6 +136,7 @@ final class DashboardManager {
}
private func replaceWithRouteFailure(_ current: DashboardWindowController) {
current.releaseFrameAutosaveForReplacement()
current.closeDashboard()
let replacement = DashboardWindowController(
url: Self.failureURL,
@@ -180,6 +184,24 @@ final class DashboardManager {
return true
}
/// Preload failures stay invisible: navigation errors land in the
/// controller's `showLoadFailure`, which never orders the window front, and
/// preload skips `observeEndpointChanges()` so no observer path can call
/// `showFailure`. The failure page is only seen on a later explicit show.
func preloadIfConfigured() {
guard self.controller == nil,
AppStateStore.shared.onboardingSeen,
let (mode, url, auth) = self.immediateWindowConfiguration()
else { return }
let controller = DashboardWindowController(
url: url,
auth: auth,
updater: self.updater,
updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode))
self.controller = controller
controller.loadInBackground(url: url, auth: auth)
}
func show() async throws {
let mode = AppStateStore.shared.connectionMode
dashboardManagerLogger.info("dashboard show requested mode=\(String(describing: mode), privacy: .public)")
@@ -249,6 +271,37 @@ final class DashboardManager {
self.controller?.navigateForward()
}
func dispatchNativeCommand(_ command: DashboardNativeCommand) {
NSApp.activate(ignoringOtherApps: true)
if let controller, controller.isWindowOpen, controller.canDeliverNativeCommands {
controller.show()
controller.dispatchNativeCommand(command)
return
}
// One coalesced open drains the queue in press order; a Task per key
// press would race window creation and reorder N/K delivery.
self.pendingOpenCommands.append(command)
guard self.openForCommandTask == nil else { return }
self.openForCommandTask = Task { @MainActor in
defer { self.openForCommandTask = nil }
if !self.showConfiguredWindowIfPossible() {
do {
try await self.show()
} catch {
// Commands are moment-bound; drop them with the failed open.
self.pendingOpenCommands = []
self.showFailure(error)
return
}
}
let commands = self.pendingOpenCommands
self.pendingOpenCommands = []
for command in commands {
self.controller?.dispatchNativeCommand(command)
}
}
}
private static func websocketURLString(for dashboardURL: URL) -> String {
guard var components = URLComponents(url: dashboardURL, resolvingAgainstBaseURL: false) else {
return dashboardURL.absoluteString
@@ -294,6 +347,23 @@ final class DashboardManager {
return nil
}
private func immediateWindowConfiguration()
-> (AppState.ConnectionMode, URL, DashboardWindowAuth)?
{
let mode = AppStateStore.shared.connectionMode
guard let config = self.immediateDashboardConfig(mode: mode),
let url = try? GatewayEndpointStore.dashboardURL(
for: config,
mode: mode,
authToken: config.token)
else { return nil }
let auth = DashboardWindowAuth(
gatewayUrl: Self.websocketURLString(for: url),
token: config.token,
password: (config.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty))
return auth.hasCredential ? (mode, url, auth) : nil
}
}
#if DEBUG
@@ -0,0 +1,38 @@
import AppKit
import WebKit
final class DashboardWebView: WKWebView {
private static let hiddenContextMenuIdentifiers: Set<String> = [
"WKMenuItemIdentifierReload",
"WKMenuItemIdentifierOpenLinkInNewWindow",
"WKMenuItemIdentifierOpenImageInNewWindow",
"WKMenuItemIdentifierOpenMediaInNewWindow",
"WKMenuItemIdentifierOpenFrameInNewWindow",
"WKMenuItemIdentifierDownloadLinkedFile",
"WKMenuItemIdentifierDownloadImage",
"WKMenuItemIdentifierDownloadMedia",
]
override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {
super.willOpenMenu(menu, with: event)
let items = Self.filteredContextMenuItems(menu.items)
menu.removeAllItems()
for item in items {
menu.addItem(item)
}
}
static func filteredContextMenuItems(_ items: [NSMenuItem]) -> [NSMenuItem] {
var filtered: [NSMenuItem] = []
for item in items where !Self.hiddenContextMenuIdentifiers.contains(item.identifier?.rawValue ?? "") {
if item.isSeparatorItem, filtered.last?.isSeparatorItem != false {
continue
}
filtered.append(item)
}
if filtered.last?.isSeparatorItem == true {
filtered.removeLast()
}
return filtered
}
}
@@ -13,6 +13,24 @@ enum DashboardWindowLayout {
static let linkBrowserPreferredFraction: CGFloat = 0.4
static let linkBrowserTabBarHeight: CGFloat = 30
static let linkBrowserSplitAutosaveName = "OpenClawDashboardLinkBrowserSplit"
static let windowFrameAutosaveName = "OpenClawDashboardWindow"
}
/// Raw values are window event names the Control UI handles. `newSession`
/// reuses the shipped pre-web-chrome event; `commandPalette` gets a dedicated
/// toggle event because the legacy `native-open-search` contract is open-only.
enum DashboardNativeCommand: String {
case newSession = "openclaw:native-new-session"
case commandPalette = "openclaw:native-toggle-search"
/// Older gateway bundles lack the toggle listener; dispatch degrades to the
/// open-only legacy event when the primary event goes unhandled.
var legacyFallbackEventName: String? {
switch self {
case .newSession: nil
case .commandPalette: "openclaw:native-open-search"
}
}
}
enum DashboardLinkTarget: String, Equatable {
@@ -65,7 +65,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
private static let windowDragMessageHandlerName = "openclawWindowDrag"
private static let updateMessageHandlerName = "openclawUpdate"
private let webView: WKWebView
private let webView: DashboardWebView
private let linkBrowser: DashboardLinkBrowserView
private let linkBrowserItem: NSSplitViewItem
private let splitViewController: NSSplitViewController
@@ -82,6 +82,9 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
private var browserProfileImportOfferIsArmed = false
private var browserProfileImportOfferRequestIsInFlight = false
private var browserProfileImportOfferRetryPending = false
private var hasLiveContent = false
private var isShowingFailurePage = false
private var pendingNativeCommands: [DashboardNativeCommand] = []
init(
url: URL,
@@ -121,10 +124,11 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
Self.installNativeChromeScript(into: config.userContentController)
Self.installNativeAuthScript(into: config.userContentController, url: url, auth: auth)
self.webView = WKWebView(
self.webView = DashboardWebView(
frame: NSRect(origin: .zero, size: DashboardWindowLayout.windowSize),
configuration: config)
self.webView.setValue(true, forKey: "drawsBackground")
self.webView.underPageBackgroundColor = .windowBackgroundColor
// The Control UI routes via pushState, so WKWebView's back-forward list
// carries in-app navigation; the web titlebar buttons use this list.
self.webView.allowsBackForwardNavigationGestures = true
@@ -161,6 +165,11 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
let window = Self.makeWindow(contentView: splitViewController.view)
super.init(window: window)
// NSWindowController adopts its own frame state during initialization;
// keep it aligned with the autosave name installed by makeWindow, then
// re-correct placement in case the assignment re-applied a stale frame.
self.windowFrameAutosaveName = DashboardWindowLayout.windowFrameAutosaveName
WindowPlacement.ensureOnScreen(window: window, defaultSize: DashboardWindowLayout.windowSize)
// Width is autosaved, while each new dashboard window starts with the
// optional browser collapsed until a link explicitly opens it.
@@ -304,19 +313,32 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
self.show()
}
func loadInBackground(url: URL, auth: DashboardWindowAuth) {
self.update(url: url, auth: auth)
}
/// Swap the dashboard to a new gateway endpoint without reordering the window:
/// re-injects the native auth script for the new origin and reloads. Used when
/// the remote tunnel is recreated on a new local port while the window stays
/// open; ordering the window front here would steal focus on background
/// tunnel recreation.
func update(url: URL, auth: DashboardWindowAuth, updateBridgeEnabled: Bool? = nil) {
let shouldReload = Self.shouldReloadDashboard(
currentURL: self.currentURL,
newURL: url,
currentAuth: self.auth,
newAuth: auth,
hasUsableDocument: self.hasLiveContent || self.webView.isLoading,
isShowingFailurePage: self.isShowingFailurePage)
self.currentURL = url
self.auth = auth
self.refreshNativeAuthScript(url: url, auth: auth)
if let updateBridgeEnabled {
self.setUpdateBridgeEnabled(updateBridgeEnabled)
}
self.load(url)
if shouldReload {
self.refreshNativeAuthScript(url: url, auth: auth)
self.load(url)
}
self.requestBrowserProfileImportOfferIfNeeded()
}
@@ -327,6 +349,14 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
return window.isVisible || window.isMiniaturized
}
/// Commands are deliverable when a document is live or a load is in flight
/// (the queue flushes at `didFinish`). A failure page, or a terminally
/// cancelled load with no successor, needs a reload before dispatch
/// otherwise queued N/K would wait on a `didFinish` that never comes.
var canDeliverNativeCommands: Bool {
!self.isShowingFailurePage && (self.hasLiveContent || self.webView.isLoading)
}
func show() {
if let window {
let frame = window.frame
@@ -347,7 +377,19 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
window?.performClose(nil)
}
func releaseFrameAutosaveForReplacement() {
// AppKit rejects duplicate autosave owners. Release only when the manager
// replaces this controller so the successor can restore the saved frame.
self.window?.saveFrame(usingName: DashboardWindowLayout.windowFrameAutosaveName)
self.windowFrameAutosaveName = ""
}
func showFailure(title: String, message: String, detail: String? = nil) {
self.hasLiveContent = false
self.isShowingFailurePage = true
// Queued commands are moment-bound user intent; replaying them after a
// later recovery reload would toggle or navigate unexpectedly.
self.pendingNativeCommands = []
self.currentURL = URL(string: "about:blank")!
self.auth = DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil)
self.setUpdateBridgeEnabled(false)
@@ -360,6 +402,9 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
}
private func load(_ url: URL) {
// Endpoint swaps must queue commands for the replacement document.
self.hasLiveContent = false
self.isShowingFailurePage = false
dashboardWindowLogger.debug("dashboard load \(dashboardLogString(for: url), privacy: .public)")
self.webView.load(URLRequest(url: url))
}
@@ -676,6 +721,9 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
window.contentViewController = viewController
window.center()
window.minSize = DashboardWindowLayout.windowMinSize
// Autosave restore first, placement correction last: a frame saved on
// a since-disconnected monitor must not leave the window off-screen.
window.setFrameAutosaveName(DashboardWindowLayout.windowFrameAutosaveName)
WindowPlacement.ensureOnScreen(window: window, defaultSize: DashboardWindowLayout.windowSize)
return window
}
@@ -850,9 +898,17 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
private func showLoadFailure(_ error: Error) {
let nsError = error as NSError
// A cancelled provisional navigation never commits, so the prior
// document survives and stays command-capable; clearing live state
// here would queue native commands forever with no reload to flush.
if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled {
return
}
self.hasLiveContent = false
self.isShowingFailurePage = true
// Same moment-bound rule as showFailure: a terminal load failure
// invalidates commands queued for the document that never arrived.
self.pendingNativeCommands = []
dashboardWindowLogger.error(
"""
dashboard load failed url=\(dashboardLogString(for: self.currentURL), privacy: .public) \
@@ -867,6 +923,64 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
}
}
extension DashboardWindowController {
static func shouldReloadDashboard(
currentURL: URL,
newURL: URL,
currentAuth: DashboardWindowAuth,
newAuth: DashboardWindowAuth,
hasUsableDocument: Bool,
isShowingFailurePage: Bool) -> Bool
{
// Token changes surface in the URL fragment, but password-only auth keeps
// the URL identical; comparing auth prevents serving stale credentials.
// An in-flight load counts as usable so opening mid-preload does not
// cancel and restart it unless the in-flight document is the failure
// page, which must always be replaced.
currentURL != newURL || currentAuth != newAuth || isShowingFailurePage || !hasUsableDocument
}
func dispatchNativeCommand(_ command: DashboardNativeCommand) {
guard self.hasLiveContent else {
// Ordered queue, duplicates included: two K presses while loading
// must toggle twice, and N followed by K must deliver both.
self.pendingNativeCommands.append(command)
return
}
self.evaluateNativeCommand(command)
}
private func evaluateNativeCommand(_ command: DashboardNativeCommand) {
guard let fallback = command.legacyFallbackEventName else {
self.webView.evaluateJavaScript(
"window.dispatchEvent(new CustomEvent(\(Self.jsStringLiteral(command.rawValue))))")
return
}
// Older gateway-served bundles predate the toggle event but handled K
// via page keydown, which the menu item now intercepts. A handler that
// knows the new event calls preventDefault; otherwise fall back to the
// legacy open-only event so K keeps working against old bundles.
self.webView.evaluateJavaScript(
"""
(() => {
const handled = !window.dispatchEvent(
new CustomEvent(\(Self.jsStringLiteral(command.rawValue)), {cancelable: true}));
if (!handled) {
window.dispatchEvent(new CustomEvent(\(Self.jsStringLiteral(fallback))));
}
})();
""")
}
private func flushPendingNativeCommands() {
let commands = self.pendingNativeCommands
self.pendingNativeCommands = []
for command in commands {
self.evaluateNativeCommand(command)
}
}
}
/// WKNavigationDelegate policy lives in an extension to keep the class
/// body inside the swiftlint type_body_length budget.
extension DashboardWindowController {
@@ -962,11 +1076,29 @@ extension DashboardWindowController {
}
}
/// The displayed document is replaced at commit, not at provisional start.
/// Clearing here covers page/WebKit-initiated main-frame navigations that
/// never pass through `load(_:)`, so commands queue for the new document.
func webView(_ webView: WKWebView, didCommit _: WKNavigation!) {
guard webView === self.webView else { return }
self.hasLiveContent = false
// Swipe-back/[ can leave the failure page through WKWebView history
// without a `load(_:)`; a committed http(s) document is a real
// dashboard again (the failure page itself commits as about:blank).
if webView.url?.scheme?.lowercased().hasPrefix("http") == true {
self.isShowingFailurePage = false
}
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
if self.linkBrowser.owns(webView) {
self.linkBrowser.navigationDidFinish(navigation, for: webView)
} else if webView === self.webView {
guard !self.isShowingFailurePage else { return }
self.hasLiveContent = true
self.publishNativeHistoryState()
// History state must reach the shell before a queued command can navigate it.
self.flushPendingNativeCommands()
}
}
@@ -1126,6 +1258,10 @@ extension DashboardWindowController {
self.webView.allowsBackForwardNavigationGestures
}
var _testPendingNativeCommands: [DashboardNativeCommand] {
self.pendingNativeCommands
}
var _testNavigationWebViewIdentity: ObjectIdentifier {
ObjectIdentifier(self.activeNavigationWebView)
}
+17
View File
@@ -99,6 +99,12 @@ struct OpenClawApp: App {
.defaultSize(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
.windowResizability(.contentSize)
.commands {
CommandGroup(replacing: .newItem) {
Button("New Session") {
DashboardManager.shared.dispatchNativeCommand(.newSession)
}
.keyboardShortcut("n", modifiers: .command)
}
CommandGroup(replacing: .appSettings) {
Button("Settings...") {
self.openWindow(id: SettingsWindowOpener.windowID)
@@ -116,6 +122,13 @@ struct OpenClawApp: App {
DashboardManager.shared.navigateForward()
}
.keyboardShortcut("]", modifiers: .command)
Divider()
Button("Command Palette…") {
DashboardManager.shared.dispatchNativeCommand(.commandPalette)
}
.keyboardShortcut("k", modifiers: .command)
}
}
.onChange(of: self.isMenuPresented) { _, _ in
@@ -449,6 +462,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "launch")
}
Task {
try? await Task.sleep(for: .seconds(2))
DashboardManager.shared.preloadIfConfigured()
}
#if DEBUG
// Screenshot/demo helper: show the pairing panel with sample requests.
@@ -68,9 +68,121 @@ struct DashboardWindowSmokeTests {
#expect(controller.window?.toolbar?.isVisible == true)
#expect((controller.window?.frame.width ?? 0) >= DashboardWindowLayout.windowMinSize.width)
#expect((controller.window?.frame.height ?? 0) >= DashboardWindowLayout.windowMinSize.height)
#expect(controller.window?.frameAutosaveName == DashboardWindowLayout.windowFrameAutosaveName)
controller.closeDashboard()
}
@Test func `dashboard context menu removes browser items and collapses separators`() {
let hiddenIdentifiers = [
"WKMenuItemIdentifierReload",
"WKMenuItemIdentifierOpenLinkInNewWindow",
"WKMenuItemIdentifierOpenImageInNewWindow",
"WKMenuItemIdentifierOpenMediaInNewWindow",
"WKMenuItemIdentifierOpenFrameInNewWindow",
"WKMenuItemIdentifierDownloadLinkedFile",
"WKMenuItemIdentifierDownloadImage",
"WKMenuItemIdentifierDownloadMedia",
]
let hiddenItems = hiddenIdentifiers.map { identifier in
let item = NSMenuItem(title: identifier, action: nil, keyEquivalent: "")
item.identifier = NSUserInterfaceItemIdentifier(identifier)
return item
}
let copy = NSMenuItem(title: "Copy", action: nil, keyEquivalent: "")
let inspect = NSMenuItem(title: "Inspect Element", action: nil, keyEquivalent: "")
let filtered = DashboardWebView.filteredContextMenuItems([
.separator(),
hiddenItems[0],
.separator(),
copy,
.separator(),
.separator(),
hiddenItems[1],
hiddenItems[2],
hiddenItems[3],
hiddenItems[4],
hiddenItems[5],
hiddenItems[6],
hiddenItems[7],
.separator(),
inspect,
.separator(),
])
#expect(filtered.map(\.title) == ["Copy", "", "Inspect Element"])
#expect(filtered[1].isSeparatorItem)
#expect(!filtered.contains { hiddenIdentifiers.contains($0.identifier?.rawValue ?? "") })
}
@Test func `dashboard reload decision preserves live same URL content`() throws {
let current = try #require(URL(string: "http://127.0.0.1:18789/control/"))
let replacement = try #require(URL(string: "http://127.0.0.1:18790/control/"))
let auth = DashboardWindowAuth(
gatewayUrl: "ws://127.0.0.1:18789/control/",
token: nil,
password: "secret")
let rotatedAuth = DashboardWindowAuth(
gatewayUrl: "ws://127.0.0.1:18789/control/",
token: nil,
password: "rotated")
#expect(!DashboardWindowController.shouldReloadDashboard(
currentURL: current,
newURL: current,
currentAuth: auth,
newAuth: auth,
hasUsableDocument: true,
isShowingFailurePage: false))
#expect(DashboardWindowController.shouldReloadDashboard(
currentURL: current,
newURL: current,
currentAuth: auth,
newAuth: auth,
hasUsableDocument: false,
isShowingFailurePage: false))
#expect(DashboardWindowController.shouldReloadDashboard(
currentURL: current,
newURL: replacement,
currentAuth: auth,
newAuth: auth,
hasUsableDocument: true,
isShowingFailurePage: false))
// Password-only auth keeps the URL identical; rotation must reload.
#expect(DashboardWindowController.shouldReloadDashboard(
currentURL: current,
newURL: current,
currentAuth: auth,
newAuth: rotatedAuth,
hasUsableDocument: true,
isShowingFailurePage: false))
// An in-flight failure page is never a usable document to keep.
#expect(DashboardWindowController.shouldReloadDashboard(
currentURL: current,
newURL: current,
currentAuth: auth,
newAuth: auth,
hasUsableDocument: true,
isShowingFailurePage: true))
}
@Test func `dashboard native command queues before page load`() throws {
let url = try #require(URL(string: "http://127.0.0.1:18789/control/"))
let controller = DashboardWindowController(
url: url,
auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil))
controller.dispatchNativeCommand(.newSession)
controller.dispatchNativeCommand(.commandPalette)
controller.dispatchNativeCommand(.commandPalette)
#expect(controller._testPendingNativeCommands == [.newSession, .commandPalette, .commandPalette])
// A terminal failure drops moment-bound intent instead of replaying it
// after a later recovery reload.
controller.showFailure(title: "Dashboard unavailable", message: "offline")
#expect(controller._testPendingNativeCommands.isEmpty)
}
@Test func `dashboard navigation stays on same endpoint`() throws {
let dashboard = try #require(URL(string: "http://127.0.0.1:18789/control/"))
let staleEndpoint = try #require(URL(string: "http://127.0.0.1:18790/control/chat"))
@@ -686,6 +798,7 @@ struct DashboardWindowSmokeTests {
detail: "Reset the remote tunnel and try again.")
#expect(controller.window?.isVisible == true)
#expect(controller.window?.styleMask.contains(.closable) == true)
#expect(!controller.canDeliverNativeCommands)
controller.closeDashboard()
}
+25 -1
View File
@@ -77,6 +77,7 @@ type ShellNavigationState = {
};
handleNativeToggleSidebar: () => void;
handleNativeOpenSearch: () => void;
handleNativeToggleSearch: (event: Event) => void;
handleNativeNewSession: () => void;
handleNativeHistoryState: (event: Event) => void;
nativeHistoryState: { canGoBack: boolean; canGoForward: boolean };
@@ -447,10 +448,11 @@ describe("OpenClaw shell keyboard shortcuts", () => {
it("opens search and starts a session from native titlebar events", () => {
const navigate = vi.fn();
const openPalette = vi.fn();
const togglePalette = vi.fn();
const shell = document.createElement("openclaw-app-shell") as unknown as ShellNavigationState;
Object.defineProperty(shell, "commandPalette", {
configurable: true,
value: { openPalette },
value: { openPalette, togglePalette },
});
shell.runtime = {
context: {
@@ -459,12 +461,34 @@ describe("OpenClaw shell keyboard shortcuts", () => {
} as unknown as ApplicationContext,
};
shell.handleNativeOpenSearch();
const toggleEvent = new CustomEvent("openclaw:native-toggle-search", { cancelable: true });
shell.handleNativeToggleSearch(toggleEvent);
shell.handleNativeNewSession();
expect(openPalette).toHaveBeenCalledOnce();
expect(togglePalette).toHaveBeenCalledOnce();
// preventDefault is the handled signal for the native legacy fallback.
expect(toggleEvent.defaultPrevented).toBe(true);
expect(navigate).toHaveBeenCalledWith("new-session", { search: "?agent=agent%2Fa" });
});
it("retains a native new-session request until a context exists", () => {
const navigate = vi.fn();
const shell = document.createElement("openclaw-app-shell") as unknown as ShellNavigationState;
shell.handleNativeNewSession();
shell.runtime = {
context: {
navigate,
agentSelection: { state: { selectedId: "main" } },
} as unknown as ApplicationContext,
};
shell.handleNativeNewSession();
expect(navigate).toHaveBeenCalledExactlyOnceWith("new-session", { search: "?agent=main" });
});
it("does not start a native session during onboarding", () => {
const navigate = vi.fn();
const shell = document.createElement("openclaw-app-shell") as unknown as ShellNavigationState;
+84 -84
View File
@@ -52,8 +52,17 @@ import {
type ApplicationNavigationOptions,
} from "./context.ts";
import { resolveControlUiAuthToken } from "./control-ui-auth.ts";
import { ensureCustomElementDefined } from "./lazy-custom-element.ts";
import {
BROWSER_PANEL_ELEMENT,
COMMAND_PALETTE_ELEMENT,
ensureOptionalElementForHost,
isOptionalElementDefined,
preloadOptionalElement,
TERMINAL_PANEL_ELEMENT,
type OptionalCustomElement,
} from "./lazy-custom-element.ts";
import { postNativeNavState, type NativeNavState } from "./native-nav-state.ts";
import { considerRouteRestore, persistRoute } from "./native-route-memory.ts";
import {
isNativeWebChromeHost,
NATIVE_HISTORY_STATE_EVENT,
@@ -74,96 +83,26 @@ import {
type ShellRouteState = {
routeId?: RouteId;
location?: RouteLocation;
committedRouteId?: RouteId;
committedLocation?: RouteLocation;
};
type AppSidebarElement = HTMLElement & {
dismissTransientMenus: () => boolean;
};
type OptionalCustomElement = {
tagName: string;
label: string;
loadModule: () => Promise<unknown>;
};
type UpdatingHost = {
requestUpdate: () => unknown;
};
const COMMAND_PALETTE_ELEMENT = {
tagName: "openclaw-command-palette",
label: "command palette",
loadModule: () => import("../components/command-palette.ts"),
} satisfies OptionalCustomElement;
const TERMINAL_PANEL_ELEMENT = {
tagName: "openclaw-terminal-panel",
label: "terminal panel",
loadModule: () => import("../components/terminal/terminal-panel-registration.ts"),
} satisfies OptionalCustomElement;
const BROWSER_PANEL_ELEMENT = {
tagName: "openclaw-browser-panel",
label: "browser panel",
loadModule: () => import("../components/browser/browser-panel.ts"),
} satisfies OptionalCustomElement;
const hostElementLoads = new WeakMap<UpdatingHost, Map<string, Promise<void>>>();
function isOptionalElementDefined(element: OptionalCustomElement): boolean {
return customElements.get(element.tagName) !== undefined;
}
function ensureOptionalElementForHost(
host: UpdatingHost,
element: OptionalCustomElement,
): Promise<void> {
if (isOptionalElementDefined(element)) {
host.requestUpdate();
return Promise.resolve();
}
const existingLoads = hostElementLoads.get(host);
const loads = existingLoads ?? new Map<string, Promise<void>>();
if (!existingLoads) {
hostElementLoads.set(host, loads);
}
const pending = loads.get(element.tagName);
if (pending) {
return pending;
}
const load = ensureCustomElementDefined(element.tagName, element.loadModule)
.then(() => {
host.requestUpdate();
})
.catch((error: unknown) => {
console.error(`[openclaw] failed to load ${element.label}`, error);
throw error;
})
.finally(() => {
loads.delete(element.tagName);
});
loads.set(element.tagName, load);
return load;
}
function preloadOptionalElement(host: UpdatingHost, element: OptionalCustomElement): void {
if (isOptionalElementDefined(element)) {
return;
}
void ensureOptionalElementForHost(host, element).catch(() => undefined);
}
// Stable references so the sidebar's enabledRouteIds property does not churn
// on every shell render.
const ROUTE_IDS_WITHOUT_WORKBOARD = APP_ROUTE_IDS.filter((routeId) => routeId !== "workboard");
function selectShellRouteState(routerState: RouterState<RouteId>): ShellRouteState {
const match = selectRenderedRouteMatch(routerState.matches[0], routerState.pendingMatches[0]);
return match
? {
routeId: match.routeId,
location: match.location,
}
: {};
const committedMatch = routerState.matches[0];
return {
...(match ? { routeId: match.routeId, location: match.location } : {}),
...(committedMatch
? { committedRouteId: committedMatch.routeId, committedLocation: committedMatch.location }
: {}),
};
}
function equalShellRouteState(previous: ShellRouteState, next: ShellRouteState): boolean {
@@ -171,7 +110,11 @@ function equalShellRouteState(previous: ShellRouteState, next: ShellRouteState):
previous.routeId === next.routeId &&
previous.location?.pathname === next.location?.pathname &&
previous.location?.search === next.location?.search &&
previous.location?.hash === next.location?.hash
previous.location?.hash === next.location?.hash &&
previous.committedRouteId === next.committedRouteId &&
previous.committedLocation?.pathname === next.committedLocation?.pathname &&
previous.committedLocation?.search === next.committedLocation?.search &&
previous.committedLocation?.hash === next.committedLocation?.hash
);
}
@@ -512,6 +455,8 @@ class OpenClawShell extends OpenClawLightDomElement {
private runtimeConfigClient: GatewayBrowserClient | null = null;
private runtimeConfigSource: ApplicationContext["runtimeConfig"] | null = null;
private lastNativeNavState: NativeNavState | undefined;
private didConsiderNativeRouteRestore = false;
private pendingNativeNewSession = false;
private readonly settingsPreloadTimers = new Map<
EventTarget,
ReturnType<typeof globalThis.setTimeout>
@@ -527,7 +472,13 @@ class OpenClawShell extends OpenClawLightDomElement {
this.subscriptions
.effect(
() => this.context,
() => () => this.resetShellEpochState(),
() => {
if (this.pendingNativeNewSession) {
this.pendingNativeNewSession = false;
this.handleNativeNewSession();
}
return () => this.resetShellEpochState();
},
)
.watch(
() => this.context?.navigation,
@@ -590,9 +541,12 @@ class OpenClawShell extends OpenClawLightDomElement {
document.addEventListener("keydown", this.handleDocumentKeydown);
window.addEventListener("resize", this.handleWindowResize);
window.addEventListener(NATIVE_HISTORY_STATE_EVENT, this.handleNativeHistoryState);
// Shipped Mac app builds without web chrome still drive these events.
// Shipped Mac app builds without web chrome still drive these events; the
// app's ⌘N menu item reuses native-new-session, while its ⌘K menu item
// uses native-toggle-search because the legacy open-search is open-only.
window.addEventListener("openclaw:native-toggle-sidebar", this.handleNativeToggleSidebar);
window.addEventListener("openclaw:native-open-search", this.handleNativeOpenSearch);
window.addEventListener("openclaw:native-toggle-search", this.handleNativeToggleSearch);
window.addEventListener("openclaw:native-new-session", this.handleNativeNewSession);
window.addEventListener(TERMINAL_PANEL_TOGGLE_EVENT, this.handleDeferredTerminalToggle);
window.addEventListener(BROWSER_PANEL_TOGGLE_EVENT, this.handleDeferredBrowserToggle);
@@ -605,6 +559,7 @@ class OpenClawShell extends OpenClawLightDomElement {
window.removeEventListener(NATIVE_HISTORY_STATE_EVENT, this.handleNativeHistoryState);
window.removeEventListener("openclaw:native-toggle-sidebar", this.handleNativeToggleSidebar);
window.removeEventListener("openclaw:native-open-search", this.handleNativeOpenSearch);
window.removeEventListener("openclaw:native-toggle-search", this.handleNativeToggleSearch);
window.removeEventListener("openclaw:native-new-session", this.handleNativeNewSession);
window.removeEventListener(TERMINAL_PANEL_TOGGLE_EVENT, this.handleDeferredTerminalToggle);
window.removeEventListener(BROWSER_PANEL_TOGGLE_EVENT, this.handleDeferredBrowserToggle);
@@ -766,9 +721,25 @@ class OpenClawShell extends OpenClawLightDomElement {
this.openPalette();
};
private readonly handleNativeToggleSearch = (event: Event) => {
// The ⌘K menu item intercepts the key equivalent before page keydown, so
// closing must route through here; preventDefault acknowledges the event
// (the native dispatcher falls back to open-search when unhandled).
event.preventDefault();
this.togglePalette();
};
private readonly handleNativeNewSession = () => {
const context = this.context;
if (!context || this.onboarding) {
if (this.onboarding) {
return;
}
if (!context) {
// Native hosts flush queued commands at document-finish, which can beat
// runtime initialization; retain the request and replay once the
// context effect fires instead of silently dropping the ⌘N. A boolean is
// enough: the destination is idempotent, so repeated presses collapse.
this.pendingNativeNewSession = true;
return;
}
const agentId = context.agentSelection.state.selectedId ?? "";
@@ -1085,6 +1056,35 @@ class OpenClawShell extends OpenClawLightDomElement {
private updateRouteState(routeState: ShellRouteState) {
this.routeState = routeState;
const committedRouteId = routeState.committedRouteId;
const committedSearch = routeState.committedLocation?.search ?? "";
// Restoration and persistence both wait for a live context: consuming the
// one-shot restore without a router to call, or persisting the bootstrap
// route first, would clobber the stored route.
const routeContext = this.context;
if (committedRouteId && routeContext) {
// A rendered/pending match that differs from the committed route is an
// in-flight navigation: it wins over the one-shot restore, and the stale
// committed route must not be persisted over the remembered destination.
const pendingDiffers =
routeState.routeId !== committedRouteId ||
(routeState.location?.search ?? "") !== committedSearch;
if (!this.didConsiderNativeRouteRestore) {
this.didConsiderNativeRouteRestore = true;
const storedRoute = pendingDiffers
? null
: considerRouteRestore(committedRouteId, committedSearch);
if (storedRoute) {
// Replace instead of push so a fresh window does not start with a
// Back entry pointing at the bootstrap chat route.
routeContext.replace(storedRoute.routeId, { search: storedRoute.search });
return;
}
}
if (!pendingDiffers) {
persistRoute(committedRouteId, committedSearch);
}
}
const context = this.context;
if (context) {
this.ensureAgentsList(context.gateway.snapshot);
+73
View File
@@ -27,3 +27,76 @@ export function ensureCustomElementDefined(
pendingLoads.set(tagName, load);
return load;
}
export type OptionalCustomElement = {
tagName: string;
label: string;
loadModule: () => Promise<unknown>;
};
type UpdatingHost = {
requestUpdate: () => unknown;
};
export const COMMAND_PALETTE_ELEMENT = {
tagName: "openclaw-command-palette",
label: "command palette",
loadModule: () => import("../components/command-palette.ts"),
} satisfies OptionalCustomElement;
export const TERMINAL_PANEL_ELEMENT = {
tagName: "openclaw-terminal-panel",
label: "terminal panel",
loadModule: () => import("../components/terminal/terminal-panel-registration.ts"),
} satisfies OptionalCustomElement;
export const BROWSER_PANEL_ELEMENT = {
tagName: "openclaw-browser-panel",
label: "browser panel",
loadModule: () => import("../components/browser/browser-panel.ts"),
} satisfies OptionalCustomElement;
const hostElementLoads = new WeakMap<UpdatingHost, Map<string, Promise<void>>>();
export function isOptionalElementDefined(element: OptionalCustomElement): boolean {
return customElements.get(element.tagName) !== undefined;
}
export function ensureOptionalElementForHost(
host: UpdatingHost,
element: OptionalCustomElement,
): Promise<void> {
if (isOptionalElementDefined(element)) {
host.requestUpdate();
return Promise.resolve();
}
const existingLoads = hostElementLoads.get(host);
const loads = existingLoads ?? new Map<string, Promise<void>>();
if (!existingLoads) {
hostElementLoads.set(host, loads);
}
const pending = loads.get(element.tagName);
if (pending) {
return pending;
}
const load = ensureCustomElementDefined(element.tagName, element.loadModule)
.then(() => {
host.requestUpdate();
})
.catch((error: unknown) => {
console.error(`[openclaw] failed to load ${element.label}`, error);
throw error;
})
.finally(() => {
loads.delete(element.tagName);
});
loads.set(element.tagName, load);
return load;
}
export function preloadOptionalElement(host: UpdatingHost, element: OptionalCustomElement): void {
if (isOptionalElementDefined(element)) {
return;
}
void ensureOptionalElementForHost(host, element).catch(() => undefined);
}
+66
View File
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it } from "vitest";
import { createStorageMock } from "../test-helpers/storage.ts";
import { considerRouteRestore, persistRoute } from "./native-route-memory.ts";
let storage: Storage;
beforeEach(() => {
storage = createStorageMock();
});
describe("native route memory", () => {
it("persists and restores known routes", () => {
persistRoute("usage", "?agent=main", storage, true);
expect(considerRouteRestore("chat", "", storage, true)).toEqual({
routeId: "usage",
search: "?agent=main",
});
});
it("drops corrupt and invalid entries", () => {
storage.setItem("openclaw.native.lastRoute", "{");
expect(considerRouteRestore("chat", "", storage, true)).toBeNull();
expect(storage.getItem("openclaw.native.lastRoute")).toBeNull();
storage.setItem(
"openclaw.native.lastRoute",
JSON.stringify({ routeId: "retired", search: "" }),
);
expect(considerRouteRestore("chat", "", storage, true)).toBeNull();
expect(storage.getItem("openclaw.native.lastRoute")).toBeNull();
});
it("does nothing outside the native host", () => {
storage.setItem("openclaw.native.lastRoute", JSON.stringify({ routeId: "usage", search: "" }));
persistRoute("chat", "", storage, false);
expect(considerRouteRestore("chat", "", storage, false)).toBeNull();
expect(JSON.parse(storage.getItem("openclaw.native.lastRoute") ?? "{}")).toEqual({
routeId: "usage",
search: "",
});
});
it("restores only the default route without explicit search", () => {
persistRoute("usage", "", storage, true);
expect(considerRouteRestore("chat", "?approval=123", storage, true)).toBeNull();
expect(considerRouteRestore("chat", "?session=abc", storage, true)).toBeNull();
expect(considerRouteRestore("usage", "", storage, true)).toBeNull();
expect(considerRouteRestore("chat", "", storage, true)).toEqual({
routeId: "usage",
search: "",
});
});
it("skips restoring the route it is already on", () => {
persistRoute("chat", "", storage, true);
expect(considerRouteRestore("chat", "", storage, true)).toBeNull();
});
it("strips transient action params before persisting", () => {
persistRoute("chat", "?session=abc&draft=deploy%20", storage, true);
expect(considerRouteRestore("chat", "", storage, true)).toEqual({
routeId: "chat",
search: "?session=abc",
});
});
});
+113
View File
@@ -0,0 +1,113 @@
import { isRouteId, type RouteId } from "../app-routes.ts";
import { isNativeWebChromeHost } from "./native-web-chrome.ts";
// localStorage is per-origin: a remote tunnel recreated on a new ephemeral
// port cannot read routes stored by the old origin and falls back to the
// default route. Accepted — local gateways (the common case) have stable
// origins, and the degraded path matches pre-route-memory behavior.
const NATIVE_LAST_ROUTE_KEY = "openclaw.native.lastRoute";
type StoredNativeRoute = {
routeId: RouteId;
search: string;
};
// The `localStorage` getter itself throws on opaque origins or when
// persistence is blocked, so it must resolve inside a guard, not as a default
// argument evaluated before the function body.
function resolveStorage(storage?: Storage): Storage | null {
try {
return storage ?? localStorage;
} catch {
return null;
}
}
function readStoredRoute(
storage?: Storage,
nativeHost = isNativeWebChromeHost(),
): StoredNativeRoute | null {
const store = nativeHost ? resolveStorage(storage) : null;
if (!store) {
return null;
}
try {
const raw = store.getItem(NATIVE_LAST_ROUTE_KEY);
if (raw === null) {
return null;
}
const value = JSON.parse(raw) as Partial<StoredNativeRoute>;
if (
typeof value.routeId === "string" &&
isRouteId(value.routeId) &&
typeof value.search === "string"
) {
return { routeId: value.routeId, search: value.search };
}
store.removeItem(NATIVE_LAST_ROUTE_KEY);
} catch {
try {
store.removeItem(NATIVE_LAST_ROUTE_KEY);
} catch {
// Storage may be unavailable for this origin; route memory stays optional.
}
}
return null;
}
// One-shot action params (palette slash-command drafts) must not replay on a
// later launch; navigation state like ?session= is exactly what memory keeps.
const TRANSIENT_SEARCH_PARAMS = ["draft"];
function restorableSearch(search: string): string {
const params = new URLSearchParams(search);
for (const name of TRANSIENT_SEARCH_PARAMS) {
params.delete(name);
}
const filtered = params.toString();
return filtered ? `?${filtered}` : "";
}
export function persistRoute(
routeId: RouteId,
search: string,
storage?: Storage,
nativeHost = isNativeWebChromeHost(),
): void {
const store = nativeHost ? resolveStorage(storage) : null;
if (!store) {
return;
}
try {
store.setItem(
NATIVE_LAST_ROUTE_KEY,
JSON.stringify({ routeId, search: restorableSearch(search) }),
);
} catch {
// Storage may be unavailable for this origin; navigation must still work.
}
}
function shouldRestore(routeId: RouteId, search: string, nativeHost: boolean): boolean {
return nativeHost && routeId === "chat" && search === "";
}
/**
* Returns the stored route to restore, or null when the boot route is an
* explicit deep link, matches the stored route, or no valid entry exists.
*/
export function considerRouteRestore(
routeId: RouteId,
search: string,
storage?: Storage,
nativeHost = isNativeWebChromeHost(),
): StoredNativeRoute | null {
if (!shouldRestore(routeId, search, nativeHost)) {
return null;
}
const stored = readStoredRoute(storage, nativeHost);
if (!stored || (stored.routeId === routeId && stored.search === search)) {
return null;
}
return stored;
}
+3 -3
View File
@@ -5,21 +5,21 @@
"count": 1,
"kind": "object-property",
"name": "label",
"path": "ui/src/app/app-host.ts",
"path": "ui/src/app/lazy-custom-element.ts",
"text": "browser panel"
},
{
"count": 1,
"kind": "object-property",
"name": "label",
"path": "ui/src/app/app-host.ts",
"path": "ui/src/app/lazy-custom-element.ts",
"text": "command palette"
},
{
"count": 1,
"kind": "object-property",
"name": "label",
"path": "ui/src/app/app-host.ts",
"path": "ui/src/app/lazy-custom-element.ts",
"text": "terminal panel"
},
{