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
+32 -28
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(() => {