feat(mobile): match every text input to the chat composer and rebuild the terminal dock (#36166)

* feat(mobile): give every text input the chat composer's look

Adds shared MessageBox, TextField and ComposerSendButton built from the
composer's own styles, and moves chat, form, search, Tasks, PR, review and
commit inputs onto them. The commit message becomes multi-line, every input
is 16pt (no page zoom on focus), and focus rings are gone. The terminal and
browser typing docks are unchanged.

* feat(mobile): rebuild the terminal dock around Keys and Message modes

Agent tabs open in Message mode, where you write in the chat composer and
Send types it into the terminal. Shell tabs open in Keys mode, where each
letter goes straight to the terminal through the live field. A pick made on
an agent tab lasts until the agent quits. The shell/agent rule is the one
keyboard dismissal already used, now shared.

The key row carries sticky ctrl, alt and shift (tap for the next key,
double tap to lock) and one arrow pad (tap a side, or hold and drag to
repeat through the existing held-key repeat). Paste, attachments, the
display width, the remaining keys and custom keys move to a ⋯ sheet. Return
in the composer adds a line; Send sends.

* fix(mobile): read arrow-pad taps from the pad and keep every key reachable from ⋯

A touch on a chevron made the icon the touch target, so the side was
measured against the icon's box and most taps read as left. The ⋯ sheet
now leaves out only the keys the current row shows, so agent tabs get Tab
and shell tabs get Shift+Tab and Enter back.

* fix(mobile): keep the last changed files clear of a growing commit box

The file list's bottom inset was a fixed 196 pt, so a commit message several lines long hid the last rows behind the commit bar. The inset now follows the bar's measured height.

* test(mobile): give the file list test its bottom inset
This commit is contained in:
Kelvin Amoaba
2026-10-30 04:51:47 +00:00
committed by GitHub
parent 8aac9c1254
commit 9b4c899dc8
115 changed files with 2637 additions and 2531 deletions
@@ -8,7 +8,7 @@ import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-depende
import {
BROWSER_VIEWPORT_ID,
CHAT_MIC_ID,
REPEAT_KEY_LABEL,
ARROW_PAD_LABEL,
TAP_KEY_LABEL,
TERMINAL_MIC_ID,
browserPaneProbeRouteSource,
@@ -73,7 +73,8 @@ beforeAll(async () => {
commandDockProbeRouteSource({
commandDockModule: join(sessionDir, 'MobileSessionCommandDock'),
keyDefinitionsModule: join(terminalDir, 'terminal-key-definitions'),
accessorySelectionModule: join(sessionDir, 'use-mobile-session-accessory-selection')
accessorySelectionModule: join(sessionDir, 'use-mobile-session-accessory-selection'),
dockKeysModule: join(sessionDir, 'use-terminal-dock-keys')
})
)
await writeFile(
@@ -174,8 +175,19 @@ async function holdLikeAndroidWebView(page, selector, { holdMs, read }) {
return { midHold, afterRelease: await read(target) }
}
async function beginRepeatKeyTouch(page) {
const key = await page.waitForSelector(`[aria-label="${REPEAT_KEY_LABEL}"]`)
// Lands on the pad's top side, which sends Arrow Up.
async function beginArrowPadTouch(page) {
const pad = await page.waitForSelector(`[aria-label="${ARROW_PAD_LABEL}"]`)
await pad.scrollIntoViewIfNeeded()
const box = await pad.boundingBox()
const point = { x: box.x + box.width / 2, y: box.y + box.height * 0.15 }
const input = await page.context().newCDPSession(page)
await input.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [point] })
return { input, point }
}
async function beginTapKeyTouch(page) {
const key = await page.waitForSelector(`[aria-label="${TAP_KEY_LABEL}"]`)
await key.scrollIntoViewIfNeeded()
const box = await key.boundingBox()
const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
@@ -252,72 +264,51 @@ describeRender(
})
describe('the key bar', () => {
it('repeats a held key without an extra send when the finger lifts', async () => {
it('repeats an arrow while a drag holds the pad, without an extra send when the finger lifts', async () => {
const { errors, page } = await openProbe(ROUTES.keys)
const { midHold, afterRelease } = await holdLikeAndroidWebView(
page,
`[aria-label="${REPEAT_KEY_LABEL}"]`,
{
holdMs: 1500,
read: () =>
page.evaluate(() => ({
repeats: globalThis.__orcaHeldPressProbe.repeats(),
sent: globalThis.__orcaHeldPressProbe.sent()
}))
}
)
expect(midHold).toEqual({
selectable: false,
repeats: [{ kind: 'start', bytes: '\x1b[A' }],
sent: expect.arrayContaining(['\x1b[A'])
})
expect(midHold.sent.length).toBeGreaterThan(1)
expect(afterRelease.sent).toEqual(midHold.sent)
expect(afterRelease.repeats).toEqual([{ kind: 'start', bytes: '\x1b[A' }, { kind: 'stop' }])
expect(errors).toEqual([])
await page.close()
}, 300_000)
it('sends a repeatable tap once on release without waiting for the hold delay', async () => {
const { errors, page } = await openProbe(ROUTES.keys)
const { input } = await beginRepeatKeyTouch(page)
await page.clock.runFor(100)
expect(await page.evaluate(() => globalThis.__orcaHeldPressProbe.sent())).toEqual([])
await input.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await page.clock.runFor(1)
expect(await page.evaluate(() => globalThis.__orcaHeldPressProbe.sent())).toEqual([
'\x1b[A'
])
await page.clock.runFor(1000)
expect(await page.evaluate(() => globalThis.__orcaHeldPressProbe.sent())).toEqual([
'\x1b[A'
])
expect(errors).toEqual([])
await page.close()
}, 300_000)
it('does not repeat after movement cancels long-press recognition', async () => {
const { errors, page } = await openProbe(ROUTES.keys)
const { input, point } = await beginRepeatKeyTouch(page)
await page.clock.runFor(100)
const { input, point } = await beginArrowPadTouch(page)
await input.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ ...point, y: point.y + 20 }]
touchPoints: [{ ...point, y: point.y - 20 }]
})
await page.clock.runFor(1000)
expect(await page.evaluate(() => globalThis.__orcaHeldPressProbe.sent())).toEqual([])
await page.clock.runFor(1500)
const midHold = await page.evaluate(() => ({
repeats: globalThis.__orcaHeldPressProbe.repeats(),
sent: globalThis.__orcaHeldPressProbe.sent()
}))
expect(midHold.repeats).toEqual([{ kind: 'start', bytes: '\x1b[A' }])
expect(midHold.sent.length).toBeGreaterThan(1)
expect(new Set(midHold.sent)).toEqual(new Set(['\x1b[A']))
await input.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await page.clock.runFor(1000)
expect(
await page.evaluate(() => globalThis.__orcaHeldPressProbe.sent().length)
).toBeLessThanOrEqual(1)
expect(await page.evaluate(() => globalThis.__orcaHeldPressProbe.sent())).toEqual(
midHold.sent
)
expect(await page.evaluate(() => globalThis.__orcaHeldPressProbe.repeats())).toEqual([
{ kind: 'start', bytes: '\x1b[A' },
{ kind: 'stop' }
])
expect(errors).toEqual([])
await page.close()
}, 300_000)
it('drops a prepared tap after switching terminals and accepts a new accessibility click', async () => {
it('sends a pad tap once on release', async () => {
const { errors, page } = await openProbe(ROUTES.keys)
const { input, key } = await beginRepeatKeyTouch(page)
const { input } = await beginArrowPadTouch(page)
await page.clock.runFor(100)
expect(await page.evaluate(() => globalThis.__orcaHeldPressProbe.sent())).toEqual([])
await input.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await page.clock.runFor(1000)
expect(await page.evaluate(() => globalThis.__orcaHeldPressProbe.sent())).toEqual([
'\x1b[A'
])
expect(errors).toEqual([])
await page.close()
}, 300_000)
it('drops a pad tap after switching terminals', async () => {
const { errors, page } = await openProbe(ROUTES.keys)
const { input } = await beginArrowPadTouch(page)
await page.clock.runFor(100)
await page.evaluate(() => globalThis.__orcaHeldPressProbe.switchTerminal())
await page.waitForFunction(
@@ -326,61 +317,13 @@ describeRender(
await input.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await page.clock.runFor(1000)
expect(await page.evaluate(() => globalThis.__orcaHeldPressProbe.sent())).toEqual([])
await key.evaluate((node) => node.click())
expect(await page.evaluate(() => globalThis.__orcaHeldPressProbe.deliveries())).toEqual([
{ bytes: '\x1b[A', target: 'other-terminal' }
])
expect(errors).toEqual([])
await page.close()
}, 300_000)
it.each([
[100, false],
[700, false],
[700, true]
])(
'keeps Enter activation bound to keydown terminal with hold=%sms and switch=%s',
async (holdMs, switchTerminal) => {
const { errors, page } = await openProbe(ROUTES.keys)
const key = await page.waitForSelector(`[aria-label="${REPEAT_KEY_LABEL}"]`)
await key.focus()
await page.keyboard.down('Enter')
await page.clock.runFor(100)
expect(await page.evaluate(() => globalThis.__orcaHeldPressProbe.deliveries())).toEqual([
{ bytes: '\x1b[A', target: 'held-press-terminal' }
])
if (switchTerminal) {
await page.evaluate(() => globalThis.__orcaHeldPressProbe.switchTerminal())
await page.waitForFunction(
() => globalThis.__orcaHeldPressProbe.activeHandle === 'other-terminal'
)
}
await page.clock.runFor(holdMs - 100)
const beforeRelease = await page.evaluate(() =>
globalThis.__orcaHeldPressProbe.deliveries()
)
if (holdMs === 100 || switchTerminal) {
expect(beforeRelease).toHaveLength(1)
} else {
expect(beforeRelease.length).toBeGreaterThan(1)
}
expect(beforeRelease).toEqual(
beforeRelease.map(() => ({ bytes: '\x1b[A', target: 'held-press-terminal' }))
)
await page.keyboard.up('Enter')
await page.clock.runFor(1000)
expect(errors).toEqual([])
expect(await page.evaluate(() => globalThis.__orcaHeldPressProbe.deliveries())).toEqual(
beforeRelease
)
await page.close()
},
300_000
)
it('scrolls across a repeatable key without sending it', async () => {
it('scrolls the key row across a tap key without sending it', async () => {
const { errors, page } = await openProbe(ROUTES.keys)
const { input, key, point } = await beginRepeatKeyTouch(page)
const { input, key, point } = await beginTapKeyTouch(page)
const before = await keyScrollOffset(key)
await page.clock.runFor(70)
for (let step = 1; step <= 8; step += 1) {
@@ -174,7 +174,7 @@ describeRender(
const opened = await openRoute(SESSION_ROUTE, 'Terminal')
const text = await opened.page.evaluate(() => document.body.innerText)
// Accessory-bar keys prove session screen, beyond generic header text.
for (const key of ['Esc', 'Tab', 'Ctrl+C', 'Ctrl+R']) {
for (const key of ['esc', 'ctrl', 'tab', 'alt']) {
expect(text).toContain(key)
}
expect(text).not.toContain(UNMATCHED)
@@ -8,7 +8,6 @@ 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,
textInputFontSizeFloor,
textInputFontSizeOffenders,
unresolvedTextInputStyles
@@ -19,6 +18,8 @@ const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.
const HUB = 'app/h/[hostId]/source-control/[worktreeId].tsx'
const REVIEW = 'app/h/[hostId]/review/[worktreeId].tsx'
// The pages' inputs take their size from the chat composer's shared styles.
const COMPOSER_INPUT_STYLES = 'src/components/composer-input/composer-input-styles.ts'
const SEAM_SOURCE = {
'src/platform/text-input-font-size.ts': 'export const TEXT_INPUT_FONT_SIZE = 14'
@@ -204,6 +205,26 @@ describe('the size a text input declares, as the census reads it', () => {
}
})
it('reads a same-module constant bound to a literal as that literal', () => {
const root = plant({
'src/ui/Field.tsx': [
"import { styles } from './field-styles'",
'export const Field = () => <TextInput style={styles.input} />'
].join('\n'),
'src/ui/field-styles.ts': [
'const TEXT_SIZE = 16',
'export const styles = { input: { fontSize: TEXT_SIZE } }'
].join('\n')
})
try {
expect(
textInputFontSizeOffenders(root, { local: ['src/ui/Field.tsx', 'src/ui/field-styles.ts'] })
).toEqual([])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
// Test below/at/above floor: high-only control could pass if census ignored literals; boundary catches off-by-one.
it.each([
['under the floor, which is the offence the seam exists for', 15, ['src/ui/Sized.tsx:1']],
@@ -418,11 +439,14 @@ describeClosure(
}
)
it.each([HUB, REVIEW])('carries the seam, so the rule is not vacuous: %s', async (route) => {
// Require reachable inputs so empty closure cannot pass floor census.
const closure = await mobileWebAppRouteClosure(route)
expect(closure.local).toContain(TEXT_INPUT_FONT_SIZE_SEAM)
})
it.each([HUB, REVIEW])(
'carries the composer inputs, so the rule is not vacuous: %s',
async (route) => {
// Require reachable inputs so empty closure cannot pass floor census.
const closure = await mobileWebAppRouteClosure(route)
expect(closure.local).toContain(COMPOSER_INPUT_STYLES)
}
)
},
240_000
)
@@ -333,10 +333,13 @@ function resolveStyleKey(mobileDir, file, exportName, key, seen = new Set()) {
// Numeric literals at/above floor are valid without seam binding; forcing larger fields onto seam could shrink them.
// Do not evaluate expressions: theme arithmetic can drift and census must not become second renderer.
function isLiteralAtOrAboveFloor(mobileDir, initializer) {
// A same-module const naming a literal (the composer's text size) is still a literal.
function isLiteralAtOrAboveFloor(mobileDir, parsed, initializer) {
const value = ts.isIdentifier(initializer) ? declarationOf(parsed, initializer.text) : initializer
return (
ts.isNumericLiteral(initializer) &&
Number(initializer.text) >= textInputFontSizeFloor(mobileDir)
value !== null &&
ts.isNumericLiteral(value) &&
Number(value.text) >= textInputFontSizeFloor(mobileDir)
)
}
@@ -349,7 +352,7 @@ function fontSizeIn(mobileDir, parsed, file, object) {
line: parsed.getLineAndCharacterOfPosition(entry.getStart(parsed)).line + 1,
onSeam:
isSeamBinding(mobileDir, parsed, file, entry.initializer) ||
isLiteralAtOrAboveFloor(mobileDir, entry.initializer)
isLiteralAtOrAboveFloor(mobileDir, parsed, entry.initializer)
}
}
}
@@ -63,18 +63,20 @@ export default function HoldDictationProbeRoute() {
`
}
export const REPEAT_KEY_LABEL = 'Arrow Up'
export const TAP_KEY_LABEL = 'Escape'
export const ARROW_PAD_LABEL = 'Arrow keys'
export const TAP_KEY_LABEL = 'Send Escape'
export function commandDockProbeRouteSource({
commandDockModule,
keyDefinitionsModule,
accessorySelectionModule
accessorySelectionModule,
dockKeysModule
}) {
return `import { useLayoutEffect, useRef, useState } from 'react'
import { MobileSessionCommandDock } from ${JSON.stringify(commandDockModule)}
import { TERMINAL_ACCESSORY_KEY_DEFINITIONS } from ${JSON.stringify(keyDefinitionsModule)}
import { useMobileSessionAccessorySelection } from ${JSON.stringify(accessorySelectionModule)}
import { useTerminalDockKeys } from ${JSON.stringify(dockKeysModule)}
const noop = () => {}
const KEYS = TERMINAL_ACCESSORY_KEY_DEFINITIONS
@@ -110,6 +112,9 @@ export default function CommandDockProbeRoute() {
worktreeId: 'held-press-workspace',
handleAccessoryKey: send
})
const dockKeys = useTerminalDockKeys({ activeHandle, sendInput: send })
const liveInputRef = useRef(null)
const commandInputRef = useRef(null)
globalThis.__orcaHeldPressProbe = {
sent: () => [...sentRef.current],
deliveries: () => [...deliveriesRef.current],
@@ -123,6 +128,13 @@ export default function CommandDockProbeRoute() {
autocompleteEnabled: false,
liveInputCapture: '',
activeHandle,
activeSessionTab: null,
dockKeys,
liveInputRef,
commandInputRef,
commandFieldAnchorRef: { current: null },
commandDictationPreview: null,
liveDictationText: '',
customKeys: [],
setShowCustomKeyModal: noop,
setDeleteKeyTarget: noop,
@@ -130,7 +142,6 @@ export default function CommandDockProbeRoute() {
terminalModes: new Map(),
canPaste: false,
dictationMode: 'toggle',
bindCommandField: noop,
handleLiveInputChange: noop,
handleLiveInputKeyPress: noop,
bindLiveInputField: noop,
@@ -172,7 +183,7 @@ export default function CommandDockProbeRoute() {
activeBrowserTab: null,
keyboardLift: 0
}
return <MobileSessionCommandDock controller={controller} />
return <MobileSessionCommandDock controller={controller} keysInRail={false} />
}
`
}
+3 -17
View File
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
View,
Text,
TextInput,
Pressable,
ActivityIndicator,
KeyboardAvoidingView,
@@ -24,6 +23,7 @@ import { hostOs } from '../../../src/platform/host-os'
import { SideSafeArea } from '../../../src/layout/side-safe-area'
import { translate } from '../../../src/i18n/mobile-i18n'
import { useMobileTranslation } from '../../../src/i18n/use-mobile-translation'
import { TextField } from '../../../src/components/composer-input/TextField'
// The host group leaves the Duo's status-bar rail to its pages; this native screen pads it.
export default function EditHostRoute() {
@@ -237,8 +237,7 @@ function EditHostScreen() {
</Text>
<Text style={styles.label}>{t('host.edit.name', 'Name')}</Text>
<TextInput
style={styles.input}
<TextField
accessibilityLabel={t('host.edit.name', 'Name')}
value={name}
onChangeText={(value) => {
@@ -248,15 +247,13 @@ function EditHostScreen() {
placeholder={
host.lastKnownMachineName ?? t('host.edit.hostNamePlaceholder', 'Host name')
}
placeholderTextColor={colors.textMuted}
autoCapitalize="words"
autoCorrect={false}
returnKeyType="next"
/>
<Text style={styles.label}>{t('host.edit.address', 'Address')}</Text>
<TextInput
style={styles.input}
<TextField
accessibilityLabel={t('host.edit.address', 'Address')}
value={address}
onChangeText={(value) => {
@@ -264,7 +261,6 @@ function EditHostScreen() {
setSaveError(null)
}}
placeholder="192.168.1.10:6768"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
autoComplete="off"
@@ -363,16 +359,6 @@ const styles = themedStyleSheet(() => ({
textTransform: 'uppercase',
letterSpacing: 0.4
},
input: {
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.row,
color: colors.textPrimary,
fontSize: typography.bodySize,
paddingHorizontal: spacing.md,
paddingVertical: Platform.OS === 'ios' ? 12 : 10
},
hint: {
color: colors.textMuted,
fontSize: typography.metaSize,
@@ -35,23 +35,6 @@
"startedAt": 0
}
},
"035edfd9a1b9": {
"crash": {
"$rpc": "null"
},
"elements": {
"ChevronLeft": 1,
"Pressable": 5,
"RefreshCw": 1,
"SafeAreaView": 1,
"SectionList": 1,
"Text": 5,
"TextInput": 1,
"View": 5
},
"labels": ["Back", "Refresh agent sessions"],
"text": ["Agent Session History", "orca-history", "Workspace", "Project", "All"]
},
"0ddf5d4952fd": {
"name": "aiVault.listSessions#1",
"ordinal": 8,
@@ -270,6 +253,24 @@
"startedAt": 0
}
},
"9a4b6a2428eb": {
"crash": {
"$rpc": "null"
},
"elements": {
"ChevronLeft": 1,
"Pressable": 5,
"RefreshCw": 1,
"SafeAreaView": 1,
"Search": 1,
"SectionList": 1,
"Text": 5,
"TextInput": 1,
"View": 6
},
"labels": ["Back", "Refresh agent sessions", "Search sessions, repo:, path:"],
"text": ["Agent Session History", "orca-history", "Workspace", "Project", "All"]
},
"a26eb8ffaa7c": {
"name": "status.get#2",
"ordinal": 7,
@@ -386,7 +387,7 @@
"settlements": {
"mount": "eb79a9b3682a"
},
"state": "035edfd9a1b9",
"state": "9a4b6a2428eb",
"effects": []
}
},
@@ -404,7 +405,7 @@
"settlements": {
"mount": "eb79a9b3682a"
},
"state": "035edfd9a1b9",
"state": "9a4b6a2428eb",
"effects": ["40fa6811eb00"]
}
}
@@ -1,10 +1,11 @@
import { loadMobileResumeMetadata } from './mobile-agent-history-resume-metadata'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { ChevronLeft, RefreshCw } from 'lucide-react-native'
import { colors } from '../theme/mobile-theme'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { MobileSearchField } from '../components/MobileSearchField'
import { useWorkspaceClient } from '../transport/use-workspace-client'
import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff'
import { useSessionScreenHandlers } from '../session/session-screen-handlers'
@@ -361,14 +362,10 @@ function MobileAgentSessionHistoryPanelContent({
})}
</View>
<View style={styles.searchRow}>
<TextInput
style={styles.searchInput}
<MobileSearchField
value={query}
onChangeText={setQuery}
placeholder={t('agentHistory.panel.placeholder', 'Search sessions, repo:, path:')}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
{issues.length > 0 ? (
@@ -34,7 +34,13 @@ vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
}))
vi.mock('react-native-svg', () => ({ default: 'Svg', Path: 'Path' }))
vi.mock('lucide-react-native', () => ({ ChevronLeft: 'Icon', Play: 'Icon', RefreshCw: 'Icon' }))
vi.mock('lucide-react-native', () => ({
ChevronLeft: 'Icon',
Play: 'Icon',
RefreshCw: 'Icon',
Search: 'Icon',
X: 'Icon'
}))
vi.mock('../platform/haptics', () => ({ triggerError: () => {}, triggerSuccess: () => {} }))
// Mock Metro PNG requires; no client means session list never renders here.
vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: () => null }))
@@ -41,7 +41,13 @@ vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
}))
vi.mock('react-native-svg', () => ({ default: 'Svg', Path: 'Path' }))
vi.mock('lucide-react-native', () => ({ ChevronLeft: 'Icon', Play: 'Icon', RefreshCw: 'Icon' }))
vi.mock('lucide-react-native', () => ({
ChevronLeft: 'Icon',
Play: 'Icon',
RefreshCw: 'Icon',
Search: 'Icon',
X: 'Icon'
}))
const notifications = vi.hoisted(() => ({
error: vi.fn(),
success: vi.fn(),
@@ -44,7 +44,13 @@ vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
}))
vi.mock('react-native-svg', () => ({ default: 'Svg', Path: 'Path' }))
vi.mock('lucide-react-native', () => ({ ChevronLeft: 'Icon', Play: 'Icon', RefreshCw: 'Icon' }))
vi.mock('lucide-react-native', () => ({
ChevronLeft: 'Icon',
Play: 'Icon',
RefreshCw: 'Icon',
Search: 'Icon',
X: 'Icon'
}))
vi.mock('../platform/haptics', () => ({ triggerError: () => {}, triggerSuccess: () => {} }))
vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: () => null }))
vi.mock('../navigation/route-handoff', async () => await import('../navigation/route-handoff.web'))
@@ -72,14 +72,6 @@ export const styles = themedStyleSheet(() => ({
paddingHorizontal: spacing.md,
paddingTop: spacing.sm
},
searchInput: {
backgroundColor: colors.bgPanel,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
color: colors.textPrimary,
fontSize: typography.bodySize
},
list: {
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
@@ -1,12 +1,19 @@
import { Platform, StyleSheet, Text, TextInput, View } from 'react-native'
import { Platform, StyleSheet, Text, View } from 'react-native'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { colors, radii, spacing } from '../theme/mobile-theme'
import { colors } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { browserAddressFieldStyles } from './browser-address-field-styles'
import { TextField } from '../components/composer-input/TextField'
import {
COMPOSER_TEXT_SIZE,
composerInputStyles
} from '../components/composer-input/composer-input-styles'
import { compactMobileBrowserFileAddress } from './browser-url'
import { MobileBrowserLoadProgress } from './MobileBrowserLoadProgress'
// The browser toolbar is a 32pt strip of 15pt icons; a 44pt field would double its height.
const ADDRESS_FIELD_HEIGHT = 28
type Props = {
disabled: boolean
focused: boolean
@@ -36,9 +43,9 @@ export function MobileBrowserAddressField({
const selection = focused ? undefined : { start: 0, end: 0 }
return (
<View style={styles.field}>
<TextInput
style={browserAddressFieldStyles.input}
<View style={styles.host}>
<TextField
style={styles.compact}
value={value}
onChangeText={onChangeText}
onFocus={onFocus}
@@ -54,16 +61,14 @@ export function MobileBrowserAddressField({
numberOfLines={1}
returnKeyType="go"
placeholder={t('browser.addressField.urlPlaceholder', 'URL')}
placeholderTextColor={colors.textMuted}
editable={!disabled}
/>
{fileLabel ? (
<View pointerEvents="none" style={styles.fileLabelHost}>
<Text
style={browserAddressFieldStyles.fileLabel}
numberOfLines={1}
ellipsizeMode="middle"
>
<View
pointerEvents="none"
style={[composerInputStyles.field, styles.compact, styles.fileLabelHost]}
>
<Text style={styles.fileLabel} numberOfLines={1} ellipsizeMode="middle">
{fileLabel}
</Text>
</View>
@@ -74,16 +79,20 @@ export function MobileBrowserAddressField({
}
const styles = themedStyleSheet(() => ({
field: {
host: {
flex: 1,
minWidth: 0,
height: 28
minWidth: 0
},
compact: {
minHeight: ADDRESS_FIELD_HEIGHT,
height: ADDRESS_FIELD_HEIGHT
},
fileLabelHost: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
paddingHorizontal: spacing.sm,
borderRadius: radii.input,
backgroundColor: colors.bgRaised
...StyleSheet.absoluteFillObject
},
fileLabel: {
flex: 1,
color: colors.textPrimary,
fontSize: COMPOSER_TEXT_SIZE
}
}))
@@ -121,7 +121,7 @@ export function MobileBrowserLoadProgress({
const styles = themedStyleSheet(() => ({
clip: {
...StyleSheet.absoluteFillObject,
borderRadius: radii.input,
borderRadius: radii.composerItem,
overflow: 'hidden'
},
line: {
@@ -1,25 +0,0 @@
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { themedStyles } from '../theme/themed-styles'
// Keep common styles outside platform siblings: .web cannot import the file it shadows, and copies drift.
export const browserAddressFieldBase = themedStyles(
() =>
({
input: {
flex: 1,
minWidth: 0,
borderRadius: radii.input,
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
paddingHorizontal: spacing.sm,
paddingVertical: 0,
includeFontPadding: false,
textAlignVertical: 'center',
fontFamily: typography.monoFamily
},
fileLabel: {
color: colors.textPrimary,
fontFamily: typography.monoFamily
}
}) as const
)
@@ -1,11 +0,0 @@
import { themedStyleSheet } from '../theme/themed-styles'
import { typography } from '../theme/mobile-theme'
import { browserAddressFieldBase } from './browser-address-field-base-styles'
// Keep compact native meta size; web raises input/overlay label together to prevent iOS focus zoom without changing address size on focus.
const ADDRESS_FONT_SIZE = typography.metaSize
export const browserAddressFieldStyles = themedStyleSheet(() => ({
input: { ...browserAddressFieldBase.input, fontSize: ADDRESS_FONT_SIZE, lineHeight: 16 },
fileLabel: { ...browserAddressFieldBase.fileLabel, fontSize: ADDRESS_FONT_SIZE, lineHeight: 16 }
}))
@@ -1,53 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
// Mock only identity StyleSheet.create; other RN exports require native runtime.
vi.mock('react-native', () => ({
StyleSheet: { create: (styles: unknown) => styles }
}))
// Resolve web text-input seam as page does; native seam could falsely pass a size no browser 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 { typography } from '../theme/mobile-theme'
import { browserAddressFieldStyles as browserAddressFieldStylesOnWeb } from './browser-address-field-styles.web'
import { mobileBrowserPaneStyles } from './mobile-browser-pane-styles'
/** Below this an iOS browser zooms the page when an input takes focus, and does not zoom back. */
const IOS_FOCUS_ZOOM_FLOOR = 16
describe('the browser pane text inputs on the web', () => {
it('raises the address field to the seam, above the focus-zoom floor', () => {
expect(browserAddressFieldStylesOnWeb.input.fontSize).toBe(TEXT_INPUT_FONT_SIZE)
expect(browserAddressFieldStylesOnWeb.input.fontSize).toBeGreaterThanOrEqual(
IOS_FOCUS_ZOOM_FLOOR
)
// A raise rather than the same number twice: the native seam is the app's body size.
expect(TEXT_INPUT_FONT_SIZE).toBeGreaterThan(typography.bodySize)
})
it('raises the key row input to the same seam', () => {
expect(mobileBrowserPaneStyles.keyboardInput.fontSize).toBe(TEXT_INPUT_FONT_SIZE)
expect(mobileBrowserPaneStyles.keyboardInput.fontSize).toBeGreaterThanOrEqual(
IOS_FOCUS_ZOOM_FLOOR
)
})
it('keeps the overlaid label on the input size, so focus does not resize the address', () => {
expect(browserAddressFieldStylesOnWeb.fileLabel.fontSize).toBe(
browserAddressFieldStylesOnWeb.input.fontSize
)
expect(browserAddressFieldStylesOnWeb.fileLabel.lineHeight).toBe(
browserAddressFieldStylesOnWeb.input.lineHeight
)
})
it('gives the raised size a line box it fits in', () => {
expect(browserAddressFieldStylesOnWeb.input.lineHeight).toBeGreaterThanOrEqual(
browserAddressFieldStylesOnWeb.input.fontSize
)
})
})
@@ -1,20 +0,0 @@
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
import { themedStyleSheet } from '../theme/themed-styles'
import { browserAddressFieldBase } from './browser-address-field-base-styles'
// Use web text-input seam (>=16px): iOS focus zoom otherwise makes keyboard-occlusion read zero for whole typing session.
// Raise line height and overlay label with input to prevent clipping and focus-time text resize.
const ADDRESS_LINE_HEIGHT = TEXT_INPUT_FONT_SIZE + 4
export const browserAddressFieldStyles = themedStyleSheet(() => ({
input: {
...browserAddressFieldBase.input,
fontSize: TEXT_INPUT_FONT_SIZE,
lineHeight: ADDRESS_LINE_HEIGHT
},
fileLabel: {
...browserAddressFieldBase.fileLabel,
fontSize: TEXT_INPUT_FONT_SIZE,
lineHeight: ADDRESS_LINE_HEIGHT
}
}))
@@ -0,0 +1,27 @@
import { describe, expect, it, vi } from 'vitest'
// Mock only identity StyleSheet.create; other RN exports require native runtime.
vi.mock('react-native', () => ({
StyleSheet: { create: (styles: unknown) => styles }
}))
// Resolve web text-input seam as page does; native seam could falsely pass a size no browser 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 { mobileBrowserPaneStyles } from './mobile-browser-pane-styles'
/** Below this an iOS browser zooms the page when an input takes focus, and does not zoom back. */
const IOS_FOCUS_ZOOM_FLOOR = 16
describe('the browser pane key row input on the web', () => {
it('raises the key row input to the seam, above the focus-zoom floor', () => {
expect(mobileBrowserPaneStyles.keyboardInput.fontSize).toBe(TEXT_INPUT_FONT_SIZE)
expect(mobileBrowserPaneStyles.keyboardInput.fontSize).toBeGreaterThanOrEqual(
IOS_FOCUS_ZOOM_FLOOR
)
})
})
+1 -13
View File
@@ -1,7 +1,6 @@
import { StyleSheet } from 'react-native'
import { colors, getMobileColorScheme, spacing, radii, typography } from '../theme/mobile-theme'
import { colors, getMobileColorScheme, spacing, typography } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
export const customKeyModalStyles = themedStyleSheet(() => ({
header: {
@@ -237,17 +236,6 @@ export const customKeyModalStyles = themedStyleSheet(() => ({
fontWeight: '500',
color: colors.textSecondary
},
fieldInput: {
backgroundColor: colors.bgBase,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: TEXT_INPUT_FONT_SIZE,
fontFamily: typography.monoFamily,
borderWidth: 1,
borderColor: colors.borderSubtle
},
switchRow: {
flexDirection: 'row',
alignItems: 'center',
+4 -6
View File
@@ -4,6 +4,7 @@ import { ChevronLeft } from 'lucide-react-native'
import { colors } from '../theme/mobile-theme'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { BottomDrawer } from './BottomDrawer'
import { TextField } from './composer-input/TextField'
import {
buildTerminalShortcutKey,
normalizeShortcutKeyInput,
@@ -378,24 +379,21 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
<View style={styles.group}>
<View style={styles.macroForm}>
<Text style={styles.fieldLabel}>{t('components.customKeyModal.label', 'Label')}</Text>
<TextInput
style={styles.fieldInput}
<TextField
value={macroLabel}
onChangeText={setMacroLabel}
placeholder={t('components.customKeyModal.labelPlaceholder', 'e.g. Build')}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={styles.fieldLabel}>
{t('components.customKeyModal.command', 'Command')}
</Text>
<TextInput
style={styles.fieldInput}
<TextField
monospace
value={macroText}
onChangeText={setMacroText}
placeholder={t('components.customKeyModal.commandPlaceholder', 'e.g. pnpm build')}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
@@ -1,5 +1,5 @@
import { useMemo } from 'react'
import { KeyboardAvoidingView, Pressable, Text, TextInput, View } from 'react-native'
import { KeyboardAvoidingView, Pressable, Text, View } from 'react-native'
import { Check, Copy, FileText, Plus, Send, Trash2, X } from 'lucide-react-native'
import type { DiffComment } from '../../../src/shared/diff-comment-types'
import { colors } from '../theme/mobile-theme'
@@ -15,6 +15,8 @@ import type { ComposerState, SendSheetState } from '../session/mobile-diff-revie
import { reviewSheetKey, type ReviewSheet } from '../session/mobile-diff-review-sheets'
import type { useMobileDiffReviewController } from '../session/use-mobile-diff-review-controller'
import { mobileDiffReviewStyles as styles } from './mobile-diff-review-screen-styles'
import { MessageBox } from './composer-input/MessageBox'
import { composerInputStyles } from './composer-input/composer-input-styles'
import { hostOs } from '../platform/host-os'
import { useWorkspaceOnPairedHost } from '../navigation/workspace-execution-host'
@@ -257,20 +259,18 @@ function NoteComposerContent({ controller, composer }: Props & { composer: Compo
<X size={18} color={colors.textPrimary} strokeWidth={2.2} />
</Pressable>
</View>
<TextInput
style={styles.composerInput}
<MessageBox
value={controller.composerBody}
onChangeText={controller.setComposerBody}
multiline
autoFocus
placeholder={t('components.diffReviewDrawers.reviewNote', 'Review note')}
placeholderTextColor={colors.textMuted}
accessibilityLabel={composerLabel(t, composer)}
onSend={() => void controller.saveComposer()}
sendAccessibilityLabel={saveLabel(t, composer)}
leading={
composer.mode === 'edit' ? <DeleteNoteButton onPress={controller.deleteComment} /> : null
}
/>
<View style={styles.drawerButtonRow}>
{composer.mode === 'edit' ? <DeleteNoteButton onPress={controller.deleteComment} /> : null}
<SaveNoteButton controller={controller} composer={composer} />
</View>
</KeyboardAvoidingView>
)
}
@@ -286,46 +286,26 @@ function composerLabel(
: t('components.diffReviewDrawers.reviewNote', 'Review note')
}
function saveLabel(t: Translate, composer: ComposerState): string {
return composer.mode === 'create' && composer.lineNumber > 0
? composerLabel(t, composer)
: t('session.diffLineRow.saveNote', 'Save note')
}
function DeleteNoteButton({ onPress }: { onPress: () => Promise<void> }) {
useMobileColorScheme()
const t = useMobileTranslation()
return (
<Pressable
style={({ pressed }) => [styles.secondaryButton, pressed && styles.buttonPressed]}
style={({ pressed }) => [
composerInputStyles.ghostButton,
pressed && composerInputStyles.pressed
]}
onPress={() => void onPress()}
accessibilityRole="button"
accessibilityLabel={t('components.diffReviewDrawers.deleteNote', 'Delete note')}
>
<Trash2 size={14} color={colors.statusRed} strokeWidth={2.2} />
<Text style={styles.destructiveText}>{t('common.delete', 'Delete')}</Text>
</Pressable>
)
}
function SaveNoteButton({
controller,
composer
}: {
controller: ReturnType<typeof useMobileDiffReviewController>
composer: ReturnType<typeof useMobileDiffReviewController>['composer']
}) {
useMobileColorScheme()
const t = useMobileTranslation()
const disabled = controller.composerBody.trim().length === 0
return (
<Pressable
style={({ pressed }) => [
styles.primaryButton,
disabled && styles.buttonDisabled,
pressed && styles.buttonPressed
]}
disabled={disabled}
onPress={() => void controller.saveComposer()}
accessibilityRole="button"
accessibilityLabel={composerLabel(t, composer)}
>
<Check size={14} color={colors.bgBase} strokeWidth={2.2} />
<Text style={styles.primaryButtonText}>{t('common.save', 'Save')}</Text>
<Trash2 size={18} color={colors.statusRed} strokeWidth={2} />
</Pressable>
)
}
+43 -88
View File
@@ -1,18 +1,17 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef } from 'react'
import {
InteractionManager,
Platform,
Pressable,
TextInput,
View,
type TextInput,
type TextInputProps
} from 'react-native'
import { Search, X } from 'lucide-react-native'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { colors, radii, spacing } from '../theme/mobile-theme'
import { colors, spacing } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
import { TextField } from './composer-input/TextField'
// Delay focus until opening chrome settles; native autoFocus alone often shows caret without iOS/Android keyboard.
const SEARCH_AUTO_FOCUS_DELAY_MS = 120
@@ -35,7 +34,6 @@ type MobileSearchFieldProps = {
accessibilityLabel?: string
}
// Raised search field stays visibly tappable above list/panel canvas.
export function MobileSearchField({
value,
onChangeText,
@@ -54,7 +52,6 @@ export function MobileSearchField({
const t = useMobileTranslation()
useMobileColorScheme()
const inputRef = useRef<TextInput>(null)
const [focused, setFocused] = useState(false)
const clearVisible = showClear ?? value.length > 0
useEffect(() => {
@@ -91,91 +88,49 @@ export function MobileSearchField({
}
return (
<View style={[styles.shell, focused && styles.shellFocused, !editable && styles.shellDisabled]}>
<Search
size={15}
color={focused ? colors.textPrimary : colors.textSecondary}
strokeWidth={2.2}
/>
<TextInput
ref={inputRef}
style={styles.input}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
// textSecondary keeps hint readable on bgRaised; textMuted looked empty.
placeholderTextColor={colors.textSecondary}
autoCapitalize="none"
autoCorrect={false}
// Still request native auto-focus; the delayed ref focus is the reliable path.
autoFocus={autoFocus}
showSoftInputOnFocus
editable={editable}
returnKeyType={returnKeyType}
onSubmitEditing={onSubmitEditing}
onFocus={() => setFocused(true)}
onBlur={(event) => {
setFocused(false)
onBlur?.(event)
}}
clearButtonMode="never"
accessibilityLabel={accessibilityLabel ?? placeholder}
selectionColor={colors.accentBlue}
/>
{clearVisible ? (
<Pressable
onPress={handleClear}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={
clearAccessibilityLabel ?? t('components.searchField.clearSearch', 'Clear search')
}
style={({ pressed }) => [styles.clearButton, pressed && styles.clearButtonPressed]}
>
{/* Use chip and large hit target: bare 14px X looked decorative and was hard to tap. */}
<View style={styles.clearChip}>
<X size={12} color={colors.surfaceBright} strokeWidth={2.6} />
</View>
</Pressable>
) : null}
</View>
<TextField
inputRef={inputRef}
leading={<Search size={16} color={colors.textMuted} />}
trailing={
clearVisible ? (
<Pressable
onPress={handleClear}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={
clearAccessibilityLabel ?? t('components.searchField.clearSearch', 'Clear search')
}
style={({ pressed }) => [styles.clearButton, pressed && styles.clearButtonPressed]}
>
{/* Use chip and large hit target: bare 14px X looked decorative and was hard to tap. */}
<View style={styles.clearChip}>
<X size={12} color={colors.surfaceBright} strokeWidth={2.6} />
</View>
</Pressable>
) : undefined
}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
autoCapitalize="none"
autoCorrect={false}
// Still request native auto-focus; the delayed ref focus is the reliable path.
autoFocus={autoFocus}
showSoftInputOnFocus
editable={editable}
returnKeyType={returnKeyType}
onSubmitEditing={onSubmitEditing}
onBlur={onBlur}
clearButtonMode="never"
accessibilityLabel={accessibilityLabel ?? placeholder}
/>
)
}
const styles = themedStyleSheet(() => ({
shell: {
minHeight: 42,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
// Keep search visually distinct from surrounding panels.
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
paddingLeft: spacing.md,
paddingRight: spacing.xs,
paddingVertical: Platform.OS === 'ios' ? spacing.sm : spacing.xs + 2
},
shellFocused: {
// Monochrome focus cue reserves blue for state/selection; textMuted remains visible on bgRaised.
borderColor: colors.textMuted
},
shellDisabled: {
opacity: 0.55
},
input: {
flex: 1,
minWidth: 0,
padding: 0,
margin: 0,
color: colors.textPrimary,
fontSize: TEXT_INPUT_FONT_SIZE,
// Remove Android TextInput vertical padding to align icon/clear chip.
includeFontPadding: false,
textAlignVertical: 'center'
},
clearButton: {
// Pull the 36pt target into the field's end padding so the chip sits near the edge.
marginRight: -spacing.sm,
minWidth: 36,
minHeight: 36,
alignItems: 'center',
@@ -190,7 +145,7 @@ const styles = themedStyleSheet(() => ({
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
// textMuted makes solid clear chip on bgRaised; borderSubtle looked decorative.
// A solid textMuted chip reads as a control; borderSubtle looked decorative.
backgroundColor: colors.textMuted
}
}))
@@ -1,10 +1,11 @@
import { useEffect, useRef } from 'react'
import { TextInput, type TextInputProps } from 'react-native'
import { useEffect, useRef, type ComponentProps } from 'react'
import type { TextInput } from 'react-native'
import { TextField } from './composer-input/TextField'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
const MOBILE_WORKSPACE_NAME_FOCUS_DELAY_MS = 220
type MobileWorkspaceNameInputProps = TextInputProps & {
type MobileWorkspaceNameInputProps = ComponentProps<typeof TextField> & {
shouldAutoFocus: boolean
focusKey?: unknown
}
@@ -31,8 +32,8 @@ export function MobileWorkspaceNameInput({
}, [focusKey, shouldAutoFocus])
return (
<TextInput
ref={inputRef}
<TextField
inputRef={inputRef}
placeholder={t('components.workspaceNameInput.placeholder', 'Workspace name')}
autoCapitalize="none"
autoCorrect={false}
@@ -1,4 +1,4 @@
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { ChevronDown, ChevronUp } from 'lucide-react-native'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
@@ -8,6 +8,7 @@ import { colors } from '../theme/mobile-theme'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { BottomDrawer } from './BottomDrawer'
import { MobileAgentIcon } from './MobileAgentIcon'
import { TextField } from './composer-input/TextField'
import type { NewWorktreeAgentOption } from './new-worktree-agent-selection'
import { newWorktreeFormStyles as styles } from './new-worktree-form-styles'
import type { SetupRunPolicy } from './new-worktree-modal-types'
@@ -155,12 +156,10 @@ export function NewWorktreeFormSheet(props: {
<Text style={styles.label}>
{t('components.newWorktreeFormSheet.note', 'Note')}
</Text>
<TextInput
style={styles.input}
<TextField
value={props.note}
onChangeText={props.onNoteChange}
placeholder={t('components.newWorktreeFormSheet.notePlaceholder', 'Write a note')}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
@@ -1,9 +1,10 @@
import { View, Text, Pressable, Platform } from 'react-native'
import { View, Text, Pressable } from 'react-native'
import { ChevronDown } from 'lucide-react-native'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { HostKindIcon } from './HostKindIcon'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
import { composerInputStyles, COMPOSER_TEXT_SIZE } from './composer-input/composer-input-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
type Selection = { label: string; detail?: string }
@@ -100,15 +101,8 @@ const styles = themedStyleSheet(() => ({
marginBottom: spacing.xs
},
fieldButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
borderWidth: 1,
borderColor: colors.borderSubtle,
paddingHorizontal: spacing.md,
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm
...composerInputStyles.field,
paddingVertical: spacing.sm
},
projectDot: {
width: 8,
@@ -120,7 +114,7 @@ const styles = themedStyleSheet(() => ({
minWidth: 0
},
fieldButtonText: {
fontSize: typography.bodySize,
fontSize: COMPOSER_TEXT_SIZE,
color: colors.textPrimary
},
fieldButtonDetail: {
@@ -1,12 +1,12 @@
import { useRef } from 'react'
import { Platform, Text, TextInput, View } from 'react-native'
import { Text, View, type TextInput } from 'react-native'
import type { MobileComposerSource } from '../tasks/use-mobile-composer-source'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { MobileSegmentedControl } from './MobileSegmentedControl'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
import { TextField } from './composer-input/TextField'
type Props = {
composer: MobileComposerSource
@@ -27,15 +27,13 @@ export function SmartWorkspaceAdvancedFields({ composer, selectedRepoIsGit }: Pr
<Text style={styles.label}>
{t('components.smartWorkspaceAdvancedFields.name', 'Name')}
</Text>
<TextInput
style={styles.input}
<TextField
value={composer.name}
onChangeText={composer.setName}
placeholder={t(
'components.smartWorkspaceAdvancedFields.workspaceName',
'Workspace name'
)}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
@@ -47,15 +45,13 @@ export function SmartWorkspaceAdvancedFields({ composer, selectedRepoIsGit }: Pr
<Text style={styles.label}>
{t('components.smartWorkspaceAdvancedFields.branchName', 'Branch name')}
</Text>
<TextInput
style={styles.input}
<TextField
value={composer.branchNameOverride ?? ''}
onChangeText={composer.handleBranchNameOverrideChange}
placeholder={t(
'components.smartWorkspaceAdvancedFields.derivedFromName',
'Derived from name'
)}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
@@ -96,15 +92,13 @@ export function SmartWorkspaceAdvancedFields({ composer, selectedRepoIsGit }: Pr
<Text style={styles.label}>
{t('workspace.branchChoice.newName', 'New branch name')}
</Text>
<TextInput
ref={branchNameInputRef}
<TextField
inputRef={branchNameInputRef}
accessibilityLabel={t('workspace.branchChoice.newName', 'New branch name')}
autoFocus
style={styles.input}
value={composer.branchNameOverride ?? ''}
onChangeText={composer.handleBranchNameOverrideChange}
placeholder={t('workspace.branchChoice.automaticName', 'Automatic name')}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
@@ -126,16 +120,6 @@ const styles = themedStyleSheet(() => ({
color: colors.textSecondary,
marginBottom: spacing.xs
},
input: {
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
fontSize: TEXT_INPUT_FONT_SIZE,
borderWidth: 1,
borderColor: colors.borderSubtle
},
branchDetail: {
marginTop: spacing.sm,
gap: spacing.xs
@@ -32,6 +32,7 @@ import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { BottomDrawer } from './BottomDrawer'
import { smartWorkspaceSourceDrawerStyles as styles } from './smart-workspace-source-drawer-styles'
import { TextField } from './composer-input/TextField'
import { SmartSourceModeIcon } from './SmartSourceModeIcon'
import { SmartWorkspaceSourceRow } from './SmartWorkspaceSourceRow'
@@ -345,16 +346,14 @@ export function SmartWorkspaceSourceDrawer({
})}
</View>
<TextInput
ref={inputRef}
style={styles.search}
<TextField
inputRef={inputRef}
value={composer.name}
onChangeText={composer.setName}
placeholder={t(
'components.smartWorkspaceSourceDrawer.searchPlaceholder',
'Type a name or search a source'
)}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
// Still request native auto-focus; the delayed ref focus is the reliable path.
@@ -1,4 +1,4 @@
import { Pressable, Text, TextInput, View } from 'react-native'
import { Pressable, Text, View } from 'react-native'
import {
CircleDot,
ExternalLink,
@@ -14,7 +14,7 @@ import { themedStyleSheet } from '../theme/themed-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { TaskProviderLogo } from './TaskProviderLogo'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
import { TextField } from './composer-input/TextField'
type Props = {
composer: MobileComposerSource
@@ -102,8 +102,9 @@ export function SmartWorkspaceSourceField({
</View>
) : (
// Use real TextInput sharing composer.name with docked picker field so typed text survives native focus handoff.
<TextInput
style={[styles.input, disabled && styles.disabled]}
<TextField
// A non-interactive sheet is mid-swap, not disabled, so it stays undimmed.
style={disabled ? undefined : styles.enabled}
value={composer.name}
onChangeText={composer.setName}
onFocus={openDrawer}
@@ -112,7 +113,6 @@ export function SmartWorkspaceSourceField({
'components.smartWorkspaceSourceField.searchPlaceholder',
'Type a name or search a source'
)}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
// Return opens picker, never submits create form during focus handoff.
@@ -138,18 +138,8 @@ const styles = themedStyleSheet(() => ({
fontWeight: '400',
color: colors.textMuted
},
input: {
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
borderWidth: 1,
borderColor: colors.borderSubtle,
fontSize: TEXT_INPUT_FONT_SIZE,
color: colors.textPrimary
},
disabled: {
opacity: 0.55
enabled: {
opacity: 1
},
pill: {
flexDirection: 'row',
@@ -192,12 +192,12 @@ export function TerminalShortcutSettings({
return (
<>
<Text style={[styles.groupHeading, styles.groupTopGap]}>
{t('components.terminalShortcutSettings.shortcutBar', 'SHORTCUT BAR')}
{t('components.terminalShortcutSettings.moreKeys', 'MORE KEYS')}
</Text>
<Text style={styles.groupDescription}>
{t(
'components.terminalShortcutSettings.shortcutBarDescription',
'Toggle keys to show or hide them, and hold the grip to drag a key into the order you want on the terminal shortcut bar.'
'components.terminalShortcutSettings.moreKeysDescription',
'These keys sit under ⋯ on the terminal key row. Toggle keys to show or hide them, and hold the grip to drag a key into the order you want. A key already on the row, like Esc or the arrows, is left out of ⋯.'
)}
</Text>
<View style={[styles.section, styles.sectionTopGap]}>
+3 -17
View File
@@ -1,11 +1,11 @@
import { useState } from 'react'
import { View, Text, TextInput, Pressable, Platform, type KeyboardTypeOptions } from 'react-native'
import { View, Text, Pressable, type KeyboardTypeOptions } from 'react-native'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
import { BottomDrawer } from './BottomDrawer'
import { TextField } from './composer-input/TextField'
type Props = {
visible: boolean
@@ -69,12 +69,10 @@ export function TextInputModal({
{message ? <Text style={styles.message}>{message}</Text> : null}
</View>
<TextInput
style={styles.input}
<TextField
value={value}
onChangeText={setValue}
placeholder={placeholder}
placeholderTextColor={colors.textMuted}
autoFocus
autoCapitalize="none"
autoCorrect={false}
@@ -82,7 +80,6 @@ export function TextInputModal({
keyboardType={keyboardType}
returnKeyType="done"
onSubmitEditing={handleSubmit}
selectionColor={colors.accentBlue}
/>
<View style={styles.actions}>
@@ -123,17 +120,6 @@ const styles = themedStyleSheet(() => ({
color: colors.textMuted,
marginTop: 2
},
// Match workspace input: bgRaised on bgPanel reads tappable; bgBase looked recessed.
input: {
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
fontSize: TEXT_INPUT_FONT_SIZE,
borderWidth: 1,
borderColor: colors.borderSubtle
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
@@ -0,0 +1,42 @@
import { ActivityIndicator, Pressable } from 'react-native'
import { ArrowUp } from 'lucide-react-native'
import { colors } from '../../theme/mobile-theme'
import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme'
import { composerInputStyles as styles } from './composer-input-styles'
export function ComposerSendButton({
onPress,
canSend,
sending = false,
label = 'Send'
}: {
onPress: () => void
canSend: boolean
sending?: boolean
label?: string
}): React.JSX.Element {
useMobileColorScheme()
const enabled = canSend && !sending
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={{ disabled: !enabled, busy: sending }}
// react-native-web maps aria-busy but not accessibilityState.busy.
aria-busy={sending}
style={({ pressed }) => [
styles.sendButton,
!canSend && styles.sendButtonDisabled,
pressed && enabled && styles.pressed
]}
onPress={onPress}
disabled={!enabled}
>
{sending ? (
<ActivityIndicator size="small" color={colors.bgBase} />
) : (
<ArrowUp size={19} color={canSend ? colors.bgBase : colors.textMuted} strokeWidth={2.4} />
)}
</Pressable>
)
}
@@ -0,0 +1,107 @@
import { useRef, type ReactNode, type RefObject } from 'react'
import { TextInput, View, type StyleProp, type TextInputProps, type ViewStyle } from 'react-native'
import { colors } from '../../theme/mobile-theme'
import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme'
import { useTextInputContentHeight } from '../../platform/text-input-content-height'
import { ComposerSendButton } from './ComposerSendButton'
import {
composerInputStyles as styles,
FORM_MESSAGE_MIN_HEIGHT,
MESSAGE_MAX_HEIGHT,
MESSAGE_MIN_HEIGHT
} from './composer-input-styles'
type MessageBoxProps = Omit<
TextInputProps,
'style' | 'placeholderTextColor' | 'selectionColor' | 'value' | 'onChangeText'
> & {
value: string
onChangeText: (text: string) => void
inputRef?: RefObject<TextInput | null>
/** Shows the round Send button at the end of the action row. */
onSend?: () => void
canSend?: boolean
sending?: boolean
sendAccessibilityLabel?: string
/** Controls at the start of the action row. */
leading?: ReactNode
/** Controls at the end of the action row, before Send. */
trailing?: ReactNode
/** A bar floating at the bottom of the screen; casts the composer's shadow. */
docked?: boolean
/** Drawn over the text, which hides while it is set, e.g. dictation's live preview. */
inputOverlay?: ReactNode
monospace?: boolean
style?: StyleProp<ViewStyle>
}
// Multi-line text in the chat composer's box, with its controls in a row underneath.
export function MessageBox({
value,
onChangeText,
inputRef,
onSend,
canSend = value.trim().length > 0,
sending,
sendAccessibilityLabel,
leading,
trailing,
docked = false,
inputOverlay,
monospace = false,
style,
editable = true,
multiline = true,
...inputProps
}: MessageBoxProps): React.JSX.Element {
useMobileColorScheme()
const ownRef = useRef<TextInput>(null)
const ref = inputRef ?? ownRef
const contentHeight = useTextInputContentHeight(ref, value)
const hasActions = leading !== undefined || trailing !== undefined || onSend !== undefined
const minHeight = hasActions ? MESSAGE_MIN_HEIGHT : FORM_MESSAGE_MIN_HEIGHT
return (
<View style={[styles.box, !hasActions && styles.formBox, docked && styles.docked, style]}>
{/* Always wrapped, so showing the overlay never remounts the field under the keyboard. */}
<View>
<TextInput
ref={ref}
value={value}
onChangeText={onChangeText}
editable={editable}
multiline={multiline}
textAlignVertical="top"
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
style={[
styles.messageText,
!hasActions && styles.formText,
monospace && styles.mono,
contentHeight !== undefined && {
height: Math.min(MESSAGE_MAX_HEIGHT, Math.max(minHeight, contentHeight))
},
!editable && styles.disabled,
inputOverlay !== undefined && styles.hiddenText
]}
{...inputProps}
/>
{inputOverlay}
</View>
{hasActions ? (
<View style={styles.actionRow}>
{leading}
<View style={styles.actionSpacer} />
{trailing}
{onSend ? (
<ComposerSendButton
onPress={onSend}
canSend={canSend}
sending={sending}
label={sendAccessibilityLabel}
/>
) : null}
</View>
) : null}
</View>
)
}
@@ -0,0 +1,42 @@
import type { ReactNode, RefObject } from 'react'
import { TextInput, View, type StyleProp, type TextInputProps, type ViewStyle } from 'react-native'
import { colors } from '../../theme/mobile-theme'
import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme'
import { composerInputStyles as styles } from './composer-input-styles'
type TextFieldProps = Omit<TextInputProps, 'style' | 'placeholderTextColor' | 'selectionColor'> & {
inputRef?: RefObject<TextInput | null>
/** An icon before the text, e.g. Search. */
leading?: ReactNode
/** A control after the text, e.g. a clear chip. */
trailing?: ReactNode
monospace?: boolean
style?: StyleProp<ViewStyle>
}
// One line of text on the chat composer's surface.
export function TextField({
inputRef,
leading,
trailing,
monospace = false,
style,
editable = true,
...inputProps
}: TextFieldProps): React.JSX.Element {
useMobileColorScheme()
return (
<View style={[styles.field, !editable && styles.disabled, style]}>
{leading}
<TextInput
ref={inputRef}
editable={editable}
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
style={[styles.fieldText, monospace && styles.mono]}
{...inputProps}
/>
{trailing}
</View>
)
}
@@ -0,0 +1,122 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
import { themedStyleSheet } from '../../theme/themed-styles'
/** Round controls on an action row: attach, mic, Send/Stop. */
export const COMPOSER_CONTROL_SIZE = 38
// Every input uses the composer's size, which also keeps the page above iOS's 16px focus-zoom floor.
export const COMPOSER_TEXT_SIZE = 16
export const COMPOSER_LINE_HEIGHT = 22
const MESSAGE_PADDING_VERTICAL = 2
export const MESSAGE_MIN_HEIGHT = 30
// A box with no controls is a form field: it opens a few lines tall so it reads as room to write.
export const FORM_MESSAGE_MIN_HEIGHT = 3 * COMPOSER_LINE_HEIGHT + 2 * MESSAGE_PADDING_VERTICAL
export const MESSAGE_MAX_HEIGHT = 7 * COMPOSER_LINE_HEIGHT
export const FIELD_MIN_HEIGHT = 44
const roundControl = {
width: COMPOSER_CONTROL_SIZE,
height: COMPOSER_CONTROL_SIZE,
borderRadius: COMPOSER_CONTROL_SIZE / 2,
alignItems: 'center',
justifyContent: 'center'
} as const
const surface = () =>
({
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.composerBorder,
backgroundColor: colors.composerSurface
}) as const
// The chat composer's look, shared by every text input so they cannot drift apart.
export const composerInputStyles = themedStyleSheet(() => ({
box: {
...surface(),
paddingTop: spacing.md,
paddingHorizontal: 10,
paddingBottom: 10,
borderRadius: radii.composer
},
formBox: {
paddingTop: 10,
borderRadius: radii.composerItem
},
formText: {
minHeight: FORM_MESSAGE_MIN_HEIGHT
},
// Only bars floating over content cast a shadow; an inline box would shade its own thread.
docked: {
shadowColor: '#000',
shadowOpacity: 0.45,
shadowRadius: 15,
shadowOffset: { width: 0, height: 10 },
elevation: 8
},
messageText: {
width: '100%',
minHeight: MESSAGE_MIN_HEIGHT,
maxHeight: MESSAGE_MAX_HEIGHT,
fontSize: COMPOSER_TEXT_SIZE,
lineHeight: COMPOSER_LINE_HEIGHT,
color: colors.textPrimary,
paddingHorizontal: 6,
paddingTop: MESSAGE_PADDING_VERTICAL,
paddingBottom: MESSAGE_PADDING_VERTICAL
},
actionRow: {
marginTop: 10,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
actionSpacer: {
flex: 1
},
controlButton: {
...roundControl,
backgroundColor: colors.composerControl
},
ghostButton: {
...roundControl
},
sendButton: {
...roundControl,
backgroundColor: colors.surfaceBright
},
sendButtonDisabled: {
backgroundColor: colors.composerControl
},
pressed: {
opacity: 0.7
},
field: {
...surface(),
minHeight: FIELD_MIN_HEIGHT,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
borderRadius: radii.composerItem,
paddingHorizontal: spacing.md
},
fieldText: {
flex: 1,
minWidth: 0,
alignSelf: 'stretch',
padding: 0,
margin: 0,
fontSize: COMPOSER_TEXT_SIZE,
color: colors.textPrimary,
includeFontPadding: false,
textAlignVertical: 'center'
},
mono: {
fontFamily: typography.monoFamily
},
hiddenText: {
color: 'transparent'
},
disabled: {
opacity: 0.5
}
}))
@@ -1,7 +1,6 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
export const mobileDiffReviewControlStyles = themedStyleSheet(() => ({
footer: {
@@ -91,9 +90,6 @@ export const mobileDiffReviewControlStyles = themedStyleSheet(() => ({
buttonPressed: {
opacity: 0.76
},
buttonDisabled: {
opacity: 0.45
},
composerHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
@@ -111,19 +107,6 @@ export const mobileDiffReviewControlStyles = themedStyleSheet(() => ({
fontSize: typography.metaSize,
marginTop: 2
},
composerInput: {
minHeight: 112,
borderRadius: radii.input,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgPanel,
color: colors.textPrimary,
// Through the seam, as the commit bar is: 14px focuses into a zoomed page on iOS.
fontSize: TEXT_INPUT_FONT_SIZE,
lineHeight: 20,
padding: spacing.md,
textAlignVertical: 'top'
},
drawerButtonRow: {
flexDirection: 'row',
gap: spacing.sm,
@@ -1,7 +1,6 @@
import { Platform } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
import { composerInputStyles, COMPOSER_TEXT_SIZE } from './composer-input/composer-input-styles'
export const newWorktreeFormStyles = themedStyleSheet(() => ({
header: {
@@ -34,19 +33,13 @@ export const newWorktreeFormStyles = themedStyleSheet(() => ({
fontWeight: '400',
color: colors.textMuted
},
// Pickers sit beside text fields in these forms, so they wear the same field shell.
fieldButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
borderWidth: 1,
borderColor: colors.borderSubtle
...composerInputStyles.field,
paddingVertical: spacing.sm
},
fieldButtonText: {
fontSize: typography.bodySize,
fontSize: COMPOSER_TEXT_SIZE,
color: colors.textPrimary
},
fieldButtonPlaceholder: {
@@ -118,16 +111,6 @@ export const newWorktreeFormStyles = themedStyleSheet(() => ({
color: colors.statusRed,
fontSize: 12
},
input: {
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
fontSize: TEXT_INPUT_FONT_SIZE,
borderWidth: 1,
borderColor: colors.borderSubtle
},
error: {
color: colors.statusRed,
fontSize: 13,
@@ -1,5 +1,5 @@
import { useCallback, useState } from 'react'
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
import { themedStyleSheet } from '../../theme/themed-styles'
import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme'
@@ -10,7 +10,7 @@ import {
parseGitHubIssueOrPRNumber
} from '../../../../src/shared/github/links'
import { linkMobilePr } from '../../source-control/mobile-pr-link'
import { TEXT_INPUT_FONT_SIZE } from '../../platform/text-input-font-size'
import { TextField } from '../composer-input/TextField'
import { useMobileTranslation } from '../../i18n/use-mobile-translation'
type Props = {
@@ -78,15 +78,13 @@ export function MobileLinkPrForm({ client, worktreeId, onCancel, onLinked }: Pro
<Text style={styles.label}>
{t('pullRequest.linkPrForm.referenceLabel', 'PR number or GitHub URL')}
</Text>
<TextInput
style={styles.input}
<TextField
value={input}
onChangeText={setInput}
placeholder={t(
'pullRequest.linkPrForm.referencePlaceholder',
'#123 or https://github.com/owner/repo/pull/123'
)}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
editable={!submitting}
@@ -138,14 +136,6 @@ const styles = themedStyleSheet(() => ({
marginTop: spacing.sm,
marginBottom: spacing.xs
},
input: {
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
color: colors.textPrimary,
fontSize: TEXT_INPUT_FONT_SIZE
},
error: { color: colors.statusRed, fontSize: typography.metaSize, marginTop: spacing.md },
submit: {
marginTop: spacing.lg,
@@ -1,10 +1,12 @@
import { useRef, useState } from 'react'
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
import { Pressable } from 'react-native'
import { X } from 'lucide-react-native'
import { colors } from '../../theme/mobile-theme'
import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../../i18n/use-mobile-translation'
import { isSubmittableCommentBody } from '../../session/pr-comment-actions'
import { prCommentComposerStyles as styles } from './pr-comment-composer-styles'
import { MessageBox } from '../composer-input/MessageBox'
import { composerInputStyles } from '../composer-input/composer-input-styles'
type Props = {
// Share plain-text composer across root comment, reply and edit.
@@ -51,47 +53,32 @@ export function PRCommentComposer({
}
return (
<View style={styles.container}>
<TextInput
style={styles.input}
value={body}
onChangeText={setBody}
placeholder={placeholder}
placeholderTextColor={colors.textMuted}
multiline
editable={!submitting}
autoFocus={autoFocus}
/>
<View style={styles.actions}>
{onCancel ? (
<MessageBox
value={body}
onChangeText={setBody}
placeholder={placeholder}
editable={!submitting}
autoFocus={autoFocus}
onSend={() => void submit()}
canSend={isSubmittableCommentBody(body)}
sending={submitting}
sendAccessibilityLabel={submitLabel}
leading={
onCancel ? (
<Pressable
style={({ pressed }) => [styles.cancel, pressed && styles.pressed]}
style={({ pressed }) => [
composerInputStyles.ghostButton,
pressed && composerInputStyles.pressed
]}
onPress={onCancel}
disabled={submitting}
accessibilityRole="button"
accessibilityLabel={t('common.cancel', 'Cancel')}
>
<Text style={styles.cancelText}>{t('common.cancel', 'Cancel')}</Text>
<X size={19} color={colors.textSecondary} strokeWidth={2} />
</Pressable>
) : null}
<Pressable
style={({ pressed }) => [
styles.submit,
!canSubmit && styles.submitDisabled,
pressed && styles.pressed
]}
onPress={() => void submit()}
disabled={!canSubmit}
accessibilityRole="button"
accessibilityLabel={submitLabel}
>
{submitting ? (
<ActivityIndicator size="small" color={colors.bgBase} />
) : (
<Text style={styles.submitText}>{submitLabel}</Text>
)}
</Pressable>
</View>
</View>
) : undefined
}
/>
)
}
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { ArrowRight, ExternalLink, Pencil } from 'lucide-react-native'
import { colors } from '../../theme/mobile-theme'
import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme'
@@ -12,7 +12,8 @@ import { statusColor } from './pr-sidebar-status-color'
import { canEditPRTitle } from '../../session/pr-title-edit'
import { openMobilePrUrl } from '../mobile-pr-url'
import { mobilePrSidebarStyles as styles } from './mobile-pr-sidebar-styles'
import { prCommentComposerStyles as composerStyles } from './pr-comment-composer-styles'
import { prTitleEditStyles as editStyles } from './pr-title-edit-styles'
import { TextField } from '../composer-input/TextField'
type Props = {
pr: PRInfo
@@ -154,19 +155,12 @@ function PRTitle({
if (editing) {
return (
<View style={composerStyles.container}>
<TextInput
style={composerStyles.input}
value={draft}
onChangeText={setDraft}
placeholderTextColor={colors.textMuted}
editable={!titleAction.saving}
autoFocus
/>
{titleAction.error ? <Text style={composerStyles.error}>{titleAction.error}</Text> : null}
<View style={composerStyles.actions}>
<View style={editStyles.container}>
<TextField value={draft} onChangeText={setDraft} editable={!titleAction.saving} autoFocus />
{titleAction.error ? <Text style={editStyles.error}>{titleAction.error}</Text> : null}
<View style={editStyles.actions}>
<Pressable
style={({ pressed }) => [composerStyles.cancel, pressed && composerStyles.pressed]}
style={({ pressed }) => [editStyles.cancel, pressed && editStyles.pressed]}
onPress={cancel}
disabled={titleAction.saving}
accessibilityRole="button"
@@ -175,10 +169,10 @@ function PRTitle({
'Cancel editing title'
)}
>
<Text style={composerStyles.cancelText}>{t('common.cancel', 'Cancel')}</Text>
<Text style={editStyles.cancelText}>{t('common.cancel', 'Cancel')}</Text>
</Pressable>
<Pressable
style={({ pressed }) => [composerStyles.submit, pressed && composerStyles.pressed]}
style={({ pressed }) => [editStyles.submit, pressed && editStyles.pressed]}
onPress={() => void save()}
disabled={titleAction.saving}
accessibilityRole="button"
@@ -187,7 +181,7 @@ function PRTitle({
{titleAction.saving ? (
<ActivityIndicator size="small" color={colors.bgBase} />
) : (
<Text style={composerStyles.submitText}>{t('common.save', 'Save')}</Text>
<Text style={editStyles.submitText}>{t('common.save', 'Save')}</Text>
)}
</Pressable>
</View>
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
import { Check } from 'lucide-react-native'
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { Check, Search } from 'lucide-react-native'
import { colors } from '../../theme/mobile-theme'
import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../../i18n/use-mobile-translation'
@@ -8,6 +8,7 @@ import type { GitHubAssignableUser } from '../../../../src/shared/github/pull-re
import type { RpcClient } from '../../transport/rpc-client'
import { fetchAssignableUsers } from '../../session/github-pr-rpc'
import { BottomDrawer } from '../BottomDrawer'
import { TextField } from '../composer-input/TextField'
import { mobilePrSidebarStyles as styles } from './mobile-pr-sidebar-styles'
type Props = {
@@ -94,12 +95,12 @@ export function ReviewerPickerDrawer({
<Text style={styles.pickerTitle}>
{t('pullRequest.reviewerPickerDrawer.title', 'Reviewers')}
</Text>
<TextInput
<TextField
style={styles.pickerSearch}
leading={<Search size={16} color={colors.textMuted} />}
value={query}
onChangeText={setQuery}
placeholder={t('pullRequest.reviewerPickerDrawer.searchPeople', 'Search people')}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
@@ -1,7 +1,6 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
import { themedStyleSheet } from '../../theme/themed-styles'
import { TEXT_INPUT_FONT_SIZE } from '../../platform/text-input-font-size'
// Dock width leaves diff ~380px at 700px breakpoint (KTD2/U4).
export const PR_SIDEBAR_DOCK_WIDTH = 320
@@ -273,14 +272,6 @@ export const mobilePrSidebarStyles = themedStyleSheet(() => ({
marginBottom: spacing.sm
},
pickerSearch: {
minHeight: 40,
borderRadius: radii.input,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgPanel,
color: colors.textPrimary,
paddingHorizontal: spacing.md,
fontSize: TEXT_INPUT_FONT_SIZE,
marginBottom: spacing.sm
},
// Let parent drawer scroll mapped rows; never nest VirtualizedList inside PR ScrollView.
@@ -1,25 +1,11 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
import { themedStyleSheet } from '../../theme/themed-styles'
import { TEXT_INPUT_FONT_SIZE } from '../../platform/text-input-font-size'
export const prCommentComposerStyles = themedStyleSheet(() => ({
export const prTitleEditStyles = themedStyleSheet(() => ({
container: {
// Input → Cancel/Save needs clear separation (title edit was flush without this).
// Field → Cancel/Save needs clear separation.
gap: spacing.md
},
input: {
minHeight: 64,
backgroundColor: colors.bgRaised,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
color: colors.textPrimary,
fontSize: TEXT_INPUT_FONT_SIZE,
textAlignVertical: 'top'
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
@@ -46,9 +32,6 @@ export const prCommentComposerStyles = themedStyleSheet(() => ({
borderRadius: radii.button,
backgroundColor: colors.textPrimary
},
submitDisabled: {
opacity: 0.45
},
submitText: {
color: colors.bgBase,
fontSize: typography.metaSize,
@@ -1,7 +1,6 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
export const smartWorkspaceSourceDrawerStyles = themedStyleSheet(() => ({
root: {
@@ -52,16 +51,6 @@ export const smartWorkspaceSourceDrawerStyles = themedStyleSheet(() => ({
gap: spacing.sm,
zIndex: 2
},
search: {
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
fontSize: TEXT_INPUT_FONT_SIZE,
borderWidth: 1,
borderColor: colors.borderSubtle
},
tabRow: {
flexDirection: 'row',
flexWrap: 'wrap',
+2 -21
View File
@@ -961,9 +961,7 @@
"empty": "Noch keine eigenen Tastenkürzel definiert.",
"removeShortcut": "{{label}} entfernen",
"resetDefaults": "Auf Standard zurücksetzen",
"resetDescription": "Alle integrierten Tastenkürzel in der ursprünglichen Reihenfolge anzeigen",
"shortcutBar": "TASTENKÜRZELLEISTE",
"shortcutBarDescription": "Tasten ein- oder ausblenden. Zum Anordnen in der Terminal-Tastenkürzelleiste den Ziehgriff einer Taste gedrückt halten und die Taste an die gewünschte Position ziehen."
"resetDescription": "Alle integrierten Tastenkürzel in der ursprünglichen Reihenfolge anzeigen"
},
"useCodexResetCreditAction": {
"confirmTitle": "Ratenlimit-Zurücksetzung verwenden?",
@@ -1840,16 +1838,8 @@
},
"session": {
"accessoryKeys": {
"addCustomShortcut": "Benutzerdefiniertes Tastenkürzel hinzufügen",
"dismissKeyboard": "Tastatur ausblenden",
"dismissKeyboardHint": "Blendet die Bildschirmtastatur aus und lässt die aktuelle Terminal-Sitzung geöffnet.",
"paste": "Einfügen",
"pasteFromClipboard": "Aus der Zwischenablage einfügen",
"sendKey": "{{label}} senden",
"switchToBufferedInput": "Zur gepufferten Befehlseingabe wechseln",
"switchToDesktopMode": "In den Desktop-Modus wechseln",
"switchToLiveInput": "Zur direkten Terminaleingabe wechseln",
"switchToPhoneMode": "In den Smartphone-Modus wechseln"
"sendKey": "{{label}} senden"
},
"activeContent": {
"createTab": "Tab erstellen",
@@ -1959,7 +1949,6 @@
"titleRight": "Tabs rechts schließen?"
},
"commandDock": {
"commandPlaceholder": "Befehl eingeben…",
"liveInputHint": "Eingegebener Text wird direkt an das aktive Terminal gesendet",
"liveInputLabel": "Live-Eingabe im Terminal",
"sendCommand": "Befehl senden"
@@ -1967,14 +1956,6 @@
"contentRow": {
"dismissCreationWarning": "Warnung zur Erstellung des Arbeitsbereichs ausblenden"
},
"diffCommentLineRow": {
"addNote": "Notiz zu Zeile {{line}} hinzufügen",
"deleteNote": "Notiz zu Zeile {{line}} löschen",
"diffLine": "{{title}}: Diff-Zeile {{line}}",
"line": "Zeile {{line}}",
"placeholder": "Review-Notiz hinzufügen",
"saveNote": "Notiz speichern"
},
"diffLineRow": {
"addNoteLabel": "Notiz zu Zeile {{line}} hinzufügen",
"deleteNoteLabel": "Notiz zu Zeile {{line}} löschen",
+46 -21
View File
@@ -1201,15 +1201,15 @@
"mergeRequest": "MR !{{number}}"
},
"terminalShortcutSettings": {
"shortcutBar": "SHORTCUT BAR",
"shortcutBarDescription": "Toggle keys to show or hide them, and hold the grip to drag a key into the order you want on the terminal shortcut bar.",
"resetDefaults": "Reset Defaults",
"resetDescription": "Show every built-in shortcut key in the original order",
"customShortcuts": "CUSTOM SHORTCUTS",
"empty": "No custom shortcuts defined yet.",
"removeShortcut": "Remove {{label}}",
"addShortcut": "Add Custom Shortcut…",
"addDescription": "Create key combo or text macro"
"addDescription": "Create key combo or text macro",
"moreKeys": "MORE KEYS",
"moreKeysDescription": "These keys sit under ⋯ on the terminal key row. Toggle keys to show or hide them, and hold the grip to drag a key into the order you want. A key already on the row, like Esc or the arrows, is left out of ⋯."
},
"voiceModelList": {
"modelSize": "{{size}} MB",
@@ -1702,14 +1702,6 @@
"preparingMic": "Preparing microphone",
"uploadingImage": "Uploading image to host"
},
"diffCommentLineRow": {
"diffLine": "{{title}} diff line {{line}}",
"addNote": "Add note on line {{line}}",
"line": "Line {{line}}",
"deleteNote": "Delete note on line {{line}}",
"placeholder": "Add review note",
"saveNote": "Save note"
},
"markdownReader": {
"readOnly": "Read only",
"changedOnDesktop": "Changed on desktop",
@@ -1761,15 +1753,23 @@
},
"accessoryKeys": {
"dismissKeyboard": "Dismiss keyboard",
"dismissKeyboardHint": "Hides the software keyboard and keeps the current terminal session open.",
"switchToDesktopMode": "Switch to desktop mode",
"switchToPhoneMode": "Switch to phone mode",
"switchToBufferedInput": "Switch to buffered command input",
"switchToLiveInput": "Switch to live terminal input",
"pasteFromClipboard": "Paste from clipboard",
"paste": "Paste",
"sendKey": "Send {{label}}",
"addCustomShortcut": "Add custom shortcut"
"modifierHint": "Applies to the next key. Double tap to keep it on.",
"typeCharacter": "Type {{character}}",
"control": "Control",
"escape": "Escape",
"shiftTab": "Shift Tab",
"enter": "Enter",
"interrupt": "Interrupt terminal",
"tab": "Tab",
"alt": "Alt",
"shift": "Shift",
"keysModeLabel": "Keys mode: typing goes straight to the terminal. Switch to Message mode",
"messageModeLabel": "Message mode: write, then send. Switch to Keys mode",
"keysMode": "Keys",
"messageMode": "Message",
"moreKeys": "More keys and actions",
"showKeyboard": "Show keyboard"
},
"activeContent": {
"noTabs": "No tabs in this session",
@@ -1783,8 +1783,10 @@
"commandDock": {
"liveInputLabel": "Live terminal input",
"liveInputHint": "Typed text is sent directly to the active terminal",
"commandPlaceholder": "Type a command…",
"sendCommand": "Send command"
"sendCommand": "Send command",
"messageAgentPlaceholder": "Message {{agent}}",
"theAgent": "the agent",
"writeCommandPlaceholder": "Write a command, then send"
},
"contentRow": {
"dismissCreationWarning": "Dismiss workspace creation warning"
@@ -2478,6 +2480,29 @@
"chatNotFound": "Orca can't find this chat.",
"startNewChat": "Start a new chat to continue.",
"tryAgain": "Try again."
},
"terminalArrowPad": {
"label": "Arrow keys",
"hint": "Tap a side for one arrow press, or hold and drag to repeat",
"up": "Up",
"down": "Down",
"left": "Left",
"right": "Right"
},
"terminalMoreKeys": {
"sendKey": "Send {{label}}",
"paste": "Paste",
"sending": "Sending…",
"photo": "Photo",
"file": "File",
"desktopWidth": "Desktop width",
"phoneWidth": "Phone width",
"keys": "Keys",
"combos": "Combos",
"yourKeys": "Your keys",
"deleteKeyHint": "Long press to delete this key",
"addCustomKey": "Add custom key",
"addKey": "Add key"
}
},
"chat": {
+2 -21
View File
@@ -1003,9 +1003,7 @@
"empty": "Aún no hay atajos personalizados definidos.",
"removeShortcut": "Eliminar {{label}}",
"resetDefaults": "Restablecer valores predeterminados",
"resetDescription": "Mostrar todas las teclas de atajo integradas en su orden original",
"shortcutBar": "BARRA DE ATAJOS",
"shortcutBarDescription": "Activa o desactiva las teclas para mostrarlas u ocultarlas y mantén pulsado el control de arrastre para colocarlas en el orden que quieras en la barra de atajos de la terminal."
"resetDescription": "Mostrar todas las teclas de atajo integradas en su orden original"
},
"useCodexResetCreditAction": {
"confirmTitle": "¿Usar un restablecimiento de límites de uso?",
@@ -1897,16 +1895,8 @@
},
"session": {
"accessoryKeys": {
"addCustomShortcut": "Agregar atajo personalizado",
"dismissKeyboard": "Ocultar teclado",
"dismissKeyboardHint": "Oculta el teclado en pantalla y mantiene abierta la sesión de terminal actual.",
"paste": "Pegar",
"pasteFromClipboard": "Pegar desde el portapapeles",
"sendKey": "Enviar {{label}}",
"switchToBufferedInput": "Cambiar a la entrada de comandos en búfer",
"switchToDesktopMode": "Cambiar al modo de escritorio",
"switchToLiveInput": "Cambiar a la entrada directa en la terminal",
"switchToPhoneMode": "Cambiar al modo teléfono"
"sendKey": "Enviar {{label}}"
},
"activeContent": {
"createTab": "Crear pestaña",
@@ -2032,7 +2022,6 @@
"titleRight": "¿Cerrar las pestañas de la derecha?"
},
"commandDock": {
"commandPlaceholder": "Escribe un comando…",
"liveInputHint": "El texto que escribes se envía directamente a la terminal activa.",
"liveInputLabel": "Entrada al terminal en tiempo real",
"sendCommand": "Enviar comando"
@@ -2040,14 +2029,6 @@
"contentRow": {
"dismissCreationWarning": "Descartar la advertencia de creación del espacio de trabajo"
},
"diffCommentLineRow": {
"addNote": "Agregar nota en la línea {{line}}",
"deleteNote": "Eliminar nota de la línea {{line}}",
"diffLine": "{{title}}, línea {{line}} del diff",
"line": "Línea {{line}}",
"placeholder": "Agregar nota de revisión",
"saveNote": "Guardar nota"
},
"diffLineRow": {
"addNoteLabel": "Agregar nota en la línea {{line}}",
"deleteNoteLabel": "Eliminar nota de la línea {{line}}",
+2 -21
View File
@@ -1003,9 +1003,7 @@
"empty": "Aucun raccourci personnalisé défini pour le moment.",
"removeShortcut": "Supprimer {{label}}",
"resetDefaults": "Rétablir les valeurs par défaut",
"resetDescription": "Afficher toutes les touches de raccourci intégrées dans l'ordre initial",
"shortcutBar": "BARRE DE RACCOURCIS",
"shortcutBarDescription": "Activez ou désactivez les touches pour les afficher ou les masquer. Maintenez le doigt sur la poignée pour faire glisser une touche à la position souhaitée dans la barre de raccourcis du terminal."
"resetDescription": "Afficher toutes les touches de raccourci intégrées dans l'ordre initial"
},
"useCodexResetCreditAction": {
"confirmTitle": "Utiliser une réinitialisation des limites de débit ?",
@@ -1897,16 +1895,8 @@
},
"session": {
"accessoryKeys": {
"addCustomShortcut": "Ajouter un raccourci personnalisé",
"dismissKeyboard": "Masquer le clavier",
"dismissKeyboardHint": "Masque le clavier virtuel et garde la session de terminal en cours ouverte.",
"paste": "Coller",
"pasteFromClipboard": "Coller depuis le presse-papiers",
"sendKey": "Envoyer {{label}}",
"switchToBufferedInput": "Basculer vers la saisie de commandes en mémoire tampon",
"switchToDesktopMode": "Passer en mode bureau",
"switchToLiveInput": "Basculer vers la saisie directe dans le terminal",
"switchToPhoneMode": "Basculer en mode téléphone"
"sendKey": "Envoyer {{label}}"
},
"activeContent": {
"createTab": "Créer un onglet",
@@ -2032,7 +2022,6 @@
"titleRight": "Fermer les onglets à droite ?"
},
"commandDock": {
"commandPlaceholder": "Saisissez une commande…",
"liveInputHint": "Le texte saisi est envoyé directement au terminal actif.",
"liveInputLabel": "Saisie en direct dans le terminal",
"sendCommand": "Envoyer la commande"
@@ -2040,14 +2029,6 @@
"contentRow": {
"dismissCreationWarning": "Ignorer l'avertissement de création de l'espace de travail"
},
"diffCommentLineRow": {
"addNote": "Ajouter une note à la ligne {{line}}",
"deleteNote": "Supprimer la note à la ligne {{line}}",
"diffLine": "{{title}}, ligne {{line}} du diff",
"line": "Ligne {{line}}",
"placeholder": "Ajouter une note de revue",
"saveNote": "Enregistrer la note"
},
"diffLineRow": {
"addNoteLabel": "Ajouter une note à la ligne {{line}}",
"deleteNoteLabel": "Supprimer la note à la ligne {{line}}",
+2 -21
View File
@@ -957,9 +957,7 @@
"empty": "カスタムショートカットはまだ設定されていません。",
"removeShortcut": "{{label}} を削除",
"resetDefaults": "初期設定に戻す",
"resetDescription": "すべての組み込みショートカットキーを元の順序で表示",
"shortcutBar": "ショートカットバー",
"shortcutBarDescription": "キーの表示・非表示を切り替えられます。ドラッグハンドルを長押ししてキーをドラッグすると、ターミナルのショートカットバーのキーを好みの順序に並べ替えられます。"
"resetDescription": "すべての組み込みショートカットキーを元の順序で表示"
},
"useCodexResetCreditAction": {
"confirmTitle": "レート制限のリセット枠を使用しますか?",
@@ -1836,16 +1834,8 @@
},
"session": {
"accessoryKeys": {
"addCustomShortcut": "カスタムショートカットを追加",
"dismissKeyboard": "キーボードを閉じる",
"dismissKeyboardHint": "現在のターミナルセッションを開いたまま、ソフトウェアキーボードを非表示にします。",
"paste": "貼り付け",
"pasteFromClipboard": "クリップボードから貼り付け",
"sendKey": "{{label}} を送信",
"switchToBufferedInput": "バッファー式のコマンド入力に切り替え",
"switchToDesktopMode": "デスクトップモードに切り替え",
"switchToLiveInput": "ターミナルへのリアルタイム入力に切り替え",
"switchToPhoneMode": "スマートフォンモードに切り替え"
"sendKey": "{{label}} を送信"
},
"activeContent": {
"createTab": "タブを作成",
@@ -1955,7 +1945,6 @@
"titleRight": "右側のタブを閉じますか?"
},
"commandDock": {
"commandPlaceholder": "コマンドを入力してください…",
"liveInputHint": "入力したテキストは、選択中のターミナルに直接送信されます。",
"liveInputLabel": "ターミナルへのリアルタイム入力",
"sendCommand": "コマンドを送信"
@@ -1963,14 +1952,6 @@
"contentRow": {
"dismissCreationWarning": "ワークスペース作成の警告を閉じる"
},
"diffCommentLineRow": {
"addNote": "{{line}} 行目にメモを追加",
"deleteNote": "{{line}} 行目のメモを削除",
"diffLine": "{{title}} の差分 {{line}} 行目",
"line": "{{line}} 行目",
"placeholder": "レビューメモを追加",
"saveNote": "メモを保存"
},
"diffLineRow": {
"addNoteLabel": "{{line}} 行目にメモを追加",
"deleteNoteLabel": "{{line}} 行目のメモを削除",
+2 -21
View File
@@ -957,9 +957,7 @@
"empty": "아직 설정된 맞춤 단축키가 없습니다.",
"removeShortcut": "{{label}} 제거",
"resetDefaults": "기본값 복원",
"resetDescription": "모든 기본 단축키를 원래 순서로 표시",
"shortcutBar": "단축키 바",
"shortcutBarDescription": "키를 켜거나 꺼서 표시 여부를 설정하고, 손잡이를 누른 채 드래그하여 터미널 단축키 바의 키를 원하는 순서로 배치하세요."
"resetDescription": "모든 기본 단축키를 원래 순서로 표시"
},
"useCodexResetCreditAction": {
"confirmTitle": "사용량 한도를 재설정하시겠습니까?",
@@ -1836,16 +1834,8 @@
},
"session": {
"accessoryKeys": {
"addCustomShortcut": "사용자 지정 단축키 추가",
"dismissKeyboard": "키보드 닫기",
"dismissKeyboardHint": "소프트웨어 키보드를 숨기고 현재 터미널 세션을 열린 상태로 유지합니다.",
"paste": "붙여넣기",
"pasteFromClipboard": "클립보드에서 붙여넣기",
"sendKey": "{{label}} 보내기",
"switchToBufferedInput": "버퍼링된 명령 입력으로 전환",
"switchToDesktopMode": "데스크톱 모드로 전환",
"switchToLiveInput": "실시간 터미널 입력으로 전환",
"switchToPhoneMode": "휴대폰 모드로 전환"
"sendKey": "{{label}} 보내기"
},
"activeContent": {
"createTab": "탭 만들기",
@@ -1955,7 +1945,6 @@
"titleRight": "오른쪽 탭을 닫을까요?"
},
"commandDock": {
"commandPlaceholder": "명령을 입력하세요…",
"liveInputHint": "입력한 텍스트가 활성 터미널로 바로 전송됩니다.",
"liveInputLabel": "실시간 터미널 입력",
"sendCommand": "명령 보내기"
@@ -1963,14 +1952,6 @@
"contentRow": {
"dismissCreationWarning": "워크스페이스 생성 경고 닫기"
},
"diffCommentLineRow": {
"addNote": "{{line}}행에 메모 추가",
"deleteNote": "{{line}}행 메모 삭제",
"diffLine": "{{title}} 차이점 {{line}}행",
"line": "{{line}}행",
"placeholder": "리뷰 메모 추가",
"saveNote": "메모 저장"
},
"diffLineRow": {
"addNoteLabel": "{{line}}행에 메모 추가",
"deleteNoteLabel": "{{line}}행의 메모 삭제",
+2 -21
View File
@@ -1003,9 +1003,7 @@
"empty": "Nenhum atalho personalizado definido ainda.",
"removeShortcut": "Remover {{label}}",
"resetDefaults": "Restaurar Padrões",
"resetDescription": "Mostrar todas as teclas de atalho integradas na ordem original",
"shortcutBar": "BARRA DE ATALHOS",
"shortcutBarDescription": "Ative ou desative as teclas para exibi-las ou ocultá-las e mantenha a alça pressionada para arrastar uma tecla até a posição desejada na barra de atalhos do terminal."
"resetDescription": "Mostrar todas as teclas de atalho integradas na ordem original"
},
"useCodexResetCreditAction": {
"confirmTitle": "Usar uma redefinição de limite de taxa?",
@@ -1897,16 +1895,8 @@
},
"session": {
"accessoryKeys": {
"addCustomShortcut": "Adicionar atalho personalizado",
"dismissKeyboard": "Ocultar teclado",
"dismissKeyboardHint": "Oculta o teclado virtual e mantém a sessão atual do terminal aberta.",
"paste": "Colar",
"pasteFromClipboard": "Colar da área de transferência",
"sendKey": "Enviar {{label}}",
"switchToBufferedInput": "Mudar para entrada de comandos em buffer",
"switchToDesktopMode": "Alternar para o modo desktop",
"switchToLiveInput": "Mudar para entrada em tempo real no terminal",
"switchToPhoneMode": "Mudar para o modo celular"
"sendKey": "Enviar {{label}}"
},
"activeContent": {
"createTab": "Criar Aba",
@@ -2032,7 +2022,6 @@
"titleRight": "Fechar abas à direita?"
},
"commandDock": {
"commandPlaceholder": "Digite um comando…",
"liveInputHint": "O texto digitado é enviado diretamente ao terminal ativo.",
"liveInputLabel": "Entrada em tempo real no terminal",
"sendCommand": "Enviar comando"
@@ -2040,14 +2029,6 @@
"contentRow": {
"dismissCreationWarning": "Dispensar aviso de criação do espaço de trabalho"
},
"diffCommentLineRow": {
"addNote": "Adicionar nota na linha {{line}}",
"deleteNote": "Excluir nota na linha {{line}}",
"diffLine": "{{title}}, linha {{line}} do diff",
"line": "Linha {{line}}",
"placeholder": "Adicionar nota de revisão",
"saveNote": "Salvar nota"
},
"diffLineRow": {
"addNoteLabel": "Adicionar nota na linha {{line}}",
"deleteNoteLabel": "Excluir nota da linha {{line}}",
+2 -21
View File
@@ -1045,9 +1045,7 @@
"empty": "Своих сочетаний клавиш пока нет.",
"removeShortcut": "Удалить {{label}}",
"resetDefaults": "Сбросить настройки",
"resetDescription": "Показать все встроенные клавиши в исходном порядке",
"shortcutBar": "ПАНЕЛЬ СОЧЕТАНИЙ КЛАВИШ",
"shortcutBarDescription": "Используйте переключатели для показа или скрытия клавиш. Чтобы изменить их порядок на панели терминала, удерживайте значок перетаскивания и перемещайте клавишу."
"resetDescription": "Показать все встроенные клавиши в исходном порядке"
},
"useCodexResetCreditAction": {
"confirmTitle": "Использовать сброс лимитов?",
@@ -1954,16 +1952,8 @@
},
"session": {
"accessoryKeys": {
"addCustomShortcut": "Добавить пользовательское сочетание клавиш",
"dismissKeyboard": "Скрыть клавиатуру",
"dismissKeyboardHint": "Скрывает экранную клавиатуру и оставляет текущую сессию терминала открытой.",
"paste": "Вставить",
"pasteFromClipboard": "Вставить из буфера обмена",
"sendKey": "Отправить {{label}}",
"switchToBufferedInput": "Переключиться на буферизованный ввод команд",
"switchToDesktopMode": "Переключиться в режим компьютера",
"switchToLiveInput": "Переключиться на прямой ввод в терминал",
"switchToPhoneMode": "Переключить в режим телефона"
"sendKey": "Отправить {{label}}"
},
"activeContent": {
"createTab": "Создать вкладку",
@@ -2105,7 +2095,6 @@
"titleRight": "Закрыть вкладки справа?"
},
"commandDock": {
"commandPlaceholder": "Введите команду…",
"liveInputHint": "Вводимый текст отправляется прямо в активный терминал",
"liveInputLabel": "Прямой ввод в терминал",
"sendCommand": "Отправить команду"
@@ -2113,14 +2102,6 @@
"contentRow": {
"dismissCreationWarning": "Закрыть предупреждение о создании рабочего пространства"
},
"diffCommentLineRow": {
"addNote": "Добавить заметку к строке {{line}}",
"deleteNote": "Удалить заметку к строке {{line}}",
"diffLine": "{{title}}: строка {{line}} в сравнении",
"line": "Строка {{line}}",
"placeholder": "Добавить заметку к проверке",
"saveNote": "Сохранить заметку"
},
"diffLineRow": {
"addNoteLabel": "Добавить заметку к строке {{line}}",
"deleteNoteLabel": "Удалить заметку к строке {{line}}",
+2 -21
View File
@@ -961,9 +961,7 @@
"empty": "Henüz özel kısayol tanımlanmadı.",
"removeShortcut": "{{label}} kısayolunu kaldır",
"resetDefaults": "Varsayılanlara Sıfırla",
"resetDescription": "Tüm yerleşik kısayol tuşlarını özgün sırayla göster",
"shortcutBar": "KISAYOL ÇUBUĞU",
"shortcutBarDescription": "Tuşları gösterip gizlemek için düğmeleri açıp kapatın. Terminal kısayol çubuğunda bir tuşun sırasını değiştirmek için tutamacını basılı tutup sürükleyin."
"resetDescription": "Tüm yerleşik kısayol tuşlarını özgün sırayla göster"
},
"useCodexResetCreditAction": {
"confirmTitle": "Hız sınırı sıfırlama hakkı kullanılsın mı?",
@@ -1840,16 +1838,8 @@
},
"session": {
"accessoryKeys": {
"addCustomShortcut": "Özel kısayol ekle",
"dismissKeyboard": "Klavyeyi gizle",
"dismissKeyboardHint": "Ekran klavyesini gizler ve geçerli terminal oturumunu açık tutar.",
"paste": "Yapıştır",
"pasteFromClipboard": "Panodan yapıştır",
"sendKey": "{{label}} gönder",
"switchToBufferedInput": "Arabellekli komut girişine geç",
"switchToDesktopMode": "Masaüstü moduna geç",
"switchToLiveInput": "Canlı terminal girişine geç",
"switchToPhoneMode": "Telefon moduna geç"
"sendKey": "{{label}} gönder"
},
"activeContent": {
"createTab": "Sekme Oluştur",
@@ -1959,7 +1949,6 @@
"titleRight": "Sağdaki sekmeler kapatılsın mı?"
},
"commandDock": {
"commandPlaceholder": "Bir komut yazın…",
"liveInputHint": "Yazılan metin doğrudan etkin terminale gönderilir",
"liveInputLabel": "Canlı terminal girişi",
"sendCommand": "Komut gönder"
@@ -1967,14 +1956,6 @@
"contentRow": {
"dismissCreationWarning": "Çalışma alanı oluşturma uyarısını kapat"
},
"diffCommentLineRow": {
"addNote": "{{line}}. satıra not ekle",
"deleteNote": "{{line}}. satırdaki notu sil",
"diffLine": "{{title}} diff'i, {{line}}. satır",
"line": "Satır {{line}}",
"placeholder": "İnceleme notu ekle",
"saveNote": "Notu kaydet"
},
"diffLineRow": {
"addNoteLabel": "{{line}} numaralı satıra not ekle",
"deleteNoteLabel": "{{line}} numaralı satırdaki notu sil",
+2 -21
View File
@@ -957,9 +957,7 @@
"empty": "尚未設定自訂快速鍵。",
"removeShortcut": "移除 {{label}}",
"resetDefaults": "還原預設值",
"resetDescription": "依原始順序顯示所有內建快速鍵",
"shortcutBar": "快速鍵列",
"shortcutBarDescription": "使用開關顯示或隱藏按鍵,並長按拖曳控點,調整按鍵在終端機快速鍵列上的順序。"
"resetDescription": "依原始順序顯示所有內建快速鍵"
},
"useCodexResetCreditAction": {
"confirmTitle": "要使用速率限制重設額度嗎?",
@@ -1836,16 +1834,8 @@
},
"session": {
"accessoryKeys": {
"addCustomShortcut": "新增自訂快速鍵",
"dismissKeyboard": "收起鍵盤",
"dismissKeyboardHint": "隱藏螢幕鍵盤,並保持目前的終端機工作階段開啟。",
"paste": "貼上",
"pasteFromClipboard": "從剪貼簿貼上",
"sendKey": "傳送 {{label}}",
"switchToBufferedInput": "切換到緩衝命令輸入模式",
"switchToDesktopMode": "切換至桌面模式",
"switchToLiveInput": "切換到終端機即時輸入模式",
"switchToPhoneMode": "切換到手機模式"
"sendKey": "傳送 {{label}}"
},
"activeContent": {
"createTab": "建立分頁",
@@ -1955,7 +1945,6 @@
"titleRight": "要關閉右側的分頁嗎?"
},
"commandDock": {
"commandPlaceholder": "輸入命令…",
"liveInputHint": "輸入的文字會直接傳送至目前使用中的終端機",
"liveInputLabel": "終端機即時輸入",
"sendCommand": "傳送命令"
@@ -1963,14 +1952,6 @@
"contentRow": {
"dismissCreationWarning": "關閉工作區建立警告"
},
"diffCommentLineRow": {
"addNote": "在第 {{line}} 行新增備註",
"deleteNote": "刪除第 {{line}} 行的備註",
"diffLine": "{{title}} 差異的第 {{line}} 行",
"line": "第 {{line}} 行",
"placeholder": "新增審查備註",
"saveNote": "儲存備註"
},
"diffLineRow": {
"addNoteLabel": "在第 {{line}} 行新增備註",
"deleteNoteLabel": "刪除第 {{line}} 行的備註",
+2 -21
View File
@@ -957,9 +957,7 @@
"empty": "尚未设置自定义快捷键。",
"removeShortcut": "移除 {{label}}",
"resetDefaults": "恢复默认设置",
"resetDescription": "按原始顺序显示所有内置快捷键",
"shortcutBar": "快捷键栏",
"shortcutBarDescription": "切换按键开关可显示或隐藏按键,按住拖动手柄可调整终端快捷键栏中的按键顺序。"
"resetDescription": "按原始顺序显示所有内置快捷键"
},
"useCodexResetCreditAction": {
"confirmTitle": "使用一次速率限制重置额度?",
@@ -1836,16 +1834,8 @@
},
"session": {
"accessoryKeys": {
"addCustomShortcut": "添加自定义快捷键",
"dismissKeyboard": "关闭键盘",
"dismissKeyboardHint": "隐藏软键盘,并保持当前终端会话打开。",
"paste": "粘贴",
"pasteFromClipboard": "从剪贴板粘贴",
"sendKey": "发送 {{label}}",
"switchToBufferedInput": "切换到缓冲命令输入",
"switchToDesktopMode": "切换到桌面模式",
"switchToLiveInput": "切换到实时终端输入",
"switchToPhoneMode": "切换到手机模式"
"sendKey": "发送 {{label}}"
},
"activeContent": {
"createTab": "创建标签页",
@@ -1955,7 +1945,6 @@
"titleRight": "关闭右侧标签页?"
},
"commandDock": {
"commandPlaceholder": "输入命令…",
"liveInputHint": "输入的文本会直接发送到当前终端",
"liveInputLabel": "终端实时输入",
"sendCommand": "发送命令"
@@ -1963,14 +1952,6 @@
"contentRow": {
"dismissCreationWarning": "关闭工作区创建警告"
},
"diffCommentLineRow": {
"addNote": "在第 {{line}} 行添加注释",
"deleteNote": "删除第 {{line}} 行的注释",
"diffLine": "{{title}} 的差异,第 {{line}} 行",
"line": "第 {{line}} 行",
"placeholder": "添加评论注释",
"saveNote": "保存注释"
},
"diffLineRow": {
"addNoteLabel": "在第 {{line}} 行添加注释",
"deleteNoteLabel": "删除第 {{line}} 行的注释",
@@ -1,174 +0,0 @@
import { Pressable, Text, TextInput, View } from 'react-native'
import { MessageSquare, Plus, X } from 'lucide-react-native'
import { MobileSyntaxSegments } from '../components/MobileSyntaxSegments'
import { colors } from '../theme/mobile-theme'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { styles } from './mobile-session-styles'
import type { DiffComment } from '../../../src/shared/diff-comment-types'
import type { RenderableDiffLine } from './mobile-session-route-types'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
export function MobileDiffCommentLineRow({
line,
title,
index,
comments,
activeCommentLine,
commentDraft,
commentsBusy,
onStartComment,
onCancelComment,
onDraftChange,
onSubmitComment,
onDeleteComment
}: {
line: RenderableDiffLine
title: string
index: number
comments: DiffComment[]
activeCommentLine: number | null
commentDraft: string
commentsBusy: boolean
onStartComment: (lineNumber: number) => void
onCancelComment: () => void
onDraftChange: (value: string) => void
onSubmitComment: (lineNumber: number) => void
onDeleteComment: (commentId: string) => void
}) {
useMobileColorScheme()
const t = useMobileTranslation()
const commentLine = line.newLineNumber
const isCommenting = commentLine !== undefined && activeCommentLine === commentLine
const canComment = commentLine !== undefined
// Why: review notes anchor to the modified side, so show that line number in the single mobile gutter.
const gutterLineNumber = line.newLineNumber ?? line.oldLineNumber ?? ''
return (
<View style={styles.diffLineBlock}>
<View
style={[
styles.diffLine,
line.kind === 'add' && styles.diffLineAdded,
line.kind === 'delete' && styles.diffLineDeleted
]}
>
<Text style={styles.diffGutter}>{gutterLineNumber}</Text>
<Text
selectable
style={styles.diffText}
accessibilityLabel={t(
'session.diffCommentLineRow.diffLine',
'{{title}} diff line {{line}}',
{
title,
line: index + 1
}
)}
>
<Text
style={[
styles.diffPrefix,
line.kind === 'add' && styles.diffPrefixAdded,
line.kind === 'delete' && styles.diffPrefixDeleted
]}
>
{line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '}
</Text>
<MobileSyntaxSegments segments={line.segments} />
</Text>
{canComment ? (
<Pressable
style={({ pressed }) => [
styles.diffCommentAddButton,
pressed && styles.diffCommentAddButtonPressed,
commentsBusy && styles.diffCommentButtonDisabled
]}
disabled={commentsBusy}
onPress={() => {
if (commentLine !== undefined) {
onStartComment(commentLine)
}
}}
accessibilityLabel={t(
'session.diffCommentLineRow.addNote',
'Add note on line {{line}}',
{
line: commentLine
}
)}
>
<Plus size={12} color={colors.textSecondary} strokeWidth={2.3} />
</Pressable>
) : null}
</View>
{comments.length > 0 ? (
<View style={styles.diffCommentList}>
{comments.map((comment) => (
<View key={comment.id} style={styles.diffCommentCard}>
<View style={styles.diffCommentHeader}>
<MessageSquare size={12} color={colors.textMuted} strokeWidth={2.2} />
<Text style={styles.diffCommentMeta}>
{t('session.diffCommentLineRow.line', 'Line {{line}}', {
line: comment.lineNumber
})}
</Text>
<Pressable
style={styles.diffCommentDeleteButton}
disabled={commentsBusy}
onPress={() => onDeleteComment(comment.id)}
accessibilityLabel={t(
'session.diffCommentLineRow.deleteNote',
'Delete note on line {{line}}',
{ line: comment.lineNumber }
)}
>
<X size={12} color={colors.textMuted} strokeWidth={2.2} />
</Pressable>
</View>
<Text style={styles.diffCommentBody}>{comment.body}</Text>
</View>
))}
</View>
) : null}
{isCommenting ? (
<View style={styles.diffCommentComposer}>
<TextInput
style={[styles.textInput, styles.diffCommentInput]}
value={commentDraft}
onChangeText={onDraftChange}
placeholder={t('session.diffCommentLineRow.placeholder', 'Add review note')}
placeholderTextColor={colors.textMuted}
editable={!commentsBusy}
multiline
textAlignVertical="top"
autoFocus
/>
<View style={styles.diffCommentComposerActions}>
<Pressable
style={styles.diffCommentSecondaryAction}
disabled={commentsBusy}
onPress={onCancelComment}
>
<Text style={styles.diffCommentSecondaryText}>{t('common.cancel', 'Cancel')}</Text>
</Pressable>
<Pressable
style={[
styles.diffCommentPrimaryAction,
(!commentDraft.trim() || commentsBusy) && styles.diffCommentButtonDisabled
]}
disabled={!commentDraft.trim() || commentsBusy}
onPress={() => {
if (commentLine !== undefined) {
onSubmitComment(commentLine)
}
}}
>
<Text style={styles.diffCommentPrimaryText}>
{t('session.diffCommentLineRow.saveNote', 'Save note')}
</Text>
</Pressable>
</View>
</View>
) : null}
</View>
)
}
+270 -208
View File
@@ -1,25 +1,39 @@
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import { View, Text, ScrollView, Pressable } from 'react-native'
import {
ChevronDown,
ChevronsRight,
Keyboard as KeyboardIcon,
Monitor,
Plus,
Smartphone
} from 'lucide-react-native'
import { triggerMediumImpact } from '../platform/haptics'
import { createTerminalLiveAccessoryInput } from '../terminal/terminal-live-accessory-input'
import { ChevronDown, Ellipsis, Keyboard as KeyboardIcon, PenLine } from 'lucide-react-native'
import type { TerminalShortcutModifier } from '../terminal/terminal-accessory-keys'
import { TERMINAL_ACCESSORY_REPEAT_DELAY_MS } from '../terminal/terminal-accessory-repeat'
import { SPECIAL_KEY_LABELS } from '../terminal/terminal-key-definitions'
import { createTerminalLiveAccessoryInput } from '../terminal/terminal-live-accessory-input'
import { isTerminalPhoneDisplayMode } from './mobile-session-route-helpers'
import { isMobileTerminalTabRunningAgent } from './mobile-terminal-tab-agent'
import { TerminalArrowPad } from './TerminalArrowPad'
import { TerminalMoreKeysSheet } from './TerminalMoreKeysSheet'
import { terminalDockStyles as styles } from './terminal-dock-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-session-styles'
import { useKeyboardPersistingTaps } from '../platform/keyboard-persisting-taps'
import type { StickyModifiers } from '../terminal/terminal-sticky-modifiers'
import { terminalDockKeyInput } from './use-terminal-dock-keys'
import type { MobileSessionController } from './use-mobile-session-controller'
// The terminal's key strip: a row above the input, or a column in the Duo's side rail.
const ESC = '\x1b'
const CTRL_C = '\x03'
const SHELL_CHARACTERS = ['/', '|', '~', '-', ':']
const AGENT_CHARACTERS = ['/', '@', '!']
const ARROW_IDS = ['arrowUp', 'arrowDown', 'arrowLeft', 'arrowRight']
// Built-in keys each row already shows; the ⋯ sheet offers the rest.
const SHELL_ROW_KEY_IDS: ReadonlySet<string> = new Set(['escape', 'tab', ...ARROW_IDS])
const AGENT_ROW_KEY_IDS: ReadonlySet<string> = new Set([
'escape',
'shiftTab',
'enter',
'ctrlC',
...ARROW_IDS
])
// The terminal's key row: a row under the input, or a column in the Duo's side rail. Every key goes straight to the terminal.
export function MobileSessionAccessoryKeys({
controller,
vertical = false
@@ -31,6 +45,7 @@ export function MobileSessionAccessoryKeys({
const t = useMobileTranslation()
const {
activeHandle,
activeSessionTab,
customKeys,
setShowCustomKeyModal,
setDeleteKeyTarget,
@@ -40,9 +55,14 @@ export function MobileSessionAccessoryKeys({
canSend,
canCompose,
liveInputEnabled,
isAttaching,
attachImage,
toggleDisplayMode,
handleAccessoryKey,
dockKeys: keys,
dismissSoftwareKeyboard,
liveInputRef,
commandInputRef,
toggleLiveInput,
stopAccessoryRepeat,
cancelAccessoryRepeat,
@@ -50,218 +70,260 @@ export function MobileSessionAccessoryKeys({
handlePaste,
keyboardLift
} = controller
const accessoryPresses = useRef(new Map<string, ReturnType<typeof prepareAccessoryPress>>())
const keepsKeyboard = useKeyboardPersistingTaps('always')
// An unmounting Pressable never fires onPressOut, so a held key would outlive its strip.
const [padHeld, setPadHeld] = useState(false)
const [sheetVisible, setSheetVisible] = useState(false)
const padPressRef = useRef<{
modifiers: StickyModifiers
tap: (() => Promise<boolean>) | null
} | null>(null)
// An unmounting row never sees a held arrow lift.
useEffect(() => cancelAccessoryRepeat, [cancelAccessoryRepeat])
return (
<View ref={keepsKeyboard} style={vertical ? styles.accessoryRail : styles.accessoryBar}>
{/* Why: fixed keyboard escape hatch; outside ScrollView + shortcut path so it can't scroll away or be hidden (#5106). */}
{keyboardLift > 0 && (
<Pressable
style={({ pressed }) => [
vertical ? styles.keyboardDismissKeyVertical : styles.keyboardDismissKey,
pressed && styles.accessoryKeyPressed
const runsAgent =
activeSessionTab?.type === 'terminal' && isMobileTerminalTabRunningAgent(activeSessionTab)
const keyboardUp = keyboardLift > 0
const keyStyle = vertical ? [styles.key, styles.keyVertical] : styles.key
const sendBytes = (bytes: string) => void handleAccessoryKey({ bytes })
const keyButton = (
id: string,
label: string,
accessibilityLabel: string,
onPress: () => void
): React.JSX.Element => (
<Pressable
key={id}
style={({ pressed }) => [keyStyle, pressed && styles.keyPressed, !canSend && styles.disabled]}
disabled={!canSend}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
>
<Text style={styles.keyText}>{label}</Text>
</Pressable>
)
const sendKeyLabel = (label: string) =>
t('session.accessoryKeys.sendKey', 'Send {{label}}', { label })
const specialKey = (id: string, label: string, name: string) =>
keyButton(id, label, sendKeyLabel(name), () => keys.sendKey(id))
const modifierKey = (modifier: TerminalShortcutModifier, label: string, name: string) => {
const latch = keys.modifiers[modifier]
return (
<Pressable
key={modifier}
style={({ pressed }) => [
keyStyle,
latch === 'once' && styles.keyOnce,
latch === 'locked' && styles.keyLocked,
pressed && styles.keyPressed,
!canSend && styles.disabled
]}
disabled={!canSend}
onPress={() => keys.tapModifier(modifier)}
accessibilityRole="button"
accessibilityLabel={name}
accessibilityHint={t(
'session.accessoryKeys.modifierHint',
'Applies to the next key. Double tap to keep it on.'
)}
accessibilityState={{ selected: latch !== 'off' }}
>
<Text
style={[
styles.keyText,
latch === 'once' && styles.keyOnceText,
latch === 'locked' && styles.keyLockedText
]}
onPress={dismissSoftwareKeyboard}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('session.accessoryKeys.dismissKeyboard', 'Dismiss keyboard')}
accessibilityHint={t(
'session.accessoryKeys.dismissKeyboardHint',
'Hides the software keyboard and keeps the current terminal session open.'
)}
>
<View style={styles.keyboardDismissGlyph}>
<KeyboardIcon size={15} color={colors.textSecondary} strokeWidth={2} />
<ChevronDown
size={10}
color={colors.textSecondary}
strokeWidth={2.5}
style={styles.keyboardDismissChevron}
/>
</View>
</Pressable>
)}
{/* Why: default tap handling makes the first accessory-key tap dismiss the keyboard and get swallowed (#5106). */}
{label}
</Text>
</Pressable>
)
}
const characterKeys = (characters: readonly string[]) =>
characters.map((character) =>
keyButton(
`char-${character}`,
character,
t('session.accessoryKeys.typeCharacter', 'Type {{character}}', { character }),
() => keys.sendKey(character)
)
)
const arrowPad = (
<TerminalArrowPad
key="arrows"
disabled={!canSend}
// Prepared on touch-down, so a lift after a terminal switch can't land on the new terminal.
onPressStart={(side) => {
const modifiers = keys.takeModifiers()
const input = terminalDockKeyInput(side, modifiers)
padPressRef.current = {
modifiers,
tap: input ? prepareAccessoryPress(input).tap : null
}
}}
onTap={() => {
void padPressRef.current?.tap?.()
padPressRef.current = null
}}
onRepeatStart={(arrow) => {
const input = terminalDockKeyInput(arrow, padPressRef.current?.modifiers ?? keys.modifiers)
if (input) {
prepareAccessoryPress(input).hold(TERMINAL_ACCESSORY_REPEAT_DELAY_MS)
}
}}
onRepeatStop={() => {
padPressRef.current = null
stopAccessoryRepeat()
}}
onAccessibilityArrow={keys.sendKey}
onHoldChange={setPadHeld}
/>
)
const ctrlKey = modifierKey('ctrl', 'ctrl', t('session.accessoryKeys.control', 'Control'))
const rowKeys = runsAgent
? [
specialKey('escape', 'esc', t('session.accessoryKeys.escape', 'Escape')),
keyButton(
'shiftTab',
'⇧tab',
sendKeyLabel(t('session.accessoryKeys.shiftTab', 'Shift Tab')),
() => sendBytes(`${ESC}[Z`)
),
arrowPad,
specialKey('enter', '⏎', t('session.accessoryKeys.enter', 'Enter')),
keyButton('ctrlC', '^C', t('session.accessoryKeys.interrupt', 'Interrupt terminal'), () =>
sendBytes(CTRL_C)
),
ctrlKey,
...characterKeys(AGENT_CHARACTERS)
]
: [
specialKey('escape', 'esc', t('session.accessoryKeys.escape', 'Escape')),
ctrlKey,
specialKey('tab', 'tab', t('session.accessoryKeys.tab', 'Tab')),
arrowPad,
modifierKey('alt', 'alt', t('session.accessoryKeys.alt', 'Alt')),
modifierKey('shift', '⇧', t('session.accessoryKeys.shift', 'Shift')),
...characterKeys(SHELL_CHARACTERS)
]
const showKeyboard = () => (liveInputEnabled ? liveInputRef : commandInputRef).current?.focus()
return (
<View ref={keepsKeyboard} style={vertical ? styles.keyRail : styles.keyRow}>
<Pressable
style={({ pressed }) => [
styles.modeKey,
vertical && styles.keyVertical,
pressed && styles.keyPressed,
!canCompose && styles.disabled
]}
// Offline, Keys mode is dead but the composer still composes; keep the escape hatch tappable (#6713).
disabled={!canCompose}
onPress={toggleLiveInput}
accessibilityRole="button"
accessibilityLabel={
liveInputEnabled
? t(
'session.accessoryKeys.keysModeLabel',
'Keys mode: typing goes straight to the terminal. Switch to Message mode'
)
: t(
'session.accessoryKeys.messageModeLabel',
'Message mode: write, then send. Switch to Keys mode'
)
}
>
{liveInputEnabled ? (
<KeyboardIcon size={15} color={colors.textPrimary} strokeWidth={2} />
) : (
<PenLine size={15} color={colors.textPrimary} strokeWidth={2} />
)}
<Text style={styles.modeKeyText}>
{liveInputEnabled
? t('session.accessoryKeys.keysMode', 'Keys')
: t('session.accessoryKeys.messageMode', 'Message')}
</Text>
</Pressable>
{/* Why: default tap handling makes the first key tap dismiss the keyboard and get swallowed (#5106). */}
<ScrollView
style={vertical ? styles.accessoryScrollVertical : styles.accessoryScroll}
style={vertical ? styles.keyScrollVertical : styles.keyScroll}
horizontal={!vertical}
scrollEnabled={!padHeld}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
contentContainerStyle={vertical ? styles.accessoryContentVertical : styles.accessoryContent}
contentContainerStyle={vertical ? styles.keyScrollContentVertical : styles.keyScrollContent}
keyboardShouldPersistTaps="always"
onScrollBeginDrag={cancelAccessoryRepeat}
>
{rowKeys}
<Pressable
style={({ pressed }) => [
styles.accessoryKey,
vertical && styles.accessoryKeyVertical,
pressed && styles.accessoryKeyPressed,
!canSend && styles.accessoryKeyDisabled
styles.ghostKey,
vertical && styles.keyVertical,
pressed && styles.keyPressed
]}
disabled={!canSend}
onPress={() => {
if (activeHandle) {
void toggleDisplayMode(activeHandle)
}
}}
accessibilityLabel={
isTerminalPhoneDisplayMode(activeHandle, terminalModes)
? t('session.accessoryKeys.switchToDesktopMode', 'Switch to desktop mode')
: t('session.accessoryKeys.switchToPhoneMode', 'Switch to phone mode')
}
onPress={() => setSheetVisible(true)}
accessibilityRole="button"
accessibilityLabel={t('session.accessoryKeys.moreKeys', 'More keys and actions')}
>
{isTerminalPhoneDisplayMode(activeHandle, terminalModes) ? (
<Monitor size={14} color={canSend ? colors.textSecondary : colors.textMuted} />
) : (
<Smartphone size={14} color={canSend ? colors.textSecondary : colors.textMuted} />
)}
</Pressable>
<Pressable
style={({ pressed }) => [
styles.accessoryKey,
vertical && styles.accessoryKeyVertical,
liveInputEnabled && styles.accessoryKeyActive,
pressed && styles.accessoryKeyPressed,
!canCompose && styles.accessoryKeyDisabled
]}
// Why: offline, live mode is dead but the buffered box still composes — keep the escape hatch tappable (#6713).
disabled={!canCompose}
onPress={toggleLiveInput}
accessibilityLabel={
liveInputEnabled
? t('session.accessoryKeys.switchToBufferedInput', 'Switch to buffered command input')
: t('session.accessoryKeys.switchToLiveInput', 'Switch to live terminal input')
}
>
<ChevronsRight
size={14}
color={
liveInputEnabled
? colors.bgBase
: canCompose
? colors.textSecondary
: colors.textMuted
}
/>
</Pressable>
{canPaste && (
<Pressable
style={({ pressed }) => [
styles.accessoryKey,
vertical && styles.accessoryKeyVertical,
vertical && styles.accessoryKeyVertical,
pressed && styles.accessoryKeyPressed,
!canSend && styles.accessoryKeyDisabled
]}
disabled={!canSend}
onPress={() => void handlePaste()}
accessibilityLabel={t(
'session.accessoryKeys.pasteFromClipboard',
'Paste from clipboard'
)}
>
<Text style={[styles.accessoryKeyText, !canSend && styles.accessoryKeyTextDisabled]}>
{t('session.accessoryKeys.paste', 'Paste')}
</Text>
</Pressable>
)}
{visibleBuiltInAccessoryKeys.map((key) => (
<Pressable
key={key.id}
style={({ pressed }) => [
styles.accessoryKey,
vertical && styles.accessoryKeyVertical,
vertical && styles.accessoryKeyVertical,
pressed && styles.accessoryKeyPressed,
!canSend && styles.accessoryKeyDisabled
]}
disabled={!canSend}
onPressIn={(event) => {
if (!key.repeatable) {
return
}
const press = prepareAccessoryPress(createTerminalLiveAccessoryInput(key))
accessoryPresses.current.set(key.id, press)
if (event.type === 'keydown') {
press.hold(TERMINAL_ACCESSORY_REPEAT_DELAY_MS)
}
}}
onPressOut={() => {
if (key.repeatable) {
stopAccessoryRepeat()
}
}}
onPress={(event) => {
if (key.repeatable) {
// Accessibility clicks have no touch-down to prepare a sender.
const accessibilityClick =
event.type === 'click' &&
'detail' in event.nativeEvent &&
event.nativeEvent.detail === 0
const press =
(!accessibilityClick && accessoryPresses.current.get(key.id)) ||
prepareAccessoryPress(createTerminalLiveAccessoryInput(key))
accessoryPresses.current.delete(key.id)
void press.tap()
return
}
void handleAccessoryKey(createTerminalLiveAccessoryInput(key))
}}
onLongPress={
key.repeatable ? () => accessoryPresses.current.get(key.id)?.hold() : undefined
}
delayLongPress={TERMINAL_ACCESSORY_REPEAT_DELAY_MS}
accessibilityLabel={
key.accessibilityLabel ??
t('session.accessoryKeys.sendKey', 'Send {{label}}', { label: key.label })
}
>
<Text style={[styles.accessoryKeyText, !canSend && styles.accessoryKeyTextDisabled]}>
{key.label}
</Text>
</Pressable>
))}
{customKeys.map((key) => (
<Pressable
key={key.id}
style={({ pressed }) => [
styles.accessoryKey,
vertical && styles.accessoryKeyVertical,
vertical && styles.accessoryKeyVertical,
styles.customAccessoryKey,
pressed && styles.accessoryKeyPressed,
!canSend && styles.accessoryKeyDisabled
]}
disabled={!canSend}
onPress={() => void handleAccessoryKey({ bytes: key.bytes })}
onLongPress={() => {
triggerMediumImpact()
setDeleteKeyTarget(key)
}}
delayLongPress={400}
accessibilityLabel={t('session.accessoryKeys.sendKey', 'Send {{label}}', {
label: key.label
})}
>
<Text style={[styles.accessoryKeyText, !canSend && styles.accessoryKeyTextDisabled]}>
{key.label}
</Text>
</Pressable>
))}
<Pressable
style={({ pressed }) => [
styles.accessoryKey,
vertical && styles.accessoryKeyVertical,
pressed && styles.accessoryKeyPressed
]}
onPress={() => setShowCustomKeyModal(true)}
accessibilityLabel={t('session.accessoryKeys.addCustomShortcut', 'Add custom shortcut')}
>
<Plus size={14} color={colors.textSecondary} strokeWidth={2.2} />
<Ellipsis size={18} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
</ScrollView>
{/* Why: fixed keyboard escape hatch outside the scroll view, so it can't scroll away (#5106). */}
<Pressable
style={({ pressed }) => [
styles.iconKey,
vertical && styles.keyVertical,
pressed && styles.keyPressed,
!keyboardUp && !canCompose && styles.disabled
]}
disabled={!keyboardUp && !canCompose}
onPress={keyboardUp ? dismissSoftwareKeyboard : showKeyboard}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={
keyboardUp
? t('session.accessoryKeys.dismissKeyboard', 'Dismiss keyboard')
: t('session.accessoryKeys.showKeyboard', 'Show keyboard')
}
>
{keyboardUp ? (
<ChevronDown size={18} color={colors.textSecondary} strokeWidth={2.2} />
) : (
<KeyboardIcon size={17} color={colors.textSecondary} strokeWidth={2} />
)}
</Pressable>
<TerminalMoreKeysSheet
visible={sheetVisible}
onClose={() => setSheetVisible(false)}
canSend={canSend}
canPaste={canPaste}
isAttaching={isAttaching}
phoneDisplay={isTerminalPhoneDisplayMode(activeHandle, terminalModes)}
rowKeyIds={runsAgent ? AGENT_ROW_KEY_IDS : SHELL_ROW_KEY_IDS}
builtInKeys={visibleBuiltInAccessoryKeys}
customKeys={customKeys}
onPaste={() => void handlePaste()}
onAttachImage={() => void attachImage('library')}
onAttachFile={() => void attachImage('files')}
onToggleDisplay={() => {
if (activeHandle) {
void toggleDisplayMode(activeHandle)
}
}}
onBuiltInKey={(key) =>
SPECIAL_KEY_LABELS[key.id]
? keys.sendKey(key.id)
: void handleAccessoryKey(createTerminalLiveAccessoryInput(key))
}
onSpecialKey={keys.sendKey}
onCustomKey={(key) => sendBytes(key.bytes)}
onDeleteCustomKey={setDeleteKeyTarget}
onAddCustomKey={() => setShowCustomKeyModal(true)}
/>
</View>
)
}
@@ -33,8 +33,33 @@ vi.mock('react-native', async () => {
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
}
})
vi.mock('lucide-react-native', () => ({ ArrowUp: () => null }))
vi.mock('./MobileTerminalInputActions', () => ({ MobileTerminalInputActions: () => null }))
vi.mock('./MobileTerminalInputActions', () => ({
MobileTerminalInputActions: () => null,
MobileTerminalAttachButton: () => null,
MobileTerminalDictationButton: () => null
}))
vi.mock('../components/composer-input/MessageBox', async () => {
const React = await import('react')
const { TextInput } = await import('react-native')
const Input: React.ElementType = TextInput
return {
MessageBox: ({
inputRef,
placeholder,
tabIndex
}: {
inputRef: React.Ref<unknown>
placeholder?: string
tabIndex?: number
}) => React.createElement(Input, { ref: inputRef, placeholder, tabIndex })
}
})
vi.mock('../components/composer-input/ComposerSendButton', () => ({
ComposerSendButton: () => null
}))
vi.mock('./native-chat/input/MobileNativeChatStopButton', () => ({
MobileNativeChatStopButton: () => null
}))
vi.mock('./MobileTerminalLiveInputStatus', () => ({ MobileTerminalLiveInputStatus: () => null }))
vi.mock('./MobileTerminalCommandDictationPreview', () => ({
MobileTerminalCommandDictationPreview: () => null
@@ -81,10 +106,11 @@ function Harness({ handle, liveInputEnabled }: Selection) {
dictation: { meter: null },
commandFieldAnchorRef: { current: null },
activeHandle: handle,
activeSessionTab: null,
dockKeys: { interceptTypedCharacter: () => false },
keyboardLift: 0,
bindCommandField: (field: TextInput | null) => {
commandInputRef.current = field
},
liveInputRef,
commandInputRef,
bindLiveInputField: (field: TextInput | null) => {
liveInputRef.current = field
}
@@ -116,7 +142,7 @@ it.each(['terminal selection', 'preference hydration'] as const)(
)
)
render(false)
const command = container.querySelector('input[placeholder="Type a command…"]')
const command = container.querySelector('input[placeholder="Write a command, then send"]')
const live = container.querySelector('input[aria-label="Live terminal input"]')
if (!(command instanceof HTMLInputElement) || !(live instanceof HTMLInputElement)) {
throw new Error('Both dock fields must be mounted')
+174 -110
View File
@@ -1,6 +1,9 @@
import { View, TextInput, Pressable } from 'react-native'
import { View, TextInput } from 'react-native'
import { tuiAgentDisplayName } from '../../../src/shared/tui-agent-display-names'
import { hostOs } from '../platform/host-os'
import { ArrowUp } from 'lucide-react-native'
import { ComposerSendButton } from '../components/composer-input/ComposerSendButton'
import { MessageBox } from '../components/composer-input/MessageBox'
import { composerInputStyles as inputStyles } from '../components/composer-input/composer-input-styles'
import {
getTerminalCommandKeyboardType,
getTerminalLiveInputKeyboardType
@@ -8,14 +11,26 @@ import {
import { MobileTerminalLiveInputStatus } from './MobileTerminalLiveInputStatus'
import { MobileTerminalCommandDictationPreview } from './MobileTerminalCommandDictationPreview'
import { readMobileDictationAnchor } from '../dictation/mobile-dictation-text-insertion'
import { MobileTerminalInputActions } from './MobileTerminalInputActions'
import {
MobileTerminalAttachButton,
MobileTerminalDictationButton,
MobileTerminalInputActions
} from './MobileTerminalInputActions'
import { MobileNativeChatStopButton } from './native-chat/input/MobileNativeChatStopButton'
import { MobileSessionAccessoryKeys } from './MobileSessionAccessoryKeys'
import {
isMobileTerminalTabRunningAgent,
resolveMobileTerminalTabOwnedAgentId
} from './mobile-terminal-tab-agent'
import { terminalDockStyles as dockStyles } from './terminal-dock-styles'
import { colors } from '../theme/mobile-theme'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { styles } from './mobile-session-styles'
import type { MobileSessionController } from './use-mobile-session-controller'
const ESC = '\x1b'
// Both fields must remain focusable during the tap that hands the keyboard to the other mode.
const inactiveFieldStyle = { position: 'absolute', left: 0, right: 0, opacity: 0 } as const
@@ -29,6 +44,8 @@ export function sessionCommandDockShown(controller: MobileSessionController): bo
)
}
// Keys mode types straight into the terminal; Message mode writes in the chat composer and sends on tap.
// Agent tabs open in Message, shells in Keys; the key row sends to the terminal in both.
export function MobileSessionCommandDock({
controller,
keysInRail
@@ -45,10 +62,11 @@ export function MobileSessionCommandDock({
liveInputCapture,
dictationMode,
dictationEnabled,
bindCommandField,
handleLiveInputChange,
handleLiveInputKeyPress,
bindLiveInputField,
liveInputRef,
commandInputRef,
submitLiveInput,
canSend,
canCompose,
@@ -58,6 +76,9 @@ export function MobileSessionCommandDock({
liveDictationText,
commandFieldAnchorRef,
activeHandle,
activeSessionTab,
dockKeys,
handleAccessoryKey,
cancelDictation,
handleDictationToggle,
handleDictationPressIn,
@@ -67,34 +88,69 @@ export function MobileSessionCommandDock({
attachImage,
keyboardLift
} = controller
if (!sessionCommandDockShown(controller)) {
return null
}
const terminalTab = activeSessionTab?.type === 'terminal' ? activeSessionTab : null
const runsAgent = terminalTab !== null && isMobileTerminalTabRunningAgent(terminalTab)
const agentId =
terminalTab && runsAgent ? resolveMobileTerminalTabOwnedAgentId(terminalTab) : null
const agentName = agentId ? tuiAgentDisplayName(agentId) : null
const draft = bufferedTerminalDraftState.input
const showStop =
runsAgent && terminalTab?.agentStatus?.state === 'working' && draft.trim().length === 0
const attachProps = {
canSend,
isAttaching,
onAttachImage: () => void attachImage('library'),
onAttachFile: () => void attachImage('files')
}
const dictationProps = {
canSend,
dictation,
dictationMode,
dictationEnabled,
activeButtonStyle: styles.dictationButtonActive,
onDictationToggle: handleDictationToggle,
onDictationPressIn: handleDictationPressIn,
onDictationPressOut: handleDictationPressOut,
onDictationCancel: cancelDictation
}
return (
sessionCommandDockShown(controller) && (
<View
style={[
styles.commandDock,
{ paddingBottom: insets.bottom, transform: [{ translateY: -keyboardLift }] }
]}
>
{}
{keysInRail ? null : <MobileSessionAccessoryKeys controller={controller} />}
{}
<View style={styles.inputBar}>
<View
style={[styles.liveInputField, !liveInputEnabled && inactiveFieldStyle]}
pointerEvents={liveInputEnabled ? 'auto' : 'none'}
aria-hidden={!liveInputEnabled}
accessibilityElementsHidden={!liveInputEnabled}
importantForAccessibility={liveInputEnabled ? 'auto' : 'no-hide-descendants'}
>
<View
style={[
dockStyles.dock,
{ paddingBottom: insets.bottom, transform: [{ translateY: -keyboardLift }] }
]}
>
<View>
<View
style={[dockStyles.keysBar, !liveInputEnabled && inactiveFieldStyle]}
pointerEvents={liveInputEnabled ? 'auto' : 'none'}
aria-hidden={!liveInputEnabled}
accessibilityElementsHidden={!liveInputEnabled}
importantForAccessibility={liveInputEnabled ? 'auto' : 'no-hide-descendants'}
>
<View style={[inputStyles.field, dockStyles.liveField]}>
<TextInput
ref={bindLiveInputField}
style={[
styles.textInput,
styles.liveInputCapture,
inputStyles.fieldText,
inputStyles.mono,
!canSend && styles.liveInputCaptureDisabled
]}
value={liveInputCapture}
onChange={handleLiveInputChange}
onChange={(event) => {
if (
!dockKeys.interceptTypedCharacter(
liveInputCapture,
event.nativeEvent.text,
liveInputRef
)
) {
handleLiveInputChange(event)
}
}}
onKeyPress={handleLiveInputKeyPress}
onSubmitEditing={submitLiveInput}
placeholder={t('session.terminalLiveInputStatus.liveInput', 'Live input')}
@@ -128,94 +184,102 @@ export function MobileSessionCommandDock({
/>
) : null}
</View>
<View
style={[styles.commandField, liveInputEnabled && inactiveFieldStyle]}
pointerEvents={liveInputEnabled ? 'none' : 'auto'}
aria-hidden={liveInputEnabled}
accessibilityElementsHidden={liveInputEnabled}
importantForAccessibility={liveInputEnabled ? 'no-hide-descendants' : 'auto'}
>
<TextInput
ref={bindCommandField}
// Why: Android caches IME inputType at mount, so toggling autocomplete must remount there; iOS updates in place.
key={
hostOs() === 'android'
? autocompleteEnabled
? 'cmd-input-ac-on'
: 'cmd-input-ac-off'
: 'cmd-input'
}
style={[
styles.textInput,
styles.commandFieldInput,
commandDictationPreview && styles.commandFieldInputHidden
]}
value={bufferedTerminalDraftState.input}
// Why: iOS kills active dictation/IME if JS writes a value differing from native text; store raw, normalize at send.
onChangeText={bufferedTerminalDraftState.setInput}
onSelectionChange={(e) => {
commandFieldAnchorRef.current = {
handle: activeHandle,
anchor: readMobileDictationAnchor(e, bufferedTerminalDraftState.input)
}
}}
// The preview stands in for an empty draft, so its placeholder must not show through.
placeholder={
commandDictationPreview
? undefined
: t('session.commandDock.commandPlaceholder', 'Type a command…')
}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={autocompleteEnabled}
spellCheck={autocompleteEnabled}
smartInsertDelete={false}
// Why: not autofill content, but keyboard must stay default so non-Latin IMEs remain selectable.
autoComplete="off"
keyboardType={getTerminalCommandKeyboardType(hostOs(), autocompleteEnabled)}
returnKeyType="send"
blurOnSubmit={false}
// Why: composing is local — an outage must not lock the field or discard typed text (#6713).
editable={canCompose}
tabIndex={liveInputEnabled ? -1 : 0}
onSubmitEditing={() => void handleSend()}
/>
{!liveInputEnabled && commandDictationPreview ? (
<MobileTerminalCommandDictationPreview
draft={bufferedTerminalDraftState.input}
preview={commandDictationPreview}
/>
) : null}
</View>
<MobileTerminalInputActions
canSend={canSend}
isAttaching={isAttaching}
dictation={dictation}
dictationMode={dictationMode}
dictationEnabled={dictationEnabled}
meterHalo={liveInputEnabled ? undefined : dictation.meter}
buttonStyle={styles.dictationButton}
activeButtonStyle={styles.dictationButtonActive}
disabledButtonStyle={styles.sendButtonDisabled}
onAttachImage={() => void attachImage('library')}
onAttachFile={() => void attachImage('files')}
onDictationToggle={handleDictationToggle}
onDictationPressIn={handleDictationPressIn}
onDictationPressOut={handleDictationPressOut}
onDictationCancel={cancelDictation}
{...attachProps}
{...dictationProps}
buttonStyle={inputStyles.ghostButton}
disabledButtonStyle={inputStyles.disabled}
/>
</View>
<View
style={liveInputEnabled && inactiveFieldStyle}
pointerEvents={liveInputEnabled ? 'none' : 'auto'}
aria-hidden={liveInputEnabled}
accessibilityElementsHidden={liveInputEnabled}
importantForAccessibility={liveInputEnabled ? 'no-hide-descendants' : 'auto'}
>
<MessageBox
// Why: Android caches IME inputType at mount, so toggling autocomplete must remount there; iOS updates in place.
key={hostOs() === 'android' ? `message-ac-${autocompleteEnabled}` : 'message'}
inputRef={commandInputRef}
docked
monospace={!runsAgent}
style={dockStyles.composer}
value={draft}
// Why: iOS kills active dictation/IME if JS writes a value differing from native text; store raw, normalize at send.
onChangeText={(text) => {
if (!dockKeys.interceptTypedCharacter(draft, text, commandInputRef)) {
bufferedTerminalDraftState.setInput(text)
}
}}
onSelectionChange={(e) => {
commandFieldAnchorRef.current = {
handle: activeHandle,
anchor: readMobileDictationAnchor(e, draft)
}
}}
inputOverlay={
!liveInputEnabled && commandDictationPreview ? (
<MobileTerminalCommandDictationPreview
draft={draft}
preview={commandDictationPreview}
monospace={!runsAgent}
/>
) : undefined
}
// The preview stands in for an empty draft, so its placeholder must not show through.
placeholder={
commandDictationPreview
? undefined
: runsAgent
? t('session.commandDock.messageAgentPlaceholder', 'Message {{agent}}', {
agent: agentName ?? t('session.commandDock.theAgent', 'the agent')
})
: t('session.commandDock.writeCommandPlaceholder', 'Write a command, then send')
}
autoCapitalize="none"
autoCorrect={autocompleteEnabled}
spellCheck={autocompleteEnabled}
smartInsertDelete={false}
// Why: not autofill content, but keyboard must stay default so non-Latin IMEs remain selectable.
autoComplete="off"
keyboardType={getTerminalCommandKeyboardType(hostOs(), autocompleteEnabled)}
// Why: composing is local — an outage must not lock the field or discard typed text (#6713).
editable={canCompose}
tabIndex={liveInputEnabled ? -1 : 0}
leading={
<MobileTerminalAttachButton
{...attachProps}
buttonStyle={inputStyles.controlButton}
disabledButtonStyle={inputStyles.disabled}
/>
}
trailing={
<>
<MobileTerminalDictationButton
{...dictationProps}
meterHalo={dictation.meter}
buttonStyle={inputStyles.ghostButton}
disabledButtonStyle={inputStyles.disabled}
/>
{showStop ? (
<MobileNativeChatStopButton
held={false}
onStop={() => void handleAccessoryKey({ bytes: ESC })}
/>
) : (
<ComposerSendButton
onPress={() => void handleSend()}
canSend={canSend && draft.trim().length > 0}
label={t('session.commandDock.sendCommand', 'Send command')}
/>
)}
</>
}
/>
{liveInputEnabled ? null : (
<Pressable
style={[styles.sendButton, !canSend && styles.sendButtonDisabled]}
disabled={!canSend}
onPress={() => void handleSend()}
accessibilityLabel={t('session.commandDock.sendCommand', 'Send command')}
>
<ArrowUp size={18} color={colors.textSecondary} strokeWidth={2.5} />
</Pressable>
)}
</View>
</View>
)
{keysInRail ? null : <MobileSessionAccessoryKeys controller={controller} />}
</View>
)
}
+27 -34
View File
@@ -1,6 +1,8 @@
import { View, Text, TextInput, Pressable } from 'react-native'
import { View, Text, Pressable } from 'react-native'
import { MessageSquare, Plus, X } from 'lucide-react-native'
import { MobileSyntaxSegments } from '../components/MobileSyntaxSegments'
import { MessageBox } from '../components/composer-input/MessageBox'
import { composerInputStyles } from '../components/composer-input/composer-input-styles'
import { colors } from '../theme/mobile-theme'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
@@ -128,44 +130,35 @@ export function DiffLineRow({
</View>
) : null}
{isCommenting ? (
<View style={styles.diffCommentComposer}>
<TextInput
style={[styles.textInput, styles.diffCommentInput]}
value={commentDraft}
onChangeText={onDraftChange}
placeholder={t('session.diffLineRow.notePlaceholder', 'Add review note')}
placeholderTextColor={colors.textMuted}
editable={!commentsBusy}
multiline
textAlignVertical="top"
autoFocus
/>
<View style={styles.diffCommentComposerActions}>
<MessageBox
style={styles.diffCommentComposer}
value={commentDraft}
onChangeText={onDraftChange}
placeholder={t('session.diffLineRow.notePlaceholder', 'Add review note')}
editable={!commentsBusy}
autoFocus
onSend={() => {
if (commentLine !== undefined) {
onSubmitComment(commentLine)
}
}}
canSend={commentDraft.trim().length > 0 && !commentsBusy}
sendAccessibilityLabel={t('session.diffLineRow.saveNote', 'Save note')}
leading={
<Pressable
style={styles.diffCommentSecondaryAction}
style={({ pressed }) => [
composerInputStyles.ghostButton,
pressed && composerInputStyles.pressed
]}
disabled={commentsBusy}
onPress={onCancelComment}
accessibilityRole="button"
accessibilityLabel={t('common.cancel', 'Cancel')}
>
<Text style={styles.diffCommentSecondaryText}>{t('common.cancel', 'Cancel')}</Text>
<X size={19} color={colors.textSecondary} strokeWidth={2} />
</Pressable>
<Pressable
style={[
styles.diffCommentPrimaryAction,
(!commentDraft.trim() || commentsBusy) && styles.diffCommentButtonDisabled
]}
disabled={!commentDraft.trim() || commentsBusy}
onPress={() => {
if (commentLine !== undefined) {
onSubmitComment(commentLine)
}
}}
>
<Text style={styles.diffCommentPrimaryText}>
{t('session.diffLineRow.saveNote', 'Save note')}
</Text>
</Pressable>
</View>
</View>
}
/>
) : null}
</View>
)
@@ -1,6 +1,9 @@
import { StyleSheet, Text, View } from 'react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
import { colors, typography } from '../theme/mobile-theme'
import {
COMPOSER_LINE_HEIGHT,
COMPOSER_TEXT_SIZE
} from '../components/composer-input/composer-input-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { themedStyleSheet } from '../theme/themed-styles'
import { TailClippedText } from '../components/TailClippedText'
@@ -12,16 +15,18 @@ import {
// Drawn over the command field while its own text is hidden: writing the field would end the keyboard's dictation or IME input.
export function MobileTerminalCommandDictationPreview({
draft,
preview
preview,
monospace
}: {
draft: string
preview: MobileDictationPreview
monospace: boolean
}) {
useMobileColorScheme()
const { before, after } = splitDraftForDictation(draft, preview.text, preview.anchor)
return (
<View pointerEvents="none" aria-hidden style={styles.layer}>
<TailClippedText style={styles.text} after={after}>
<TailClippedText style={[styles.text, monospace && styles.mono]} after={after}>
{before}
<Text style={styles.heard}>{preview.text}</Text>
</TailClippedText>
@@ -30,14 +35,18 @@ export function MobileTerminalCommandDictationPreview({
}
const styles = themedStyleSheet(() => ({
// Matches the composer's text inset so the preview sits where the typed draft would.
layer: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
paddingHorizontal: spacing.md
paddingHorizontal: 6,
paddingTop: 2
},
text: {
color: colors.textPrimary,
fontSize: TEXT_INPUT_FONT_SIZE,
fontSize: COMPOSER_TEXT_SIZE,
lineHeight: COMPOSER_LINE_HEIGHT
},
mono: {
fontFamily: typography.monoFamily
},
heard: {
@@ -13,9 +13,8 @@ type DictationState = {
readonly isProcessing: boolean
}
type MobileTerminalInputActionsProps = {
type MobileTerminalDictationButtonProps = {
readonly canSend: boolean
readonly isAttaching: boolean
readonly dictation: DictationState
readonly dictationMode: string | undefined
readonly dictationEnabled?: boolean
@@ -24,17 +23,72 @@ type MobileTerminalInputActionsProps = {
readonly buttonStyle: StyleProp<ViewStyle>
readonly activeButtonStyle: StyleProp<ViewStyle>
readonly disabledButtonStyle: StyleProp<ViewStyle>
readonly onAttachImage: () => void
readonly onAttachFile: () => void
readonly onDictationToggle: () => void
readonly onDictationPressIn: () => void
readonly onDictationPressOut: () => void
readonly onDictationCancel: () => void
}
export function MobileTerminalInputActions({
type MobileTerminalAttachButtonProps = {
readonly canSend: boolean
readonly isAttaching: boolean
readonly buttonStyle: StyleProp<ViewStyle>
readonly disabledButtonStyle: StyleProp<ViewStyle>
readonly onAttachImage: () => void
readonly onAttachFile: () => void
}
type MobileTerminalInputActionsProps = MobileTerminalDictationButtonProps &
MobileTerminalAttachButtonProps
export function MobileTerminalInputActions(props: MobileTerminalInputActionsProps) {
return (
<>
<MobileTerminalAttachButton {...props} />
<MobileTerminalDictationButton {...props} />
</>
)
}
export function MobileTerminalAttachButton({
canSend,
isAttaching,
buttonStyle,
disabledButtonStyle,
onAttachImage,
onAttachFile
}: MobileTerminalAttachButtonProps) {
useMobileColorScheme()
const t = useMobileTranslation()
return (
<Pressable
style={[buttonStyle, (!canSend || isAttaching) && disabledButtonStyle]}
disabled={!canSend || isAttaching}
// Upload through host RPC so remote/SSH terminals attach images the same way as local terminals.
onPress={onAttachImage}
onLongPress={onAttachFile}
delayLongPress={350}
accessibilityLabel={
isAttaching
? t('session.terminalInputActions.sendingImage', 'Sending image')
: t('session.terminalInputActions.attachPhoto', 'Attach a photo')
}
accessibilityHint={t(
'session.terminalInputActions.attachFileHint',
'Long press to attach a file instead'
)}
>
{isAttaching ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<ImagePlus size={17} color={colors.textSecondary} strokeWidth={2.4} />
)}
</Pressable>
)
}
export function MobileTerminalDictationButton({
canSend,
dictation,
dictationMode,
dictationEnabled = true,
@@ -42,15 +96,16 @@ export function MobileTerminalInputActions({
buttonStyle,
activeButtonStyle,
disabledButtonStyle,
onAttachImage,
onAttachFile,
onDictationToggle,
onDictationPressIn,
onDictationPressOut,
onDictationCancel
}: MobileTerminalInputActionsProps) {
}: MobileTerminalDictationButtonProps) {
useMobileColorScheme()
const t = useMobileTranslation()
if (!dictationEnabled) {
return null
}
const dictationActive = dictation.isStarting || dictation.isRecording
// Dictation outlives a reconnect, so its stop and cancel must too; only starting needs the connection.
const micDisabled = !canSend && !dictationActive && !dictation.isProcessing
@@ -100,42 +155,13 @@ export function MobileTerminalInputActions({
)}
</Pressable>
)
return (
<>
<Pressable
style={[buttonStyle, (!canSend || isAttaching) && disabledButtonStyle]}
disabled={!canSend || isAttaching}
// Upload through host RPC so remote/SSH terminals attach images the same way as local terminals.
onPress={onAttachImage}
onLongPress={onAttachFile}
delayLongPress={350}
accessibilityLabel={
isAttaching
? t('session.terminalInputActions.sendingImage', 'Sending image')
: t('session.terminalInputActions.attachPhoto', 'Attach a photo')
}
accessibilityHint={t(
'session.terminalInputActions.attachFileHint',
'Long press to attach a file instead'
)}
>
{isAttaching ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<ImagePlus size={17} color={colors.textSecondary} strokeWidth={2.4} />
)}
</Pressable>
{dictationEnabled ? (
meterHalo ? (
// Always wrapped, so the mic never remounts under a held press.
<View>
{dictation.isRecording ? <MobileDictationHalo meter={meterHalo} size={34} /> : null}
{mic}
</View>
) : (
mic
)
) : null}
</>
return meterHalo ? (
// Always wrapped, so the mic never remounts under a held press.
<View>
{dictation.isRecording ? <MobileDictationHalo meter={meterHalo} size={34} /> : null}
{mic}
</View>
) : (
mic
)
}
+6 -21
View File
@@ -1,10 +1,11 @@
import { useState } from 'react'
import { View, Text, Pressable, TextInput, Switch } from 'react-native'
import { View, Text, Pressable, Switch } from 'react-native'
import { ChevronDown, ChevronRight } from 'lucide-react-native'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
import { MobileAgentIcon } from '../components/MobileAgentIcon'
import { MessageBox } from '../components/composer-input/MessageBox'
import { TextField } from '../components/composer-input/TextField'
import {
getQuickCommandAgentLabel,
getTerminalQuickCommandBodySize,
@@ -100,12 +101,10 @@ export function QuickCommandEditorForm({
<View style={styles.form}>
<View style={styles.field}>
<Text style={styles.label}>{t('session.quickCommandEditorForm.label', 'Label')}</Text>
<TextInput
style={styles.input}
<TextField
value={draft.label}
onChangeText={(label) => onChange({ label })}
placeholder={t('session.quickCommandEditorForm.labelPlaceholder', 'Start dev server')}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
maxLength={MAX_QUICK_COMMAND_LABEL_LENGTH}
@@ -146,8 +145,8 @@ export function QuickCommandEditorForm({
? t('session.quickCommandEditorForm.prompt', 'Prompt')
: t('session.quickCommandEditorForm.commandText', 'Command Text')}
</Text>
<TextInput
style={[styles.input, styles.textarea, !isAgent && styles.mono]}
<MessageBox
monospace={!isAgent}
value={isAgent ? draft.prompt : draft.command}
onChangeText={(text) => onChange(isAgent ? { prompt: text } : { command: text })}
placeholder={
@@ -158,10 +157,8 @@ export function QuickCommandEditorForm({
)
: 'npm run dev'
}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
multiline
// Why: maxLength drops the tail of a paste silently, so prompts show a refusal instead.
maxLength={isAgent ? undefined : MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH}
/>
@@ -322,18 +319,6 @@ const styles = themedStyleSheet(() => ({
form: { gap: spacing.md, paddingTop: spacing.xs, paddingBottom: spacing.sm },
field: { gap: spacing.sm },
label: { fontSize: 12, fontWeight: '600', color: colors.textSecondary },
input: {
backgroundColor: colors.bgPanel,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
fontSize: TEXT_INPUT_FONT_SIZE,
borderWidth: 1,
borderColor: colors.borderSubtle
},
textarea: { minHeight: 92, textAlignVertical: 'top' },
mono: { fontFamily: typography.monoFamily },
hint: { fontSize: 12, color: colors.textMuted },
error: { fontSize: 13, color: colors.statusRed, marginTop: spacing.xs },
pressed: { backgroundColor: colors.bgRaised },
+13 -32
View File
@@ -1,10 +1,10 @@
import { View, Text, Pressable, TextInput, StyleSheet, ActivityIndicator } from 'react-native'
import { View, Text, Pressable, StyleSheet, ActivityIndicator } from 'react-native'
import { Check, Plus, Search } from 'lucide-react-native'
import { colors, spacing } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
import { MobileAgentIcon } from '../components/MobileAgentIcon'
import { TextField } from '../components/composer-input/TextField'
import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog'
import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types'
import type { TuiAgent } from '../../../src/shared/tui-agent'
@@ -56,24 +56,17 @@ export function QuickCommandsList({
return (
<View style={styles.listBody}>
{showSearch ? (
<View style={styles.search}>
<Search size={16} color={colors.textMuted} />
<TextInput
style={styles.searchInput}
value={query}
onChangeText={onQueryChange}
placeholder={t(
'session.quickCommandsList.searchPlaceholder',
'Search quick commands...'
)}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
// Why: bound pasted text before it reaches the per-keystroke JS
// search path; useful queries are far smaller than this budget.
maxLength={QUICK_COMMAND_SEARCH_QUERY_MAX_LENGTH}
/>
</View>
<TextField
leading={<Search size={16} color={colors.textMuted} />}
value={query}
onChangeText={onQueryChange}
placeholder={t('session.quickCommandsList.searchPlaceholder', 'Search quick commands...')}
autoCapitalize="none"
autoCorrect={false}
// Why: bound pasted text before it reaches the per-keystroke JS
// search path; useful queries are far smaller than this budget.
maxLength={QUICK_COMMAND_SEARCH_QUERY_MAX_LENGTH}
/>
) : null}
{error ? <Text style={styles.error}>{error}</Text> : null}
@@ -208,18 +201,6 @@ const styles = themedStyleSheet(() => ({
pressed: { backgroundColor: colors.bgRaised },
disabled: { opacity: 0.45 },
listBody: { gap: spacing.sm, paddingBottom: spacing.sm },
search: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
backgroundColor: colors.bgPanel,
borderRadius: 8,
borderWidth: 1,
borderColor: colors.borderSubtle,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm
},
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: {
+172
View File
@@ -0,0 +1,172 @@
import { useEffect, useRef, useState } from 'react'
import { View, type GestureResponderEvent, type LayoutChangeEvent } from 'react-native'
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from 'lucide-react-native'
import { colors } from '../theme/mobile-theme'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { terminalDockStyles as styles } from './terminal-dock-styles'
export type TerminalArrowKey = 'arrowUp' | 'arrowDown' | 'arrowLeft' | 'arrowRight'
const DRAG_START_DISTANCE = 10
function directionOf(dx: number, dy: number): TerminalArrowKey {
if (Math.abs(dx) > Math.abs(dy)) {
return dx < 0 ? 'arrowLeft' : 'arrowRight'
}
return dy < 0 ? 'arrowUp' : 'arrowDown'
}
// The pad is wider than tall, so compare against each half-size to split it into four even sides.
function sideAt(x: number, y: number, width: number, height: number): TerminalArrowKey {
return directionOf((x - width / 2) / (width / 2), (y - height / 2) / (height / 2))
}
type Drag = {
readonly x: number
readonly y: number
direction: TerminalArrowKey | null
}
// One key for all four arrows: tap a side for one press, or hold and drag to repeat.
export function TerminalArrowPad({
onPressStart,
onTap,
onRepeatStart,
onRepeatStop,
onHoldChange,
onAccessibilityArrow,
disabled
}: {
/** The finger landed on a side; a tap sends that side when it lifts. */
onPressStart: (key: TerminalArrowKey) => void
onTap: () => void
/** Starts repeating an arrow; a new direction replaces the old one. */
onRepeatStart: (key: TerminalArrowKey) => void
onRepeatStop: () => void
onAccessibilityArrow: (key: TerminalArrowKey) => void
/** Lets the key row stop scrolling while a finger is on the pad. */
onHoldChange: (held: boolean) => void
disabled: boolean
}): React.JSX.Element {
useMobileColorScheme()
const t = useMobileTranslation()
const [hot, setHot] = useState<TerminalArrowKey | null>(null)
const [dragging, setDragging] = useState(false)
const sizeRef = useRef({ width: 66, height: 32 })
const dragRef = useRef<Drag | null>(null)
const onRepeatStopRef = useRef(onRepeatStop)
useEffect(() => {
onRepeatStopRef.current = onRepeatStop
})
// An unmounting pad never sees the finger lift.
useEffect(() => () => onRepeatStopRef.current(), [])
const end = (event: GestureResponderEvent | null): void => {
const drag = dragRef.current
dragRef.current = null
if (drag?.direction) {
onRepeatStop()
} else if (event && drag) {
onTap()
}
setHot(null)
setDragging(false)
onHoldChange(false)
}
const iconColor = (key: TerminalArrowKey) =>
hot === key ? colors.textPrimary : colors.textSecondary
return (
<View
// The chevrons must not become the touch target, or locationX/Y would be measured from them.
pointerEvents="box-only"
style={[styles.arrowPad, dragging && styles.arrowPadDragging, disabled && styles.disabled]}
onLayout={(event: LayoutChangeEvent) => {
sizeRef.current = event.nativeEvent.layout
}}
onStartShouldSetResponder={() => !disabled}
onResponderTerminationRequest={() => false}
onResponderGrant={(event) => {
const { pageX, pageY, locationX, locationY } = event.nativeEvent
dragRef.current = { x: pageX, y: pageY, direction: null }
const { width, height } = sizeRef.current
const side = sideAt(locationX, locationY, width, height)
setHot(side)
onHoldChange(true)
onPressStart(side)
}}
onResponderMove={(event) => {
const drag = dragRef.current
if (!drag) {
return
}
const dx = event.nativeEvent.pageX - drag.x
const dy = event.nativeEvent.pageY - drag.y
if (Math.hypot(dx, dy) <= DRAG_START_DISTANCE) {
return
}
const direction = directionOf(dx, dy)
setDragging(true)
setHot(direction)
if (direction !== drag.direction) {
drag.direction = direction
onRepeatStart(direction)
}
}}
onResponderRelease={end}
onResponderTerminate={() => end(null)}
accessible
accessibilityRole="adjustable"
accessibilityLabel={t('session.terminalArrowPad.label', 'Arrow keys')}
accessibilityHint={t(
'session.terminalArrowPad.hint',
'Tap a side for one arrow press, or hold and drag to repeat'
)}
accessibilityActions={[
{ name: 'arrowUp', label: t('session.terminalArrowPad.up', 'Up') },
{ name: 'arrowDown', label: t('session.terminalArrowPad.down', 'Down') },
{ name: 'arrowLeft', label: t('session.terminalArrowPad.left', 'Left') },
{ name: 'arrowRight', label: t('session.terminalArrowPad.right', 'Right') },
{ name: 'increment', label: t('session.terminalArrowPad.up', 'Up') },
{ name: 'decrement', label: t('session.terminalArrowPad.down', 'Down') }
]}
onAccessibilityAction={({ nativeEvent }) => {
const name = nativeEvent.actionName
if (name === 'increment') {
onAccessibilityArrow('arrowUp')
} else if (name === 'decrement') {
onAccessibilityArrow('arrowDown')
} else if (
name === 'arrowUp' ||
name === 'arrowDown' ||
name === 'arrowLeft' ||
name === 'arrowRight'
) {
onAccessibilityArrow(name)
}
}}
>
<ChevronLeft
size={12}
strokeWidth={2.6}
color={iconColor('arrowLeft')}
style={styles.arrowLeft}
/>
<ChevronRight
size={12}
strokeWidth={2.6}
color={iconColor('arrowRight')}
style={styles.arrowRight}
/>
<ChevronUp size={11} strokeWidth={2.6} color={iconColor('arrowUp')} style={styles.arrowUp} />
<ChevronDown
size={11}
strokeWidth={2.6}
color={iconColor('arrowDown')}
style={styles.arrowDown}
/>
</View>
)
}
@@ -0,0 +1,241 @@
import { useRef } from 'react'
import { Pressable, Text, View } from 'react-native'
import { ClipboardPaste, FileUp, ImagePlus, Monitor, Plus, Smartphone } from 'lucide-react-native'
import type { LucideIcon } from 'lucide-react-native'
import { BottomDrawer } from '../components/BottomDrawer'
import { triggerMediumImpact } from '../platform/haptics'
import type { TerminalAccessoryKey } from '../terminal/terminal-accessory-keys'
import { TERMINAL_SHORTCUT_SPECIAL_KEYS } from '../terminal/terminal-accessory-keys'
import type { CustomKey } from '../terminal/terminal-custom-keys'
import { colors } from '../theme/mobile-theme'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { terminalDockStyles as styles } from './terminal-dock-styles'
const EXTRA_KEY_IDS = new Set([
'home',
'end',
'pageUp',
'pageDown',
'insert',
'f1',
'f2',
'f3',
'f4',
'f5',
'f6',
'f7',
'f8',
'f9',
'f10',
'f11',
'f12'
])
const EXTRA_KEYS = TERMINAL_SHORTCUT_SPECIAL_KEYS.filter((key) => EXTRA_KEY_IDS.has(key.id))
type TerminalMoreKeysSheetProps = {
/** Keys the current row already shows, which the sheet leaves out. */
readonly rowKeyIds: ReadonlySet<string>
readonly visible: boolean
readonly onClose: () => void
readonly canSend: boolean
readonly canPaste: boolean
readonly isAttaching: boolean
readonly phoneDisplay: boolean
readonly builtInKeys: readonly TerminalAccessoryKey[]
readonly customKeys: readonly CustomKey[]
readonly onPaste: () => void
readonly onAttachImage: () => void
readonly onAttachFile: () => void
readonly onToggleDisplay: () => void
readonly onBuiltInKey: (key: TerminalAccessoryKey) => void
readonly onSpecialKey: (id: string) => void
readonly onCustomKey: (key: CustomKey) => void
readonly onDeleteCustomKey: (key: CustomKey) => void
readonly onAddCustomKey: () => void
}
// Everything that does not fit on the key row: clipboard, attachments, the rest of the keys and the user's own keys.
export function TerminalMoreKeysSheet({
visible,
onClose,
canSend,
canPaste,
isAttaching,
phoneDisplay,
rowKeyIds,
builtInKeys,
customKeys,
onPaste,
onAttachImage,
onAttachFile,
onToggleDisplay,
onBuiltInKey,
onSpecialKey,
onCustomKey,
onDeleteCustomKey,
onAddCustomKey
}: TerminalMoreKeysSheetProps): React.JSX.Element {
useMobileColorScheme()
const t = useMobileTranslation()
// Pickers and modals open after the sheet is gone so they never stack under it.
const afterCloseRef = useRef<(() => void) | null>(null)
const closeThen = (action: () => void) => {
afterCloseRef.current = action
onClose()
}
const sheetKeys = builtInKeys.filter((key) => !rowKeyIds.has(key.id))
const combos = sheetKeys.filter((key) => key.id.startsWith('ctrl'))
const keys = sheetKeys.filter((key) => !key.id.startsWith('ctrl'))
const action = (label: string, Icon: LucideIcon, onPress: () => void, enabled = canSend) => (
<Pressable
key={label}
style={({ pressed }) => [
styles.sheetAction,
pressed && styles.keyPressed,
!enabled && styles.disabled
]}
disabled={!enabled}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={label}
>
<Icon size={16} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.sheetActionText}>{label}</Text>
</Pressable>
)
const key = (id: string, label: string, accessibilityLabel: string, onPress: () => void) => (
<Pressable
key={id}
style={({ pressed }) => [
styles.key,
pressed && styles.keyPressed,
!canSend && styles.disabled
]}
disabled={!canSend}
onPress={() => {
onPress()
onClose()
}}
accessibilityRole="button"
accessibilityLabel={t('session.terminalMoreKeys.sendKey', 'Send {{label}}', {
label: accessibilityLabel
})}
>
<Text style={styles.keyText}>{label}</Text>
</Pressable>
)
return (
<BottomDrawer
visible={visible}
onClose={onClose}
onAfterClose={() => {
const run = afterCloseRef.current
afterCloseRef.current = null
run?.()
}}
dragContentToDismiss
>
<View style={styles.sheetContent}>
<View style={styles.sheetActions}>
{canPaste
? action(t('session.terminalMoreKeys.paste', 'Paste'), ClipboardPaste, () =>
closeThen(onPaste)
)
: null}
{action(
isAttaching
? t('session.terminalMoreKeys.sending', 'Sending…')
: t('session.terminalMoreKeys.photo', 'Photo'),
ImagePlus,
() => closeThen(onAttachImage),
canSend && !isAttaching
)}
{action(
t('session.terminalMoreKeys.file', 'File'),
FileUp,
() => closeThen(onAttachFile),
canSend && !isAttaching
)}
{action(
phoneDisplay
? t('session.terminalMoreKeys.desktopWidth', 'Desktop width')
: t('session.terminalMoreKeys.phoneWidth', 'Phone width'),
phoneDisplay ? Monitor : Smartphone,
() => closeThen(onToggleDisplay)
)}
</View>
<Text style={styles.sheetHeading}>{t('session.terminalMoreKeys.keys', 'Keys')}</Text>
<View style={styles.sheetGrid}>
{keys.map((k) =>
key(k.id, k.label, k.accessibilityLabel ?? k.label, () => onBuiltInKey(k))
)}
{EXTRA_KEYS.map((k) =>
key(k.id, k.label, k.accessibilityLabel, () => onSpecialKey(k.id))
)}
</View>
{combos.length > 0 ? (
<>
<Text style={styles.sheetHeading}>
{t('session.terminalMoreKeys.combos', 'Combos')}
</Text>
<View style={styles.sheetGrid}>
{combos.map((k) =>
key(k.id, k.label, k.accessibilityLabel ?? k.label, () => onBuiltInKey(k))
)}
</View>
</>
) : null}
<Text style={styles.sheetHeading}>
{t('session.terminalMoreKeys.yourKeys', 'Your keys')}
</Text>
<View style={styles.sheetGrid}>
{customKeys.map((custom) => (
<Pressable
key={custom.id}
style={({ pressed }) => [
styles.key,
pressed && styles.keyPressed,
!canSend && styles.disabled
]}
disabled={!canSend}
onPress={() => {
onCustomKey(custom)
onClose()
}}
onLongPress={() => {
triggerMediumImpact()
closeThen(() => onDeleteCustomKey(custom))
}}
delayLongPress={400}
accessibilityRole="button"
accessibilityLabel={t('session.terminalMoreKeys.sendKey', 'Send {{label}}', {
label: custom.label
})}
accessibilityHint={t(
'session.terminalMoreKeys.deleteKeyHint',
'Long press to delete this key'
)}
>
<Text style={styles.keyText}>{custom.label}</Text>
</Pressable>
))}
<Pressable
style={({ pressed }) => [styles.ghostKey, pressed && styles.keyPressed]}
onPress={() => closeThen(onAddCustomKey)}
accessibilityRole="button"
accessibilityLabel={t('session.terminalMoreKeys.addCustomKey', 'Add custom key')}
>
<Plus size={14} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={[styles.keyText, styles.ghostKeyText]}>
{t('session.terminalMoreKeys.addKey', 'Add key')}
</Text>
</Pressable>
</View>
</View>
</BottomDrawer>
)
}
@@ -1,8 +1,6 @@
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import type { TuiAgent } from '../../../src/shared/tui-agent'
import { isClaudeManagementTitle } from '../../../src/shared/agent-title-core'
import { isShellProcess } from '../../../src/shared/shell-process-detection'
import { resolveMobileTerminalTabOwnedAgentId } from './mobile-terminal-tab-agent'
import { isMobileTerminalTabRunningAgent } from './mobile-terminal-tab-agent'
export type AgentSendKeyboardDismissalTab = {
readonly type: string
@@ -14,7 +12,7 @@ export type AgentSendKeyboardDismissalTab = {
readonly launchAgent?: TuiAgent | null
}
// Agent sends hand over the turn, so reveal the reply; shell commands keep the keyboard for command bursts. Count launch hints before status arrives.
// Agent sends hand over the turn, so reveal the reply; shell commands keep the keyboard for command bursts.
export function shouldDismissKeyboardAfterTerminalSend(
tab: AgentSendKeyboardDismissalTab | null | undefined,
accepted: boolean
@@ -22,11 +20,5 @@ export function shouldDismissKeyboardAfterTerminalSend(
if (!accepted || !tab || tab.type !== 'terminal') {
return false
}
if (
tab.agentStatus?.state === 'done' &&
(isShellProcess(tab.title) || isClaudeManagementTitle(tab.title))
) {
return false
}
return resolveMobileTerminalTabOwnedAgentId(tab) !== null
return isMobileTerminalTabRunningAgent(tab)
}
@@ -27,6 +27,7 @@ vi.mock('react-native', async () => {
}
})
vi.mock('lucide-react-native', () => ({
ArrowUp: 'ArrowUp',
Copy: 'Copy',
MessageSquare: 'MessageSquare',
Send: 'Send',
@@ -1,7 +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'
import { themedStyleSheet } from '../theme/themed-styles'
export const mobileSessionCommandInputStyles = themedStyleSheet(() => ({
@@ -66,100 +65,6 @@ export const mobileSessionCommandInputStyles = themedStyleSheet(() => ({
fontSize: typography.bodySize,
fontWeight: '600'
},
commandDock: {
zIndex: 20,
backgroundColor: colors.bgPanel
},
accessoryBar: {
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: 1,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
accessoryScroll: {
flex: 1,
minWidth: 0
},
accessoryContent: {
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
gap: spacing.xs
},
accessoryKey: {
backgroundColor: colors.bgRaised,
paddingHorizontal: spacing.sm + 2,
paddingVertical: spacing.xs,
borderRadius: radii.button,
minWidth: 36,
alignItems: 'center'
},
accessoryKeyPressed: {
backgroundColor: colors.borderSubtle
},
accessoryKeyActive: {
backgroundColor: colors.textPrimary
},
customAccessoryKey: {
borderWidth: 1,
borderColor: colors.borderSubtle
},
accessoryKeyDisabled: {
opacity: 0.35
},
accessoryKeyText: {
color: colors.textSecondary,
fontSize: 12,
fontFamily: typography.monoFamily
},
accessoryKeyTextActive: {
color: colors.bgBase,
fontWeight: '700'
},
accessoryKeyTextDisabled: {
color: colors.textMuted
},
keyboardDismissKey: {
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'stretch',
marginLeft: spacing.sm,
marginVertical: spacing.xs,
backgroundColor: colors.bgRaised,
paddingHorizontal: spacing.sm + 2,
paddingVertical: 0,
borderRadius: radii.button,
minWidth: 36
},
// The Duo side rail column: full-width keys with room for a thumb.
accessoryRail: {
flex: 1,
minHeight: 0
},
accessoryScrollVertical: {
flex: 1
},
// Keys span the 84pt rail less 4pt each side, so the widest label (Shift+Tab) fits.
accessoryContentVertical: {
paddingHorizontal: spacing.xs,
paddingVertical: spacing.xs,
gap: spacing.sm
},
accessoryKeyVertical: {
minHeight: 44,
justifyContent: 'center',
paddingHorizontal: 2,
borderRadius: radii.composerItem
},
keyboardDismissKeyVertical: {
alignItems: 'center',
justifyContent: 'center',
marginHorizontal: spacing.xs,
marginBottom: spacing.sm,
minHeight: 44,
backgroundColor: colors.bgRaised,
borderRadius: radii.composerItem
},
keyboardDismissGlyph: {
alignItems: 'center',
height: 18,
@@ -171,75 +76,11 @@ export const mobileSessionCommandInputStyles = themedStyleSheet(() => ({
bottom: -2,
position: 'absolute'
},
inputBar: {
flexDirection: 'row',
alignItems: 'center',
minHeight: 46,
paddingVertical: spacing.xs + 2,
paddingHorizontal: spacing.md,
borderTopWidth: 1,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
textInput: {
flex: 1,
height: 34,
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: 0,
fontSize: TEXT_INPUT_FONT_SIZE,
fontFamily: typography.monoFamily,
marginRight: spacing.sm
},
commandField: {
flex: 1,
flexDirection: 'row',
marginRight: spacing.sm
},
commandFieldInput: {
marginRight: 0
},
// Dictation's preview draws the draft instead while it is hidden.
commandFieldInputHidden: {
color: 'transparent'
},
liveInputField: {
flex: 1,
marginRight: spacing.sm
},
liveInputCapture: {
minWidth: 0,
marginRight: 0
},
liveInputCaptureDisabled: {
opacity: 0.45
},
sendButton: {
backgroundColor: colors.bgRaised,
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center'
},
dictationButton: {
backgroundColor: colors.bgRaised,
width: 34,
height: 34,
borderRadius: 17,
borderWidth: 1,
borderColor: 'transparent',
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.sm
},
dictationButtonActive: {
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.textSecondary
},
sendButtonDisabled: {
opacity: 0.35
}
}))
@@ -56,50 +56,9 @@ export const mobileSessionReviewCommentStyles = themedStyleSheet(() => ({
lineHeight: 17
},
diffCommentComposer: {
gap: spacing.xs,
marginLeft: 44,
marginRight: spacing.sm,
marginTop: spacing.xs,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
padding: spacing.sm
},
diffCommentInput: {
minHeight: 70,
height: 70,
marginRight: 0,
paddingTop: spacing.sm,
paddingBottom: spacing.sm
},
diffCommentComposerActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.xs
},
diffCommentSecondaryAction: {
minHeight: 30,
justifyContent: 'center',
borderRadius: radii.button,
paddingHorizontal: spacing.md
},
diffCommentSecondaryText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontWeight: '600'
},
diffCommentPrimaryAction: {
minHeight: 30,
justifyContent: 'center',
borderRadius: radii.button,
backgroundColor: colors.bgRaised,
paddingHorizontal: spacing.md
},
diffCommentPrimaryText: {
color: colors.textPrimary,
fontSize: typography.metaSize,
fontWeight: '700'
marginTop: spacing.xs
},
markdownButtonDisabled: {
opacity: 0.45
@@ -1,4 +1,6 @@
import { stripLeadingAgentTitleDecorationOrEmpty } from '../../../src/shared/agent-title-decoration'
import { isClaudeManagementTitle } from '../../../src/shared/agent-title-core'
import { isShellProcess } from '../../../src/shared/shell-process-detection'
import { agentTypeToIconAgent } from '../../../src/shared/agent-type-icon-agent'
import { resolveTabAgentFromSignalSnapshot } from '../../../src/shared/tab-agent-from-signals'
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
@@ -7,7 +9,7 @@ import { isBlankBrowserUrl } from '../browser/browser-url'
import { translate } from '../i18n/mobile-i18n'
import type { MobileSessionTab } from './mobile-session-route-types'
type MobileTerminalTabAgentIdentity = {
export type MobileTerminalTabAgentIdentity = {
title: string
agentStatus?: {
agentType?: AgentStatusEntry['agentType'] | null
@@ -48,6 +50,25 @@ export function resolveMobileTerminalTabOwnedAgentId(
return null
}
// An agent's tab stays an agent tab until the agent reports done and the title is a shell again. Launch hints count before status arrives.
export function isMobileTerminalTabRunningAgent(tab: MobileTerminalTabAgentIdentity): boolean {
if (
tab.agentStatus?.state === 'done' &&
(isShellProcess(tab.title) || isClaudeManagementTitle(tab.title))
) {
return false
}
return resolveMobileTerminalTabOwnedAgentId(tab) !== null
}
export function listMobileTerminalAgentHandles(tabs: readonly MobileSessionTab[]): string[] {
return tabs.flatMap((tab) =>
tab.type === 'terminal' && tab.terminal && isMobileTerminalTabRunningAgent(tab)
? [tab.terminal]
: []
)
}
// Phones get no process, sleeping-session or sibling signals; host Cursor identity stands in for an unowned pane's process, since terminal.list can keep it after the pane is reused.
export function resolveMobileTerminalTabAgentId(
tab: MobileTerminalTabAgentIdentity,
@@ -11,7 +11,7 @@ import {
import type { NativeChatSkillDiscoverySnapshot } from '../../../../../src/shared/native-chat/input/native-chat-picker-items'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { ActivityIndicator, Keyboard, Pressable, type TextInput, Text, View } from 'react-native'
import { ArrowUp, Plus } from 'lucide-react-native'
import { Plus } from 'lucide-react-native'
import { colors } from '../../../theme/mobile-theme'
import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../../../i18n/use-mobile-translation'
@@ -32,6 +32,8 @@ import { useMobileNativeChatComposerMenu } from './use-mobile-native-chat-compos
import { mobileNativeChatComposerShowsStop } from './mobile-native-chat-composer-layout'
import { MobileNativeChatStopButton } from '../input/MobileNativeChatStopButton'
import { mobileNativeChatComposerStyles as styles } from './mobile-native-chat-composer-styles'
import { composerInputStyles as inputStyles } from '../../../components/composer-input/composer-input-styles'
import { ComposerSendButton } from '../../../components/composer-input/ComposerSendButton'
import {
MobileNativeChatComposerMic,
type MobileNativeChatMicProps
@@ -302,11 +304,11 @@ export function MobileNativeChatComposer({
// The lock gates sending; the draft survives and rides the next send.
textAlignVertical="top"
/>
<View style={styles.actionRow} testID="native-chat-composer-actions">
<View style={inputStyles.actionRow} testID="native-chat-composer-actions">
{attachPress ? (
<Pressable
accessibilityLabel={attachLabel}
style={({ pressed }) => [styles.controlButton, pressed && styles.pressed]}
style={({ pressed }) => [inputStyles.controlButton, pressed && inputStyles.pressed]}
onPress={attachPress}
disabled={isAttaching || disabled}
>
@@ -332,7 +334,7 @@ export function MobileNativeChatComposer({
}}
/>
) : null}
<View style={styles.actionSpacer} />
<View style={inputStyles.actionSpacer} />
{onMicPress ? (
<MobileNativeChatComposerMic
onMicPress={onMicPress}
@@ -349,7 +351,7 @@ export function MobileNativeChatComposer({
<Pressable
accessibilityRole="button"
accessibilityLabel={t('chat.composer.cancelEditing', 'Cancel editing')}
style={styles.ghostButton}
style={inputStyles.ghostButton}
onPress={editSession.cancel}
>
<Text style={{ color: colors.textSecondary }}>{t('common.cancel', 'Cancel')}</Text>
@@ -358,33 +360,27 @@ export function MobileNativeChatComposer({
{showsStop && stop ? (
// Only this phone's own request holds Stop: a repeat is how a stuck stop escalates.
<MobileNativeChatStopButton onStop={stop.onStop} held={stop.held} />
) : (
) : editSession ? (
<Pressable
accessibilityLabel={
editSession
? t('chat.composer.saveEditedMessage', 'Save edited message')
: t('chat.composer.sendMessage', 'Send message')
}
accessibilityLabel={t('chat.composer.saveEditedMessage', 'Save edited message')}
style={({ pressed }) => [
styles.sendButton,
!canSend && styles.sendButtonDisabled,
pressed && canSend && styles.pressed
inputStyles.sendButton,
!canSend && inputStyles.sendButtonDisabled,
pressed && canSend && inputStyles.pressed
]}
onPress={handleSend}
disabled={!canSend}
>
{editSession ? (
<Text style={{ color: canSend ? colors.bgBase : colors.textMuted }}>
{t('common.save', 'Save')}
</Text>
) : (
<ArrowUp
size={19}
color={canSend ? colors.bgBase : colors.textMuted}
strokeWidth={2.4}
/>
)}
<Text style={{ color: canSend ? colors.bgBase : colors.textMuted }}>
{t('common.save', 'Save')}
</Text>
</Pressable>
) : (
<ComposerSendButton
onPress={handleSend}
canSend={canSend}
label={t('chat.composer.sendMessage', 'Send message')}
/>
)}
</View>
</View>
@@ -4,9 +4,9 @@ import {
mobileNativeChatComposerSkillStyles as styles,
type MobileNativeChatComposerFieldProps
} from './mobile-native-chat-composer-field-skill'
import { mobileNativeChatInputStyles as inputStyles } from './mobile-native-chat-input-styles'
import { MobileNativeChatComposerFieldCopy } from './MobileNativeChatComposerFieldCopy'
import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme'
import { composerInputStyles } from '../../../components/composer-input/composer-input-styles'
export function MobileNativeChatComposerField({
value,
@@ -25,7 +25,12 @@ export function MobileNativeChatComposerField({
onLayout={(event) => setFieldHeight(event.nativeEvent.layout.height)}
>
{skillToken && !dictationPreview ? (
<TextInput ref={ref} style={inputStyles.input} placeholder={placeholder} {...props}>
<TextInput
ref={ref}
style={composerInputStyles.messageText}
placeholder={placeholder}
{...props}
>
<Text style={styles.token}>{skillToken}</Text>
{value.slice(skillToken.length)}
</TextInput>
@@ -34,7 +39,11 @@ export function MobileNativeChatComposerField({
<TextInput
ref={ref}
value={value}
style={dictationPreview ? [inputStyles.input, frameStyles.hiddenText] : inputStyles.input}
style={
dictationPreview
? [composerInputStyles.messageText, frameStyles.hiddenText]
: composerInputStyles.messageText
}
placeholder={dictationPreview ? undefined : placeholder}
{...props}
/>
@@ -4,7 +4,10 @@ import { useKeyboardOcclusion } from '../../../platform/keyboard-occlusion'
import { useTextInputContentHeight } from '../../../platform/text-input-content-height'
import { colors } from '../../../theme/mobile-theme'
import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme'
import { mobileNativeChatInputStyles as inputStyles } from './mobile-native-chat-input-styles'
import {
composerInputStyles,
MESSAGE_MIN_HEIGHT
} from '../../../components/composer-input/composer-input-styles'
import type { MobileNativeChatComposerFieldProps } from './mobile-native-chat-composer-field-skill'
import { MobileNativeChatComposerFieldCopy } from './MobileNativeChatComposerFieldCopy'
@@ -52,7 +55,7 @@ export function MobileNativeChatComposerField({
const visibleHeight = Math.min(windowHeight, window.innerHeight - keyboardHeight)
const sizing = {
height: contentHeight,
maxHeight: Math.max(inputStyles.input.minHeight, Math.round(visibleHeight * MAX_HEIGHT_SHARE))
maxHeight: Math.max(MESSAGE_MIN_HEIGHT, Math.round(visibleHeight * MAX_HEIGHT_SHARE))
}
const copied = Boolean(skillToken || dictationPreview)
// Keep one tree so adding/removing a skill or dictation cannot remount the field and drop focus.
@@ -73,7 +76,7 @@ export function MobileNativeChatComposerField({
<TextInput
ref={input}
value={value}
style={[inputStyles.input, sizing, copied ? clearText().field : null]}
style={[composerInputStyles.messageText, sizing, copied ? clearText().field : null]}
onScroll={skillToken ? (event) => setScrollTop(scrollTopOf(event)) : undefined}
placeholder={dictationPreview ? undefined : placeholder}
{...props}
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { StyleSheet, Text, View } from 'react-native'
import { mobileNativeChatInputStyles as inputStyles } from './mobile-native-chat-input-styles'
import { composerInputStyles } from '../../../components/composer-input/composer-input-styles'
import {
mobileNativeChatComposerSkillStyles as skillStyles,
type MobileNativeChatComposerFieldProps
@@ -36,7 +36,7 @@ export function MobileNativeChatComposerFieldCopy({
}) {
const [heardEnd, setHeardEnd] = useState(0)
// The copy grows past the field's height cap so it can be shifted; the frame clips it.
const copyStyle = withoutHeightCap(StyleSheet.flatten(inputStyles.input))
const copyStyle = withoutHeightCap(StyleSheet.flatten(composerInputStyles.messageText))
// While dictating, the line holding the newest word sits at the field's bottom, whatever the draft holds after it.
const shift = dictationPreview ? Math.min(0, fieldHeight - heardEnd) : -scrollTop
return (
@@ -5,6 +5,7 @@ import { colors } from '../../../theme/mobile-theme'
import { MobileDictationGrapes, MobileDictationTimer } from '../../MobileDictationMeter'
import { MobileDictationMeterContext } from '../../mobile-dictation-meter-context'
import { keepHeldPressThroughLongPress } from '../../held-press-long-press'
import { composerInputStyles as inputStyles } from '../../../components/composer-input/composer-input-styles'
import { mobileNativeChatComposerStyles as styles } from './mobile-native-chat-composer-styles'
export type MobileNativeChatMicProps = {
@@ -53,7 +54,7 @@ export function MobileNativeChatComposerMic({
? 'Transcribing dictation'
: 'Dictate'
}
style={({ pressed }) => [styles.ghostButton, pressed && styles.pressed]}
style={({ pressed }) => [inputStyles.ghostButton, pressed && inputStyles.pressed]}
// Hold mode is walkie-talkie (press-in/out); toggle mode taps.
onPress={dictationMode === 'hold' ? undefined : onMicPress}
onPressIn={dictationMode === 'hold' ? onMicPressIn : undefined}
@@ -1,66 +1,17 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing } from '../../../theme/mobile-theme'
import { spacing } from '../../../theme/mobile-theme'
import { themedStyleSheet } from '../../../theme/themed-styles'
import { composerInputStyles } from '../../../components/composer-input/composer-input-styles'
/** Round controls on the action row: the attach button, the mic and Send/Stop. */
const COMPOSER_CONTROL_SIZE = 38
/** Space above the composer box; the queue box above reaches through it. */
export const COMPOSER_INSET_TOP = 6
const roundControl = {
width: COMPOSER_CONTROL_SIZE,
height: COMPOSER_CONTROL_SIZE,
borderRadius: COMPOSER_CONTROL_SIZE / 2,
alignItems: 'center',
justifyContent: 'center'
} as const
export const mobileNativeChatComposerStyles = themedStyleSheet(() => ({
composerInset: {
paddingHorizontal: 10,
paddingTop: COMPOSER_INSET_TOP,
paddingBottom: spacing.sm
},
bar: {
paddingTop: spacing.md,
paddingHorizontal: 10,
paddingBottom: 10,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.composerBorder,
borderRadius: radii.composer,
backgroundColor: colors.composerSurface,
shadowColor: '#000',
shadowOpacity: 0.45,
shadowRadius: 15,
shadowOffset: { width: 0, height: 10 },
elevation: 8
},
actionRow: {
marginTop: 10,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
actionSpacer: {
flex: 1
},
controlButton: {
...roundControl,
backgroundColor: colors.composerControl
},
ghostButton: {
...roundControl
},
sendButton: {
...roundControl,
backgroundColor: colors.surfaceBright
},
sendButtonDisabled: {
backgroundColor: colors.composerControl
},
pressed: {
opacity: 0.7
},
bar: { ...composerInputStyles.box, ...composerInputStyles.docked },
dictationMeter: {
flexDirection: 'row',
alignItems: 'center',
@@ -1,28 +0,0 @@
import { colors, radii, spacing } from '../../../theme/mobile-theme'
import { themedStyles } from '../../../theme/themed-styles'
// Keep platform-neutral styles separate because a web sibling cannot import the file it shadows.
export const mobileNativeChatInputBase = themedStyles(() => {
const chatInputSurface = {
minHeight: 40,
color: colors.textPrimary,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
paddingBottom: spacing.sm
} as const
return {
input: {
width: '100%',
minHeight: 30,
maxHeight: 154,
lineHeight: 22,
color: colors.textPrimary,
paddingHorizontal: 6,
paddingTop: 2,
paddingBottom: 2
},
freeInput: { ...chatInputSurface, flex: 1, maxHeight: 120 }
} as const
})
@@ -1,12 +0,0 @@
import { typography } from '../../../theme/mobile-theme'
import { mobileNativeChatInputBase } from './mobile-native-chat-input-base-styles'
import { themedStyleSheet } from '../../../theme/themed-styles'
// Native sizes need no zoom guard; the web sibling applies the iOS focus-zoom floor.
const CHAT_INPUT_FONT_SIZE = typography.bodySize + 1
const COMPOSER_INPUT_FONT_SIZE = 16
export const mobileNativeChatInputStyles = themedStyleSheet(() => ({
input: { ...mobileNativeChatInputBase.input, fontSize: COMPOSER_INPUT_FONT_SIZE },
freeInput: { ...mobileNativeChatInputBase.freeInput, fontSize: CHAT_INPUT_FONT_SIZE }
}))
@@ -1,9 +0,0 @@
import { TEXT_INPUT_FONT_SIZE } from '../../../platform/text-input-font-size'
import { mobileNativeChatInputBase } from './mobile-native-chat-input-base-styles'
import { themedStyleSheet } from '../../../theme/themed-styles'
// Keep inputs at the focus-zoom floor: iOS zoom would make keyboard geometry return zero for the rest of typing.
export const mobileNativeChatInputStyles = themedStyleSheet(() => ({
input: { ...mobileNativeChatInputBase.input, fontSize: TEXT_INPUT_FONT_SIZE },
freeInput: { ...mobileNativeChatInputBase.freeInput, fontSize: TEXT_INPUT_FONT_SIZE }
}))
@@ -1,5 +1,5 @@
import { useRef, useState } from 'react'
import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { Check } from 'lucide-react-native'
import type { AskAnswerSelection } from '../../../../../src/shared/native-chat-ask-answer'
import type { AskPrompt } from '../../../../../src/shared/native-chat/input/native-chat-ask-types'
@@ -16,7 +16,7 @@ import { themedStyleSheet } from '../../../theme/themed-styles'
import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme'
import type { Translate } from '../../../i18n/mobile-i18n'
import { useMobileTranslation } from '../../../i18n/use-mobile-translation'
import { TEXT_INPUT_FONT_SIZE } from '../../../platform/text-input-font-size'
import { MessageBox } from '../../../components/composer-input/MessageBox'
import { MobileNativeChatCardHeaderAction } from '../../MobileNativeChatCardHeaderAction'
import { useMobileNativeChatAskAutoAdvance } from './use-mobile-native-chat-ask-auto-advance'
import { MobileNativeChatAskOptionRow } from './MobileNativeChatAskOptionRow'
@@ -243,16 +243,15 @@ export function MobileNativeChatAsk({
/>
) : null}
{otherSelected || typesOnly(index) ? (
<TextInput
ref={inputRef}
<MessageBox
inputRef={inputRef}
onFocus={revealReply}
style={styles.input}
value={otherText[index]}
style={styles.other}
value={otherText[index] ?? ''}
onChangeText={(v) => setOther(index, v)}
placeholder={
freeTextInput?.placeholder ?? t('chat.ask.answerPlaceholder', 'Type your answer')
}
placeholderTextColor={colors.textMuted}
// A declared input (Pi's `input`) is one line unless it asks for more, as on desktop.
multiline={freeTextInput ? freeTextInput.multiline === true : true}
autoFocus
@@ -339,15 +338,7 @@ const styles = themedStyleSheet(() => ({
fontWeight: '600',
marginVertical: spacing.sm
},
input: {
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.card,
color: colors.textPrimary,
fontSize: TEXT_INPUT_FONT_SIZE,
padding: spacing.sm,
minHeight: 44,
other: {
marginBottom: spacing.xs
},
footer: {
@@ -1,12 +1,12 @@
import { useMemo, useRef, useState } from 'react'
import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'
import { ArrowUp, Check, CircleHelp } from 'lucide-react-native'
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { Check, CircleHelp } from 'lucide-react-native'
import { MobileNativeChatCardHeaderAction } from '../../MobileNativeChatCardHeaderAction'
import { colors, radii, spacing, typography } from '../../../theme/mobile-theme'
import { themedStyleSheet } from '../../../theme/themed-styles'
import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../../../i18n/use-mobile-translation'
import { mobileNativeChatInputStyles } from '../composer/mobile-native-chat-input-styles'
import { MessageBox } from '../../../components/composer-input/MessageBox'
import { useMobileNativeChatReplyScroll } from './use-mobile-native-chat-reply-scroll'
import {
formatQuestionAnswerByIndexes,
@@ -188,43 +188,25 @@ export function MobileNativeChatQuestion({
) : null}
{allowOther ? (
<View style={styles.freeTextRow}>
<TextInput
ref={inputRef}
onFocus={revealReply}
style={mobileNativeChatInputStyles.freeInput}
value={freeText}
onChangeText={setFreeText}
placeholder={
question.freeTextInput?.placeholder ??
(hasOptions
? t('chat.question.otherReplyPlaceholder', 'Or type a reply…')
: t('chat.question.replyPlaceholder', 'Type your reply…'))
}
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
onSubmitEditing={submitFreeText}
returnKeyType="send"
// A declared input (Pi's `input`) is one line unless it asks for more, as on desktop.
multiline={question.freeTextInput ? question.freeTextInput.multiline === true : true}
/>
<Pressable
accessibilityLabel={t('chat.question.sendReply', 'Send reply')}
style={({ pressed }) => [
styles.freeSend,
!canSendFreeText && styles.freeSendDisabled,
pressed && canSendFreeText && styles.pressed
]}
onPress={submitFreeText}
disabled={!canSendFreeText}
>
<ArrowUp
size={18}
color={canSendFreeText ? colors.bgBase : colors.textMuted}
strokeWidth={2.6}
/>
</Pressable>
</View>
<MessageBox
inputRef={inputRef}
onFocus={revealReply}
value={freeText}
onChangeText={setFreeText}
placeholder={
question.freeTextInput?.placeholder ??
(hasOptions
? t('chat.question.otherReplyPlaceholder', 'Or type a reply…')
: t('chat.question.replyPlaceholder', 'Type your reply…'))
}
onSubmitEditing={submitFreeText}
returnKeyType="send"
// A declared input (Pi's `input`) is one line unless it asks for more, as on desktop.
multiline={question.freeTextInput ? question.freeTextInput.multiline === true : true}
onSend={submitFreeText}
canSend={canSendFreeText}
sendAccessibilityLabel={t('chat.question.sendReply', 'Send reply')}
/>
) : null}
</ScrollView>
)
@@ -320,22 +302,6 @@ const styles = themedStyleSheet(() => ({
submitTextDisabled: {
color: colors.textMuted
},
freeTextRow: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: spacing.sm
},
freeSend: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.textPrimary
},
freeSendDisabled: {
backgroundColor: colors.bgRaised
},
pressed: {
opacity: 0.7
}
@@ -3,7 +3,7 @@ import { Square } from 'lucide-react-native'
import { colors } from '../../../theme/mobile-theme'
import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../../../i18n/use-mobile-translation'
import { mobileNativeChatComposerStyles as styles } from '../composer/mobile-native-chat-composer-styles'
import { composerInputStyles as styles } from '../../../components/composer-input/composer-input-styles'
// held tracks this phone’s in-flight Stop request, not the host’s stopping status.
export function MobileNativeChatStopButton({
+213
View File
@@ -0,0 +1,213 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
const KEY_HEIGHT = 32
const KEY_RADIUS = 9
const key = {
height: KEY_HEIGHT,
minWidth: 34,
paddingHorizontal: 9,
borderRadius: KEY_RADIUS,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 5
} as const
export const terminalDockStyles = themedStyleSheet(() => ({
dock: {
zIndex: 20,
backgroundColor: colors.bgPanel
},
// Keys mode: the live field and its attach and mic buttons, on the composer's field shell.
keysBar: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
paddingHorizontal: spacing.sm,
paddingTop: spacing.sm,
paddingBottom: 6
},
liveField: {
flex: 1,
minWidth: 0
},
composer: {
marginHorizontal: spacing.sm,
marginTop: spacing.sm,
marginBottom: 6
},
keyRow: {
height: 46,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 6,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
// The Duo's side rail is 84pt wide; keys span it less 4pt each side.
keyRail: {
flex: 1,
minHeight: 0,
gap: spacing.sm,
paddingHorizontal: spacing.xs,
paddingVertical: spacing.xs
},
keyScroll: {
flex: 1,
minWidth: 0
},
keyScrollVertical: {
flex: 1,
minHeight: 0
},
keyScrollContentVertical: {
alignItems: 'stretch',
gap: spacing.sm
},
keyVertical: {
width: 'auto',
height: 44,
alignSelf: 'stretch',
paddingHorizontal: 2
},
keyScrollContent: {
alignItems: 'center',
gap: 6,
paddingVertical: 7
},
// Every key carries the one-shot outline's width so arming ctrl doesn't nudge the row.
key: {
...key,
borderWidth: 1.5,
borderColor: 'transparent',
backgroundColor: colors.bgRaised
},
keyText: {
color: colors.textPrimary,
fontSize: 13,
fontWeight: '500',
fontFamily: typography.monoFamily
},
keyPressed: {
opacity: 0.7
},
keyOnce: {
borderColor: colors.accentBlue
},
keyOnceText: {
color: colors.accentBlue
},
keyLocked: {
backgroundColor: colors.accentBlue
},
keyLockedText: {
color: colors.onAccent
},
ghostKey: {
...key,
backgroundColor: 'transparent'
},
ghostKeyText: {
color: colors.textSecondary
},
iconKey: {
...key,
width: 34,
paddingHorizontal: 0,
backgroundColor: 'transparent'
},
modeKey: {
...key,
paddingHorizontal: 10,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.composerBorder,
backgroundColor: colors.composerSurface
},
modeKeyText: {
color: colors.textPrimary,
fontSize: 13,
fontWeight: '600'
},
disabled: {
opacity: 0.35
},
arrowPad: {
width: 66,
height: KEY_HEIGHT,
borderRadius: KEY_HEIGHT / 2,
backgroundColor: colors.bgRaised
},
arrowPadDragging: {
backgroundColor: colors.borderSubtle
},
arrowLeft: {
position: 'absolute',
left: 6,
top: 10
},
arrowRight: {
position: 'absolute',
right: 6,
top: 10
},
arrowUp: {
position: 'absolute',
left: 27.5,
top: 2
},
arrowDown: {
position: 'absolute',
left: 27.5,
bottom: 2
},
liveInputCapture: {
position: 'absolute',
opacity: 0,
width: 1,
height: 1,
color: colors.textPrimary
},
sheetContent: {
gap: spacing.md,
paddingBottom: spacing.lg
},
sheetActions: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm
},
sheetAction: {
...key,
height: 38,
paddingHorizontal: spacing.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.composerBorder,
backgroundColor: colors.composerSurface
},
sheetActionText: {
color: colors.textPrimary,
fontSize: 14,
fontWeight: '500'
},
sheetHeading: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontWeight: '600',
letterSpacing: 0.4,
textTransform: 'uppercase'
},
sheetGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6
},
sheetEmpty: {
color: colors.textMuted,
fontSize: typography.metaSize
}
}))
@@ -10,7 +10,6 @@ import type { TerminalLiveInputSender } from '../terminal/terminal-live-input-se
import type { MobileSessionTerminalWebviewModel } from './use-mobile-session-terminal-webview'
import type { TerminalModes } from '../terminal/terminal-webview-contract'
// Bind once before the client/handle arrives, then dispatch after rerender to prove the frozen field listener reads current send state.
const mocks = vi.hoisted(() => {
const sent: Array<Record<string, unknown>> = []
return { sent, acknowledge: async () => {} }
@@ -57,7 +56,6 @@ const READY: Session = {
draft: 'ls -la'
}
const field = document.createElement('input')
let restoredDrafts: string[] = []
function buildScope(session: Session): MobileSessionTerminalWebviewModel {
@@ -112,46 +110,6 @@ function buildScope(session: Session): MobileSessionTerminalWebviewModel {
return scope as unknown as MobileSessionTerminalWebviewModel
}
function Harness({ session }: { readonly session: Session }): null {
const { bindCommandField } = useMobileSessionTerminalSendActions(buildScope(session))
// Keep ref identity stable as React does; the bound handler must update independently.
useEffect(() => {
bindCommandField(fieldAsTextInput)
}, [bindCommandField])
return null
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: on React Native Web a TextInput ref is the DOM node, which is what the page's binding reads and what this dispatches on.
const fieldAsTextInput = field as unknown as RefObject<never>['current']
describe('the buffered field submit the page binds', () => {
it('reaches a handleSend that can send, not the one the first render had', () => {
mocks.sent.length = 0
restoredDrafts = []
let renderer: ReactTestRenderer | null = null
act(() => {
renderer = create(createElement(Harness, { session: OFFLINE }))
})
act(() => {
renderer?.update(createElement(Harness, { session: READY }))
})
act(() => {
field.dispatchEvent(
new InputEvent('beforeinput', {
bubbles: true,
cancelable: true,
inputType: 'insertLineBreak'
})
)
})
expect(mocks.sent).toHaveLength(1)
expect(mocks.sent[0]).toMatchObject({ terminal: 'terminal-a', text: 'ls -la', enter: true })
act(() => renderer?.unmount())
})
})
let changeLiveText: (text: string) => void = () => {}
const liveField = Object.assign(document.createElement('input'), {
setNativeProps: ({ text }: { text: string }) => {
@@ -23,6 +23,7 @@ import { useMobileSessionTerminalSendActions } from './use-mobile-session-termin
import { useMobileSessionFileActions } from './use-mobile-session-file-actions'
import { useMobileSessionTerminalInput } from './use-mobile-session-terminal-input'
import { useMobileSessionAccessorySelection } from './use-mobile-session-accessory-selection'
import { useTerminalDockKeys } from './use-terminal-dock-keys'
import { useMobileSessionAttachments } from './use-mobile-session-attachments'
import { useMobileSessionTerminalCreateActions } from './use-mobile-session-terminal-create-actions'
import { useMobileSessionContentCreateActions } from './use-mobile-session-content-create-actions'
@@ -98,10 +99,14 @@ export function useMobileSessionController() {
terminalInput,
useMobileSessionAccessorySelection(terminalInput)
)
const attachments = Object.assign(
accessorySelection,
useMobileSessionAttachments(accessorySelection)
)
// The key row and the input fields share held ctrl/alt/shift, and the Duo rail renders the row apart from the fields.
const dockKeys = Object.assign(accessorySelection, {
dockKeys: useTerminalDockKeys({
activeHandle: accessorySelection.activeHandle,
sendInput: accessorySelection.handleAccessoryKey
})
})
const attachments = Object.assign(dockKeys, useMobileSessionAttachments(dockKeys))
const terminalCreateActions = Object.assign(
attachments,
useMobileSessionTerminalCreateActions(attachments)
@@ -9,6 +9,7 @@ import {
import type { CustomKey } from '../terminal/terminal-custom-keys'
import type { MobileNewTabAgentOption } from './mobile-new-tab-agent-options'
import { useTerminalLiveInputModePreference } from './use-terminal-live-input-mode-preference'
import { listMobileTerminalAgentHandles } from './mobile-terminal-tab-agent'
import type { AppliedSnapshotMarker } from './session-tab-snapshot-gate'
import { useWorktreeSessionTabsLoaded } from './use-initial-session-terminal-autocreate'
import { createMobileSessionCreateWarningState } from './mobile-session-create-warning-state'
@@ -43,6 +44,11 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel)
const [terminalLinkOpenMode, setTerminalLinkOpenMode] =
useState<MobileTerminalLinkOpenMode>('orca-browser')
const [liveInputCapture, setLiveInputCapture] = useState('')
const agentHandleKey = listMobileTerminalAgentHandles(sessionTabs).join('\n')
const agentHandles = useMemo(
() => new Set(agentHandleKey ? agentHandleKey.split('\n') : []),
[agentHandleKey]
)
const {
clearTerminalLiveInputDefault,
defaultTerminalHandlesToLiveInput,
@@ -50,7 +56,7 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel)
liveInputTerminalHandlesRef,
pruneTerminalHandlesFromLiveInput,
toggleTerminalLiveInput
} = useTerminalLiveInputModePreference({ hostId, worktreeId })
} = useTerminalLiveInputModePreference({ hostId, worktreeId, agentHandles })
const [activeHandle, setActiveHandle] = useState<string | null>(null)
const [coveredStreamRevision, setCoveredStreamRevision] = useState(0)
const [activeSessionTabId, setActiveSessionTabId] = useState<string | null>(null)
@@ -97,10 +97,6 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal
return true
}
)
const submitBufferedDraft = (): void => {
void handleSend()
}
const bindCommandField = useTerminalTextFieldSubmitBinding(commandInputRef, submitBufferedDraft)
async function handleSend() {
// Offline Return must preserve composed text instead of firing a doomed write.
@@ -312,7 +308,6 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal
dismissSoftwareKeyboard,
dismissKeyboardAfterAgentSend,
bindLiveInputField,
bindCommandField,
submitLiveInput
}
}
@@ -0,0 +1,91 @@
import { useRef, useState, type RefObject } from 'react'
import type { TextInput } from 'react-native'
import type { TerminalShortcutModifier } from '../terminal/terminal-accessory-keys'
import { createTerminalKeyChordInput } from '../terminal/terminal-key-chord-input'
import type { TerminalLiveAccessoryInput } from '../terminal/terminal-live-accessory-input'
import { writeTerminalLiveInputText } from '../terminal/terminal-live-input-text-write'
import {
appendedCharacter,
consumeStickyModifiers,
hasStickyModifier,
NO_STICKY_MODIFIERS,
STICKY_MODIFIER_DOUBLE_TAP_MS,
tapStickyModifier,
type StickyModifiers
} from '../terminal/terminal-sticky-modifiers'
type UseTerminalDockKeysOptions = {
readonly activeHandle: string | null
readonly sendInput: (input: TerminalLiveAccessoryInput) => Promise<unknown>
}
// Key-row presses and sticky ctrl/alt/shift; held modifiers apply to the next key or typed letter.
export function useTerminalDockKeys({ activeHandle, sendInput }: UseTerminalDockKeysOptions) {
const [modifiers, setModifiers] = useState(NO_STICKY_MODIFIERS)
const [modifierHandle, setModifierHandle] = useState(activeHandle)
const lastTapRef = useRef<{ modifier: TerminalShortcutModifier; at: number } | null>(null)
// A held modifier belongs to the tab it was pressed on.
if (modifierHandle !== activeHandle) {
setModifierHandle(activeHandle)
setModifiers(NO_STICKY_MODIFIERS)
}
const tapModifier = (modifier: TerminalShortcutModifier): void => {
const now = Date.now()
const last = lastTapRef.current
const doubleTap =
last?.modifier === modifier &&
now - last.at < STICKY_MODIFIER_DOUBLE_TAP_MS &&
modifiers[modifier] === 'once'
lastTapRef.current = { modifier, at: now }
setModifiers(tapStickyModifier(modifiers, modifier, doubleTap))
}
/** The held modifiers, released for the next key unless locked. */
const takeModifiers = (): StickyModifiers => {
if (hasStickyModifier(modifiers)) {
setModifiers(consumeStickyModifiers(modifiers))
}
return modifiers
}
/** Sends a special key id (`arrowUp`, `tab`, `f5`…) or a printable character with the held modifiers. */
const sendKey = (key: string): void => {
const input = terminalDockKeyInput(key, takeModifiers())
if (input) {
void sendInput(input)
}
}
// With a modifier held, the next typed letter becomes a combo instead of text.
const interceptTypedCharacter = (
previous: string,
next: string,
field: RefObject<TextInput | null>
): boolean => {
if (!hasStickyModifier(modifiers)) {
return false
}
const typed = appendedCharacter(previous, next)
if (typed === null) {
return false
}
writeTerminalLiveInputText(field, previous)
sendKey(typed)
return true
}
return { modifiers, tapModifier, takeModifiers, sendKey, interceptTypedCharacter }
}
export function terminalDockKeyInput(
key: string,
modifiers: StickyModifiers
): TerminalLiveAccessoryInput | null {
return createTerminalKeyChordInput({
key,
ctrl: modifiers.ctrl !== 'off',
alt: modifiers.alt !== 'off',
shift: modifiers.shift !== 'off'
})
}
@@ -15,6 +15,7 @@ vi.mock('../storage/preferences', () => ({
type TerminalLiveInputModePreferenceHarness = {
readonly current: ReturnType<typeof useTerminalLiveInputModePreference>
readonly setAgentHandles: (handles: readonly string[]) => void
readonly unmount: () => void
}
@@ -37,11 +38,13 @@ function createDeferred<T>(): Deferred<T> {
function createTerminalLiveInputModePreferenceHarness(): TerminalLiveInputModePreferenceHarness {
let current: ReturnType<typeof useTerminalLiveInputModePreference> | null = null
let renderer: ReactTestRenderer | null = null
let agentHandles: ReadonlySet<string> = new Set()
function Harness(): null {
current = useTerminalLiveInputModePreference({
hostId: 'host-1',
worktreeId: 'worktree-1'
worktreeId: 'worktree-1',
agentHandles
})
return null
}
@@ -60,6 +63,10 @@ function createTerminalLiveInputModePreferenceHarness(): TerminalLiveInputModePr
}
return current
},
setAgentHandles: (handles) => {
agentHandles = new Set(handles)
act(() => renderer?.update(createElement(Harness)))
},
unmount: () => {
act(() => renderer?.unmount())
}
@@ -118,4 +125,30 @@ describe('terminal live input mode preference hook', () => {
expect(saveDisabledTerminalLiveInputHandles).not.toHaveBeenCalled()
harness.unmount()
})
it('opens agent tabs in the composer and drops a live-keys pick once the agent quits', async () => {
vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockResolvedValue({
handles: new Set(),
loaded: true
})
const harness = createTerminalLiveInputModePreferenceHarness()
await act(async () => {})
harness.setAgentHandles(['agent-pty'])
act(() => {
harness.current.defaultTerminalHandlesToLiveInput(['agent-pty', 'shell-pty'])
})
expect([...harness.current.liveInputTerminalHandles]).toEqual(['shell-pty'])
act(() => {
expect(harness.current.toggleTerminalLiveInput('agent-pty')).toBe(true)
})
expect(harness.current.liveInputTerminalHandles.has('agent-pty')).toBe(true)
harness.setAgentHandles([])
expect(harness.current.liveInputTerminalHandles.has('agent-pty')).toBe(true)
harness.setAgentHandles(['agent-pty'])
expect(harness.current.liveInputTerminalHandles.has('agent-pty')).toBe(false)
expect(saveDisabledTerminalLiveInputHandles).not.toHaveBeenCalled()
harness.unmount()
})
})
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import {
readDisabledTerminalLiveInputHandlesPreference,
saveDisabledTerminalLiveInputHandles
@@ -13,16 +13,47 @@ import {
type UseTerminalLiveInputModePreferenceOptions = {
readonly hostId: string
readonly worktreeId: string
/** Terminals running an agent; they open in the composer instead of live keys. */
readonly agentHandles?: ReadonlySet<string>
}
const NO_AGENT_HANDLES: ReadonlySet<string> = new Set()
export function useTerminalLiveInputModePreference({
hostId,
worktreeId
worktreeId,
agentHandles = NO_AGENT_HANDLES
}: UseTerminalLiveInputModePreferenceOptions) {
const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState<Set<string>>(
// The shell-level choice: live by default, minus persisted opt-outs.
const [shellLiveInputHandles, setLiveInputTerminalHandles] = useState<Set<string>>(
() => new Set()
)
const liveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
const shellLiveInputHandlesRef = useRef<Set<string>>(new Set())
// Agent tabs where the user picked live keys; the pick ends when the agent quits.
const [agentLiveInputHandles, setAgentLiveInputHandles] = useState<ReadonlySet<string>>(
() => new Set()
)
const agentHandlesRef = useRef(agentHandles)
const liveInputTerminalHandles = useMemo(
() =>
new Set(
[...shellLiveInputHandles].filter(
(handle) => !agentHandles.has(handle) || agentLiveInputHandles.has(handle)
)
),
[agentHandles, agentLiveInputHandles, shellLiveInputHandles]
)
const liveInputTerminalHandlesRef = useRef<Set<string>>(liveInputTerminalHandles)
useLayoutEffect(() => {
agentHandlesRef.current = agentHandles
liveInputTerminalHandlesRef.current = liveInputTerminalHandles
}, [agentHandles, liveInputTerminalHandles])
useEffect(() => {
setAgentLiveInputHandles((prev) => {
const kept = [...prev].filter((handle) => agentHandles.has(handle))
return kept.length === prev.size ? prev : new Set(kept)
})
}, [agentHandles])
const defaultedLiveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
const disabledLiveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
const disabledLiveInputHydratedRef = useRef(false)
@@ -42,7 +73,7 @@ export function useTerminalLiveInputModePreference({
disabledLiveInputTerminalHandlesRef.current
)
const result = defaultTerminalLiveInputHandles(
liveInputTerminalHandlesRef.current,
shellLiveInputHandlesRef.current,
defaultedLiveInputTerminalHandlesRef.current,
defaultableHandles
)
@@ -51,7 +82,7 @@ export function useTerminalLiveInputModePreference({
}
const nextEnabledHandles = new Set(result.enabledHandles)
const nextDefaultedHandles = new Set(result.defaultedHandles)
liveInputTerminalHandlesRef.current = nextEnabledHandles
shellLiveInputHandlesRef.current = nextEnabledHandles
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
setLiveInputTerminalHandles(nextEnabledHandles)
}, [])
@@ -67,7 +98,7 @@ export function useTerminalLiveInputModePreference({
const pruneTerminalHandlesFromLiveInput = useCallback(
(liveHandles: ReadonlySet<string>) => {
const result = pruneTerminalLiveInputHandles(
liveInputTerminalHandlesRef.current,
shellLiveInputHandlesRef.current,
defaultedLiveInputTerminalHandlesRef.current,
liveHandles
)
@@ -90,7 +121,7 @@ export function useTerminalLiveInputModePreference({
}
const nextEnabledHandles = new Set(result.enabledHandles)
const nextDefaultedHandles = new Set(result.defaultedHandles)
liveInputTerminalHandlesRef.current = nextEnabledHandles
shellLiveInputHandlesRef.current = nextEnabledHandles
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
setLiveInputTerminalHandles(nextEnabledHandles)
},
@@ -100,7 +131,7 @@ export function useTerminalLiveInputModePreference({
const clearTerminalLiveInputDefault = useCallback(
(handle: string) => {
const liveHandles = new Set([
...liveInputTerminalHandlesRef.current,
...shellLiveInputHandlesRef.current,
...defaultedLiveInputTerminalHandlesRef.current
])
liveHandles.delete(handle)
@@ -119,7 +150,23 @@ export function useTerminalLiveInputModePreference({
const toggleTerminalLiveInput = useCallback(
(handle: string): boolean => {
const nextEnabled = !liveInputTerminalHandlesRef.current.has(handle)
if (agentHandlesRef.current.has(handle)) {
const nextEnabled = !liveInputTerminalHandlesRef.current.has(handle)
setAgentLiveInputHandles((prev) => {
const next = new Set(prev)
if (nextEnabled) {
next.add(handle)
} else {
next.delete(handle)
}
return next
})
// Live keys also need the shell-level choice on, or the pick would have no effect.
if (!nextEnabled || shellLiveInputHandlesRef.current.has(handle)) {
return nextEnabled
}
}
const nextEnabled = !shellLiveInputHandlesRef.current.has(handle)
if (nextEnabled) {
disabledLiveInputTerminalHandlesRef.current.delete(handle)
} else {
@@ -133,29 +180,28 @@ export function useTerminalLiveInputModePreference({
if (disabledLiveInputHydratedRef.current) {
persistDisabledLiveInputHandles()
}
setLiveInputTerminalHandles((prev) => {
const next = new Set(prev)
if (nextEnabled) {
next.add(handle)
} else {
next.delete(handle)
}
liveInputTerminalHandlesRef.current = next
return next
})
const next = new Set(shellLiveInputHandlesRef.current)
if (nextEnabled) {
next.add(handle)
} else {
next.delete(handle)
}
shellLiveInputHandlesRef.current = next
setLiveInputTerminalHandles(next)
return nextEnabled
},
[persistDisabledLiveInputHandles]
)
useEffect(() => {
liveInputTerminalHandlesRef.current = new Set()
shellLiveInputHandlesRef.current = new Set()
defaultedLiveInputTerminalHandlesRef.current = new Set()
disabledLiveInputTerminalHandlesRef.current = new Set()
disabledLiveInputHydratedRef.current = false
pendingDisabledLiveInputHydrationEditsRef.current = new Map()
pendingLiveInputDefaultHandlesRef.current = new Set()
setLiveInputTerminalHandles(new Set())
setAgentLiveInputHandles(new Set())
let disposed = false
// Hydrate before subscriptions can default live-input mode over persisted choices.
@@ -179,13 +225,13 @@ export function useTerminalLiveInputModePreference({
persistDisabledLiveInputHandles()
}
const result = applyDisabledTerminalLiveInputHandles(
liveInputTerminalHandlesRef.current,
shellLiveInputHandlesRef.current,
defaultedLiveInputTerminalHandlesRef.current,
hydratedDisabledHandles
)
const nextEnabledHandles = new Set(result.enabledHandles)
const nextDefaultedHandles = new Set(result.defaultedHandles)
liveInputTerminalHandlesRef.current = nextEnabledHandles
shellLiveInputHandlesRef.current = nextEnabledHandles
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
setLiveInputTerminalHandles(nextEnabledHandles)
const pendingDefaultHandles = [...pendingLiveInputDefaultHandlesRef.current]
@@ -1,9 +1,11 @@
import { useMemo } from 'react'
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
import { useMemo, useState } from 'react'
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { Minus, MoreHorizontal, Plus, Sparkles } from 'lucide-react-native'
import { colors, spacing } from '../theme/mobile-theme'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { MessageBox } from '../components/composer-input/MessageBox'
import { composerInputStyles } from '../components/composer-input/composer-input-styles'
import { MobileSourceControlCreatePrEntry } from './MobileSourceControlCreatePrEntry'
import { MobileCommitFailurePanel } from './MobileCommitFailurePanel'
import { KEYBOARD_COMMIT_BAR_CLEARANCE } from './mobile-source-control-screen-state'
@@ -13,6 +15,8 @@ import type { MobileSourceControlPrimaryAction } from './mobile-source-control-p
import { styles } from './mobile-source-control-styles'
import { hubStyles } from './mobile-source-control-hub-styles'
const INITIAL_COMMIT_BAR_HEIGHT = 180
type Props = {
state: Omit<MobileSourceControlState, 'primaryActionArgs'>
// The panel builds it once it knows the branch's review.
@@ -62,9 +66,13 @@ export function MobileSourceControlContent({ state, primaryAction }: Props) {
const generateDisabled = busyAction !== null || generateBlockedByMessage
const createPrHeroActive =
createPrAction.visible && !createPrAction.disabled && !createPrAction.pushFirst
// Measured, because the commit box grows as the message wraps; the first frame uses a one-line bar.
const [commitBarHeight, setCommitBarHeight] = useState(INITIAL_COMMIT_BAR_HEIGHT)
const listBottomInset = commitBarHeight + spacing.lg
const fileList = useMemo(
() => (
<MobileSourceControlFileList
bottomInset={listBottomInset}
state={{
sections,
openFile,
@@ -84,6 +92,7 @@ export function MobileSourceControlContent({ state, primaryAction }: Props) {
/>
),
[
listBottomInset,
sections,
openFile,
resolveConflict,
@@ -196,6 +205,7 @@ export function MobileSourceControlContent({ state, primaryAction }: Props) {
)}
<View
onLayout={(event) => setCommitBarHeight(event.nativeEvent.layout.height)}
style={[
styles.commitBar,
{
@@ -204,93 +214,95 @@ export function MobileSourceControlContent({ state, primaryAction }: Props) {
}
]}
>
<View style={styles.commitRow}>
{stagedCount === 0 ? (
<View
style={[styles.commitInput, styles.commitInputDisabled]}
accessibilityRole="text"
accessibilityState={{ disabled: true }}
accessibilityLabel={t(
'sourceControl.content.commitMessageDisabled',
'Commit message disabled. No staged files.'
)}
>
<Text style={styles.commitInputDisabledText}>
{t('sourceControl.content.noStagedFiles', 'No staged files')}
</Text>
</View>
) : (
<TextInput
style={styles.commitInput}
value={commitMessage}
onChangeText={setCommitMessage}
placeholder={t('sourceControl.content.commitMessagePlaceholder', 'Commit message')}
placeholderTextColor={colors.textMuted}
editable={busyAction === null && openingPath === null && openingBranchPath === null}
returnKeyType="done"
onSubmitEditing={primaryAction.onPress}
/>
)}
{shouldShowGenerateButton ? (
<MessageBox
value={stagedCount === 0 ? '' : commitMessage}
onChangeText={setCommitMessage}
placeholder={
stagedCount === 0
? t('sourceControl.content.noStagedFiles', 'No staged files')
: t('sourceControl.content.commitMessagePlaceholder', 'Commit message')
}
editable={stagedCount > 0 && !ioBusy}
accessibilityLabel={
stagedCount === 0
? t(
'sourceControl.content.commitMessageDisabled',
'Commit message disabled. No staged files.'
)
: undefined
}
leading={
shouldShowGenerateButton ? (
<Pressable
style={({ pressed }) => [
composerInputStyles.controlButton,
generateDisabled && composerInputStyles.disabled,
pressed && composerInputStyles.pressed
]}
// Keep commit-message AI with commit actions so Stage All remains unambiguous.
disabled={generateDisabled}
accessibilityState={{ disabled: generateDisabled }}
accessibilityHint={
generateBlockedByMessage
? t('sourceControl.content.regenerateHint', 'Clear the message to regenerate.')
: undefined
}
onPress={() =>
generatingMessage ? cancelGenerateCommitMessage() : void generateCommitMessage()
}
accessibilityLabel={
generatingMessage
? t(
'sourceControl.content.cancelGeneration',
'Cancel commit message generation'
)
: t('sourceControl.content.generateMessage', 'Generate commit message with AI')
}
>
{generatingMessage ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Sparkles size={17} color={colors.textSecondary} strokeWidth={2} />
)}
</Pressable>
) : null
}
trailing={
<Pressable
style={({ pressed }) => [
styles.generateButton,
generateDisabled && styles.commitButtonDisabled,
pressed && styles.commitButtonPressed
styles.commitButton,
createPrHeroActive && styles.commitButtonSecondary,
primaryAction.disabled && styles.commitButtonDisabled,
pressed && composerInputStyles.pressed
]}
// Keep commit-message AI with commit actions so Stage All remains unambiguous.
disabled={generateDisabled}
accessibilityState={{ disabled: generateDisabled }}
accessibilityHint={
generateBlockedByMessage
? t('sourceControl.content.regenerateHint', 'Clear the message to regenerate.')
: undefined
}
onPress={() =>
generatingMessage ? cancelGenerateCommitMessage() : void generateCommitMessage()
}
accessibilityLabel={
generatingMessage
? t('sourceControl.content.cancelGeneration', 'Cancel commit message generation')
: t('sourceControl.content.generateMessage', 'Generate commit message with AI')
}
onPress={primaryAction.onPress}
disabled={primaryAction.disabled}
accessibilityLabel={primaryAction.accessibilityLabel}
accessibilityHint={primaryAction.accessibilityHint}
>
{generatingMessage ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
{primaryAction.loading ? (
<ActivityIndicator
size="small"
color={
createPrHeroActive || primaryAction.disabled
? colors.textPrimary
: colors.bgBase
}
/>
) : (
<Sparkles size={16} color={colors.textSecondary} strokeWidth={2.1} />
<Text
style={[
styles.commitButtonText,
createPrHeroActive && styles.commitButtonSecondaryText,
primaryAction.disabled && styles.commitButtonDisabledText
]}
>
{primaryAction.label}
</Text>
)}
</Pressable>
) : null}
<Pressable
style={({ pressed }) => [
styles.commitButton,
createPrHeroActive && styles.commitButtonSecondary,
primaryAction.disabled && styles.commitButtonDisabled,
pressed && styles.commitButtonPressed
]}
onPress={primaryAction.onPress}
disabled={primaryAction.disabled}
accessibilityLabel={primaryAction.accessibilityLabel}
accessibilityHint={primaryAction.accessibilityHint}
>
{primaryAction.loading ? (
<ActivityIndicator
size="small"
color={createPrHeroActive ? colors.textPrimary : colors.bgBase}
/>
) : (
<Text
style={[
styles.commitButtonText,
createPrHeroActive && styles.commitButtonSecondaryText
]}
>
{primaryAction.label}
</Text>
)}
</Pressable>
</View>
}
/>
</View>
</>
)
@@ -39,7 +39,14 @@ export type FileListState = Pick<
| 'setDiscardTarget'
>
export function MobileSourceControlFileList({ state }: { state: FileListState }) {
export function MobileSourceControlFileList({
state,
bottomInset
}: {
state: FileListState
/** Clears the floating commit bar, which grows with its message. */
bottomInset: number
}) {
const colorScheme = useMobileColorScheme()
const t = useMobileTranslation()
const sections: FileSection[] = state.sections.map((section) => ({
@@ -84,7 +91,7 @@ export function MobileSourceControlFileList({ state }: { state: FileListState })
)
}
stickySectionHeadersEnabled={false}
contentContainerStyle={styles.listContent}
contentContainerStyle={[styles.listContent, { paddingBottom: bottomInset }]}
/>
)
}
@@ -104,7 +104,9 @@ describe('source-control file virtualization in the mobile web page', () => {
for (const count of [1_000, 20_000]) {
const state = fileListState(count, dirty)
await act(async () => {
root.render(createElement(MobileSourceControlFileList, { key: count, state }))
root.render(
createElement(MobileSourceControlFileList, { key: count, state, bottomInset: 0 })
)
})
const rows = container.querySelectorAll('[aria-label^="Open committed change "]')
mountedCounts.push(rows.length)
@@ -1,12 +1,11 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
import { COMPOSER_CONTROL_SIZE } from '../components/composer-input/composer-input-styles'
export const listStyles = themedStyleSheet(() => ({
listContent: {
paddingHorizontal: spacing.lg,
paddingBottom: 136
paddingHorizontal: spacing.lg
},
sectionHeader: {
flexDirection: 'row',
@@ -144,70 +143,34 @@ export const listStyles = themedStyleSheet(() => ({
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle
},
commitRow: {
flexDirection: 'row',
gap: spacing.sm
},
commitInput: {
flex: 1,
minHeight: 42,
borderRadius: radii.input,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgBase,
color: colors.textPrimary,
paddingHorizontal: spacing.md,
// Keep input sizing above iOS’s web focus-zoom threshold.
fontSize: TEXT_INPUT_FONT_SIZE
},
commitInputDisabled: {
backgroundColor: colors.bgPanel,
borderColor: colors.borderSubtle,
borderStyle: 'dashed',
alignItems: 'center',
justifyContent: 'center'
},
commitInputDisabledText: {
color: colors.textMuted,
fontSize: typography.bodySize,
fontWeight: '600'
},
// A capsule beside the composer's round controls; the action's name matters more than an arrow.
commitButton: {
minWidth: 88,
minHeight: 42,
borderRadius: radii.button,
backgroundColor: colors.textPrimary,
height: COMPOSER_CONTROL_SIZE,
borderRadius: COMPOSER_CONTROL_SIZE / 2,
backgroundColor: colors.surfaceBright,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.md
paddingHorizontal: spacing.lg
},
commitButtonSecondary: {
backgroundColor: 'transparent',
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle
},
generateButton: {
width: 42,
minHeight: 42,
borderRadius: radii.button,
backgroundColor: colors.bgRaised,
alignItems: 'center',
justifyContent: 'center'
},
commitButtonDisabled: {
opacity: 0.45
},
commitButtonPressed: {
opacity: 0.75
backgroundColor: colors.composerControl
},
commitButtonText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '700'
fontWeight: '600'
},
commitButtonSecondaryText: {
color: colors.textPrimary
},
commitButtonDisabledText: {
color: colors.textMuted
},
commitFailurePanel: {
marginTop: spacing.sm,
padding: spacing.sm,
+16 -25
View File
@@ -8,9 +8,7 @@ import {
Text,
View,
MobileSyntaxSegments,
TextInput,
colors,
Pressable,
MessageBox,
useMobileColorScheme
} from './mobile-tasks-dependencies'
import { MAX_RENDERED_PR_DIFF_LINES } from './mobile-tasks-options'
@@ -123,28 +121,21 @@ export function GitHubPrFileDiff({
</Text>
</View>
{commentLine !== undefined ? (
<>
<TextInput
style={[styles.input, styles.replyInput]}
value={commentDrafts[draftKey] ?? ''}
onChangeText={(next) => onCommentDraftChange(draftKey, next)}
placeholder={t('tasks.prFileDiff.commentPlaceholder', 'Add review comment')}
placeholderTextColor={colors.textMuted}
multiline
textAlignVertical="top"
/>
<Pressable
style={styles.inlineSaveButtonCompact}
disabled={disabled || !(commentDrafts[draftKey] ?? '').trim()}
onPress={() => onSubmitComment(commentLine)}
>
<Text style={styles.inlineSaveText}>
{t('tasks.prFileDiff.commentOnLine', 'Comment on line {{line}}', {
line: commentLine
})}
</Text>
</Pressable>
</>
<MessageBox
style={styles.stackedInput}
value={commentDrafts[draftKey] ?? ''}
onChangeText={(next) => onCommentDraftChange(draftKey, next)}
placeholder={t('tasks.prFileDiff.commentPlaceholder', 'Add review comment')}
onSend={() => onSubmitComment(commentLine)}
canSend={!disabled && !!(commentDrafts[draftKey] ?? '').trim()}
sendAccessibilityLabel={t(
'tasks.prFileDiff.commentOnLine',
'Comment on line {{line}}',
{
line: commentLine
}
)}
/>
) : null}
</View>
)
@@ -1,11 +1,4 @@
import {
themedStyleSheet,
Platform,
colors,
radii,
spacing,
typography
} from './mobile-tasks-dependencies'
import { themedStyleSheet, colors, radii, spacing, typography } from './mobile-tasks-dependencies'
export const mobileTasksComposerActionStyles = themedStyleSheet(() => ({
createForm: {
@@ -102,57 +95,6 @@ export const mobileTasksComposerActionStyles = themedStyleSheet(() => ({
paddingVertical: spacing.lg,
alignItems: 'center'
},
input: {
borderWidth: 1,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgPanel,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
color: colors.textPrimary,
fontSize: typography.bodySize
},
bodyInput: {
minHeight: 88
},
monoInput: {
fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' })
},
commentInput: {
minHeight: 72,
marginTop: spacing.sm
},
commentComposer: {
position: 'relative',
marginTop: spacing.sm
},
commentComposerInput: {
minHeight: 40,
maxHeight: 120,
marginTop: 0,
paddingRight: 44
},
commentComposerSend: {
position: 'absolute',
right: spacing.xs,
bottom: spacing.xs,
width: 32,
height: 32,
borderRadius: radii.button,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised
},
commentComposerSendPressed: {
opacity: 0.75
},
commentComposerSendDisabled: {
opacity: 0.5
},
replyInput: {
minHeight: 48,
marginTop: spacing.xs
},
stackedInput: {
marginTop: spacing.sm
},
@@ -8,7 +8,8 @@ import {
Text,
Pressable,
ChevronDown,
TextInput,
MessageBox,
TextField,
ActivityIndicator,
PickerModal,
Linking,
@@ -169,12 +170,10 @@ export function renderMobileTasksCreateDrawer(model: ConnectionPresentationModel
<Text style={styles.fieldLabel}>
{translate('tasks.tasksCreateDrawers.title', 'Title')}
</Text>
<TextInput
style={styles.input}
<TextField
value={createTitle}
onChangeText={setCreateTitle}
placeholder={translate('tasks.tasksCreateDrawers.titlePlaceholder', 'Task title')}
placeholderTextColor={colors.textMuted}
autoCapitalize="sentences"
returnKeyType="next"
/>
@@ -182,14 +181,10 @@ export function renderMobileTasksCreateDrawer(model: ConnectionPresentationModel
<Text style={styles.fieldLabel}>
{translate('tasks.tasksCreateDrawers.description', 'Description')}
</Text>
<TextInput
style={[styles.input, styles.bodyInput]}
<MessageBox
value={createBody}
onChangeText={setCreateBody}
placeholder={translate('tasks.tasksCreateDrawers.descriptionPlaceholder', 'Add context')}
placeholderTextColor={colors.textMuted}
multiline
textAlignVertical="top"
/>
<Pressable
@@ -294,8 +289,7 @@ export function renderMobileTasksLinearConnectDrawer(model: ConnectionPresentati
<Text style={styles.fieldLabel}>
{translate('tasks.tasksCreateDrawers.personalApiKey', 'Personal API key')}
</Text>
<TextInput
style={styles.input}
<TextField
value={linearApiKeyDraft}
onChangeText={(next) => {
setLinearApiKeyDraft(next)
@@ -305,7 +299,6 @@ export function renderMobileTasksLinearConnectDrawer(model: ConnectionPresentati
}
}}
placeholder="lin_api_..."
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
@@ -9,7 +9,6 @@ export {
ScrollView,
StyleSheet,
Text,
TextInput,
View
} from 'react-native'
// Use the platform seam: WebView window.open is refused by both shells despite resolving successfully.
@@ -33,7 +32,7 @@ export {
Pencil,
Plus,
RefreshCw,
Send,
Search,
X
} from 'lucide-react-native'
export type { RpcClient } from '../transport/rpc-client'
@@ -53,6 +52,8 @@ export { ConfirmModal } from '../components/ConfirmModal'
export { MobileMarkdown } from '../components/MobileMarkdown'
export { MobileAgentIcon } from '../components/MobileAgentIcon'
export { MobileWorkspaceNameInput } from '../components/MobileWorkspaceNameInput'
export { MessageBox } from '../components/composer-input/MessageBox'
export { TextField } from '../components/composer-input/TextField'
export { MobileSearchField } from '../components/MobileSearchField'
export { MobileSyntaxSegments } from '../components/MobileSyntaxSegments'
export { PickerModal } from '../components/PickerModal'
+8 -11
View File
@@ -2,10 +2,13 @@ import {
StyleSheet,
themedStyleSheet,
colors,
radii,
spacing,
typography
} from './mobile-tasks-dependencies'
import {
composerInputStyles,
COMPOSER_TEXT_SIZE
} from '../components/composer-input/composer-input-styles'
export const mobileTasksDetailStyles = themedStyleSheet(() => ({
groupSeparator: {
@@ -151,16 +154,10 @@ export const mobileTasksDetailStyles = themedStyleSheet(() => ({
fontSize: 11,
color: colors.textMuted
},
// Pickers sit beside text fields in these forms, so they wear the same field shell.
fieldButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderWidth: 1,
borderColor: colors.borderSubtle
...composerInputStyles.field,
paddingVertical: spacing.sm
},
fieldButtonDisabled: {
opacity: 0.55
@@ -170,7 +167,7 @@ export const mobileTasksDetailStyles = themedStyleSheet(() => ({
},
fieldButtonText: {
flex: 1,
fontSize: typography.bodySize,
fontSize: COMPOSER_TEXT_SIZE,
color: colors.textPrimary
}
}))

Some files were not shown because too many files have changed in this diff Show More