feat(neode-ui): mobile app-launch UX — store-driven panel, loader, ElectrumX icon

- Mobile launches use the store-driven panel (no route push) so the background
  tab no longer changes and closing returns to where you launched from.
- Tab-only apps open directly (in-app WebView on companion / new tab on PWA) —
  no "this app opens in a tab" interstitial.
- Shared AppLoadingScreen (app icon + progress bar) on the app session and the
  legacy iframe overlay instead of a black screen.
- Pin the dashboard to 100dvh on mobile so the mesh chat/tools panes stop sliding
  under the bottom tab bar in mobile browsers (no-op in the companion WebView).
- ElectrumX/electrs/electrs-ui ids now resolve to the real ElectrumX icon in My Apps.
- isMobile made reactive so overlay/footer/teleport decisions track the viewport.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-23 03:48:57 -04:00
co-authored by Claude Opus 4.8
parent db7d424bff
commit a7c7c44843
11 changed files with 387 additions and 56 deletions
@@ -55,7 +55,7 @@ describe('useAppLauncherStore', () => {
expect(mockWindowOpen).not.toHaveBeenCalled()
})
it('uses route-based app sessions on mobile instead of panel mode', () => {
it('uses the store-driven panel on mobile (no route change, no background swap)', () => {
Object.defineProperty(window, 'innerWidth', {
value: 390,
writable: true,
@@ -65,8 +65,10 @@ describe('useAppLauncherStore', () => {
store.openSession('indeedhub')
expect(store.panelAppId).toBe(null)
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'indeedhub' }, query: { returnTo: '/dashboard/apps' } })
// Mobile now uses the store-driven panel like desktop panel mode so the
// underlying page/tab never changes and closing returns to the origin.
expect(store.panelAppId).toBe('indeedhub')
expect(mockPush).not.toHaveBeenCalled()
})
it('normalizes localhost launch URLs to current host before resolving', () => {
@@ -117,7 +119,7 @@ describe('useAppLauncherStore', () => {
)
})
it('routes desktop new-tab apps into app session on mobile', () => {
it('opens tab-only apps directly on mobile (new tab in PWA, no interstitial)', () => {
Object.defineProperty(window, 'innerWidth', {
value: 390,
writable: true,
@@ -127,10 +129,17 @@ describe('useAppLauncherStore', () => {
store.open({ url: 'http://192.168.1.228:8081', title: 'Nginx Proxy Manager' })
// Tab-only app on mobile-web: open directly in a new browser tab (the
// companion would use the in-app WebView). No session, no route push, no
// "this app opens in a tab" interstitial.
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'nginx-proxy-manager' }, query: { returnTo: '/dashboard/apps' } })
expect(mockPush).not.toHaveBeenCalled()
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:8081',
'_blank',
'noopener,noreferrer',
)
})
it('opens Nginx Proxy Manager in new tab using title hint when URL is path-only', () => {
@@ -264,7 +273,7 @@ describe('useAppLauncherStore', () => {
)
})
it('routes prepackaged websites into app session on mobile', () => {
it('opens prepackaged websites in the store-driven panel on mobile', () => {
Object.defineProperty(window, 'innerWidth', {
value: 390,
writable: true,
@@ -274,9 +283,12 @@ describe('useAppLauncherStore', () => {
store.open({ url: 'https://present.l484.com', title: 'Arch Presentation', openInNewTab: true })
// Iframeable prepackaged sites stay in-app via the store panel (no route
// change, no background swap) just like every other mobile launch.
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe('arch-presentation')
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'arch-presentation' }, query: { returnTo: '/dashboard/apps' } })
expect(mockPush).not.toHaveBeenCalled()
})
it('routes HTTPS same-host apps via session view', () => {
+17 -5
View File
@@ -4,6 +4,7 @@ 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'
/**
* Open a URL in a new browser tab — but if a companion (phone) is currently
@@ -222,14 +223,25 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
function openSession(appId: string) {
recordAppLaunch(appId)
const mobile = isMobileViewport()
const launchUrl = NEW_TAB_APP_IDS.has(appId) ? directAppUrl(appId) : null
if (launchUrl && !mobile) {
openExternal(launchUrl)
return
// 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)) {
const launchUrl = directAppUrl(appId)
if (launchUrl) {
if (mobile) openInAppOrNewTab(launchUrl)
else openExternal(launchUrl)
return
}
}
// 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 (mode === 'panel' && !mobile) {
if (mobile || mode === 'panel') {
panelAppId.value = appId
} else {
panelAppId.value = null