From bc4ed5cf61ef0e17b39b54efd7addd2771e8d271 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 14 Jul 2026 03:33:25 -0700 Subject: [PATCH] =?UTF-8?q?feat(macos):=20native-feel=20dashboard=20hostin?= =?UTF-8?q?g=20=E2=80=94=20instant=20reopen,=20preload,=20frame=20autosave?= =?UTF-8?q?,=20=E2=8C=98N/=E2=8C=98K,=20route=20memory=20(#106997)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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). --- apps/.i18n/native-source.json | 40 +++-- apps/.i18n/native/ar.json | 10 ++ apps/.i18n/native/de.json | 10 ++ apps/.i18n/native/es.json | 10 ++ apps/.i18n/native/fa.json | 10 ++ apps/.i18n/native/fr.json | 10 ++ apps/.i18n/native/hi.json | 10 ++ apps/.i18n/native/id.json | 10 ++ apps/.i18n/native/it.json | 10 ++ apps/.i18n/native/ja-JP.json | 10 ++ apps/.i18n/native/ko.json | 10 ++ apps/.i18n/native/nl.json | 10 ++ apps/.i18n/native/pl.json | 10 ++ apps/.i18n/native/pt-BR.json | 10 ++ apps/.i18n/native/ru.json | 10 ++ apps/.i18n/native/sv.json | 10 ++ apps/.i18n/native/th.json | 10 ++ apps/.i18n/native/tr.json | 10 ++ apps/.i18n/native/uk.json | 10 ++ apps/.i18n/native/vi.json | 10 ++ apps/.i18n/native/zh-CN.json | 10 ++ apps/.i18n/native/zh-TW.json | 10 ++ .../Sources/OpenClaw/DashboardManager.swift | 70 ++++++++ .../Sources/OpenClaw/DashboardWebView.swift | 38 ++++ .../Sources/OpenClaw/DashboardWindow.swift | 18 ++ .../OpenClaw/DashboardWindowController.swift | 144 ++++++++++++++- apps/macos/Sources/OpenClaw/MenuBar.swift | 17 ++ .../DashboardWindowSmokeTests.swift | 113 ++++++++++++ ui/src/app/app-host.test.ts | 26 ++- ui/src/app/app-host.ts | 168 +++++++++--------- ui/src/app/lazy-custom-element.ts | 73 ++++++++ ui/src/app/native-route-memory.test.ts | 66 +++++++ ui/src/app/native-route-memory.ts | 113 ++++++++++++ ui/src/i18n/.i18n/raw-copy-baseline.json | 6 +- 34 files changed, 998 insertions(+), 104 deletions(-) create mode 100644 apps/macos/Sources/OpenClaw/DashboardWebView.swift create mode 100644 ui/src/app/native-route-memory.test.ts create mode 100644 ui/src/app/native-route-memory.ts diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 422b474afefb..0932f329bdde 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -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", diff --git a/apps/.i18n/native/ar.json b/apps/.i18n/native/ar.json index 04ee893c66c2..cca7245665a3 100644 --- a/apps/.i18n/native/ar.json +++ b/apps/.i18n/native/ar.json @@ -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", diff --git a/apps/.i18n/native/de.json b/apps/.i18n/native/de.json index 86310a401ab6..c40525ad0839 100644 --- a/apps/.i18n/native/de.json +++ b/apps/.i18n/native/de.json @@ -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", diff --git a/apps/.i18n/native/es.json b/apps/.i18n/native/es.json index 73d6f70b69fb..e162dcaceae5 100644 --- a/apps/.i18n/native/es.json +++ b/apps/.i18n/native/es.json @@ -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", diff --git a/apps/.i18n/native/fa.json b/apps/.i18n/native/fa.json index 738cbac53cb2..1cc73ea60650 100644 --- a/apps/.i18n/native/fa.json +++ b/apps/.i18n/native/fa.json @@ -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", diff --git a/apps/.i18n/native/fr.json b/apps/.i18n/native/fr.json index 17288ec0a0f2..71d87b56188a 100644 --- a/apps/.i18n/native/fr.json +++ b/apps/.i18n/native/fr.json @@ -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", diff --git a/apps/.i18n/native/hi.json b/apps/.i18n/native/hi.json index 324aa19b1828..31966efcc39a 100644 --- a/apps/.i18n/native/hi.json +++ b/apps/.i18n/native/hi.json @@ -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", diff --git a/apps/.i18n/native/id.json b/apps/.i18n/native/id.json index d10b77c2989d..a7dd97977275 100644 --- a/apps/.i18n/native/id.json +++ b/apps/.i18n/native/id.json @@ -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", diff --git a/apps/.i18n/native/it.json b/apps/.i18n/native/it.json index 064c3d71d6b4..cb5da1b23450 100644 --- a/apps/.i18n/native/it.json +++ b/apps/.i18n/native/it.json @@ -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", diff --git a/apps/.i18n/native/ja-JP.json b/apps/.i18n/native/ja-JP.json index 907af5630988..42593c9c8e4c 100644 --- a/apps/.i18n/native/ja-JP.json +++ b/apps/.i18n/native/ja-JP.json @@ -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", diff --git a/apps/.i18n/native/ko.json b/apps/.i18n/native/ko.json index b91255b1d341..4dd3f9595d86 100644 --- a/apps/.i18n/native/ko.json +++ b/apps/.i18n/native/ko.json @@ -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", diff --git a/apps/.i18n/native/nl.json b/apps/.i18n/native/nl.json index e1acb65b8a31..86bd0175a179 100644 --- a/apps/.i18n/native/nl.json +++ b/apps/.i18n/native/nl.json @@ -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", diff --git a/apps/.i18n/native/pl.json b/apps/.i18n/native/pl.json index ebee06c9e3c9..55fde5097652 100644 --- a/apps/.i18n/native/pl.json +++ b/apps/.i18n/native/pl.json @@ -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", diff --git a/apps/.i18n/native/pt-BR.json b/apps/.i18n/native/pt-BR.json index 0564453a9fc0..58ab7eea41ec 100644 --- a/apps/.i18n/native/pt-BR.json +++ b/apps/.i18n/native/pt-BR.json @@ -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", diff --git a/apps/.i18n/native/ru.json b/apps/.i18n/native/ru.json index 4d441855be1d..fa993fbd9d9e 100644 --- a/apps/.i18n/native/ru.json +++ b/apps/.i18n/native/ru.json @@ -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", diff --git a/apps/.i18n/native/sv.json b/apps/.i18n/native/sv.json index 035f4f1099ec..bb07d693152a 100644 --- a/apps/.i18n/native/sv.json +++ b/apps/.i18n/native/sv.json @@ -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", diff --git a/apps/.i18n/native/th.json b/apps/.i18n/native/th.json index c9d379b9bea1..41ea9dff9b79 100644 --- a/apps/.i18n/native/th.json +++ b/apps/.i18n/native/th.json @@ -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", diff --git a/apps/.i18n/native/tr.json b/apps/.i18n/native/tr.json index 4621704b6b45..2d85f4a36525 100644 --- a/apps/.i18n/native/tr.json +++ b/apps/.i18n/native/tr.json @@ -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", diff --git a/apps/.i18n/native/uk.json b/apps/.i18n/native/uk.json index 746e86ad90e3..5d2326ef7c2e 100644 --- a/apps/.i18n/native/uk.json +++ b/apps/.i18n/native/uk.json @@ -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", diff --git a/apps/.i18n/native/vi.json b/apps/.i18n/native/vi.json index 4f969e9a5747..a698df423863 100644 --- a/apps/.i18n/native/vi.json +++ b/apps/.i18n/native/vi.json @@ -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", diff --git a/apps/.i18n/native/zh-CN.json b/apps/.i18n/native/zh-CN.json index 217c86d0dc68..70c5bbae2b2d 100644 --- a/apps/.i18n/native/zh-CN.json +++ b/apps/.i18n/native/zh-CN.json @@ -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", diff --git a/apps/.i18n/native/zh-TW.json b/apps/.i18n/native/zh-TW.json index f6bcf98a6c9a..ba9373d17359 100644 --- a/apps/.i18n/native/zh-TW.json +++ b/apps/.i18n/native/zh-TW.json @@ -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", diff --git a/apps/macos/Sources/OpenClaw/DashboardManager.swift b/apps/macos/Sources/OpenClaw/DashboardManager.swift index ff0291320127..0f22c0c07e9b 100644 --- a/apps/macos/Sources/OpenClaw/DashboardManager.swift +++ b/apps/macos/Sources/OpenClaw/DashboardManager.swift @@ -11,6 +11,8 @@ final class DashboardManager { private var controller: DashboardWindowController? private var endpointTask: Task? + private var pendingOpenCommands: [DashboardNativeCommand] = [] + private var openForCommandTask: Task? 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 diff --git a/apps/macos/Sources/OpenClaw/DashboardWebView.swift b/apps/macos/Sources/OpenClaw/DashboardWebView.swift new file mode 100644 index 000000000000..48b326013181 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/DashboardWebView.swift @@ -0,0 +1,38 @@ +import AppKit +import WebKit + +final class DashboardWebView: WKWebView { + private static let hiddenContextMenuIdentifiers: Set = [ + "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 + } +} diff --git a/apps/macos/Sources/OpenClaw/DashboardWindow.swift b/apps/macos/Sources/OpenClaw/DashboardWindow.swift index a60dcaa3c7c4..fe575a63b05c 100644 --- a/apps/macos/Sources/OpenClaw/DashboardWindow.swift +++ b/apps/macos/Sources/OpenClaw/DashboardWindow.swift @@ -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 { diff --git a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift index 254aa2a75556..2ddca527aa42 100644 --- a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift +++ b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift @@ -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) } diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index ae5025aab529..651ed3bc2536 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -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. diff --git a/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift index 9d2a1ac6df59..1eb2475f1c2c 100644 --- a/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift @@ -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() } diff --git a/ui/src/app/app-host.test.ts b/ui/src/app/app-host.test.ts index 1492feb01909..5982497569d4 100644 --- a/ui/src/app/app-host.test.ts +++ b/ui/src/app/app-host.test.ts @@ -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; diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index af719cc3191a..999a692ae9c2 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -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; -}; - -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>>(); - -function isOptionalElementDefined(element: OptionalCustomElement): boolean { - return customElements.get(element.tagName) !== undefined; -} - -function ensureOptionalElementForHost( - host: UpdatingHost, - element: OptionalCustomElement, -): Promise { - if (isOptionalElementDefined(element)) { - host.requestUpdate(); - return Promise.resolve(); - } - const existingLoads = hostElementLoads.get(host); - const loads = existingLoads ?? new Map>(); - 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): 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 @@ -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); diff --git a/ui/src/app/lazy-custom-element.ts b/ui/src/app/lazy-custom-element.ts index be73cb4cdb22..9dff552b5adb 100644 --- a/ui/src/app/lazy-custom-element.ts +++ b/ui/src/app/lazy-custom-element.ts @@ -27,3 +27,76 @@ export function ensureCustomElementDefined( pendingLoads.set(tagName, load); return load; } + +export type OptionalCustomElement = { + tagName: string; + label: string; + loadModule: () => Promise; +}; + +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>>(); + +export function isOptionalElementDefined(element: OptionalCustomElement): boolean { + return customElements.get(element.tagName) !== undefined; +} + +export function ensureOptionalElementForHost( + host: UpdatingHost, + element: OptionalCustomElement, +): Promise { + if (isOptionalElementDefined(element)) { + host.requestUpdate(); + return Promise.resolve(); + } + const existingLoads = hostElementLoads.get(host); + const loads = existingLoads ?? new Map>(); + 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); +} diff --git a/ui/src/app/native-route-memory.test.ts b/ui/src/app/native-route-memory.test.ts new file mode 100644 index 000000000000..a87af627fc4d --- /dev/null +++ b/ui/src/app/native-route-memory.test.ts @@ -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", + }); + }); +}); diff --git a/ui/src/app/native-route-memory.ts b/ui/src/app/native-route-memory.ts new file mode 100644 index 000000000000..9a3d6de43088 --- /dev/null +++ b/ui/src/app/native-route-memory.ts @@ -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; + 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; +} diff --git a/ui/src/i18n/.i18n/raw-copy-baseline.json b/ui/src/i18n/.i18n/raw-copy-baseline.json index 92dccc9c7196..a00f0dbfc1f7 100644 --- a/ui/src/i18n/.i18n/raw-copy-baseline.json +++ b/ui/src/i18n/.i18n/raw-copy-baseline.json @@ -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" }, {