mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
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:
@@ -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
|
||||
)
|
||||
|
||||
+7
-4
@@ -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} />
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
@@ -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,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 +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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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]}>
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-19
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}} 行目のメモを削除",
|
||||
|
||||
@@ -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}}행의 메모 삭제",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}} 行的備註",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user