From ca18b8be0f41ba5c3e84bca21c6d75a227522ef8 Mon Sep 17 00:00:00 2001 From: shekohex Date: Wed, 4 Mar 2026 00:32:23 +0200 Subject: [PATCH] feat(pwa): pre-install naming, install UX, and manifest shortcuts (#554) * feat(web-pwa): add dynamic manifest endpoint with blob fallback * feat(ui-pwa): add install prompt and manifest sync hooks * feat(settings): add web-only preinstall app name preference * fix(web-pwa): scope recent shortcuts to active project sessions --------- Co-authored-by: Bohdan Triapitsyn --- packages/ui/src/App.tsx | 4 + .../sections/openchamber/OpenChamberPage.tsx | 3 +- .../openchamber/OpenChamberVisualSettings.tsx | 126 +++++++- packages/ui/src/hooks/usePwaDetection.ts | 57 ++++ packages/ui/src/hooks/usePwaInstallPrompt.ts | 87 ++++++ packages/ui/src/hooks/usePwaManifestSync.ts | 90 ++++++ packages/ui/src/lib/api/types.ts | 1 + packages/ui/src/lib/desktop.ts | 1 + packages/ui/src/lib/persistence.ts | 12 + packages/ui/src/lib/pwa.ts | 41 +++ packages/ui/src/lib/settings/metadata.ts | 2 +- packages/web/index.html | 292 ++++++++++++++++-- packages/web/server/index.js | 246 ++++++++++++++- 13 files changed, 926 insertions(+), 36 deletions(-) create mode 100644 packages/ui/src/hooks/usePwaDetection.ts create mode 100644 packages/ui/src/hooks/usePwaInstallPrompt.ts create mode 100644 packages/ui/src/hooks/usePwaManifestSync.ts create mode 100644 packages/ui/src/lib/pwa.ts diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index e662a3bb..91237013 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -16,6 +16,8 @@ import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup'; import { useQueuedMessageAutoSend } from '@/hooks/useQueuedMessageAutoSend'; import { useRouter } from '@/hooks/useRouter'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; +import { usePwaManifestSync } from '@/hooks/usePwaManifestSync'; +import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt'; import { useWindowTitle } from '@/hooks/useWindowTitle'; import { GitPollingProvider } from '@/hooks/useGitPolling'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -319,6 +321,8 @@ function App({ apis }: AppProps) { useServerSessionStatus({ enabled: embeddedBackgroundWorkEnabled }); usePushVisibilityBeacon({ enabled: embeddedBackgroundWorkEnabled }); + usePwaManifestSync(); + usePwaInstallPrompt(); useWindowTitle(); diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 3c5a18f2..c539bc34 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -103,8 +103,9 @@ const ShortcutsSectionContent: React.FC = () => { // Visual section: Theme Mode, Font Size, Spacing, Corner Radius, Input Bar Offset (mobile), Nav Rail const VisualSectionContent: React.FC = () => { const isVSCode = isVSCodeRuntime(); - const visibleSettings: Array<'theme' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'terminalQuickKeys' | 'navRail' | 'mermaidRendering' | 'userMessageRendering' | 'stickyUserHeader'> = [ + const visibleSettings: Array<'theme' | 'pwaInstallName' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'terminalQuickKeys' | 'navRail' | 'mermaidRendering' | 'userMessageRendering' | 'stickyUserHeader'> = [ 'theme', + 'pwaInstallName', 'fontSize', 'terminalFontSize', 'spacing', diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index a9a77701..4290e6dd 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -11,6 +11,7 @@ import { ButtonSmall } from '@/components/ui/button-small'; import { Checkbox } from '@/components/ui/checkbox'; import { NumberInput } from '@/components/ui/number-input'; import { Radio } from '@/components/ui/radio'; +import { Input } from '@/components/ui/input'; import { Select, SelectContent, @@ -18,8 +19,9 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { isVSCodeRuntime } from '@/lib/desktop'; +import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; import { useDeviceInfo } from '@/lib/device'; +import { usePwaDetection } from '@/hooks/usePwaDetection'; import { updateDesktopSettings } from '@/lib/persistence'; import { setDirectoryShowHidden, @@ -97,6 +99,13 @@ const MERMAID_RENDERING_OPTIONS: Option<'svg' | 'ascii'>[] = [ }, ]; +const DEFAULT_PWA_INSTALL_NAME = 'OpenChamber - AI Coding Assistant'; + +type PwaInstallNameWindow = Window & { + __OPENCHAMBER_SET_PWA_INSTALL_NAME__?: (value: string) => string; + __OPENCHAMBER_UPDATE_PWA_MANIFEST__?: () => void; +}; + const USER_MESSAGE_RENDERING_OPTIONS: Option<'markdown' | 'plain'>[] = [ { id: 'markdown', @@ -114,7 +123,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' return mode === 'markdown' ? 'markdown' : 'plain'; }; -export type VisibleSetting = 'theme' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'navRail' | 'toolOutput' | 'mermaidRendering' | 'userMessageRendering' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'queueMode' | 'textJustificationActivity' | 'terminalQuickKeys' | 'persistDraft'; +export type VisibleSetting = 'theme' | 'pwaInstallName' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'navRail' | 'toolOutput' | 'mermaidRendering' | 'userMessageRendering' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'queueMode' | 'textJustificationActivity' | 'terminalQuickKeys' | 'persistDraft'; interface OpenChamberVisualSettingsProps { /** Which settings to show. If undefined, shows all. */ @@ -123,6 +132,7 @@ interface OpenChamberVisualSettingsProps { export const OpenChamberVisualSettings: React.FC = ({ visibleSettings }) => { const { isMobile } = useDeviceInfo(); + const { browserTab } = usePwaDetection(); const directoryShowHidden = useDirectoryShowHidden(); const showReasoningTraces = useUIStore(state => state.showReasoningTraces); const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces); @@ -218,7 +228,7 @@ export const OpenChamberVisualSettings: React.FC }; const isVSCode = isVSCodeRuntime(); - const hasAppearanceSettings = shouldShow('theme') && !isVSCode; + const hasAppearanceSettings = (shouldShow('theme') || shouldShow('pwaInstallName')) && !isVSCode; const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('cornerRadius') || shouldShow('inputBarOffset'); const hasNavigationSettings = (!isMobile && shouldShow('navRail')) || (shouldShow('terminalQuickKeys') && !isMobile); const hasBehaviorSettings = shouldShow('toolOutput') @@ -232,6 +242,74 @@ export const OpenChamberVisualSettings: React.FC || shouldShow('queueMode') || shouldShow('textJustificationActivity') || shouldShow('persistDraft'); + + const showPwaInstallNameSetting = shouldShow('pwaInstallName') && isWebRuntime() && browserTab; + const [pwaInstallName, setPwaInstallName] = React.useState(''); + + const applyPwaInstallName = React.useCallback(async (value: string) => { + if (typeof window === 'undefined') { + return; + } + + const win = window as PwaInstallNameWindow; + const normalized = value.trim().replace(/\s+/g, ' ').slice(0, 64); + const persistedValue = normalized; + + await updateDesktopSettings({ pwaAppName: persistedValue }); + + if (typeof win.__OPENCHAMBER_SET_PWA_INSTALL_NAME__ === 'function') { + const resolved = win.__OPENCHAMBER_SET_PWA_INSTALL_NAME__(persistedValue); + setPwaInstallName(resolved); + return; + } + + setPwaInstallName(persistedValue || DEFAULT_PWA_INSTALL_NAME); + win.__OPENCHAMBER_UPDATE_PWA_MANIFEST__?.(); + }, []); + + React.useEffect(() => { + if (typeof window === 'undefined' || !showPwaInstallNameSetting) { + return; + } + + let cancelled = false; + + const loadPwaInstallName = async () => { + try { + const response = await fetch('/api/config/settings', { + method: 'GET', + headers: { Accept: 'application/json' }, + cache: 'no-store', + }); + + if (!response.ok) { + if (!cancelled) { + setPwaInstallName(DEFAULT_PWA_INSTALL_NAME); + } + return; + } + + const settings = await response.json().catch(() => ({})); + const raw = typeof settings?.pwaAppName === 'string' ? settings.pwaAppName : ''; + const normalized = raw.trim().replace(/\s+/g, ' ').slice(0, 64); + + if (!cancelled) { + setPwaInstallName(normalized || DEFAULT_PWA_INSTALL_NAME); + } + } catch { + if (!cancelled) { + setPwaInstallName(DEFAULT_PWA_INSTALL_NAME); + } + } + }; + + void loadPwaInstallName(); + + return () => { + cancelled = true; + }; + }, [showPwaInstallNameSetting]); + return (
@@ -333,6 +411,48 @@ export const OpenChamberVisualSettings: React.FC
+ + {showPwaInstallNameSetting && ( +
+
+ Install App Name + Used by Chrome install prompt before install. +
+
+ { + setPwaInstallName(event.target.value); + }} + onBlur={() => { + void applyPwaInstallName(pwaInstallName); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + void applyPwaInstallName(pwaInstallName); + } + }} + className="h-7" + maxLength={64} + aria-label="PWA install app name" + /> + { + setPwaInstallName(DEFAULT_PWA_INSTALL_NAME); + void applyPwaInstallName(''); + }} + className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground" + aria-label="Reset install app name" + title="Reset" + > + + +
+
+ )} )} diff --git a/packages/ui/src/hooks/usePwaDetection.ts b/packages/ui/src/hooks/usePwaDetection.ts new file mode 100644 index 00000000..efcc375e --- /dev/null +++ b/packages/ui/src/hooks/usePwaDetection.ts @@ -0,0 +1,57 @@ +import React from 'react'; +import { getPWADisplayMode, type PWADisplayMode } from '@/lib/pwa'; + +type PwaDetectionState = { + displayMode: PWADisplayMode; + installed: boolean; + browserTab: boolean; +}; + +const getState = (): PwaDetectionState => { + const displayMode = getPWADisplayMode(); + return { + displayMode, + installed: displayMode !== 'browser', + browserTab: displayMode === 'browser', + }; +}; + +export const usePwaDetection = (): PwaDetectionState => { + const [state, setState] = React.useState(() => getState()); + + React.useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return; + } + + const queries = [ + window.matchMedia('(display-mode: standalone)'), + window.matchMedia('(display-mode: minimal-ui)'), + window.matchMedia('(display-mode: fullscreen)'), + window.matchMedia('(display-mode: window-controls-overlay)'), + ]; + + const onChange = () => { + setState(getState()); + }; + + onChange(); + + for (const query of queries) { + query.addEventListener('change', onChange); + } + + window.addEventListener('appinstalled', onChange); + window.addEventListener('focus', onChange); + + return () => { + for (const query of queries) { + query.removeEventListener('change', onChange); + } + window.removeEventListener('appinstalled', onChange); + window.removeEventListener('focus', onChange); + }; + }, []); + + return state; +}; diff --git a/packages/ui/src/hooks/usePwaInstallPrompt.ts b/packages/ui/src/hooks/usePwaInstallPrompt.ts new file mode 100644 index 00000000..de1fbc69 --- /dev/null +++ b/packages/ui/src/hooks/usePwaInstallPrompt.ts @@ -0,0 +1,87 @@ +import React from 'react'; +import { toast } from '@/components/ui'; +import { isWebRuntime } from '@/lib/desktop'; +import { usePwaDetection } from '@/hooks/usePwaDetection'; + +type InstallPromptOutcome = 'accepted' | 'dismissed'; + +type BeforeInstallPromptEvent = Event & { + prompt: () => Promise; + userChoice: Promise<{ outcome: InstallPromptOutcome }>; +}; + +export const usePwaInstallPrompt = () => { + const { browserTab } = usePwaDetection(); + + React.useEffect(() => { + if (typeof window === 'undefined' || !isWebRuntime() || !browserTab) { + return; + } + + let deferredPrompt: BeforeInstallPromptEvent | null = null; + let installToastId: string | number | null = null; + + const dismissInstallToast = () => { + if (installToastId === null) { + return; + } + toast.dismiss(installToastId); + installToastId = null; + }; + + const triggerInstall = async () => { + if (!deferredPrompt) { + return; + } + + const promptEvent = deferredPrompt; + deferredPrompt = null; + dismissInstallToast(); + + await promptEvent.prompt(); + const { outcome } = await promptEvent.userChoice; + if (outcome === 'accepted') { + toast.success('Install started'); + } + }; + + const onBeforeInstallPrompt = (event: Event) => { + const installEvent = event as BeforeInstallPromptEvent; + if (typeof installEvent.prompt !== 'function') { + return; + } + + installEvent.preventDefault(); + deferredPrompt = installEvent; + + if (installToastId !== null) { + return; + } + + installToastId = toast.info('Install OpenChamber for quicker access', { + duration: Infinity, + action: { + label: 'Install', + onClick: () => { + void triggerInstall(); + }, + }, + }); + }; + + const onAppInstalled = () => { + deferredPrompt = null; + dismissInstallToast(); + toast.success('OpenChamber installed'); + }; + + window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt as EventListener); + window.addEventListener('appinstalled', onAppInstalled); + + return () => { + dismissInstallToast(); + window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt as EventListener); + window.removeEventListener('appinstalled', onAppInstalled); + }; + }, [browserTab]); +}; diff --git a/packages/ui/src/hooks/usePwaManifestSync.ts b/packages/ui/src/hooks/usePwaManifestSync.ts new file mode 100644 index 00000000..d2770dbc --- /dev/null +++ b/packages/ui/src/hooks/usePwaManifestSync.ts @@ -0,0 +1,90 @@ +import React from 'react'; +import { useSessionStore } from '@/stores/useSessionStore'; +import { isWebRuntime } from '@/lib/desktop'; +import { PWA_RECENT_SESSIONS_STORAGE_KEY } from '@/lib/pwa'; + +type RecentSessionShortcut = { + sessionId: string; + title: string; +}; + +type ManifestSyncWindow = Window & { + __OPENCHAMBER_UPDATE_PWA_MANIFEST__?: () => void; +}; + +const MAX_RECENT_SHORTCUTS = 3; + +const normalizeRecentTitle = (value: string | undefined, fallback: string): string => { + if (typeof value !== 'string') { + return fallback; + } + const normalized = value.trim().replace(/\s+/g, ' '); + if (!normalized) { + return fallback; + } + return normalized.slice(0, 48); +}; + +const buildRecentShortcuts = ( + sessions: Array<{ id: string; title?: string }>, + currentSessionId: string | null, +): RecentSessionShortcut[] => { + const ordered = currentSessionId + ? [ + ...sessions.filter((session) => session.id === currentSessionId), + ...sessions.filter((session) => session.id !== currentSessionId), + ] + : sessions; + + const shortcuts: RecentSessionShortcut[] = []; + const seen = new Set(); + + for (const session of ordered) { + const sessionId = typeof session.id === 'string' ? session.id.trim() : ''; + if (!sessionId || seen.has(sessionId)) { + continue; + } + + seen.add(sessionId); + shortcuts.push({ + sessionId, + title: normalizeRecentTitle(session.title, `Session ${shortcuts.length + 1}`), + }); + + if (shortcuts.length >= MAX_RECENT_SHORTCUTS) { + break; + } + } + + return shortcuts; +}; + +export const usePwaManifestSync = () => { + const sessions = useSessionStore((state) => state.sessions); + const currentSessionId = useSessionStore((state) => state.currentSessionId); + + const recentShortcuts = React.useMemo(() => { + return buildRecentShortcuts(sessions, currentSessionId); + }, [currentSessionId, sessions]); + + const signature = React.useMemo(() => JSON.stringify(recentShortcuts), [recentShortcuts]); + + React.useEffect(() => { + if (typeof window === 'undefined' || !isWebRuntime()) { + return; + } + + try { + if (recentShortcuts.length === 0) { + localStorage.removeItem(PWA_RECENT_SESSIONS_STORAGE_KEY); + } else { + localStorage.setItem(PWA_RECENT_SESSIONS_STORAGE_KEY, signature); + } + } catch { + return; + } + + const win = window as ManifestSyncWindow; + win.__OPENCHAMBER_UPDATE_PWA_MANIFEST__?.(); + }, [recentShortcuts, signature]); +}; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 0cf40886..77b4cf8a 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -537,6 +537,7 @@ export interface SettingsPayload { openInAppId?: string; gitProviderId?: string; gitModelId?: string; + pwaAppName?: string; [key: string]: unknown; } diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 83834a1d..d60cec6e 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -110,6 +110,7 @@ export type DesktopSettings = { zenModel?: string; gitProviderId?: string; gitModelId?: string; + pwaAppName?: string; toolCallExpansion?: 'collapsed' | 'activity' | 'detailed'; userMessageRenderingMode?: 'markdown' | 'plain'; stickyUserHeader?: boolean; diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 9e15fafc..ebfc7c24 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -74,6 +74,14 @@ const persistToLocalStorage = (settings: DesktopSettings) => { if (typeof settings.openInAppId === 'string' && settings.openInAppId.length > 0) { localStorage.setItem('openInAppId', settings.openInAppId); } + if (typeof settings.pwaAppName === 'string') { + const normalized = settings.pwaAppName.trim().replace(/\s+/g, ' ').slice(0, 64); + if (normalized.length > 0) { + localStorage.setItem('openchamber.pwaName', normalized); + } else { + localStorage.removeItem('openchamber.pwaName'); + } + } }; type PersistApi = { @@ -784,6 +792,10 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.openInAppId === 'string' && candidate.openInAppId.length > 0) { result.openInAppId = candidate.openInAppId; } + if (typeof candidate.pwaAppName === 'string') { + const normalized = candidate.pwaAppName.trim().replace(/\s+/g, ' ').slice(0, 64); + result.pwaAppName = normalized.length > 0 ? normalized : ''; + } if (typeof candidate.messageLimit === 'number' && Number.isFinite(candidate.messageLimit)) { result.messageLimit = candidate.messageLimit; diff --git a/packages/ui/src/lib/pwa.ts b/packages/ui/src/lib/pwa.ts new file mode 100644 index 00000000..4be9c352 --- /dev/null +++ b/packages/ui/src/lib/pwa.ts @@ -0,0 +1,41 @@ +export type PWADisplayMode = + | 'browser' + | 'standalone' + | 'minimal-ui' + | 'fullscreen' + | 'window-controls-overlay' + | 'twa'; + +const DISPLAY_MODES: Array> = ['standalone', 'minimal-ui', 'fullscreen', 'window-controls-overlay']; + +export const PWA_INSTALL_NAME_STORAGE_KEY = 'openchamber.pwaName'; +export const PWA_RECENT_SESSIONS_STORAGE_KEY = 'openchamber.pwaRecentSessions'; + +const matchesDisplayMode = (mode: Exclude): boolean => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return false; + } + return window.matchMedia(`(display-mode: ${mode})`).matches; +}; + +export const getPWADisplayMode = (): PWADisplayMode => { + if (typeof window === 'undefined') { + return 'browser'; + } + + if (typeof document !== 'undefined' && document.referrer.startsWith('android-app://')) { + return 'twa'; + } + + const navigatorStandalone = Boolean((window.navigator as Navigator & { standalone?: boolean }).standalone); + if (navigatorStandalone) { + return 'standalone'; + } + + const matched = DISPLAY_MODES.find((mode) => matchesDisplayMode(mode)); + return matched ?? 'browser'; +}; + +export const isInstalledPWARuntime = (): boolean => { + return getPWADisplayMode() !== 'browser'; +}; diff --git a/packages/ui/src/lib/settings/metadata.ts b/packages/ui/src/lib/settings/metadata.ts index ddd60b19..b1bb50fa 100644 --- a/packages/ui/src/lib/settings/metadata.ts +++ b/packages/ui/src/lib/settings/metadata.ts @@ -143,7 +143,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [ title: 'Appearance', group: 'appearance', kind: 'single', - keywords: ['theme', 'font', 'spacing', 'padding', 'corner radius', 'radius', 'input bar', 'terminal'], + keywords: ['theme', 'font', 'spacing', 'padding', 'corner radius', 'radius', 'input bar', 'terminal', 'pwa', 'install name', 'app shortcuts'], }, { slug: 'chat', diff --git a/packages/web/index.html b/packages/web/index.html index 92126e44..1493439d 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -21,39 +21,275 @@ - +