feat(mobile): put the session screen's nine text inputs on the web font seam (OTA phase C, C7.2)

The landed text-input census, run over `app/h/[hostId]/session/[worktreeId].tsx`,
reports nine sizes that do not come from `TEXT_INPUT_FONT_SIZE`. Six declare the
app's body size and move in place, which is the same number natively. Three do
not — a 22px key-capture field and the chat's two 15px fields — so each gets a
`.web.ts` sibling of the address bar's shape, with a shared base so the two
halves can differ in nothing but the size.

The capture field is the one the move shrinks rather than raises: 22 already
clears the focus-zoom floor, and the census reads the seam as a binding rather
than as a number, so there is no expression that keeps 22 and still says where
the size came from.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-20 05:56:04 -04:00
parent 96c1dd8b70
commit cad2bb128c
22 changed files with 603 additions and 49 deletions
@@ -0,0 +1,81 @@
/**
* What the session screen may reach for a URL, and whose clipboard it writes.
*
* Inside the shell's WebView react-native-web's `Linking.openURL` calls
* `window.open(url, '_blank', 'noopener')`, which both shells refuse — iOS returns nil from
* `createWebViewWith`, Android false from `onCreateWindow` — and resolves whether or not anything
* opened. A call site left on that path reports success into a tap that did nothing, which is the
* one failure the `externalLink` grant exists to remove.
*
* This screen's openers are a terminal link tap whose open mode is the phone's browser, and the two
* WebView-backed readers it reaches through the file and Markdown panels, each of which sends a
* tapped link to the system browser rather than navigating the artifact away.
*
* The rule, not the three call sites it happens to have today: a module entering this closure later
* is held to it without anyone remembering to add it here.
*/
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import {
EXTERNAL_LINK_SEAM as SEAM,
externalLinkOffenders
} from './mobile-web-app-external-link-seam.mjs'
const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url))
const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip
const SESSION = 'app/h/[hostId]/session/[worktreeId].tsx'
/** The clipboard seam, as the web build resolves it. */
const CLIPBOARD_SEAM = 'src/platform/clipboard.web.ts'
describeClosure(
'the session screen closure',
() => {
it('opens every external URL through the platform seam', async () => {
const closure = await mobileWebAppRouteClosure(SESSION)
expect(externalLinkOffenders(mobileDir, closure)).toEqual([])
})
it('contains the seam, so the rule above is not vacuous', async () => {
// Without this an empty offender list would also be what a closure that reaches no link code
// at all produces, and the census would pass against a page that opens nothing.
const closure = await mobileWebAppRouteClosure(SESSION)
expect(closure.local).toContain(SEAM)
expect(closure.local.length).toBeGreaterThan(900)
})
},
240_000
)
/**
* The screen does not touch the clipboard through the browser's own.
*
* `expo-clipboard` resolves to `ExpoClipboard.web.js`, which is `navigator.clipboard`: it needs a
* secure context, and the iOS shell serves the page from a custom scheme while Android serves
* `https`, so that path works on one platform and silently not on the other. This screen is the
* heaviest clipboard user in the app — a quick command's body, a diff note, a Markdown document, a
* terminal selection, a structured send prompt, and the terminal's own paste — so all of it goes
* through the seam and none of it through the browser.
*
* Asserted as the module's absence from the closure rather than as a count of importers: a new
* import anywhere in the tree puts the file back, whoever writes it and whatever they name it.
*/
describeClosure(
'the clipboard the session screen reaches',
() => {
it("does not carry expo-clipboard's web module at all", async () => {
const closure = await mobileWebAppRouteClosure(SESSION)
expect(closure.modules.filter((file) => file.endsWith('ExpoClipboard.web.js'))).toEqual([])
})
it('carries the seam that replaced it, so the absence above is not vacuous', async () => {
// An empty list is also what a closure reaching no clipboard code at all would produce.
const closure = await mobileWebAppRouteClosure(SESSION)
expect(closure.local).toContain(CLIPBOARD_SEAM)
})
},
240_000
)
@@ -0,0 +1,79 @@
/**
* Every text input the session screen reaches, and the size it declares.
*
* iOS zooms the page on focus of any input under 16px and does not zoom back out, so the document
* spends the rest of that typing session at a scale other than 1 — which `keyboard-occlusion.web.ts`
* reads as "not a keyboard" on purpose, because geometry cannot separate a zoom from a keyboard.
*
* This closure is the one that cannot afford it. The terminal's own input is a hidden field the
* keyboard seam's geometry is the whole basis of, and this screen reaches nine inputs that declare
* a size — a custom-key capture field, a prompt modal, the chat's ask/composer/question fields, the
* quick-command editor and its search, and the terminal command bar. One of them left off the seam
* leaves every later focus on this screen measuring a zoomed document.
*
* The rule is the closure rather than the nine sites it happens to have today: a module entering it
* later is held to it without anyone remembering to add it here.
*/
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import {
TEXT_INPUT_FONT_SIZE_SEAM,
textInputFontSizeOffenders,
unresolvedTextInputStyles
} from './mobile-web-app-text-input-font-size-seam.mjs'
const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url))
const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip
const SESSION = 'app/h/[hostId]/session/[worktreeId].tsx'
/**
* The two style modules this screen splits, as the page bundle resolves them.
*
* Named rather than left to the offender list because a split is the one fix that can be undone
* without reopening the offence: delete the `.web.ts` and the native sibling's size is what the
* page runs, which is a 15px chat composer and a zoomed document, and the offender list would say
* so — but only the next time someone reads it. Listed here, the closure says which file the page
* loads.
*/
const SPLIT_WEB_STYLES = [
'src/components/custom-key-input-styles.web.ts',
'src/session/mobile-native-chat-input-styles.web.ts'
]
describeClosure(
'the text inputs the session screen reaches',
() => {
it('takes every input size through the seam', async () => {
const closure = await mobileWebAppRouteClosure(SESSION)
expect(textInputFontSizeOffenders(mobileDir, closure)).toEqual([])
})
it('reads every input it found, so the list above is complete', async () => {
// The completeness half: an empty offender list is evidence only if every input was read.
const closure = await mobileWebAppRouteClosure(SESSION)
expect(unresolvedTextInputStyles(mobileDir, closure)).toEqual([])
})
it('carries the seam, so the rule is not vacuous', async () => {
// Without this an empty offender list would also be what a closure reaching no text input at
// all produces, and the census would pass against a page that has nothing to raise.
const closure = await mobileWebAppRouteClosure(SESSION)
expect(closure.local).toContain(TEXT_INPUT_FONT_SIZE_SEAM)
expect(closure.local.length).toBeGreaterThan(900)
})
it('loads the web half of both split style modules, not the native one', async () => {
const closure = await mobileWebAppRouteClosure(SESSION)
expect(closure.local).toEqual(expect.arrayContaining(SPLIT_WEB_STYLES))
expect(
closure.local.filter((file) =>
SPLIT_WEB_STYLES.some((web) => file === web.replace('.web.ts', '.ts'))
)
).toEqual([])
})
},
240_000
)
+2 -14
View File
@@ -1,5 +1,6 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
export const customKeyModalStyles = StyleSheet.create({
header: {
@@ -153,19 +154,6 @@ export const customKeyModalStyles = StyleSheet.create({
chipGlyphSelected: {
color: 'rgba(10,10,10,0.5)'
},
keyInput: {
width: '100%',
height: 56,
borderRadius: 10,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: 22,
fontWeight: '600',
textAlign: 'center'
},
moreLink: {
paddingVertical: spacing.sm,
alignItems: 'center'
@@ -240,7 +228,7 @@ export const customKeyModalStyles = StyleSheet.create({
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: 14,
fontSize: TEXT_INPUT_FONT_SIZE,
fontFamily: typography.monoFamily,
borderWidth: 1,
borderColor: colors.borderSubtle
+2 -1
View File
@@ -12,6 +12,7 @@ import {
type TerminalShortcutSpecialKey
} from '../terminal/terminal-accessory-keys'
import { customKeyModalStyles as styles } from './CustomKeyModal.styles'
import { customKeyInputStyles } from './custom-key-input-styles'
const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys'
@@ -293,7 +294,7 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
<View style={styles.section}>
<Text style={styles.sectionLabel}>Key</Text>
<TextInput
style={styles.keyInput}
style={customKeyInputStyles.keyInput}
value={shortcutKey.length === 1 ? shortcutKey.toUpperCase() : ''}
onChangeText={handleShortcutKeyInput}
placeholder={SPECIAL_KEY_BY_ID[shortcutKey]?.label ?? 'C'}
+2 -1
View File
@@ -9,6 +9,7 @@ import {
type KeyboardTypeOptions
} from 'react-native'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
import { BottomDrawer } from './BottomDrawer'
type Props = {
@@ -133,7 +134,7 @@ const styles = StyleSheet.create({
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
fontSize: typography.bodySize,
fontSize: TEXT_INPUT_FONT_SIZE,
borderWidth: 1,
borderColor: colors.borderSubtle
},
@@ -0,0 +1,21 @@
import { colors, typography } from '../theme/mobile-theme'
/**
* The custom-key capture field, minus the one thing that is a platform answer.
*
* Shared because a `.web.ts` cannot import a value from the file it shadows, and two copies of a
* style object is how the two platforms drift apart on everything except the difference that was
* meant to be between them.
*/
export const customKeyInputBase = {
width: '100%',
height: 56,
borderRadius: 10,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontWeight: '600',
textAlign: 'center'
} as const
@@ -0,0 +1,15 @@
import { StyleSheet } from 'react-native'
import { customKeyInputBase } from './custom-key-input-base-styles'
/**
* Native: the single character this field captures is shown large, which is what it has rendered
* at since the modal existed.
*
* The `.web.ts` sibling puts it on the text-input seam instead. That is a reduction rather than
* the raise every other input in this closure gets — 22 is already clear of the focus-zoom floor —
* and it is the price of the seam being a binding rule rather than a number: a size the census
* cannot follow to the seam module is one nobody can tell from a 14 that was left behind.
*/
export const customKeyInputStyles = StyleSheet.create({
keyInput: { ...customKeyInputBase, fontSize: 22 }
})
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from 'vitest'
// StyleSheet.create is identity in React Native and on RN Web alike, and every other export of the
// module reaches the native runtime this test does not have.
vi.mock('react-native', () => ({
StyleSheet: { create: (styles: unknown) => styles }
}))
// The seam as the page bundle resolves it. Without this the `.web.ts` style below would read the
// native seam and the test would pass on a size that no browser ever renders.
vi.mock(
'../platform/text-input-font-size',
async () => await import('../platform/text-input-font-size.web')
)
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
import { colors, typography } from '../theme/mobile-theme'
import { customKeyInputBase } from './custom-key-input-base-styles'
import { customKeyInputStyles } from './custom-key-input-styles'
import { customKeyInputStyles as customKeyInputStylesOnWeb } from './custom-key-input-styles.web'
/** Below this an iOS browser zooms the page when an input takes focus, and does not zoom back. */
const IOS_FOCUS_ZOOM_FLOOR = 16
/** Every property the capture field carried before it was split, read off the commit that split it. */
const BEFORE_THE_SPLIT = {
width: '100%',
height: 56,
borderRadius: 10,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: 22,
fontWeight: '600',
textAlign: 'center'
}
describe('the custom-key capture field natively', () => {
it('renders exactly what it rendered before the split, property for property', () => {
expect(customKeyInputStyles.keyInput).toEqual(BEFORE_THE_SPLIT)
// Key for key as well as value for value: `toEqual` would pass over an extra undefined.
expect(Object.keys(customKeyInputStyles.keyInput).sort()).toEqual(
Object.keys(BEFORE_THE_SPLIT).sort()
)
})
})
describe('the custom-key capture field on the web', () => {
it('takes its size from the seam, which clears the focus-zoom floor', () => {
expect(customKeyInputStylesOnWeb.keyInput.fontSize).toBe(TEXT_INPUT_FONT_SIZE)
expect(customKeyInputStylesOnWeb.keyInput.fontSize).toBeGreaterThanOrEqual(IOS_FOCUS_ZOOM_FLOOR)
})
/**
* The one input on this screen the seam lowers rather than raises, recorded rather than implied.
*
* 22 already clears the floor, so this move buys nothing for the keyboard seam; what it buys is
* that the census reads every size on the screen as a binding to one module. Written as a
* comparison rather than as the number 16, so a theme that raised the body size past 22 would
* make this fail and be read rather than silently reverse the direction.
*/
it('is the one field the move shrinks, and says so', () => {
expect(customKeyInputStylesOnWeb.keyInput.fontSize).toBeLessThan(BEFORE_THE_SPLIT.fontSize)
})
// The split is one value, not a second style: everything the siblings do not differ on comes from
// the same object, so a padding or a colour cannot drift between the platforms.
it('differs from the native style in nothing but the size', () => {
expect(customKeyInputBase).not.toHaveProperty('fontSize')
expect(customKeyInputStyles.keyInput).toMatchObject(customKeyInputBase)
expect(customKeyInputStylesOnWeb.keyInput).toMatchObject(customKeyInputBase)
expect(Object.keys(customKeyInputStylesOnWeb.keyInput).sort()).toEqual(
Object.keys(customKeyInputStyles.keyInput).sort()
)
})
})
@@ -0,0 +1,17 @@
import { StyleSheet } from 'react-native'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
import { customKeyInputBase } from './custom-key-input-base-styles'
/**
* Web sibling: the capture field goes on the text-input seam.
*
* Every other input on this screen is raised by that move; this one is lowered, from 22 to the
* seam's 16. Both sizes clear the floor below which iOS zooms the page on focus, so nothing about
* the keyboard seam turns on which of them renders — what turns on it is that the census reads the
* seam as a binding and not as a number, so there is no expression that keeps 22 here and still
* says where the size came from. A 56px box holding one capitalised character carries 16 legibly,
* and the alternative is a per-site exemption the next 14px input would inherit.
*/
export const customKeyInputStyles = StyleSheet.create({
keyInput: { ...customKeyInputBase, fontSize: TEXT_INPUT_FONT_SIZE }
})
@@ -59,3 +59,47 @@ export function callsRouteHandoff(source: ts.SourceFile): boolean {
ts.forEachChild(source, visit)
return found
}
/**
* Every value name a module imports from expo-router, so a domain can say which ones it allows.
*
* The two landed censuses answer "no value import at all", which is the right rule for a domain
* whose only reach into expo-router is a router. The session domain's is not: eight of its hooks
* take `useFocusEffect` and two take `useLocalSearchParams`, neither of which can navigate, and a
* blanket rule there would have to be turned off rather than narrowed.
*
* Names rather than a boolean for `useRouter`, because the hazard is the category and not the one
* spelling of it: `import { router }` is expo-router's module singleton and navigates from anywhere,
* and a rule written against `useRouter` alone would have read it as clean.
*
* The imported name, not the local one: `import { useRouter as useAppRouter }` is the same import.
*/
export function expoRouterValueImports(source: ts.SourceFile): string[] {
const names = new Set<string>()
for (const statement of source.statements) {
if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly === true) {
continue
}
const specifier = statement.moduleSpecifier
if (!ts.isStringLiteral(specifier) || specifier.text !== 'expo-router') {
continue
}
const bindings = statement.importClause?.namedBindings
if (bindings !== undefined && ts.isNamedImports(bindings)) {
for (const element of bindings.elements) {
if (element.isTypeOnly) {
continue
}
names.add((element.propertyName ?? element.name).text)
}
}
// A default or namespace import hands the whole module over under one name, router included.
if (statement.importClause?.name !== undefined) {
names.add('default')
}
if (bindings !== undefined && ts.isNamespaceImport(bindings)) {
names.add('*')
}
}
return [...names].sort()
}
@@ -15,12 +15,14 @@ import { browserAddressFieldStyles } from '../browser/browser-address-field-styl
import { mobileBrowserPaneStyles } from '../browser/mobile-browser-pane-styles'
import { listStyles } from '../source-control/mobile-source-control-list-styles'
import { mobileDiffReviewControlStyles } from '../components/mobile-diff-review-control-styles'
import { customKeyModalStyles } from '../components/CustomKeyModal.styles'
import { mobileSessionCommandInputStyles } from '../session/mobile-session-command-input-styles'
import { typography } from '../theme/mobile-theme'
import { TEXT_INPUT_FONT_SIZE } from './text-input-font-size'
import { TEXT_INPUT_FONT_SIZE as WEB_TEXT_INPUT_FONT_SIZE } from './text-input-font-size.web'
/**
* The size the two page-served text inputs carry, on each platform.
* The size every page-served text input carries, on each platform.
*
* Both halves are asserted from here because a node test resolves the native sibling, so the web
* value cannot be read off the style object: the bundler is what swaps the module, and that swap
@@ -31,7 +33,15 @@ const MOBILE_ROOT = join(import.meta.dirname, '..', '..')
const STYLE_MODULES = [
'src/source-control/mobile-source-control-list-styles.ts',
'src/components/mobile-diff-review-control-styles.ts',
'src/browser/mobile-browser-pane-styles.ts'
'src/browser/mobile-browser-pane-styles.ts',
// The session screen's six, which declare the app's body size and so need no sibling: the
// native seam is that size, so the move is the same number and the swap is the whole change.
'src/components/CustomKeyModal.styles.ts',
'src/components/TextInputModal.tsx',
'src/session/MobileNativeChatAsk.tsx',
'src/session/QuickCommandEditorForm.tsx',
'src/session/QuickCommandsList.tsx',
'src/session/mobile-session-command-input-styles.ts'
]
/**
@@ -41,7 +51,13 @@ const STYLE_MODULES = [
* seam's value on both platforms the way the four above do. Its web half is where the raise lives,
* and that is the file that has to carry the binding.
*/
const SPLIT_STYLE_MODULES = ['src/browser/browser-address-field-styles.web.ts']
const SPLIT_STYLE_MODULES = [
'src/browser/browser-address-field-styles.web.ts',
// The session screen's two: a capture field at 22 and the chat's two fields at 15, none of
// which is the body size, so each keeps its own native sibling.
'src/components/custom-key-input-styles.web.ts',
'src/session/mobile-native-chat-input-styles.web.ts'
]
/** The seam's export, so the source check below looks for a binding rather than for a mention. */
const SEAM_EXPORT_NAME = 'TEXT_INPUT_FONT_SIZE'
@@ -58,6 +74,8 @@ describe('the font size the page-served text inputs carry', () => {
expect(listStyles.commitInput.fontSize).toBe(typography.bodySize)
expect(mobileDiffReviewControlStyles.composerInput.fontSize).toBe(typography.bodySize)
expect(mobileBrowserPaneStyles.keyboardInput.fontSize).toBe(typography.bodySize)
expect(customKeyModalStyles.fieldInput.fontSize).toBe(typography.bodySize)
expect(mobileSessionCommandInputStyles.textInput.fontSize).toBe(typography.bodySize)
// The pane's address bar is the one that is split: it keeps the compact size natively, so the
// seam reaches it through the `.web.ts` sibling rather than through this constant.
expect(browserAddressFieldStyles.input.fontSize).toBe(typography.metaSize)
+2 -1
View File
@@ -3,6 +3,7 @@ import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-
import { Check } from 'lucide-react-native'
import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
type Props = {
prompt: AskPrompt
@@ -331,7 +332,7 @@ const styles = StyleSheet.create({
borderColor: colors.borderSubtle,
borderRadius: radii.card,
color: colors.textPrimary,
fontSize: typography.bodySize,
fontSize: TEXT_INPUT_FONT_SIZE,
padding: spacing.sm,
minHeight: 44,
marginBottom: spacing.xs
@@ -30,6 +30,7 @@ import {
type MobileNativeChatSessionOptionPickersProps
} from './MobileNativeChatSessionOptionPickers'
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
import { mobileNativeChatInputStyles } from './mobile-native-chat-input-styles'
const NO_FILE_PATHS: string[] = []
const NO_ATTACHMENTS: PendingNativeChatImage[] = []
@@ -248,7 +249,7 @@ export function MobileNativeChatComposer({
<View style={styles.composerInset} testID="native-chat-composer-inset">
<View style={styles.bar} testID="native-chat-composer">
<TextInput
style={styles.input}
style={mobileNativeChatInputStyles.input}
value={value}
onChangeText={handleChange}
// Controlled only transiently right after an autocomplete insert.
@@ -397,18 +398,6 @@ const styles = StyleSheet.create({
actionSpacer: {
flex: 1
},
input: {
width: '100%',
maxHeight: 140,
minHeight: 40,
color: colors.textPrimary,
fontSize: typography.bodySize + 1,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
paddingBottom: spacing.sm
},
iconButton: {
width: 40,
height: 40,
@@ -2,6 +2,7 @@ import { useMemo, useRef, useState } from 'react'
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
import { ArrowUp, Check, CircleHelp, X } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { mobileNativeChatInputStyles } from './mobile-native-chat-input-styles'
import {
formatQuestionAnswerByIndexes,
formatQuestionAnswerWithOtherByIndexes,
@@ -175,7 +176,7 @@ export function MobileNativeChatQuestion({
{allowOther ? (
<View style={styles.freeTextRow}>
<TextInput
style={styles.freeInput}
style={mobileNativeChatInputStyles.freeInput}
value={freeText}
onChangeText={setFreeText}
placeholder={hasOptions ? 'Or type a reply…' : 'Type your reply…'}
@@ -303,18 +304,6 @@ const styles = StyleSheet.create({
alignItems: 'flex-end',
gap: spacing.sm
},
freeInput: {
flex: 1,
minHeight: 40,
maxHeight: 120,
color: colors.textPrimary,
fontSize: typography.bodySize + 1,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
paddingBottom: spacing.sm
},
freeSend: {
width: 40,
height: 40,
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native'
import { ChevronDown, ChevronRight } from 'lucide-react-native'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
import { MobileAgentIcon } from '../components/MobileAgentIcon'
import {
getQuickCommandAgentLabel,
@@ -250,7 +251,7 @@ const styles = StyleSheet.create({
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
fontSize: 14,
fontSize: TEXT_INPUT_FONT_SIZE,
borderWidth: 1,
borderColor: colors.borderSubtle
},
+2 -1
View File
@@ -1,6 +1,7 @@
import { View, Text, Pressable, TextInput, StyleSheet, ActivityIndicator } from 'react-native'
import { Check, Plus, Search } from 'lucide-react-native'
import { colors, spacing } from '../theme/mobile-theme'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
import { MobileAgentIcon } from '../components/MobileAgentIcon'
import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog'
import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types'
@@ -207,7 +208,7 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm
},
searchInput: { flex: 1, color: colors.textPrimary, fontSize: 14, padding: 0 },
searchInput: { flex: 1, color: colors.textPrimary, fontSize: TEXT_INPUT_FONT_SIZE, padding: 0 },
error: { color: colors.statusRed, fontSize: 13, paddingHorizontal: spacing.xs },
loading: { paddingVertical: spacing.lg },
empty: {
@@ -0,0 +1,24 @@
import { colors, radii, spacing } from '../theme/mobile-theme'
/**
* The chat's two free-text fields, minus the one thing that is a platform answer.
*
* The composer spans its row and grows to 140; the question's field shares the row with a send
* button and stops at 120. Everything else about them is the same and is here, because a `.web.ts`
* cannot import a value from the file it shadows and two copies of a style object drift apart on
* everything except the difference that was meant to be between them.
*/
const chatInputSurface = {
minHeight: 40,
color: colors.textPrimary,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
paddingBottom: spacing.sm
} as const
export const mobileNativeChatInputBase = {
input: { ...chatInputSurface, width: '100%', maxHeight: 140 },
freeInput: { ...chatInputSurface, flex: 1, maxHeight: 120 }
} as const
@@ -0,0 +1,17 @@
import { StyleSheet } from 'react-native'
import { typography } from '../theme/mobile-theme'
import { mobileNativeChatInputBase } from './mobile-native-chat-input-base-styles'
/**
* Native: both chat fields sit one point above the body size, which is what they have rendered at.
*
* The `.web.ts` sibling raises them to the text-input seam, because 15 is under the size below
* which iOS zooms the page on focus — and this screen is the one that cannot afford that zoom, the
* terminal's keyboard lift being pure geometry on a document it assumes is at scale 1.
*/
const CHAT_INPUT_FONT_SIZE = typography.bodySize + 1
export const mobileNativeChatInputStyles = StyleSheet.create({
input: { ...mobileNativeChatInputBase.input, fontSize: CHAT_INPUT_FONT_SIZE },
freeInput: { ...mobileNativeChatInputBase.freeInput, fontSize: CHAT_INPUT_FONT_SIZE }
})
@@ -0,0 +1,95 @@
import { describe, expect, it, vi } from 'vitest'
// StyleSheet.create is identity in React Native and on RN Web alike, and every other export of the
// module reaches the native runtime this test does not have.
vi.mock('react-native', () => ({
StyleSheet: { create: (styles: unknown) => styles }
}))
// The seam as the page bundle resolves it. Without this the `.web.ts` styles below would read the
// native seam and the test would pass on a size that no browser ever renders.
vi.mock(
'../platform/text-input-font-size',
async () => await import('../platform/text-input-font-size.web')
)
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { mobileNativeChatInputBase } from './mobile-native-chat-input-base-styles'
import { mobileNativeChatInputStyles } from './mobile-native-chat-input-styles'
import { mobileNativeChatInputStyles as onWeb } from './mobile-native-chat-input-styles.web'
/** Below this an iOS browser zooms the page when an input takes focus, and does not zoom back. */
const IOS_FOCUS_ZOOM_FLOOR = 16
/** Every property the two fields carried before the split, read off the commit that split them. */
const BEFORE_THE_SPLIT = {
input: {
width: '100%',
maxHeight: 140,
minHeight: 40,
color: colors.textPrimary,
fontSize: typography.bodySize + 1,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
paddingBottom: spacing.sm
},
freeInput: {
flex: 1,
minHeight: 40,
maxHeight: 120,
color: colors.textPrimary,
fontSize: typography.bodySize + 1,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
paddingBottom: spacing.sm
}
} as const
const KEYS = ['input', 'freeInput'] as const
describe('the chat composer and question fields natively', () => {
it.each(KEYS)('renders exactly what it rendered before the split: %s', (key) => {
expect(mobileNativeChatInputStyles[key]).toEqual(BEFORE_THE_SPLIT[key])
// Key for key as well as value for value: `toEqual` would pass over an extra undefined.
expect(Object.keys(mobileNativeChatInputStyles[key]).sort()).toEqual(
Object.keys(BEFORE_THE_SPLIT[key]).sort()
)
})
it('sits one point under the floor, which is why the split exists', () => {
// The premise, not a restatement: if the body size ever rose to 15 this whole pair collapses
// into an in-place move and someone should be told rather than left maintaining three files.
expect(BEFORE_THE_SPLIT.input.fontSize).toBeLessThan(IOS_FOCUS_ZOOM_FLOOR)
})
})
describe('the chat composer and question fields on the web', () => {
it.each(KEYS)('takes its size from the seam, clear of the focus-zoom floor: %s', (key) => {
expect(onWeb[key].fontSize).toBe(TEXT_INPUT_FONT_SIZE)
expect(onWeb[key].fontSize).toBeGreaterThanOrEqual(IOS_FOCUS_ZOOM_FLOOR)
expect(onWeb[key].fontSize).toBeGreaterThan(BEFORE_THE_SPLIT[key].fontSize)
})
// The split is one value, not a second style: everything the siblings do not differ on comes from
// the same object, so a padding or a colour cannot drift between the platforms.
it.each(KEYS)('differs from the native style in nothing but the size: %s', (key) => {
expect(mobileNativeChatInputBase[key]).not.toHaveProperty('fontSize')
expect(mobileNativeChatInputStyles[key]).toMatchObject(mobileNativeChatInputBase[key])
expect(onWeb[key]).toMatchObject(mobileNativeChatInputBase[key])
expect(Object.keys(onWeb[key]).sort()).toEqual(
Object.keys(mobileNativeChatInputStyles[key]).sort()
)
})
it('keeps the two fields apart where they were always apart', () => {
// A shared base is how two styles drift into one. The composer spans its row; the question's
// field shares the row with a send button, and neither shape is the other's.
expect(onWeb.input).toMatchObject({ width: '100%', maxHeight: 140 })
expect(onWeb.freeInput).toMatchObject({ flex: 1, maxHeight: 120 })
})
})
@@ -0,0 +1,17 @@
import { StyleSheet } from 'react-native'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
import { mobileNativeChatInputBase } from './mobile-native-chat-input-base-styles'
/**
* Web sibling: both chat fields go on the text-input seam, one point up from the 15 they carry
* natively and clear of the floor below which iOS zooms the page on focus.
*
* The zoom is not cosmetic on this screen. `keyboard-occlusion.web.ts` reads a visual viewport
* scale other than 1 as "not a keyboard" and answers 0, so one focus of the composer would leave
* the terminal's own keyboard lift at 0 for the rest of the session — and the terminal's input is
* a hidden field whose only feedback that it is focused is the lift.
*/
export const mobileNativeChatInputStyles = StyleSheet.create({
input: { ...mobileNativeChatInputBase.input, fontSize: TEXT_INPUT_FONT_SIZE },
freeInput: { ...mobileNativeChatInputBase.freeInput, fontSize: TEXT_INPUT_FONT_SIZE }
})
@@ -1,6 +1,7 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
export const mobileSessionCommandInputStyles = StyleSheet.create({
createWarningBanner: {
@@ -157,7 +158,7 @@ export const mobileSessionCommandInputStyles = StyleSheet.create({
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: 0,
fontSize: 14,
fontSize: TEXT_INPUT_FONT_SIZE,
fontFamily: typography.monoFamily,
marginRight: spacing.sm
},
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import {
callsRouteHandoff,
expoRouterValueImports,
parse,
productFiles
} from '../navigation/router-seam-census.test-support'
const SESSION_ROOT = import.meta.dirname
/**
* Which modules here hold a router, so the census cannot pass by seeing nothing.
*
* Three, and each for a different target. The foundation hook bounces a deleted workspace back to
* its host; the file-tap handlers push a preview route from a terminal link or a chat path; the
* notification hook consumes a pane tap by rewriting this route's own params. Only the first two
* can leave the page, which is the whole reason the third is in the list anyway — a `setParams` on
* expo-router's router and a `setParams` on the handoff's are the same call, and listing it here is
* what stops someone later giving it back its own `useRouter` because "it never navigates".
*/
const ROUTER_HOLDERS = [
'use-mobile-file-tap-handlers.ts',
'use-mobile-session-foundation.ts',
'use-notification-pane-navigation.ts'
]
/**
* The expo-router names this domain may still import, and why each one is not a router.
*
* `useFocusEffect` reads whether this screen is the focused one in the document's own stack and
* `useLocalSearchParams` reads the params of the route already mounted. Neither takes a target, so
* neither can put a screen in front of the page; both are the page's own router answering about the
* page's own route, which is exactly what it is for.
*
* A closed list rather than a ban on `useRouter`: the hazard is anything that navigates, and
* expo-router exports a module-singleton `router` that does it from a plain function. A rule written
* against the one spelling would have read that as clean.
*/
const NON_NAVIGATING_ROUTER_NAMES = ['useFocusEffect', 'useLocalSearchParams']
describe('the session domain reaches the router through the handoff seam', () => {
const files = productFiles(SESSION_ROOT)
it('walks the modules it is written against', () => {
expect(files).toEqual(expect.arrayContaining(ROUTER_HOLDERS))
expect(files.length).toBeGreaterThan(200)
})
it('imports nothing from expo-router that can navigate', () => {
const offenders = files
.map((name) => ({
name,
imported: expoRouterValueImports(parse(SESSION_ROOT, name)).filter(
(imported) => !NON_NAVIGATING_ROUTER_NAMES.includes(imported)
)
}))
.filter((entry) => entry.imported.length > 0)
.map((entry) => `${entry.name} (${entry.imported.join(', ')})`)
expect(offenders).toEqual([])
})
it('takes the router from useRouteHandoff at every screen that holds one', () => {
expect(files.filter((name) => callsRouteHandoff(parse(SESSION_ROOT, name))).sort()).toEqual(
[...ROUTER_HOLDERS].sort()
)
})
it('still reaches expo-router for the two names that answer about its own route', () => {
// The completeness half: the rule above also passes over a domain that imports nothing at all,
// which is what it would read as if someone moved these hooks and left the list behind.
const imported = new Set(
files.flatMap((name) => expoRouterValueImports(parse(SESSION_ROOT, name)))
)
expect([...imported].sort()).toEqual([...NON_NAVIGATING_ROUTER_NAMES].sort())
})
})