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
parent fa37155ff3
commit 7d7a7d08af
8 changed files with 84 additions and 71 deletions

View File

@ -258,13 +258,11 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { useAppLauncherStore } from '@/stores/appLauncher'
defineProps<{ compact?: boolean }>()
const router = useRouter()
interface Channel {
chan_id: string
remote_pubkey: string
@ -321,7 +319,8 @@ function fundingTxid(ch: Channel): string {
function openInMempool(txid: string) {
if (!txid) return
router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txid}` } })
// Overlay the explorer above the current page never navigate away.
useAppLauncherStore().openSession('mempool', { path: `/tx/${txid}` })
}
function capacityPercent(amount: number, capacity: number): number {

View File

@ -75,9 +75,9 @@
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import BaseModal from '@/components/BaseModal.vue'
import { useAppLauncherStore } from '@/stores/appLauncher'
interface WalletTransaction {
tx_hash: string
@ -100,7 +100,6 @@ defineProps<{
const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
const router = useRouter()
function close() {
emit('close')
@ -120,7 +119,8 @@ function kindLabel(tx: WalletTransaction): string {
function openInMempool(tx: WalletTransaction) {
if (!isOnchain(tx)) return
router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${tx.tx_hash}` } })
// Overlay the explorer above the current page never navigate away.
useAppLauncherStore().openSession('mempool', { path: `/tx/${tx.tx_hash}` })
}
function formatTxTime(timestamp: number): string {

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,

View File

@ -1,6 +1,6 @@
<template>
<div class="app-session-root">
<Teleport to="body" :disabled="isInlinePanel && !isMobile">
<Teleport to="body" :disabled="inlinePanelMode">
<div
:class="backdropClasses"
@click.self="handleBackdropClick"
@ -92,7 +92,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { ref, computed, nextTick, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useAppStore } from '@/stores/app'
@ -110,10 +110,12 @@ import { useAppIdentity } from './appSession/useAppIdentity'
import { useNostrBridge } from './appSession/useNostrBridge'
import { openExternalUrl, openInAppOrNewTab } from '@/utils/openExternal'
import { useElectrsSync } from '@/composables/useElectrsSync'
import { IS_DEMO, isDemoExternal } from '@/composables/useDemoIntro'
import { IS_DEMO, isDemoApp, isDemoExternal } from '@/composables/useDemoIntro'
const props = defineProps<{
appIdProp?: string
/** Deep-link path inside the app (store-driven sessions), e.g. /tx/<hash> */
pathProp?: string
}>()
const emit = defineEmits<{
@ -167,10 +169,12 @@ const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value ==
// viewport (and match the CSS `md` breakpoint) instead of a stale one-shot read.
const isMobile = ref(typeof window !== 'undefined' && window.innerWidth < 768)
function updateIsMobile() { isMobile.value = window.innerWidth < 768 }
// In the demo, apps backed by a real external site that blocks iframing
// (mempool.space) open in a new tab rather than the in-app session frame.
// In the demo, apps backed by a real external site that blocks iframing open
// in a new tab rather than the in-app session frame. Demoable apps are served
// same-origin by the mock backend, so the prod new-tab list doesn't apply.
const mustOpenNewTab = computed(() =>
NEW_TAB_APPS.has(appId.value) || (IS_DEMO && isDemoExternal(appId.value))
(NEW_TAB_APPS.has(appId.value) && !(IS_DEMO && isDemoApp(appId.value))) ||
(IS_DEMO && isDemoExternal(appId.value))
)
// ElectrumX shows a sync screen before its real UI (the Electrum server only
@ -200,7 +204,8 @@ const screensaverSuppressedApps = new Set([
const appUrl = computed(() => {
const runtimeUrl = store.data?.['package-data']?.[appId.value]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
return resolveAppUrl(appId.value, route.query.path as string | undefined, runtimeUrl)
const deepPath = props.pathProp ?? (route.query.path as string | undefined)
return resolveAppUrl(appId.value, deepPath, runtimeUrl)
})
function closeRouteSession() {
@ -227,16 +232,8 @@ function setMode(mode: DisplayMode) {
displayMode.value = mode
localStorage.setItem(DISPLAY_MODE_KEY, mode)
// Switch from inline panel to route-based overlay/fullscreen
if (isInlinePanel.value && mode !== 'panel') {
const id = appId.value
emit('close')
const returnTo = route.fullPath.startsWith('/dashboard') ? route.fullPath : '/dashboard/apps'
router.push({ name: 'app-session', params: { appId: id }, query: { returnTo } })
return
}
// Switch from route-based to inline panel
// Route-based sessions (deep links) hand off to the store-driven session so
// the app keeps floating above the dashboard instead of owning the route.
if (!isInlinePanel.value && mode === 'panel') {
const id = appId.value
const launcher = useAppLauncherStore()
@ -250,22 +247,24 @@ function setMode(mode: DisplayMode) {
return
}
if (mode === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
sessionRef.value.requestFullscreen().catch(() => {})
}
}
// Reactive classes based on display mode. On mobile the store-driven panel
// renders as a full-screen overlay (teleported to body) so it covers the nav
// and the underlying page never changes desktop keeps the inline panel.
// Reactive classes based on display mode. The store-driven session honors the
// selected display mode in place: panel renders inline beside the page,
// overlay/fullscreen render above it (teleported to body) the underlying
// route never changes. Mobile always uses the full overlay.
const inlinePanelMode = computed(() =>
isInlinePanel.value && !isMobile.value && displayMode.value === 'panel'
)
const backdropClasses = computed(() => {
if (isInlinePanel.value && !isMobile.value) return 'app-session-backdrop-inline'
if (inlinePanelMode.value) return 'app-session-backdrop-inline'
return 'app-session-backdrop-overlay'
})
const panelClasses = computed(() => {
const base = 'app-session-panel glass-card'
if (isInlinePanel.value && !isMobile.value) return `${base} app-session-inline`
if (inlinePanelMode.value) return `${base} app-session-inline`
if (displayMode.value === 'fullscreen' && !isMobile.value) return `${base} app-session-fullscreen`
return `${base} app-session-overlay`
})
@ -380,9 +379,14 @@ function onMessage(e: MessageEvent) {
// Enter fullscreen on mount if mode is fullscreen
watch(displayMode, (mode) => {
if (mode === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
sessionRef.value.requestFullscreen().catch(() => {})
}
if (mode !== 'fullscreen') return
// The panel may teleport to <body> on this mode change request fullscreen
// after the DOM settles so we grab the element at its new location.
void nextTick(() => {
if (displayMode.value === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
sessionRef.value.requestFullscreen().catch(() => {})
}
})
})
onMounted(() => {

View File

@ -120,7 +120,11 @@
<!-- Panel mode app session renders alongside current page content -->
<Transition name="panel-slide">
<div v-if="appLauncher.panelAppId" class="app-panel-container">
<AppSession :app-id-prop="appLauncher.panelAppId" @close="appLauncher.closePanel()" />
<AppSession
:app-id-prop="appLauncher.panelAppId"
:path-prop="appLauncher.panelPath ?? undefined"
@close="appLauncher.closePanel()"
/>
</div>
</Transition>
</main>

View File

@ -517,7 +517,8 @@ async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet',
const walletConnected = ref(false); const walletOnchain = ref(0); const walletLightning = ref(0); const walletEcash = ref(0); const walletFedimint = ref(0)
const walletTransactions = ref<WalletTransaction[]>([])
function openInMempool(txHash: string) { router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } }) }
// Overlay the explorer above the current page never navigate away.
function openInMempool(txHash: string) { useAppLauncherStore().openSession('mempool', { path: `/tx/${txHash}` }) }
// wallet.ecash-history's shape (see handle_wallet_ecash_history in
// api/rpc/wallet.rs) distinct from the LND-shaped WalletTransaction used

View File

@ -82,7 +82,11 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
// mempool). Non-demoable apps fall through to a generic notice page.
if (IS_DEMO) {
const base = demoAppUrl(id)
if (base) return routeQueryPath ? base + routeQueryPath : base
if (base) {
if (!routeQueryPath) return base
// Join without a double slash (/app/mempool/ + /tx/x → /app/mempool/tx/x)
return base.replace(/\/+$/, '') + (routeQueryPath.startsWith('/') ? routeQueryPath : '/' + routeQueryPath)
}
return `/app/${id}/`
}

View File

@ -148,12 +148,11 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { formatTxTime } from './utils'
import type { WalletTransaction } from './types'
import { useAppLauncherStore } from '@/stores/appLauncher'
const router = useRouter()
const { t } = useI18n()
const showIncomingTxPanel = ref(false)
@ -179,6 +178,7 @@ defineEmits<{
}>()
function openInMempool(txHash: string) {
router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } })
// Overlay the explorer above the current page never navigate away.
useAppLauncherStore().openSession('mempool', { path: `/tx/${txHash}` })
}
</script>