From e4feda6965a1506d1f894d26c34989acb7b817a3 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 3 Sep 2026 03:29:37 -0400 Subject: [PATCH] fix(mobile): plain-language hybrid shell states and a single primary action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hybrid shell spoke in implementation terms — "Verified desktop-served interface", "Preparing verified interface…", "Connect to this desktop once to cache its verified workspace UI." — and rendered four equal recovery buttons inside a grey box. Every user-facing string is now one plain sentence, carried as a MobileWebShellNotice so the stable failure code lives on a small "Error: " support line instead of inside the copy. The header drops its subtitle, the download state collapses to one line plus the bar, and recovery promotes Retry to the app's primary button style with the rest demoted to text links; the grey container is gone. Recovery labels move to Use last version / Reset and carry stable testIDs so e2e no longer depends on visible copy. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../mobile-hybrid-webview-architecture.md | 13 +-- .../mobile-hybrid-webview-rollback.md | 26 +++-- mobile/app/hybrid.tsx | 8 +- .../MobileWebHybridShellPresentation.tsx | 63 +++++++----- .../mobile-web/MobileWebPackageProgress.tsx | 10 +- .../mobile-web/MobileWebRecoveryActions.tsx | 97 ++++++++++++++----- mobile/src/mobile-web/hybrid-shell-styles.ts | 77 ++++++++------- .../mobile-web-package-refresh-warning.ts | 26 +++-- .../mobile-web-package-session-state.ts | 5 +- .../mobile-web-rollback-runbook.test.ts | 4 +- ...ile-web-shell-accessibility-source.test.ts | 60 +++++++++--- .../src/mobile-web/mobile-web-shell-notice.ts | 12 +++ ...se-mobile-web-navigation-intent-handoff.ts | 5 +- .../use-mobile-web-package-recovery.ts | 35 ++++--- .../use-mobile-web-package-refresh.ts | 12 +-- .../use-mobile-web-package-session.test.ts | 45 ++++----- .../use-mobile-web-package-session.ts | 21 ++-- 17 files changed, 328 insertions(+), 191 deletions(-) create mode 100644 mobile/src/mobile-web/mobile-web-shell-notice.ts diff --git a/docs/reference/mobile-hybrid-webview-architecture.md b/docs/reference/mobile-hybrid-webview-architecture.md index 1c2731d61a7..ce853837833 100644 --- a/docs/reference/mobile-hybrid-webview-architecture.md +++ b/docs/reference/mobile-hybrid-webview-architecture.md @@ -210,9 +210,10 @@ rather than reused. Repeated WebView process loss or a health timeout can promote the compatible verified previous generation. The recovery UI exposes: -- **Retry** — request the current package from the authenticated Desktop. -- **Use previous** — promote this host's verified prior generation. -- **Clear cache** — remove this host's package cache and require a verified +- **Retry** — request the current package from the authenticated Desktop. This + is the only primary button; the rest render as demoted text links. +- **Use last version** — promote this host's verified prior generation. +- **Reset** — remove this host's package cache and require a verified redownload. - **Switch hosts** — leave the affected host without changing another host's cache or credentials. @@ -302,11 +303,11 @@ result persists after reconnect, restart, or switching hosts. | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | | No paired Desktop | Confirm Desktop is running and pairing completed | Scan a new QR code; never copy the encoded credential into a ticket | | Desktop unreachable | Check LAN/Tailscale state, firewall port 6768, sleep state, and Connection Log | Reconnect, wake Desktop, or switch hosts | -| “Connect once to cache” | No compatible verified generation exists for that host | Connect to the paired Desktop and choose **Retry** | +| “Connect to … to finish setting up.” | No compatible verified generation exists for that host | Connect to the paired Desktop and choose **Retry** | | Refresh warning while UI still works | Healthy cached generation is active but the new package failed | Keep using the cache; correct Desktop delivery, then **Retry** | | Interface fails after one Desktop update | Regression follows the Desktop package | Stop that Desktop rollout and use the rollback runbook | -| Repeated WebView termination | Health/crash-loop recovery should select a compatible previous generation | Choose **Use previous** if offered; otherwise switch hosts | -| Corrupt or unreadable host cache | Package fails native verification or open | Choose **Clear cache**, then redownload from the authenticated Desktop | +| Repeated WebView termination | Health/crash-loop recovery should select a compatible previous generation | Choose **Use last version** if offered; otherwise switch hosts | +| Corrupt or unreadable host cache | Package fails native verification or open | Choose **Reset**, then redownload from the authenticated Desktop | | Incompatible bridge | Desktop package does not support the installed shell | Restore a compatible Desktop package or install the required store release | | Pairing, origin, bridge, picker, audio, notification, or recovery failure | Native-owned boundary is affected | Halt the native rollout and ship a corrected store build | diff --git a/docs/reference/mobile-hybrid-webview-rollback.md b/docs/reference/mobile-hybrid-webview-rollback.md index b542abc73db..dcb2a099699 100644 --- a/docs/reference/mobile-hybrid-webview-rollback.md +++ b/docs/reference/mobile-hybrid-webview-rollback.md @@ -34,7 +34,7 @@ boundaries used by this runbook. | ------------------------------------------------------------------------- | ----------------------------- | --------------------------------------------------------------------------- | | Workspace UI regression follows one Desktop version | Desktop web package | Stop that Desktop rollout and restore known-good package content | | New package times out or repeatedly terminates its WebView process | Native verified generation | Let automatic recovery promote the previous verified generation | -| One host reports corrupt or unreadable cached assets | Host-scoped native cache | Use **Clear cache** and redownload from an authenticated paired Desktop | +| One host reports corrupt or unreadable cached assets | Host-scoped native cache | Use **Reset** and redownload from an authenticated paired Desktop | | Package requires an unsupported bridge version | Desktop/native compatibility | Restore a package compatible with the installed shell; do not force-open it | | Pairing, encrypted connectivity, asset origin, cache, or bridge is broken | Native shell | Halt the store rollout and prepare a corrected native release | | Notification, deep-link, permission, audio, picker, or recovery UI fails | Native shell | Halt the store rollout and prepare a corrected native release | @@ -81,9 +81,9 @@ restarting must not make an affected Desktop safe. manifest and assets and the page reaches the health boundary. - A health timeout or third WebView process loss inside the crash window attempts to promote the compatible verified previous generation. -- **Use previous** promotes the verified previous generation and removes the - failed generation from the rollback position. -- **Clear cache** closes the current package session, removes only the selected +- **Use last version** promotes the verified previous generation and removes + the failed generation from the rollback position. +- **Reset** closes the current package session, removes only the selected paired host's cache, and requires a verified redownload. - An implicit cold open may replace an invalid active generation with a compatible verified previous generation. An explicit build open fails @@ -134,17 +134,23 @@ or native recovery UI. Use the least destructive control that addresses the observed failure: +The recovery UI promotes **Retry** as the single primary button and demotes the +remaining controls to text links, so support must name them by these labels: + - **Retry** asks the authenticated Desktop for its current package again. Use it after connectivity or Desktop package delivery is corrected. -- **Use previous** switches to the verified prior generation for the selected - host. Use it for a newly activated functional regression or repeated process - failure when the previous action is available. -- **Clear cache** removes the selected host's verified generations and forces a +- **Use last version** switches to the verified prior generation for the + selected host. Use it for a newly activated functional regression or repeated + process failure when the previous action is available. +- **Reset** removes the selected host's verified generations and forces a redownload. Use it for host-scoped corruption or when support explicitly needs to eliminate cached-package state. - **Switch hosts** leaves the affected paired Desktop without changing another host's cache or credentials. +The shell shows a plain-language notice plus an `Error: ` support line; +that code is the same stable failure code recorded in diagnostics. + Cache clearing is not a Desktop rollback. If the Desktop still serves the bad package, a cleared client downloads the same bad package again. @@ -173,8 +179,8 @@ rather than a routine rollback. Before production cutover, record iOS and Android evidence for: 1. Readiness timeout and three-process-loss automatic recovery. -2. Manual **Use previous** recovery. -3. **Clear cache** followed by an authenticated verified redownload. +2. Manual **Use last version** recovery. +3. **Reset** followed by an authenticated verified redownload. 4. Corrupt active generation with compatible previous-generation fallback. 5. Incompatible bridge, disconnected Desktop, pairing removal, and WebView process loss. diff --git a/mobile/app/hybrid.tsx b/mobile/app/hybrid.tsx index 3e0a348cf66..37f7fc8989d 100644 --- a/mobile/app/hybrid.tsx +++ b/mobile/app/hybrid.tsx @@ -124,7 +124,7 @@ export default function HybridScreen() { if (sessionId && view) { void view.activateSessionView(sessionId).catch(() => { if (activeSessionIdRef.current === sessionId) { - showWarning('Hosted session could not be restored.') + showWarning('Couldn’t reopen Orca. Try again.', 'session_reopen_failed') } }) } @@ -320,7 +320,7 @@ export default function HybridScreen() { routeHandoff: nativeRouteHandoffRef.current, setHostedViewActive, navigateToTerminalSettings: () => router.push('/terminal-settings'), - onNavigationFailure: () => showWarning('Terminal settings could not be opened.') + onNavigationFailure: () => showWarning('Couldn’t open Terminal settings.') }) } } @@ -377,14 +377,14 @@ export default function HybridScreen() { onUsePrevious={recoverPrevious} onClearCache={clearCache} onRecoveryFailure={() => - showWarning('The workspace interface recovery action could not be completed.') + showWarning('That didn’t work. Try again.', 'recovery_action_failed') } onBridgeMessage={(message) => void handleBridgeMessage(message)} onPageLoaded={() => { hardwareBackHandoff.resetPage() void postInit() }} - onNavigationBlocked={() => showWarning('Navigation outside Orca was blocked.')} + onNavigationBlocked={() => showWarning('That link can’t be opened here.')} onProcessTerminated={(sessionId) => { hardwareBackHandoff.resetPage() healthDeadlineRef.current.clear() diff --git a/mobile/src/mobile-web/MobileWebHybridShellPresentation.tsx b/mobile/src/mobile-web/MobileWebHybridShellPresentation.tsx index 183ad149ad5..d9c1daca23e 100644 --- a/mobile/src/mobile-web/MobileWebHybridShellPresentation.tsx +++ b/mobile/src/mobile-web/MobileWebHybridShellPresentation.tsx @@ -17,6 +17,7 @@ import { mobileWebShellShowsNativeChrome } from './mobile-web-shell-presentation-state' import type { MobileWebPackageDownloadProgress } from './mobile-web-package-downloader' +import { mobileWebShellHostName, type MobileWebShellNotice } from './mobile-web-shell-notice' type MobileWebHybridShellPresentationProps = { viewRef: RefObject @@ -25,7 +26,7 @@ type MobileWebHybridShellPresentationProps = { viewEpoch: number packageLoading: boolean packageProgress: MobileWebPackageDownloadProgress | undefined - packageWarning: string | undefined + packageWarning: MobileWebShellNotice | undefined hostedViewActive: boolean onBack: () => void onShowHosts: () => void @@ -66,6 +67,14 @@ export function MobileWebHybridShellPresentation({ packageLoading }) const showNativeChrome = mobileWebShellShowsNativeChrome(presentationState) + // The notice already reads as a full sentence, so it replaces the generic status line. + const statusLine = + packageWarning?.message ?? + (presentationState === 'package-loading' + ? selectedHost + ? `Connecting to ${mobileWebShellHostName(selectedHost.name)}…` + : 'Getting things ready…' + : `Couldn’t connect to ${mobileWebShellHostName(selectedHost?.name)}.`) return ( @@ -82,9 +91,8 @@ export function MobileWebHybridShellPresentation({ - {selectedHost?.name ?? 'Hybrid workspace UI'} + {selectedHost?.name ?? 'Orca'} - Verified desktop-served interface {selectedHost ? ( ) : null} {packageWarning ? ( - <> - - {packageWarning} + + + {packageWarning.message} + {packageWarning.code ? ( + Error: {packageWarning.code} + ) : null} - + ) : null} )} - - {presentationState === 'package-loading' - ? 'Preparing verified interface…' - : 'Workspace interface unavailable'} - {packageLoading && packageProgress ? ( + ) : ( + + {statusLine} + + )} + {packageWarning?.code ? ( + Error: {packageWarning.code} ) : null} {packageWarning ? ( - <> - - {packageWarning} - - - + ) : null} )} diff --git a/mobile/src/mobile-web/MobileWebPackageProgress.tsx b/mobile/src/mobile-web/MobileWebPackageProgress.tsx index c8d14814f70..6c79271eca0 100644 --- a/mobile/src/mobile-web/MobileWebPackageProgress.tsx +++ b/mobile/src/mobile-web/MobileWebPackageProgress.tsx @@ -13,14 +13,13 @@ export function MobileWebPackageProgress({ : 0 const label = progress.phase === 'downloading' - ? `Downloading workspace interface… ${percent}%` - : progress.phase === 'verifying' - ? 'Verifying workspace interface…' - : 'Starting workspace interface…' + ? `Downloading… ${formatBytes(progress.completedBytes)} of ${formatBytes(progress.totalBytes)}` + : 'Almost ready…' return ( - - {formatBytes(progress.completedBytes)} of {formatBytes(progress.totalBytes)} - ) } diff --git a/mobile/src/mobile-web/MobileWebRecoveryActions.tsx b/mobile/src/mobile-web/MobileWebRecoveryActions.tsx index c0ad728a169..311c6ffb004 100644 --- a/mobile/src/mobile-web/MobileWebRecoveryActions.tsx +++ b/mobile/src/mobile-web/MobileWebRecoveryActions.tsx @@ -7,6 +7,7 @@ type RecoveryAction = 'retry' | 'previous' | 'clear' | 'hosts' type MobileWebRecoveryActionsProps = { canUsePrevious: boolean + align?: 'center' | 'start' onRetry: () => void | Promise onUsePrevious: () => void | Promise onClearCache: () => void | Promise @@ -16,6 +17,7 @@ type MobileWebRecoveryActionsProps = { export function MobileWebRecoveryActions({ canUsePrevious, + align = 'center', onRetry, onUsePrevious, onClearCache, @@ -48,37 +50,80 @@ export function MobileWebRecoveryActions({ } } - const actions = [ - { id: 'retry' as const, label: 'Retry', operation: onRetry }, + // Retry is the only action most people need; the rest stay reachable as demoted links. + const secondaryActions = [ ...(canUsePrevious - ? [{ id: 'previous' as const, label: 'Use previous', operation: onUsePrevious }] + ? [ + { + id: 'previous' as const, + label: 'Use last version', + testID: 'mobile-web-recovery-previous', + operation: onUsePrevious + } + ] : []), - { id: 'clear' as const, label: 'Clear cache', operation: onClearCache }, - { id: 'hosts' as const, label: 'Switch hosts', operation: onShowHosts } + { + id: 'clear' as const, + label: 'Reset', + testID: 'mobile-web-recovery-reset', + operation: onClearCache + }, + { + id: 'hosts' as const, + label: 'Switch hosts', + testID: 'mobile-web-recovery-hosts', + operation: onShowHosts + } ] + const busy = Boolean(busyAction) return ( - - {actions.map((action) => ( - [ - styles.recoveryButton, - pressed && styles.recoveryButtonPressed, - busyAction && styles.recoveryButtonDisabled - ]} - onPress={() => void run(action.id, action.operation)} - > - {busyAction === action.id && showBusy ? ( - - ) : null} - {action.label} - - ))} + + [ + styles.recoveryPrimaryButton, + pressed && styles.recoveryPressed, + busy && styles.recoveryDisabled + ]} + onPress={() => void run('retry', onRetry)} + > + {busyAction === 'retry' && showBusy ? ( + + ) : null} + Retry + + + {secondaryActions.map((action) => ( + [ + styles.recoveryLink, + pressed && styles.recoveryPressed, + busy && styles.recoveryDisabled + ]} + onPress={() => void run(action.id, action.operation)} + > + {busyAction === action.id && showBusy ? ( + + ) : null} + {action.label} + + ))} + ) } diff --git a/mobile/src/mobile-web/hybrid-shell-styles.ts b/mobile/src/mobile-web/hybrid-shell-styles.ts index 3549434d269..ea939eafaab 100644 --- a/mobile/src/mobile-web/hybrid-shell-styles.ts +++ b/mobile/src/mobile-web/hybrid-shell-styles.ts @@ -15,7 +15,6 @@ export const hybridShellStyles = StyleSheet.create({ headerButton: { width: 38, height: 38, alignItems: 'center', justifyContent: 'center' }, headerCopy: { flex: 1, minWidth: 0 }, heading: { color: colors.textPrimary, fontSize: 16, fontWeight: '600' }, - headerMeta: { color: colors.textMuted, fontSize: 11, marginTop: 1 }, hostsButton: { paddingHorizontal: spacing.md, paddingVertical: spacing.sm }, hostsButtonText: { color: colors.textSecondary, @@ -24,53 +23,64 @@ export const hybridShellStyles = StyleSheet.create({ }, webContainer: { flex: 1, minHeight: 0 }, webView: { flex: 1, backgroundColor: colors.bgBase }, - warning: { - color: colors.textSecondary, - backgroundColor: colors.bgRaised, - fontSize: typography.metaSize, - lineHeight: 17, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm + noticeBanner: { + gap: spacing.sm, + backgroundColor: colors.bgPanel, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md }, - recoveryActions: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: spacing.xs, - backgroundColor: colors.bgRaised, - paddingHorizontal: spacing.md, - paddingBottom: spacing.sm - }, - recoveryButton: { - minHeight: 32, + noticeBannerText: { color: colors.textPrimary, fontSize: 13, lineHeight: 18 }, + noticeCode: { color: colors.textMuted, fontSize: 11 }, + recoveryActions: { alignItems: 'center', gap: spacing.md }, + recoveryActionsStart: { alignItems: 'flex-start' }, + recoveryPrimaryButton: { + minHeight: 36, flexDirection: 'row', alignItems: 'center', - gap: spacing.xs, + justifyContent: 'center', + gap: spacing.sm, borderRadius: radii.button, - backgroundColor: colors.bgPanel, - paddingHorizontal: spacing.sm + backgroundColor: colors.textPrimary, + paddingHorizontal: spacing.xl, + paddingVertical: spacing.sm }, - recoveryButtonPressed: { backgroundColor: colors.bgBase }, - recoveryButtonDisabled: { opacity: 0.6 }, - recoveryButtonText: { - color: colors.textPrimary, + recoveryPrimaryButtonText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + }, + recoveryLinkRow: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + justifyContent: 'center', + columnGap: spacing.lg, + rowGap: spacing.sm + }, + recoveryLink: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs }, + recoveryLinkText: { + color: colors.accentBlue, fontSize: typography.metaSize, fontWeight: '600' }, + recoveryPressed: { opacity: 0.7 }, + recoveryDisabled: { opacity: 0.5 }, loadingState: { flex: 1, alignItems: 'center', justifyContent: 'center', - gap: spacing.sm, + gap: spacing.md, paddingHorizontal: spacing.xl }, - loadingTitle: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' }, - loadingBody: { - color: colors.textSecondary, - fontSize: typography.metaSize, - lineHeight: 18, + loadingTitle: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600', textAlign: 'center' }, - packageProgress: { width: '100%', maxWidth: 320, gap: spacing.xs }, + packageProgress: { width: '100%', maxWidth: 320, gap: spacing.sm }, packageProgressLabel: { color: colors.textSecondary, fontSize: typography.metaSize, @@ -86,6 +96,5 @@ export const hybridShellStyles = StyleSheet.create({ height: '100%', borderRadius: radii.button, backgroundColor: colors.textSecondary - }, - packageProgressBytes: { color: colors.textMuted, fontSize: 11, textAlign: 'center' } + } }) diff --git a/mobile/src/mobile-web/mobile-web-package-refresh-warning.ts b/mobile/src/mobile-web/mobile-web-package-refresh-warning.ts index 80fcdd9930b..6736df435e7 100644 --- a/mobile/src/mobile-web/mobile-web-package-refresh-warning.ts +++ b/mobile/src/mobile-web/mobile-web-package-refresh-warning.ts @@ -1,13 +1,23 @@ +import { mobileWebShellHostName, type MobileWebShellNotice } from './mobile-web-shell-notice' + export function mobileWebPackageRefreshWarning( failureCode: string, - hasHealthyInterface: boolean -): string { + hasHealthyInterface: boolean, + hostName?: string +): MobileWebShellNotice { + const host = mobileWebShellHostName(hostName) if (failureCode === 'incompatible_bridge') { - return hasHealthyInterface - ? 'Using the last healthy interface because the refreshed interface is not compatible with this Orca Mobile version.' - : 'This desktop’s workspace interface is not compatible with this Orca Mobile version.' + return { + message: hasHealthyInterface + ? `Update Orca Mobile to get the latest from ${host}.` + : `Update Orca Mobile to open ${host}.`, + code: failureCode + } + } + return { + message: hasHealthyInterface + ? `Couldn’t update from ${host}. Showing the last version that worked.` + : `Couldn’t load ${host}.`, + code: failureCode } - return hasHealthyInterface - ? 'Using the last healthy interface because the desktop package could not be refreshed.' - : 'The desktop did not provide a valid workspace interface.' } diff --git a/mobile/src/mobile-web/mobile-web-package-session-state.ts b/mobile/src/mobile-web/mobile-web-package-session-state.ts index 24fcf2a9f64..08a86a12ea6 100644 --- a/mobile/src/mobile-web/mobile-web-package-session-state.ts +++ b/mobile/src/mobile-web/mobile-web-package-session-state.ts @@ -1,5 +1,6 @@ import type { MobileWebShellSession } from '@orca/expo-mobile-web-shell' import type { MobileWebPackageDownloadProgress } from './mobile-web-package-downloader' +import type { MobileWebShellNotice } from './mobile-web-shell-notice' export type MobileWebPackageSession = { session: MobileWebShellSession | null @@ -7,12 +8,12 @@ export type MobileWebPackageSession = { viewEpoch: number packageLoading: boolean packageProgress: MobileWebPackageDownloadProgress | undefined - packageWarning: string | undefined + packageWarning: MobileWebShellNotice | undefined markHealthy: (sessionId: string) => Promise handleHealthTimeout: (sessionId: string) => Promise handleProcessTerminated: (sessionId: string) => Promise retryPackage: () => void recoverPrevious: () => Promise clearCache: () => Promise - showWarning: (warning: string) => void + showWarning: (message: string, code?: string) => void } diff --git a/mobile/src/mobile-web/mobile-web-rollback-runbook.test.ts b/mobile/src/mobile-web/mobile-web-rollback-runbook.test.ts index 713766b07b2..f042b25784e 100644 --- a/mobile/src/mobile-web/mobile-web-rollback-runbook.test.ts +++ b/mobile/src/mobile-web/mobile-web-rollback-runbook.test.ts @@ -18,7 +18,9 @@ describe('mobile hybrid rollback runbook', () => { }) it('documents every native recovery action by its product label', () => { - for (const label of ['Retry', 'Use previous', 'Clear cache', 'Switch hosts']) { + expect(recoveryActions).toContain('accessibilityLabel="Retry"') + expect(runbook).toContain('**Retry**') + for (const label of ['Use last version', 'Reset', 'Switch hosts']) { expect(recoveryActions).toContain(`label: '${label}'`) expect(runbook).toContain(`**${label}**`) } diff --git a/mobile/src/mobile-web/mobile-web-shell-accessibility-source.test.ts b/mobile/src/mobile-web/mobile-web-shell-accessibility-source.test.ts index ceb61705ee6..d7806d752e9 100644 --- a/mobile/src/mobile-web/mobile-web-shell-accessibility-source.test.ts +++ b/mobile/src/mobile-web/mobile-web-shell-accessibility-source.test.ts @@ -9,6 +9,10 @@ const recoveryActionsSource = readFileSync( new URL('./MobileWebRecoveryActions.tsx', import.meta.url), 'utf8' ) +const progressSource = readFileSync( + new URL('./MobileWebPackageProgress.tsx', import.meta.url), + 'utf8' +) describe('mobile web shell accessibility', () => { it('keeps shell navigation controls named and exposed as buttons', () => { @@ -20,24 +24,48 @@ describe('mobile web shell accessibility', () => { ) }) - it('announces package loading and warnings', () => { - expect(shellSource).toMatch( - /accessibilityLiveRegion="polite"[\s\S]*?Preparing verified interface…/ - ) - expect(shellSource.match(/accessibilityRole="alert"/g)).toHaveLength(2) + it('announces status and warnings', () => { + expect(shellSource).toMatch(/accessibilityLiveRegion="polite"[\s\S]*?\{statusLine\}/) + expect(shellSource).toContain("accessibilityRole={packageWarning ? 'alert' : undefined}") + expect(shellSource).toContain('accessibilityRole="alert"') + expect(progressSource).toContain('accessibilityRole="progressbar"') + expect(progressSource).toContain('accessibilityLiveRegion="polite"') }) - it('names recovery controls', () => { + it('names recovery controls and anchors them on stable test ids', () => { expect(recoveryActionsSource).toContain('accessibilityRole="toolbar"') - expect(recoveryActionsSource).toContain( - 'accessibilityLabel={`${action.label} workspace interface`}' - ) - expect(recoveryActionsSource).toContain("label: 'Retry'") - expect(recoveryActionsSource).toContain("label: 'Use previous'") - expect(recoveryActionsSource).toContain("label: 'Clear cache'") - expect(recoveryActionsSource).toContain("label: 'Switch hosts'") - expect(recoveryActionsSource).toContain( - 'accessibilityState={{ disabled: Boolean(busyAction) }}' - ) + expect(recoveryActionsSource).toContain('accessibilityLabel={action.label}') + expect(recoveryActionsSource).toContain('accessibilityState={{ disabled: busy }}') + for (const [testID, label] of [ + ['mobile-web-recovery-retry', 'Retry'], + ['mobile-web-recovery-previous', 'Use last version'], + ['mobile-web-recovery-reset', 'Reset'], + ['mobile-web-recovery-hosts', 'Switch hosts'] + ]) { + expect(recoveryActionsSource).toContain(testID) + expect(recoveryActionsSource).toContain(label) + } + }) + + it('keeps implementation vocabulary out of the shell copy', () => { + for (const source of [shellSource, recoveryActionsSource, progressSource]) { + for (const banned of [ + 'verified', + 'cache', + 'hosted session', + 'workspace interface', + 'workspace UI', + 'desktop-served' + ]) { + expect(renderedCopy(source).toLowerCase()).not.toContain(banned.toLowerCase()) + } + } }) }) + +/** Quoted literals a user can read, excluding prop names and identifiers. */ +function renderedCopy(source: string): string { + return [...source.matchAll(/'([^'\n]*)'/g), ...source.matchAll(/`([^`\n]*)`/g)] + .map((match) => match[1]) + .join('\n') +} diff --git a/mobile/src/mobile-web/mobile-web-shell-notice.ts b/mobile/src/mobile-web/mobile-web-shell-notice.ts new file mode 100644 index 00000000000..b6fa36f68b5 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-shell-notice.ts @@ -0,0 +1,12 @@ +// A shell notice is what the user reads; `code` is the stable support identifier shown +// underneath it, so product copy never has to carry an error token. +export type MobileWebShellNotice = { + message: string + code?: string +} + +const DEFAULT_HOST_NAME = 'your computer' + +export function mobileWebShellHostName(name: string | undefined): string { + return name?.trim() || DEFAULT_HOST_NAME +} diff --git a/mobile/src/mobile-web/use-mobile-web-navigation-intent-handoff.ts b/mobile/src/mobile-web/use-mobile-web-navigation-intent-handoff.ts index 0d0e08d336a..54286e343a6 100644 --- a/mobile/src/mobile-web/use-mobile-web-navigation-intent-handoff.ts +++ b/mobile/src/mobile-web/use-mobile-web-navigation-intent-handoff.ts @@ -32,7 +32,7 @@ export function useMobileWebNavigationIntentHandoff(options: { postMessage: (message: MobileWebBridgeShellMessage) => Promise rememberRoute: (route: MobileWebResumeRoute) => void onNavigationResolved?: (intent: MobileWebNavigationIntent, route: MobileWebResumeRoute) => void - showWarning: (warning: string) => void + showWarning: (message: string, code?: string) => void }): void { const [intent, setIntent] = useState(null) const activeIntent = @@ -107,7 +107,8 @@ export function useMobileWebNavigationIntentHandoff(options: { if (!cancelled && MOBILE_WEB_NAVIGATION_INTENTS.consume(activeIntent.sequence)) { setIntent(null) options.showWarning( - `${navigationIntentFailureSubject(activeIntent.source)} could not be verified (${mobileWebBridgeErrorCode(error)}).` + `${navigationIntentFailureSubject(activeIntent.source)} couldn’t be opened.`, + mobileWebBridgeErrorCode(error) ) } } diff --git a/mobile/src/mobile-web/use-mobile-web-package-recovery.ts b/mobile/src/mobile-web/use-mobile-web-package-recovery.ts index 47bb35d337f..a29f3c64a08 100644 --- a/mobile/src/mobile-web/use-mobile-web-package-recovery.ts +++ b/mobile/src/mobile-web/use-mobile-web-package-recovery.ts @@ -4,6 +4,7 @@ import type { HostProfile } from '../transport/types' import { mobileWebDiagnosticsStore } from './mobile-web-diagnostics-store' import { removeMobileWebHostCache } from './mobile-web-native-stager' import type { MobileWebProcessFailureTracker } from './mobile-web-process-failure-tracker' +import { mobileWebShellHostName, type MobileWebShellNotice } from './mobile-web-shell-notice' type PackageRecoveryState = { host: HostProfile | undefined @@ -17,7 +18,7 @@ type PackageRecoveryState = { setSessionHostId: Dispatch> setViewEpoch: Dispatch> setPackageLoading: Dispatch> - setPackageWarning: Dispatch> + setPackageWarning: Dispatch> setRefreshEpoch: Dispatch> } @@ -46,7 +47,7 @@ export function useMobileWebPackageRecovery({ setRefreshEpoch }: PackageRecoveryState): MobileWebPackageRecoveryActions { const recoverSession = useCallback( - async (sessionId: string, warning: string, failureCode: string) => { + async (sessionId: string, warning: MobileWebShellNotice, failureCode: string) => { const current = ownedSessionRef.current const hostEpoch = hostEpochRef.current const hostId = activeHostIdRef.current @@ -76,9 +77,10 @@ export function useMobileWebPackageRecovery({ ownedSessionRef.current?.sessionId === sessionId ) { setViewEpoch((value) => value + 1) - setPackageWarning( - 'The workspace view restarted; no previous healthy interface is available.' - ) + setPackageWarning({ + message: 'Orca restarted. There’s no earlier version to go back to.', + code: 'no_previous_version' + }) mobileWebDiagnosticsStore.restarted(hostId, current.buildId) } } @@ -123,7 +125,10 @@ export function useMobileWebPackageRecovery({ current?.sessionId === sessionId && current.buildId === owned.buildId ) { - setPackageWarning('The workspace interface is running but could not be marked healthy.') + setPackageWarning({ + message: 'Orca started, but couldn’t finish its checks.', + code: 'health_mark_failed' + }) const hostId = activeHostIdRef.current if (hostId) { mobileWebDiagnosticsStore.warning(hostId, 'health_mark_failed') @@ -138,7 +143,7 @@ export function useMobileWebPackageRecovery({ async (sessionId: string) => { await recoverSession( sessionId, - 'The refreshed interface did not become healthy; the previous verified version was restored.', + { message: 'The update didn’t start correctly, so the last version that worked is back.' }, 'health_timeout' ) }, @@ -153,7 +158,7 @@ export function useMobileWebPackageRecovery({ } if (!processFailuresRef.current.record(current.buildId)) { setViewEpoch((value) => value + 1) - setPackageWarning('The workspace view stopped and was restarted.') + setPackageWarning({ message: 'Orca stopped unexpectedly and restarted.' }) const hostId = activeHostIdRef.current if (hostId) { mobileWebDiagnosticsStore.restarted(hostId, current.buildId) @@ -162,7 +167,7 @@ export function useMobileWebPackageRecovery({ } await recoverSession( sessionId, - 'The workspace view stopped repeatedly; the previous verified version was restored.', + { message: 'Orca kept stopping, so the last version that worked is back.' }, 'webview_crash_loop' ) }, @@ -188,12 +193,15 @@ export function useMobileWebPackageRecovery({ const recoverPrevious = useCallback(async () => { const current = ownedSessionRef.current if (!current) { - setPackageWarning('No previous verified workspace interface is available.') + setPackageWarning({ + message: 'There’s no earlier version to go back to.', + code: 'no_previous_version' + }) return } await recoverSession( current.sessionId, - 'The previous verified workspace interface was restored.', + { message: 'Went back to the last version that worked.' }, 'manual_recovery' ) }, [ownedSessionRef, recoverSession, setPackageWarning]) @@ -222,7 +230,10 @@ export function useMobileWebPackageRecovery({ } catch { if (hostEpochRef.current === hostEpoch) { setPackageLoading(false) - setPackageWarning('The workspace interface cache could not be cleared.') + setPackageWarning({ + message: `Couldn’t reset ${mobileWebShellHostName(host.name)}. Try again.`, + code: 'reset_failed' + }) } return } diff --git a/mobile/src/mobile-web/use-mobile-web-package-refresh.ts b/mobile/src/mobile-web/use-mobile-web-package-refresh.ts index 500db6f64ce..9589ac09507 100644 --- a/mobile/src/mobile-web/use-mobile-web-package-refresh.ts +++ b/mobile/src/mobile-web/use-mobile-web-package-refresh.ts @@ -12,6 +12,7 @@ import { import { mobileWebDiagnosticsStore } from './mobile-web-diagnostics-store' import type { MobileWebCachedBuildProbe } from './mobile-web-cached-build-probe' import { mobileWebPackageRefreshWarning } from './mobile-web-package-refresh-warning' +import type { MobileWebShellNotice } from './mobile-web-shell-notice' import type { MobileWebPackageCapability } from './use-mobile-web-package-capability' type PublishSession = ( @@ -35,7 +36,7 @@ export function useMobileWebPackageRefresh(args: { publishSession: PublishSession refreshEpoch: number setPackageLoading: (loading: boolean) => void - setPackageWarning: (warning: string | undefined) => void + setPackageWarning: (warning: MobileWebShellNotice | undefined) => void setPackageProgress: (progress: MobileWebPackageDownloadProgress | undefined) => void }) { const { @@ -102,10 +103,7 @@ export function useMobileWebPackageRefresh(args: { } if (rejectedBuildIdsRef.current.has(downloaded.commit.buildId)) { mobileWebDiagnosticsStore.warning(host.id, 'rejected_build') - finishPackage( - false, - 'Using the previous verified interface until the desktop build changes.' - ) + finishPackage(false, { message: 'Showing the last version that worked.' }) return } const activationStartedAt = Date.now() @@ -127,7 +125,7 @@ export function useMobileWebPackageRefresh(args: { console.warn('[mobile-web] package refresh failed', { code: failureCode }) finishPackage( false, - mobileWebPackageRefreshWarning(failureCode, Boolean(ownedSessionRef.current)) + mobileWebPackageRefreshWarning(failureCode, Boolean(ownedSessionRef.current), host.name) ) } } @@ -148,7 +146,7 @@ export function useMobileWebPackageRefresh(args: { finishPackage(true) } - function finishPackage(success: boolean, warning?: string): void { + function finishPackage(success: boolean, warning?: MobileWebShellNotice): void { setPackageLoading(false) setPackageProgress(undefined) if (success || warning) { diff --git a/mobile/src/mobile-web/use-mobile-web-package-session.test.ts b/mobile/src/mobile-web/use-mobile-web-package-session.test.ts index 2d168bffca3..e0113e05613 100644 --- a/mobile/src/mobile-web/use-mobile-web-package-session.test.ts +++ b/mobile/src/mobile-web/use-mobile-web-package-session.test.ts @@ -224,9 +224,10 @@ describe('useMobileWebPackageSession', () => { expect(packageSession?.session).toBeNull() expect(packageSession?.packageLoading).toBe(false) - expect(packageSession?.packageWarning).toBe( - 'Update Orca on this desktop to use its workspace interface.' - ) + expect(packageSession?.packageWarning).toEqual({ + message: 'Update Orca on Desktop to continue.', + code: 'host_update_required' + }) expect(native.closeSession).toHaveBeenCalledWith(SESSION_A.sessionId) expect(downloadPackage).not.toHaveBeenCalled() expect(sendRequest).toHaveBeenCalledTimes(1) @@ -242,9 +243,10 @@ describe('useMobileWebPackageSession', () => { await mount('connected') expect(packageSession?.session).toBeNull() - expect(packageSession?.packageWarning).toBe( - 'Update Orca on this desktop to use its workspace interface.' - ) + expect(packageSession?.packageWarning).toEqual({ + message: 'Update Orca on Desktop to continue.', + code: 'host_update_required' + }) expect(native.openSession).not.toHaveBeenCalled() expect(downloadPackage).not.toHaveBeenCalled() }) @@ -468,14 +470,8 @@ describe('useMobileWebPackageSession', () => { }) it.each([ - [ - 'incompatible_bridge', - 'Using the last healthy interface because the refreshed interface is not compatible with this Orca Mobile version.' - ], - [ - 'test_failure', - 'Using the last healthy interface because the desktop package could not be refreshed.' - ] + ['incompatible_bridge', 'Update Orca Mobile to get the latest from Desktop.'], + ['test_failure', 'Couldn’t update from Desktop. Showing the last version that worked.'] ])('retains a cached session after %s package refresh failure', async (code, warning) => { native.openSession.mockResolvedValue(SESSION_A) downloadFailure.code = code @@ -484,15 +480,12 @@ describe('useMobileWebPackageSession', () => { await mount('connected') expect(packageSession?.session).toEqual(SESSION_A) - expect(packageSession?.packageWarning).toBe(warning) + expect(packageSession?.packageWarning).toEqual({ message: warning, code }) }) it.each([ - [ - 'incompatible_bridge', - 'This desktop’s workspace interface is not compatible with this Orca Mobile version.' - ], - ['test_failure', 'The desktop did not provide a valid workspace interface.'] + ['incompatible_bridge', 'Update Orca Mobile to open Desktop.'], + ['test_failure', 'Couldn’t load Desktop.'] ])('reports %s package refresh failure without a cached session', async (code, warning) => { native.openSession.mockRejectedValue(new Error('cache unavailable')) downloadFailure.code = code @@ -502,7 +495,7 @@ describe('useMobileWebPackageSession', () => { expect(packageSession?.session).toBeNull() expect(packageSession?.packageLoading).toBe(false) - expect(packageSession?.packageWarning).toBe(warning) + expect(packageSession?.packageWarning).toEqual({ message: warning, code }) }) it('remounts after isolated process loss and rolls back a crash loop', async () => { @@ -515,7 +508,9 @@ describe('useMobileWebPackageSession', () => { await packageSession?.markHealthy(SESSION_B.sessionId) }) expect(packageSession?.viewEpoch).toBe(1) - expect(packageSession?.packageWarning).toBe('The workspace view stopped and was restarted.') + expect(packageSession?.packageWarning).toEqual({ + message: 'Orca stopped unexpectedly and restarted.' + }) expect(native.recoverSession).not.toHaveBeenCalled() await act(async () => { @@ -544,9 +539,9 @@ describe('useMobileWebPackageSession', () => { expect(native.recoverSession).toHaveBeenCalledWith(SESSION_B.sessionId) expect(packageSession?.session).toEqual(SESSION_A) - expect(packageSession?.packageWarning).toBe( - 'The previous verified workspace interface was restored.' - ) + expect(packageSession?.packageWarning).toEqual({ + message: 'Went back to the last version that worked.' + }) }) it('clears only the selected host cache and downloads it again', async () => { diff --git a/mobile/src/mobile-web/use-mobile-web-package-session.ts b/mobile/src/mobile-web/use-mobile-web-package-session.ts index f13c9392505..a1c531d759d 100644 --- a/mobile/src/mobile-web/use-mobile-web-package-session.ts +++ b/mobile/src/mobile-web/use-mobile-web-package-session.ts @@ -14,10 +14,9 @@ import { useMobileWebPackageCapability } from './use-mobile-web-package-capabili import { useMobileWebPackageRecovery } from './use-mobile-web-package-recovery' import { useMobileWebPackageRefresh } from './use-mobile-web-package-refresh' import type { MobileWebPackageSession } from './mobile-web-package-session-state' +import { mobileWebShellHostName, type MobileWebShellNotice } from './mobile-web-shell-notice' export type { MobileWebPackageSession } from './mobile-web-package-session-state' -const MOBILE_WEB_PACKAGE_UPDATE_REQUIRED_WARNING = - 'Update Orca on this desktop to use its workspace interface.' export function useMobileWebPackageSession({ client, host, @@ -42,7 +41,7 @@ export function useMobileWebPackageSession({ const [viewEpoch, setViewEpoch] = useState(0) const [packageLoading, setPackageLoading] = useState(false) const [packageProgress, setPackageProgress] = useState() - const [packageWarning, setPackageWarning] = useState() + const [packageWarning, setPackageWarning] = useState() const [refreshEpoch, setRefreshEpoch] = useState(0) const packageCapability = useMobileWebPackageCapability({ client, @@ -58,7 +57,10 @@ export function useMobileWebPackageSession({ const effectivePackageWarning = packageWarning ?? (packageCapability.status === 'update-required' - ? MOBILE_WEB_PACKAGE_UPDATE_REQUIRED_WARNING + ? { + message: `Update Orca on ${mobileWebShellHostName(host?.name)} to continue.`, + code: 'host_update_required' + } : undefined) const publishSession = useCallback( @@ -152,7 +154,9 @@ export function useMobileWebPackageSession({ setPackageLoading(false) setPackageWarning( (current) => - current ?? 'Connect to this desktop once to cache its verified workspace UI.' + current ?? { + message: `Connect to ${mobileWebShellHostName(host.name)} to finish setting up.` + } ) } } @@ -200,6 +204,11 @@ export function useMobileWebPackageSession({ setPackageProgress }) + const showWarning = useCallback( + (message: string, code?: string) => setPackageWarning({ message, code }), + [] + ) + const { markHealthy, handleHealthTimeout, @@ -236,6 +245,6 @@ export function useMobileWebPackageSession({ retryPackage, recoverPrevious, clearCache, - showWarning: setPackageWarning + showWarning } }