Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { setupQueries } from './hooks/queries.js'
import { queryClient, persistOptions } from './queryClient.js'
import { shellReload } from './lib/shellReloadState.js'
import { beginEmbedBootstrap } from './lib/chatEmbedBootstrap.js'
import { startInstallPromptCapture } from './lib/installPrompt.js'

// These flows are mutually exclusive. Keep setup, login, the full shell, and
// the opaque embed out of one another's startup path; first boot should not
Expand Down Expand Up @@ -38,6 +39,10 @@ const EMBED_ROUTE = isEmbedRoute()
if (EMBED_ROUTE) {
beginEphemeralAuth()
beginEmbedBootstrap()
} else {
// Capture Chromium's one-shot install event before setup or sign-in can keep
// the first-use shell card from mounting.
startInstallPromptCapture()
}

// Validate a ?return= target: same-origin in-app path only. Rejects
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ test('first-use guidance is a labeled non-modal region with a dismiss action', (
assert.match(source, /role="region"/)
assert.match(source, /aria-labelledby="wt-title"/)
assert.match(source, /aria-label="Dismiss welcome"/)
assert.match(source, /aria-labelledby="wt-install-title"/)
assert.match(source, /aria-expanded=/)
assert.match(source, /role="status"/)
assert.doesNotMatch(source, /aria-modal="true"/)
})

Expand Down
9 changes: 4 additions & 5 deletions frontend/src/components/Shell/Shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -424,11 +424,10 @@

/* The install affordance lives in the first-sign-in walkthrough now
(see frontend/src/components/Walkthrough/WalkthroughOverlay.jsx).
beforeinstallprompt capture and the drawer banner that used to
live here are gone — the walkthrough's "Add to home screen" step
tells the user how to install for their platform exactly once,
then never appears again. Sub-app install lives in the standalone
card surfaced from drawer ⋮ on a mini-app. */
App.jsx captures beforeinstallprompt early enough to survive account
setup; the walkthrough consumes it or shows platform-specific manual
guidance. The old drawer banner stays retired. Sub-app installation
lives in the standalone card surfaced from drawer ⋮ on a mini-app. */

