fix(mobile): refresh open terminal settings (#37772)

Fixes #33798
This commit is contained in:
Kelvin Amoaba
2026-11-03 09:40:19 +00:00
committed by GitHub
parent 2f6601da60
commit cd2f282bc3
8 changed files with 145 additions and 22 deletions
@@ -1,4 +1,4 @@
import { TERMINAL_SHORTCUT_STORAGE_KEYS } from '../page-storage-keys'
import { TERMINAL_SETTINGS_STORAGE_KEYS } from '../page-storage-keys'
import { readShellSession, type BridgeShellSession } from './bridge-client-session'
import { bridgeRouteMoved } from './bridge-route-update'
import {
@@ -110,20 +110,20 @@ export function createBridgeClientShellSession(args: {
if (held !== null && update === null) {
args.onReplaced()
}
const refreshShortcuts = update !== null && message.refreshTerminalShortcuts === true
const refreshSettings = update !== null && message.refreshTerminalShortcuts === true
const sentStorage = message.storage ?? {}
const sentOversize = message.storageOversize ?? []
const storage = refreshShortcuts ? { ...update.storage } : undefined
const storageOversize = refreshShortcuts
const storage = refreshSettings ? { ...update.storage } : undefined
const storageOversize = refreshSettings
? [
...update.storageOversize.filter(
(key) => !TERMINAL_SHORTCUT_STORAGE_KEYS.some((item) => item === key)
(key) => !TERMINAL_SETTINGS_STORAGE_KEYS.some((item) => item === key)
),
...TERMINAL_SHORTCUT_STORAGE_KEYS.filter((key) => sentOversize.includes(key))
...TERMINAL_SETTINGS_STORAGE_KEYS.filter((key) => sentOversize.includes(key))
]
: update?.storageOversize
if (refreshShortcuts && storage) {
for (const key of TERMINAL_SHORTCUT_STORAGE_KEYS) {
if (refreshSettings && storage) {
for (const key of TERMINAL_SETTINGS_STORAGE_KEYS) {
if (sentStorage[key] === undefined) {
delete storage[key]
} else {
@@ -182,7 +182,7 @@ export function createBridgeClientShellSession(args: {
listener()
}
readyListeners.clear()
if (refreshShortcuts) {
if (refreshSettings) {
for (const listener of storageListeners) {
listener()
}
@@ -1,5 +1,5 @@
import {
TERMINAL_SHORTCUT_STORAGE_KEYS,
TERMINAL_SETTINGS_STORAGE_KEYS,
isHostWorkspaceStorageKey,
isPageStorageKeyForRoute,
PAGE_STORAGE_MAX_VALUE_CHARS
@@ -59,13 +59,13 @@ export function publishPageStorage(
readWorkspaceKey = forWorkspaceKeyReader
}
/** Apply native shortcut settings without replacing page-owned drafts or pending journals. */
export function refreshPageTerminalShortcuts(
/** Apply native terminal settings without replacing page-owned drafts or pending journals. */
export function refreshPageTerminalSettings(
entries: Readonly<Record<string, string>>,
oversize: readonly string[]
): void {
const nextOversize = new Set(oversizeKeys)
for (const key of TERMINAL_SHORTCUT_STORAGE_KEYS) {
for (const key of TERMINAL_SETTINGS_STORAGE_KEYS) {
const value = entries[key]
if (value === undefined) {
values.delete(key)
@@ -21,6 +21,13 @@ export const TERMINAL_SHORTCUT_STORAGE_KEYS = [
'orca:custom-accessory-keys'
] as const
export const TERMINAL_SETTINGS_STORAGE_KEYS = [
...TERMINAL_SHORTCUT_STORAGE_KEYS,
'orca:terminalTextScale',
'orca:terminalAutocompleteEnabled',
'orca:terminalLinkOpenMode'
] as const
export const PAGE_STORAGE_EXACT_KEYS = [
'orca:last-visited-worktree',
'orca:hostSidebarWidth',
@@ -10,7 +10,9 @@ import {
retainMirroredStorage,
writeMirroredStorage
} from '../storage/mirrored-storage-keys'
import { saveTerminalLinkOpenMode } from '../storage/preferences'
import { TERMINAL_SHORTCUT_STORAGE_KEYS } from './page-storage-keys'
import { createFakeBridgePortPair } from './bridge/bridge-port-pair-test-harness'
const store = vi.hoisted(() => ({
values: new Map<string, string>(),
@@ -154,3 +156,97 @@ it.each(TERMINAL_SHORTCUT_STORAGE_KEYS)(
releaseKeys()
}
)
it('publishes the last queued browser-link save after native return hydrated the earlier value', async () => {
const key = 'orca:terminalLinkOpenMode'
focus.active = false
store.values.set(key, 'orca-browser')
const releaseKeys = retainMirroredStorage([key])
const refresh = () => hydrateMirroredStorage([key])
let page = 'orca-browser'
const publish = () => {
page = readMirroredStorage([key])[key]!
}
function Harness() {
useShellPageStorageRefresh(refresh, publish)
return null
}
let renderer!: ReturnType<typeof create>
await act(async () => {
renderer = create(createElement(Harness))
})
const first = saveTerminalLinkOpenMode('phone-browser')
const last = saveTerminalLinkOpenMode('orca-browser')
await act(async () => {
await Promise.resolve()
store.pending.shift()!()
await first
focus.active = true
renderer.update(createElement(Harness))
})
expect(page).toBe('phone-browser')
await act(async () => {
store.pending.shift()!()
await last
})
expect(store.values.get(key)).toBe('orca-browser')
expect(page).toBe('orca-browser')
act(() => renderer.unmount())
releaseKeys()
})
it('republishes an accepted page zoom after an older native snapshot crosses the pinch', async () => {
const key = 'orca:terminalTextScale'
store.values.set(key, '1')
const releaseKeys = retainMirroredStorage([key])
const storage = { [key]: '1' }
let hold = false
const held: string[] = []
const pair = createFakeBridgePortPair({
route: { pathname: '/h/host-a/session/wt-1' },
storage,
holdToPage: (json) => {
if (hold) {
held.push(json)
}
return hold
},
onStorageWrite: writeMirroredStorage
})
await pair.flush()
let displayedScale = 1
pair.client.onStorageUpdate(() => {
displayedScale = Number(pair.client.getShellSession()!.storage[key])
})
const refresh = () => hydrateMirroredStorage([key])
const publish = () => {
Object.assign(storage, readMirroredStorage([key]))
pair.host.publishStorage()
}
function Harness() {
useShellPageStorageRefresh(refresh, publish)
return null
}
let renderer!: ReturnType<typeof create>
await act(async () => {
renderer = create(createElement(Harness))
await pair.flush()
})
hold = true
pair.host.publishStorage()
displayedScale = 1.5
expect(pair.client.notifyStorageWrite(key, '1.5')).toBe(true)
await act(async () => pair.flush())
pair.deliverToPage(held.shift()!)
expect(displayedScale).toBe(1)
for (const frame of held) {
pair.deliverToPage(frame)
}
await act(async () => store.pending.shift()!())
expect(store.values.get(key)).toBe('1.5')
expect(displayedScale).toBe(1.5)
act(() => renderer.unmount())
pair.client.close()
pair.host.dispose()
releaseKeys()
})
@@ -2,7 +2,7 @@ import { useCallback } from 'react'
import { AppState } from 'react-native'
import { useFocusEffect } from 'expo-router'
import { subscribeMirroredKey } from '../storage/mirrored-storage-keys'
import { TERMINAL_SHORTCUT_STORAGE_KEYS } from './page-storage-keys'
import { TERMINAL_SETTINGS_STORAGE_KEYS } from './page-storage-keys'
export function useShellPageStorageRefresh(refresh: () => Promise<unknown>, publish: () => void) {
useFocusEffect(
@@ -18,9 +18,9 @@ export function useShellPageStorageRefresh(refresh: () => Promise<unknown>, publ
(error: unknown) => console.warn('[web-shell] could not refresh page settings', error)
)
}
const stopWatching = TERMINAL_SHORTCUT_STORAGE_KEYS.map((key) =>
const stopWatching = TERMINAL_SETTINGS_STORAGE_KEYS.map((key) =>
subscribeMirroredKey(key, (_value, source) => {
if (focused && source === 'persisted') {
if (focused && (source === 'persisted' || key === 'orca:terminalTextScale')) {
publish()
}
})
@@ -3,7 +3,7 @@ import { createElement, useEffect } from 'react'
import { createFakeBridgePortPair } from '../mobile-web-shell/bridge/bridge-port-pair-test-harness'
import pageAsyncStorage, {
publishPageStorage,
refreshPageTerminalShortcuts
refreshPageTerminalSettings
} from '../mobile-web-shell/bridge/page-async-storage'
import type { BridgeRpcClient } from '../mobile-web-shell/bridge/bridge-rpc-client'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
@@ -421,7 +421,7 @@ it('finishes custom-key hydration when side-rail geometry arrives during the rea
expect(scope.setVisibleBuiltInIds.mock.calls.at(-1)![0]).not.toContain('arrowUp')
})
it('refreshes a mounted terminal from native shortcut settings without router focus, remount or draft writes', async () => {
it('refreshes mounted terminal settings without router focus, remount or draft writes', async () => {
const layoutKey = 'orca:terminal-accessory-layout'
const customKey = 'orca:custom-accessory-keys'
const draftKey = 'orca:terminalDrafts:host-a:wt-1'
@@ -436,6 +436,9 @@ it('refreshes a mounted terminal from native shortcut settings without router fo
const storage: Record<string, string> = {
[layoutKey]: initial,
[customKey]: '[]',
'orca:terminalTextScale': '1',
'orca:terminalAutocompleteEnabled': 'false',
'orca:terminalLinkOpenMode': 'orca-browser',
[draftKey]: 'old draft'
}
const pair = createFakeBridgePortPair({ route: { pathname: '/h/host-a/session/wt-1' }, storage })
@@ -449,15 +452,17 @@ it('refreshes a mounted terminal from native shortcut settings without router fo
)
pair.client.onStorageUpdate(() => {
const session = pair.client.getShellSession()!
refreshPageTerminalShortcuts(session.storage, session.storageOversize)
refreshPageTerminalSettings(session.storage, session.storageOversize)
})
vi.mocked(AsyncStorage.getItem).mockImplementation(pageAsyncStorage.getItem)
const scope = sessionScope()
let ready = false
let mounts = 0
function Harness() {
useEffect(() => {
mounts += 1
}, [])
ready = useMobileSessionPreferenceFocus(scope).terminalPreferencesReady
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lifecycle reads only the supplied session members.
useMobileSessionLifecycle(scope as never)
return null
@@ -469,12 +474,19 @@ it('refreshes a mounted terminal from native shortcut settings without router fo
await pageAsyncStorage.setItem(draftKey, 'unsent page draft')
await pair.flush()
const writes = pair.storageWrites.length
storage['orca:terminalTextScale'] = '1.5'
storage['orca:terminalAutocompleteEnabled'] = 'true'
storage['orca:terminalLinkOpenMode'] = 'phone-browser'
storage[layoutKey] = layout(['enter', 'arrowUp'], false)
storage[customKey] = JSON.stringify([{ id: 'two', label: 'Two', bytes: '2', enter: false }])
await act(async () => {
pair.host.publishStorage()
await pair.flush()
})
expect(scope.setTerminalTextScale).toHaveBeenLastCalledWith(1.5)
expect(scope.setAutocompleteEnabled).toHaveBeenLastCalledWith(true)
expect(scope.setTerminalLinkOpenMode).toHaveBeenLastCalledWith('phone-browser')
expect(ready).toBe(true)
expect(scope.setDirectionPadEnabled).toHaveBeenLastCalledWith(false)
expect(scope.setVisibleBuiltInIds).toHaveBeenLastCalledWith(['enter', 'arrowUp'])
expect(scope.setCustomKeys).toHaveBeenLastCalledWith([
@@ -502,11 +514,17 @@ it('refreshes a mounted terminal from native shortcut settings without router fo
delete storage[layoutKey]
delete storage[customKey]
delete storage['orca:terminalTextScale']
delete storage['orca:terminalAutocompleteEnabled']
delete storage['orca:terminalLinkOpenMode']
const afterLocalWrite = pair.storageWrites.length
await act(async () => {
pair.host.publishStorage()
await pair.flush()
})
expect(scope.setTerminalTextScale).toHaveBeenLastCalledWith(1)
expect(scope.setAutocompleteEnabled).toHaveBeenLastCalledWith(false)
expect(scope.setTerminalLinkOpenMode).toHaveBeenLastCalledWith('orca-browser')
expect(await pageAsyncStorage.getItem(layoutKey)).toBeNull()
expect(scope.setDirectionPadEnabled).toHaveBeenLastCalledWith(true)
expect(scope.setCustomKeys).toHaveBeenLastCalledWith([])
@@ -1,3 +1,4 @@
import { useShellStorage } from '../platform/shell-storage'
import { settleSessionPreferenceRead } from './session-preference-read'
import { useCallback, useState } from 'react'
import { useFocusEffect } from 'expo-router'
@@ -23,6 +24,7 @@ export function useMobileSessionPreferenceFocus(
setAutocompleteEnabled,
setTerminalLinkOpenMode
} = scope
const shellStorage = useShellStorage()
const [terminalPreferencesReady, setTerminalPreferencesReady] = useState(false)
// Reload on Settings return without dropping readiness or remounting terminal panes.
useFocusEffect(
@@ -51,7 +53,7 @@ export function useMobileSessionPreferenceFocus(
return () => {
active = false
}
}, [])
}, [shellStorage])
)
return { terminalPreferencesReady }
}
+2 -2
View File
@@ -18,7 +18,7 @@ import {
import { storageReadResultSchema } from '../src/mobile-web-shell/bridge/bridge-native-verbs'
import {
publishPageStorage,
refreshPageTerminalShortcuts
refreshPageTerminalSettings
} from '../src/mobile-web-shell/bridge/page-async-storage'
import {
RouteScreenPaintProvider,
@@ -162,7 +162,7 @@ bootstrapShellPage({
client.onStorageUpdate(() => {
const current = client.getShellSession()
if (current !== null) {
refreshPageTerminalShortcuts(current.storage, current.storageOversize)
refreshPageTerminalSettings(current.storage, current.storageOversize)
}
})
// Activation settles to English on failure, so the page always mounts.