feat(ui): app sessions overlay the current page in every display mode

Launching an app (or opening a tx in mempool from Home/wallet/channels) used
to router.push the app-session page in overlay/fullscreen modes, swapping the
page underneath — confusing and lossy. All display modes are now store-driven:
panel renders beside the page, overlay/fullscreen render above it (teleported
to body), and the route never changes. Deep-link paths (/tx/<hash>) ride along
via the launcher's panelPath instead of a route query; closing always returns
exactly where the user was. The app-session route stays for direct links.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-07-14 03:39:32 +01:00
co-authored by Claude Fable 5
parent fa37155ff3
commit 7d7a7d08af
8 changed files with 84 additions and 71 deletions
+31 -30
View File
@@ -1,11 +1,10 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import router from '@/router'
import { recordAppLaunch } from '@/utils/appUsage'
import { requestExternalOpen } from '@/api/remote-relay'
import { openInAppOrNewTab } from '@/utils/openExternal'
import { IS_DEMO, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
/**
* Open a URL in a new browser tab — but if a companion (phone) is currently
@@ -209,25 +208,17 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const showConsent = ref(false)
let previousActiveElement: HTMLElement | null = null
/** Active app in panel mode (store-based, no route change) */
/** Active app in the store-driven session (no route change) */
const panelAppId = ref<string | null>(null)
/** Optional deep-link path inside the active app (e.g. /tx/<hash> for mempool) */
const panelPath = ref<string | null>(null)
/** Open app in session view — panel mode uses store, overlay/fullscreen uses route */
function dashboardReturnPath(): string {
const current = router.currentRoute?.value
if (!current) return '/dashboard/apps'
const fullPath = current.fullPath || '/dashboard/apps'
if (!fullPath.startsWith('/dashboard') || current.name === 'app-session') return '/dashboard/apps'
return fullPath
}
function openSession(appId: string) {
function openSession(appId: string, opts: { path?: string } = {}) {
recordAppLaunch(appId)
const mobile = isMobileViewport()
// Demo: apps backed by a real external site that blocks iframing
// (mempool.space) open externally; everything else demoable renders in the
// in-app session.
// Demo: apps backed by a real external site that blocks iframing open
// externally; everything else demoable renders in the in-app session.
if (IS_DEMO && isDemoExternal(appId)) {
const ext = demoAppUrl(appId)
if (ext) {
@@ -239,7 +230,9 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// Tab-only apps (set X-Frame-Options, can't be iframed). No interstitial:
// desktop opens a new browser tab; mobile opens the in-app WebView (Android
// companion) or a new browser tab (PWA) — see openInAppOrNewTab.
if (NEW_TAB_APP_IDS.has(appId)) {
// In the demo, demoable apps are served same-origin by the mock backend
// (no framing headers), so they always render in the in-app session.
if (NEW_TAB_APP_IDS.has(appId) && !(IS_DEMO && isDemoApp(appId))) {
const launchUrl = directAppUrl(appId)
if (launchUrl) {
if (mobile) openInAppOrNewTab(launchUrl)
@@ -248,21 +241,17 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
}
}
// Iframeable apps. Mobile and desktop-panel mode both use the store-driven
// panel so the underlying page/tab never changes (no background swap) and
// closing returns the user to wherever they launched from. Only desktop
// overlay/fullscreen modes use a routed session.
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
if (mobile || mode === 'panel') {
panelAppId.value = appId
} else {
panelAppId.value = null
router.push({ name: 'app-session', params: { appId }, query: { returnTo: dashboardReturnPath() } })
}
// Iframeable apps always use the store-driven session so the underlying
// page never changes: panel mode renders beside the page, overlay and
// fullscreen modes render above it (AppSession styles per display mode).
// Closing always returns the user exactly where they launched from.
panelPath.value = opts.path ?? null
panelAppId.value = appId
}
function closePanel() {
panelAppId.value = null
panelPath.value = null
}
/** Legacy: open app in iframe overlay (kept for backward compat) */
@@ -299,9 +288,20 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
return
}
// Route to full-page session if we can resolve an app ID from the URL
// Open the in-app session if we can resolve an app ID from the URL,
// carrying through any deep-link path (e.g. /tx/<hash>).
if (resolvedId) {
openSession(resolvedId)
let deepPath: string | undefined
try {
const u = new URL(launchUrl, window.location.origin)
let p = `${u.pathname}${u.search}${u.hash}`
// /app/<id>/-style URLs carry the mount prefix — strip it so only the
// app-internal path is treated as the deep link.
const prefix = `/app/${resolvedId}`
if (p.startsWith(prefix)) p = p.slice(prefix.length) || '/'
if (p && p !== '/') deepPath = p
} catch { /* no deep link */ }
openSession(resolvedId, { path: deepPath })
return
}
@@ -517,6 +517,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
close,
closePanel,
panelAppId,
panelPath,
showConsent,
consentRequest,
approveConsent,