/* Global offline indicator pill in the top bar. Shown only when the
browser reports offline (useOnlineStatus). Keeps the user oriented
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/Shell/__tests__/workspaceUi.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ test('the first-run walkthrough stays short and action-first', () => {
assert.match(walkthrough, /Your Möbius is ready/)
assert.match(walkthrough, /Connect an agent/)
assert.match(walkthrough, /Open the App Store/)
assert.match(walkthrough, /Keep Möbius close/)
assert.match(walkthrough, /requestInstall/)
assert.match(walkthrough, /I’ll explore/)
assert.match(walkthrough, /mobius:walkthrough-completed/)
})
Expand Down
92 changes: 92 additions & 0 deletions frontend/src/components/Walkthrough/WalkthroughOverlay.css
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
}

.wt__close:focus-visible,
.wt__install-btn:focus-visible,
.wt__path:focus-visible,
.wt__dismiss:focus-visible {
outline: 2px solid var(--accent, #8b6cf7);
Expand Down Expand Up @@ -116,6 +117,92 @@
line-height: 1.55;
}

.wt__install {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
gap: 4px 10px;
align-items: center;
margin-top: 18px;
padding: 12px;
background: color-mix(in srgb, var(--accent, #8b6cf7) 8%, var(--surface2, #1a1f28));
border: 1px solid color-mix(in srgb, var(--accent, #8b6cf7) 22%, var(--border, #252b36));
border-radius: 12px;
}

.wt__install-icon {
display: grid;
place-items: center;
width: 36px;
height: 36px;
color: var(--accent, #a78bfa);
background: color-mix(in srgb, var(--accent, #8b6cf7) 15%, transparent);
border-radius: 10px;
}

.wt__install-copy {
min-width: 0;
}

.wt__install-copy h3 {
margin: 0;
color: var(--text, #e4e4e7);
font-size: 14px;
font-weight: 680;
line-height: 1.25;
}

.wt__install-copy p {
margin: 3px 0 0;
color: var(--muted, #a1a1aa);
font-size: 12px;
line-height: 1.35;
}

.wt__install-btn {
min-height: 34px;
padding: 6px 10px;
color: var(--accent, #a78bfa);
background: color-mix(in srgb, var(--accent, #8b6cf7) 11%, transparent);
border: 0;
border-radius: 9px;
font: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}

.wt__install-btn:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent, #8b6cf7) 18%, transparent);
}

.wt__install-btn:disabled {
opacity: 0.64;
cursor: wait;
}

.wt__install-help,
.wt__install-feedback {
grid-column: 2 / -1;
margin: 7px 0 0;
font-size: 12px;
line-height: 1.45;
}

.wt__install-help {
display: grid;
gap: 2px;
color: var(--muted, #a1a1aa);
}

.wt__install-help strong {
color: var(--text, #e4e4e7);
font-weight: 650;
}

.wt__install-feedback {
color: var(--muted, #a1a1aa);
}

.wt__paths {
display: grid;
gap: 1px;
Expand Down Expand Up @@ -195,6 +282,11 @@
.wt__path small {
white-space: normal;
}

.wt__install {
grid-template-columns: 34px minmax(0, 1fr) auto;
padding: 10px;
}
}

@media (prefers-reduced-motion: reduce) {
Expand Down
97 changes: 88 additions & 9 deletions frontend/src/components/Walkthrough/WalkthroughOverlay.jsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,32 @@
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Download } from 'lucide-react'
import { api } from '../../api/client.js'
import { ownerQueries } from '../../hooks/queries.js'
import {
getInstallPromptSnapshot,
requestInstall,
subscribeInstallPrompt,
} from '../../lib/installPrompt.js'
import {
detectInstallPlatform,
installCopyForPlatform,
} from '../../utils/installPlatform.js'
import './WalkthroughOverlay.css'

export default function WalkthroughOverlay({ onDone, onOpenSettings, onExploreApps }) {
const queryClient = useQueryClient()
const closingRef = useRef(false)
const [platform] = useState(() => detectInstallPlatform())
const [installCopy] = useState(() => installCopyForPlatform(platform))
const [showInstallHelp, setShowInstallHelp] = useState(false)
const [installBusy, setInstallBusy] = useState(false)
const [installFeedback, setInstallFeedback] = useState('')
const installState = useSyncExternalStore(
subscribeInstallPrompt,
getInstallPromptSnapshot,
getInstallPromptSnapshot,
)

function finish() {
if (closingRef.current) return
Expand All @@ -25,16 +45,41 @@ export default function WalkthroughOverlay({ onDone, onOpenSettings, onExploreAp
action?.()
}

async function handleInstall() {
setInstallFeedback('')
if (installState !== 'ready') {
setShowInstallHelp((visible) => !visible)
return
}

setInstallBusy(true)
const result = await requestInstall()
setInstallBusy(false)
if (result.outcome === 'accepted') {
finish()
return
}

setShowInstallHelp(true)
setInstallFeedback(
result.outcome === 'dismissed'
? 'Not installed. You can use the browser menu whenever you’re ready.'
: 'The browser prompt wasn’t available. Use the steps below instead.',
)
}

useEffect(() => {
const standalone =
(typeof window !== 'undefined' &&
window.matchMedia &&
window.matchMedia('(display-mode: standalone)').matches) ||
(typeof navigator !== 'undefined' && navigator.standalone === true)
if (standalone) finish()
// finish is guarded and this should run only once on mount.
if (installState === 'installed') finish()
// The event can arrive after mount; finish is guarded across rerenders.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [installState])

const nativeInstallReady = installState === 'ready'
const installButtonLabel = installBusy
? 'Opening…'
: (nativeInstallReady
? 'Install'
: (showInstallHelp ? 'Hide' : installCopy.ctaLabel))

return (
<aside
Expand All @@ -59,6 +104,40 @@ export default function WalkthroughOverlay({ onDone, onOpenSettings, onExploreAp
You can explore now. Add an agent only when you want chats to act and
build on your behalf.
</p>
<section className="wt__install" aria-labelledby="wt-install-title">
<span className="wt__install-icon" aria-hidden="true">
<Download size={18} strokeWidth={2} />
</span>
<div className="wt__install-copy">
<h3 id="wt-install-title">Keep Möbius close</h3>
<p>
{nativeInstallReady
? 'Install it on this device for a full-screen, one-tap launch.'
: installCopy.summary}
</p>
</div>
<button
type="button"
className="wt__install-btn"
onClick={handleInstall}
disabled={installBusy}
aria-expanded={nativeInstallReady ? undefined : showInstallHelp}
aria-controls={nativeInstallReady ? undefined : 'wt-install-help'}
>
{installButtonLabel}
</button>
{showInstallHelp && (
<div className="wt__install-help" id="wt-install-help">
<strong>{installCopy.title}</strong>
<span>{installCopy.body}</span>
</div>
)}
{installFeedback && (
<p className="wt__install-feedback" role="status">
{installFeedback}
</p>
)}
</section>
<div className="wt__paths">
<button
type="button"
Expand Down
90 changes: 90 additions & 0 deletions frontend/src/lib/__tests__/installPrompt.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* Unit tests for the early PWA install-prompt capture.
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'

async function freshModule() {
return import(new URL(`../installPrompt.js?t=${Math.random()}`, import.meta.url))
}

function makeTarget({ standalone = false } = {}) {
const handlers = new Map()
return {
navigator: { standalone: false },
matchMedia: () => ({ matches: standalone }),
addEventListener(type, handler) {
handlers.set(type, handler)
},
dispatch(type, event = {}) {
handlers.get(type)?.(event)
},
}
}

test('captures and consumes a one-shot native install prompt', async () => {
const installPrompt = await freshModule()
const target = makeTarget()
let prevented = false
let promptCalls = 0
installPrompt.startInstallPromptCapture(target)

target.dispatch('beforeinstallprompt', {
preventDefault() { prevented = true },
async prompt() {
promptCalls += 1
return { outcome: 'accepted' }
},
})

assert.equal(prevented, true)
assert.equal(installPrompt.getInstallPromptSnapshot(), 'ready')
assert.deepEqual(await installPrompt.requestInstall(), { outcome: 'accepted' })
assert.equal(promptCalls, 1)
assert.equal(installPrompt.getInstallPromptSnapshot(), 'manual')
assert.deepEqual(await installPrompt.requestInstall(), { outcome: 'unavailable' })
assert.equal(promptCalls, 1)
})

test('falls back to userChoice for older Chromium prompt results', async () => {
const installPrompt = await freshModule()
const target = makeTarget()
installPrompt.startInstallPromptCapture(target)
target.dispatch('beforeinstallprompt', {
preventDefault() {},
async prompt() {},
userChoice: Promise.resolve({ outcome: 'dismissed' }),
})

assert.deepEqual(await installPrompt.requestInstall(), { outcome: 'dismissed' })
})

test('appinstalled and standalone launch suppress the install invitation', async () => {
const captured = await freshModule()
const target = makeTarget()
captured.startInstallPromptCapture(target)
target.dispatch('appinstalled')
assert.equal(captured.getInstallPromptSnapshot(), 'installed')

const standalone = await freshModule()
standalone.startInstallPromptCapture(makeTarget({ standalone: true }))
assert.equal(standalone.getInstallPromptSnapshot(), 'installed')
})

test('subscribers are notified when prompt availability changes', async () => {
const installPrompt = await freshModule()
const target = makeTarget()
let changes = 0
installPrompt.startInstallPromptCapture(target)
const unsubscribe = installPrompt.subscribeInstallPrompt(() => { changes += 1 })

target.dispatch('beforeinstallprompt', {
preventDefault() {},
async prompt() { return { outcome: 'dismissed' } },
})
await installPrompt.requestInstall()
unsubscribe()
target.dispatch('appinstalled')

assert.equal(changes, 2)
})
Loading
Loading