mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
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:
committed by
GitHub
parent
5bc101bab2
commit
bc4ed5cf61
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+3
-3
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user