diff --git a/config/scripts/mobile-web-app/mobile-web-app-held-press-render.test.mjs b/config/scripts/mobile-web-app/mobile-web-app-held-press-render.test.mjs index 64960718451..80d53918a28 100644 --- a/config/scripts/mobile-web-app/mobile-web-app-held-press-render.test.mjs +++ b/config/scripts/mobile-web-app/mobile-web-app-held-press-render.test.mjs @@ -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) { diff --git a/config/scripts/mobile-web-app/mobile-web-app-session-render.test.mjs b/config/scripts/mobile-web-app/mobile-web-app-session-render.test.mjs index 42a530a5229..aeecd55ec7a 100644 --- a/config/scripts/mobile-web-app/mobile-web-app-session-render.test.mjs +++ b/config/scripts/mobile-web-app/mobile-web-app-session-render.test.mjs @@ -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) diff --git a/config/scripts/mobile-web-app/mobile-web-app-source-control-text-inputs.test.mjs b/config/scripts/mobile-web-app/mobile-web-app-source-control-text-inputs.test.mjs index 71a8e91183f..235137c9fbf 100644 --- a/config/scripts/mobile-web-app/mobile-web-app-source-control-text-inputs.test.mjs +++ b/config/scripts/mobile-web-app/mobile-web-app-source-control-text-inputs.test.mjs @@ -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 = () => ' + ].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 ) diff --git a/config/scripts/mobile-web-app/platform-seams/mobile-web-app-text-input-font-size-seam.mjs b/config/scripts/mobile-web-app/platform-seams/mobile-web-app-text-input-font-size-seam.mjs index 08326aaa52b..b19c5d14cc9 100644 --- a/config/scripts/mobile-web-app/platform-seams/mobile-web-app-text-input-font-size-seam.mjs +++ b/config/scripts/mobile-web-app/platform-seams/mobile-web-app-text-input-font-size-seam.mjs @@ -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) } } } diff --git a/config/scripts/mobile-web-app/preview/mobile-web-app-held-press-probe-routes.mjs b/config/scripts/mobile-web-app/preview/mobile-web-app-held-press-probe-routes.mjs index ea738595942..b5c2b5ef4e8 100644 --- a/config/scripts/mobile-web-app/preview/mobile-web-app-held-press-probe-routes.mjs +++ b/config/scripts/mobile-web-app/preview/mobile-web-app-held-press-probe-routes.mjs @@ -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 + return } ` } diff --git a/mobile/app/h/[hostId]/edit.tsx b/mobile/app/h/[hostId]/edit.tsx index f87b3ec06bb..df42c15ad32 100644 --- a/mobile/app/h/[hostId]/edit.tsx +++ b/mobile/app/h/[hostId]/edit.tsx @@ -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() { {t('host.edit.name', 'Name')} - { @@ -248,15 +247,13 @@ function EditHostScreen() { placeholder={ host.lastKnownMachineName ?? t('host.edit.hostNamePlaceholder', 'Host name') } - placeholderTextColor={colors.textMuted} autoCapitalize="words" autoCorrect={false} returnKeyType="next" /> {t('host.edit.address', 'Address')} - { @@ -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, diff --git a/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json b/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json index 0acfff96f4f..1523050b7fe 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json +++ b/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json @@ -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"] } } diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx index 666828a2212..918dcd14cbf 100644 --- a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx +++ b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx @@ -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({ })} - {issues.length > 0 ? ( diff --git a/mobile/src/agent-history/agent-history-back-handoff.test.tsx b/mobile/src/agent-history/agent-history-back-handoff.test.tsx index d1a7c9be8e9..b83a09bd91e 100644 --- a/mobile/src/agent-history/agent-history-back-handoff.test.tsx +++ b/mobile/src/agent-history/agent-history-back-handoff.test.tsx @@ -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 })) diff --git a/mobile/src/agent-history/agent-history-pending-resume-ownership.test.tsx b/mobile/src/agent-history/agent-history-pending-resume-ownership.test.tsx index d5bcee0d241..06b20f384f8 100644 --- a/mobile/src/agent-history/agent-history-pending-resume-ownership.test.tsx +++ b/mobile/src/agent-history/agent-history-pending-resume-ownership.test.tsx @@ -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(), diff --git a/mobile/src/agent-history/agent-history-retry.test.tsx b/mobile/src/agent-history/agent-history-retry.test.tsx index df17f69cd80..abea6590ff6 100644 --- a/mobile/src/agent-history/agent-history-retry.test.tsx +++ b/mobile/src/agent-history/agent-history-retry.test.tsx @@ -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')) diff --git a/mobile/src/agent-history/agent-history-styles.ts b/mobile/src/agent-history/agent-history-styles.ts index dacec771a72..1f3c7b527cf 100644 --- a/mobile/src/agent-history/agent-history-styles.ts +++ b/mobile/src/agent-history/agent-history-styles.ts @@ -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, diff --git a/mobile/src/browser/MobileBrowserAddressField.tsx b/mobile/src/browser/MobileBrowserAddressField.tsx index 8d3b073510e..6a2ae3c70df 100644 --- a/mobile/src/browser/MobileBrowserAddressField.tsx +++ b/mobile/src/browser/MobileBrowserAddressField.tsx @@ -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 ( - - + {fileLabel ? ( - - + + {fileLabel} @@ -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 } })) diff --git a/mobile/src/browser/MobileBrowserLoadProgress.tsx b/mobile/src/browser/MobileBrowserLoadProgress.tsx index df5e041ca26..efc2ee53a5e 100644 --- a/mobile/src/browser/MobileBrowserLoadProgress.tsx +++ b/mobile/src/browser/MobileBrowserLoadProgress.tsx @@ -121,7 +121,7 @@ export function MobileBrowserLoadProgress({ const styles = themedStyleSheet(() => ({ clip: { ...StyleSheet.absoluteFillObject, - borderRadius: radii.input, + borderRadius: radii.composerItem, overflow: 'hidden' }, line: { diff --git a/mobile/src/browser/browser-address-field-base-styles.ts b/mobile/src/browser/browser-address-field-base-styles.ts deleted file mode 100644 index d46e2ad60cd..00000000000 --- a/mobile/src/browser/browser-address-field-base-styles.ts +++ /dev/null @@ -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 -) diff --git a/mobile/src/browser/browser-address-field-styles.ts b/mobile/src/browser/browser-address-field-styles.ts deleted file mode 100644 index 2b5f316556b..00000000000 --- a/mobile/src/browser/browser-address-field-styles.ts +++ /dev/null @@ -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 } -})) diff --git a/mobile/src/browser/browser-address-field-styles.web.test.ts b/mobile/src/browser/browser-address-field-styles.web.test.ts deleted file mode 100644 index 256fe255427..00000000000 --- a/mobile/src/browser/browser-address-field-styles.web.test.ts +++ /dev/null @@ -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 - ) - }) -}) diff --git a/mobile/src/browser/browser-address-field-styles.web.ts b/mobile/src/browser/browser-address-field-styles.web.ts deleted file mode 100644 index 243c2d98dee..00000000000 --- a/mobile/src/browser/browser-address-field-styles.web.ts +++ /dev/null @@ -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 - } -})) diff --git a/mobile/src/browser/mobile-browser-pane-styles.web.test.ts b/mobile/src/browser/mobile-browser-pane-styles.web.test.ts new file mode 100644 index 00000000000..eba91b85535 --- /dev/null +++ b/mobile/src/browser/mobile-browser-pane-styles.web.test.ts @@ -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 + ) + }) +}) diff --git a/mobile/src/components/CustomKeyModal.styles.ts b/mobile/src/components/CustomKeyModal.styles.ts index d75e695ccab..399e8911816 100644 --- a/mobile/src/components/CustomKeyModal.styles.ts +++ b/mobile/src/components/CustomKeyModal.styles.ts @@ -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', diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx index fce1ab1a646..5753eb334bb 100644 --- a/mobile/src/components/CustomKeyModal.tsx +++ b/mobile/src/components/CustomKeyModal.tsx @@ -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 {t('components.customKeyModal.label', 'Label')} - {t('components.customKeyModal.command', 'Command')} - diff --git a/mobile/src/components/MobileDiffReviewDrawers.tsx b/mobile/src/components/MobileDiffReviewDrawers.tsx index 29b596f510d..3e470dadaa8 100644 --- a/mobile/src/components/MobileDiffReviewDrawers.tsx +++ b/mobile/src/components/MobileDiffReviewDrawers.tsx @@ -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 - void controller.saveComposer()} + sendAccessibilityLabel={saveLabel(t, composer)} + leading={ + composer.mode === 'edit' ? : null + } /> - - {composer.mode === 'edit' ? : null} - - ) } @@ -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 }) { useMobileColorScheme() const t = useMobileTranslation() return ( [styles.secondaryButton, pressed && styles.buttonPressed]} + style={({ pressed }) => [ + composerInputStyles.ghostButton, + pressed && composerInputStyles.pressed + ]} onPress={() => void onPress()} accessibilityRole="button" accessibilityLabel={t('components.diffReviewDrawers.deleteNote', 'Delete note')} > - - {t('common.delete', 'Delete')} - - ) -} - -function SaveNoteButton({ - controller, - composer -}: { - controller: ReturnType - composer: ReturnType['composer'] -}) { - useMobileColorScheme() - const t = useMobileTranslation() - const disabled = controller.composerBody.trim().length === 0 - return ( - [ - styles.primaryButton, - disabled && styles.buttonDisabled, - pressed && styles.buttonPressed - ]} - disabled={disabled} - onPress={() => void controller.saveComposer()} - accessibilityRole="button" - accessibilityLabel={composerLabel(t, composer)} - > - - {t('common.save', 'Save')} + ) } diff --git a/mobile/src/components/MobileSearchField.tsx b/mobile/src/components/MobileSearchField.tsx index 0fb30950a67..7b889b092fe 100644 --- a/mobile/src/components/MobileSearchField.tsx +++ b/mobile/src/components/MobileSearchField.tsx @@ -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(null) - const [focused, setFocused] = useState(false) const clearVisible = showClear ?? value.length > 0 useEffect(() => { @@ -91,91 +88,49 @@ export function MobileSearchField({ } return ( - - - setFocused(true)} - onBlur={(event) => { - setFocused(false) - onBlur?.(event) - }} - clearButtonMode="never" - accessibilityLabel={accessibilityLabel ?? placeholder} - selectionColor={colors.accentBlue} - /> - {clearVisible ? ( - [styles.clearButton, pressed && styles.clearButtonPressed]} - > - {/* Use chip and large hit target: bare 14px X looked decorative and was hard to tap. */} - - - - - ) : null} - + } + trailing={ + clearVisible ? ( + [styles.clearButton, pressed && styles.clearButtonPressed]} + > + {/* Use chip and large hit target: bare 14px X looked decorative and was hard to tap. */} + + + + + ) : 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 } })) diff --git a/mobile/src/components/MobileWorkspaceNameInput.tsx b/mobile/src/components/MobileWorkspaceNameInput.tsx index 1c6cf2de8b2..b943f577405 100644 --- a/mobile/src/components/MobileWorkspaceNameInput.tsx +++ b/mobile/src/components/MobileWorkspaceNameInput.tsx @@ -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 & { shouldAutoFocus: boolean focusKey?: unknown } @@ -31,8 +32,8 @@ export function MobileWorkspaceNameInput({ }, [focusKey, shouldAutoFocus]) return ( - {t('components.newWorktreeFormSheet.note', 'Note')} - diff --git a/mobile/src/components/NewWorktreeProjectTargetFields.tsx b/mobile/src/components/NewWorktreeProjectTargetFields.tsx index b24a1917418..43ae95e08f6 100644 --- a/mobile/src/components/NewWorktreeProjectTargetFields.tsx +++ b/mobile/src/components/NewWorktreeProjectTargetFields.tsx @@ -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: { diff --git a/mobile/src/components/SmartWorkspaceAdvancedFields.tsx b/mobile/src/components/SmartWorkspaceAdvancedFields.tsx index f20423bd50a..b20bab4d096 100644 --- a/mobile/src/components/SmartWorkspaceAdvancedFields.tsx +++ b/mobile/src/components/SmartWorkspaceAdvancedFields.tsx @@ -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 {t('components.smartWorkspaceAdvancedFields.name', 'Name')} - @@ -47,15 +45,13 @@ export function SmartWorkspaceAdvancedFields({ composer, selectedRepoIsGit }: Pr {t('components.smartWorkspaceAdvancedFields.branchName', 'Branch name')} - @@ -96,15 +92,13 @@ export function SmartWorkspaceAdvancedFields({ composer, selectedRepoIsGit }: Pr {t('workspace.branchChoice.newName', 'New branch name')} - @@ -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 diff --git a/mobile/src/components/SmartWorkspaceSourceDrawer.tsx b/mobile/src/components/SmartWorkspaceSourceDrawer.tsx index ea1d58625b9..2da62482a11 100644 --- a/mobile/src/components/SmartWorkspaceSourceDrawer.tsx +++ b/mobile/src/components/SmartWorkspaceSourceDrawer.tsx @@ -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({ })} - ) : ( // Use real TextInput sharing composer.name with docked picker field so typed text survives native focus handoff. - ({ 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', diff --git a/mobile/src/components/TerminalShortcutSettings.tsx b/mobile/src/components/TerminalShortcutSettings.tsx index ccb65636ef0..2a1dc095e6d 100644 --- a/mobile/src/components/TerminalShortcutSettings.tsx +++ b/mobile/src/components/TerminalShortcutSettings.tsx @@ -192,12 +192,12 @@ export function TerminalShortcutSettings({ return ( <> - {t('components.terminalShortcutSettings.shortcutBar', 'SHORTCUT BAR')} + {t('components.terminalShortcutSettings.moreKeys', 'MORE KEYS')} {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 ⋯.' )} diff --git a/mobile/src/components/TextInputModal.tsx b/mobile/src/components/TextInputModal.tsx index 32baea10c71..750ff295f4b 100644 --- a/mobile/src/components/TextInputModal.tsx +++ b/mobile/src/components/TextInputModal.tsx @@ -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 ? {message} : null} - @@ -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', diff --git a/mobile/src/components/composer-input/ComposerSendButton.tsx b/mobile/src/components/composer-input/ComposerSendButton.tsx new file mode 100644 index 00000000000..81563f76854 --- /dev/null +++ b/mobile/src/components/composer-input/ComposerSendButton.tsx @@ -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 ( + [ + styles.sendButton, + !canSend && styles.sendButtonDisabled, + pressed && enabled && styles.pressed + ]} + onPress={onPress} + disabled={!enabled} + > + {sending ? ( + + ) : ( + + )} + + ) +} diff --git a/mobile/src/components/composer-input/MessageBox.tsx b/mobile/src/components/composer-input/MessageBox.tsx new file mode 100644 index 00000000000..3aed7479362 --- /dev/null +++ b/mobile/src/components/composer-input/MessageBox.tsx @@ -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 + /** 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 +} + +// 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(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 ( + + {/* Always wrapped, so showing the overlay never remounts the field under the keyboard. */} + + + {inputOverlay} + + {hasActions ? ( + + {leading} + + {trailing} + {onSend ? ( + + ) : null} + + ) : null} + + ) +} diff --git a/mobile/src/components/composer-input/TextField.tsx b/mobile/src/components/composer-input/TextField.tsx new file mode 100644 index 00000000000..e26f6297e66 --- /dev/null +++ b/mobile/src/components/composer-input/TextField.tsx @@ -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 & { + inputRef?: RefObject + /** 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 +} + +// 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 ( + + {leading} + + {trailing} + + ) +} diff --git a/mobile/src/components/composer-input/composer-input-styles.ts b/mobile/src/components/composer-input/composer-input-styles.ts new file mode 100644 index 00000000000..21d3901cdde --- /dev/null +++ b/mobile/src/components/composer-input/composer-input-styles.ts @@ -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 + } +})) diff --git a/mobile/src/components/mobile-diff-review-control-styles.ts b/mobile/src/components/mobile-diff-review-control-styles.ts index ea61cb1849c..7c5d911f65b 100644 --- a/mobile/src/components/mobile-diff-review-control-styles.ts +++ b/mobile/src/components/mobile-diff-review-control-styles.ts @@ -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, diff --git a/mobile/src/components/new-worktree-form-styles.ts b/mobile/src/components/new-worktree-form-styles.ts index 28747c32c07..0de129e7e9a 100644 --- a/mobile/src/components/new-worktree-form-styles.ts +++ b/mobile/src/components/new-worktree-form-styles.ts @@ -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, diff --git a/mobile/src/components/pr-sidebar/MobileLinkPrForm.tsx b/mobile/src/components/pr-sidebar/MobileLinkPrForm.tsx index f8eb3f77681..3e844ade230 100644 --- a/mobile/src/components/pr-sidebar/MobileLinkPrForm.tsx +++ b/mobile/src/components/pr-sidebar/MobileLinkPrForm.tsx @@ -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 {t('pullRequest.linkPrForm.referenceLabel', 'PR number or GitHub URL')} - ({ 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, diff --git a/mobile/src/components/pr-sidebar/PRCommentComposer.tsx b/mobile/src/components/pr-sidebar/PRCommentComposer.tsx index e8336eb627a..f2d286666bc 100644 --- a/mobile/src/components/pr-sidebar/PRCommentComposer.tsx +++ b/mobile/src/components/pr-sidebar/PRCommentComposer.tsx @@ -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 ( - - - - {onCancel ? ( + void submit()} + canSend={isSubmittableCommentBody(body)} + sending={submitting} + sendAccessibilityLabel={submitLabel} + leading={ + onCancel ? ( [styles.cancel, pressed && styles.pressed]} + style={({ pressed }) => [ + composerInputStyles.ghostButton, + pressed && composerInputStyles.pressed + ]} onPress={onCancel} disabled={submitting} accessibilityRole="button" accessibilityLabel={t('common.cancel', 'Cancel')} > - {t('common.cancel', 'Cancel')} + - ) : null} - [ - styles.submit, - !canSubmit && styles.submitDisabled, - pressed && styles.pressed - ]} - onPress={() => void submit()} - disabled={!canSubmit} - accessibilityRole="button" - accessibilityLabel={submitLabel} - > - {submitting ? ( - - ) : ( - {submitLabel} - )} - - - + ) : undefined + } + /> ) } diff --git a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx index 78f867bb3f3..3758dac4ec0 100644 --- a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx +++ b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx @@ -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 ( - - - {titleAction.error ? {titleAction.error} : null} - + + + {titleAction.error ? {titleAction.error} : null} + [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' )} > - {t('common.cancel', 'Cancel')} + {t('common.cancel', 'Cancel')} [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 ? ( ) : ( - {t('common.save', 'Save')} + {t('common.save', 'Save')} )} diff --git a/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx b/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx index d6536f1b21c..b42ba830b4c 100644 --- a/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx +++ b/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx @@ -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({ {t('pullRequest.reviewerPickerDrawer.title', 'Reviewers')} - } value={query} onChangeText={setQuery} placeholder={t('pullRequest.reviewerPickerDrawer.searchPeople', 'Search people')} - placeholderTextColor={colors.textMuted} autoCapitalize="none" autoCorrect={false} /> diff --git a/mobile/src/components/pr-sidebar/mobile-pr-sidebar-styles.ts b/mobile/src/components/pr-sidebar/mobile-pr-sidebar-styles.ts index 86e410ea71f..7dbf8d2cdea 100644 --- a/mobile/src/components/pr-sidebar/mobile-pr-sidebar-styles.ts +++ b/mobile/src/components/pr-sidebar/mobile-pr-sidebar-styles.ts @@ -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. diff --git a/mobile/src/components/pr-sidebar/pr-comment-composer-styles.ts b/mobile/src/components/pr-sidebar/pr-title-edit-styles.ts similarity index 59% rename from mobile/src/components/pr-sidebar/pr-comment-composer-styles.ts rename to mobile/src/components/pr-sidebar/pr-title-edit-styles.ts index 0b3ece77a74..01ba7e56ff0 100644 --- a/mobile/src/components/pr-sidebar/pr-comment-composer-styles.ts +++ b/mobile/src/components/pr-sidebar/pr-title-edit-styles.ts @@ -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, diff --git a/mobile/src/components/smart-workspace-source-drawer-styles.ts b/mobile/src/components/smart-workspace-source-drawer-styles.ts index 2ada4c9e912..b4801dc19c4 100644 --- a/mobile/src/components/smart-workspace-source-drawer-styles.ts +++ b/mobile/src/components/smart-workspace-source-drawer-styles.ts @@ -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', diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json index 38485e987c9..0e973934c6a 100644 --- a/mobile/src/i18n/locales/de.json +++ b/mobile/src/i18n/locales/de.json @@ -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", diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index 8e3a130a11d..27e44fec81c 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -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": { diff --git a/mobile/src/i18n/locales/es.json b/mobile/src/i18n/locales/es.json index e774aadf9cf..f4d076ec3f3 100644 --- a/mobile/src/i18n/locales/es.json +++ b/mobile/src/i18n/locales/es.json @@ -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}}", diff --git a/mobile/src/i18n/locales/fr.json b/mobile/src/i18n/locales/fr.json index 6c2c5734feb..9dd8501705b 100644 --- a/mobile/src/i18n/locales/fr.json +++ b/mobile/src/i18n/locales/fr.json @@ -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}}", diff --git a/mobile/src/i18n/locales/ja.json b/mobile/src/i18n/locales/ja.json index 08cb70e56d3..3bc83b3e724 100644 --- a/mobile/src/i18n/locales/ja.json +++ b/mobile/src/i18n/locales/ja.json @@ -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}} 行目のメモを削除", diff --git a/mobile/src/i18n/locales/ko.json b/mobile/src/i18n/locales/ko.json index 85d9effedae..fe198726d5d 100644 --- a/mobile/src/i18n/locales/ko.json +++ b/mobile/src/i18n/locales/ko.json @@ -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}}행의 메모 삭제", diff --git a/mobile/src/i18n/locales/pt.json b/mobile/src/i18n/locales/pt.json index ae3665c92d3..4e57c1d2b32 100644 --- a/mobile/src/i18n/locales/pt.json +++ b/mobile/src/i18n/locales/pt.json @@ -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}}", diff --git a/mobile/src/i18n/locales/ru.json b/mobile/src/i18n/locales/ru.json index 8526bc980dd..33906837470 100644 --- a/mobile/src/i18n/locales/ru.json +++ b/mobile/src/i18n/locales/ru.json @@ -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}}", diff --git a/mobile/src/i18n/locales/tr.json b/mobile/src/i18n/locales/tr.json index 44afc2f6942..f045ee7fd62 100644 --- a/mobile/src/i18n/locales/tr.json +++ b/mobile/src/i18n/locales/tr.json @@ -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", diff --git a/mobile/src/i18n/locales/zh-Hant.json b/mobile/src/i18n/locales/zh-Hant.json index ce9cf32ca81..da88c25bd6c 100644 --- a/mobile/src/i18n/locales/zh-Hant.json +++ b/mobile/src/i18n/locales/zh-Hant.json @@ -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}} 行的備註", diff --git a/mobile/src/i18n/locales/zh.json b/mobile/src/i18n/locales/zh.json index e38731d692f..c36e3076774 100644 --- a/mobile/src/i18n/locales/zh.json +++ b/mobile/src/i18n/locales/zh.json @@ -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}} 行的注释", diff --git a/mobile/src/session/MobileDiffCommentLineRow.tsx b/mobile/src/session/MobileDiffCommentLineRow.tsx deleted file mode 100644 index 3ea78a8f87b..00000000000 --- a/mobile/src/session/MobileDiffCommentLineRow.tsx +++ /dev/null @@ -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 ( - - - {gutterLineNumber} - - - {line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '} - - - - {canComment ? ( - [ - 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 - } - )} - > - - - ) : null} - - {comments.length > 0 ? ( - - {comments.map((comment) => ( - - - - - {t('session.diffCommentLineRow.line', 'Line {{line}}', { - line: comment.lineNumber - })} - - onDeleteComment(comment.id)} - accessibilityLabel={t( - 'session.diffCommentLineRow.deleteNote', - 'Delete note on line {{line}}', - { line: comment.lineNumber } - )} - > - - - - {comment.body} - - ))} - - ) : null} - {isCommenting ? ( - - - - - {t('common.cancel', 'Cancel')} - - { - if (commentLine !== undefined) { - onSubmitComment(commentLine) - } - }} - > - - {t('session.diffCommentLineRow.saveNote', 'Save note')} - - - - - ) : null} - - ) -} diff --git a/mobile/src/session/MobileSessionAccessoryKeys.tsx b/mobile/src/session/MobileSessionAccessoryKeys.tsx index fa131747720..8b58de08b4c 100644 --- a/mobile/src/session/MobileSessionAccessoryKeys.tsx +++ b/mobile/src/session/MobileSessionAccessoryKeys.tsx @@ -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 = new Set(['escape', 'tab', ...ARROW_IDS]) +const AGENT_ROW_KEY_IDS: ReadonlySet = 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>()) 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) | null + } | null>(null) + // An unmounting row never sees a held arrow lift. useEffect(() => cancelAccessoryRepeat, [cancelAccessoryRepeat]) - return ( - - {/* Why: fixed keyboard escape hatch; outside ScrollView + shortcut path so it can't scroll away or be hidden (#5106). */} - {keyboardLift > 0 && ( - [ - 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 => ( + [keyStyle, pressed && styles.keyPressed, !canSend && styles.disabled]} + disabled={!canSend} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={accessibilityLabel} + > + {label} + + ) + 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 ( + [ + 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' }} + > + - - - - - - )} - {/* Why: default tap handling makes the first accessory-key tap dismiss the keyboard and get swallowed (#5106). */} + {label} + + + ) + } + const characterKeys = (characters: readonly string[]) => + characters.map((character) => + keyButton( + `char-${character}`, + character, + t('session.accessoryKeys.typeCharacter', 'Type {{character}}', { character }), + () => keys.sendKey(character) + ) + ) + const arrowPad = ( + { + 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 ( + + [ + 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 ? ( + + ) : ( + + )} + + {liveInputEnabled + ? t('session.accessoryKeys.keysMode', 'Keys') + : t('session.accessoryKeys.messageMode', 'Message')} + + + {/* Why: default tap handling makes the first key tap dismiss the keyboard and get swallowed (#5106). */} + {rowKeys} [ - 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) ? ( - - ) : ( - - )} - - [ - 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') - } - > - - - {canPaste && ( - [ - 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' - )} - > - - {t('session.accessoryKeys.paste', 'Paste')} - - - )} - {visibleBuiltInAccessoryKeys.map((key) => ( - [ - 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 }) - } - > - - {key.label} - - - ))} - {customKeys.map((key) => ( - [ - 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 - })} - > - - {key.label} - - - ))} - [ - styles.accessoryKey, - vertical && styles.accessoryKeyVertical, - pressed && styles.accessoryKeyPressed - ]} - onPress={() => setShowCustomKeyModal(true)} - accessibilityLabel={t('session.accessoryKeys.addCustomShortcut', 'Add custom shortcut')} - > - + + {/* Why: fixed keyboard escape hatch outside the scroll view, so it can't scroll away (#5106). */} + [ + 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 ? ( + + ) : ( + + )} + + 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)} + /> ) } diff --git a/mobile/src/session/MobileSessionCommandDock.test.tsx b/mobile/src/session/MobileSessionCommandDock.test.tsx index c30a3254ff9..1a5ff79cda4 100644 --- a/mobile/src/session/MobileSessionCommandDock.test.tsx +++ b/mobile/src/session/MobileSessionCommandDock.test.tsx @@ -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 + 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') diff --git a/mobile/src/session/MobileSessionCommandDock.tsx b/mobile/src/session/MobileSessionCommandDock.tsx index a5a715f6c48..fe93335da7e 100644 --- a/mobile/src/session/MobileSessionCommandDock.tsx +++ b/mobile/src/session/MobileSessionCommandDock.tsx @@ -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) && ( - - {} - {keysInRail ? null : } - {} - - + + + + { + 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} - - { - 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 ? ( - - ) : null} - void attachImage('library')} - onAttachFile={() => void attachImage('files')} - onDictationToggle={handleDictationToggle} - onDictationPressIn={handleDictationPressIn} - onDictationPressOut={handleDictationPressOut} - onDictationCancel={cancelDictation} + {...attachProps} + {...dictationProps} + buttonStyle={inputStyles.ghostButton} + disabledButtonStyle={inputStyles.disabled} + /> + + + { + if (!dockKeys.interceptTypedCharacter(draft, text, commandInputRef)) { + bufferedTerminalDraftState.setInput(text) + } + }} + onSelectionChange={(e) => { + commandFieldAnchorRef.current = { + handle: activeHandle, + anchor: readMobileDictationAnchor(e, draft) + } + }} + inputOverlay={ + !liveInputEnabled && commandDictationPreview ? ( + + ) : 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={ + + } + trailing={ + <> + + {showStop ? ( + void handleAccessoryKey({ bytes: ESC })} + /> + ) : ( + void handleSend()} + canSend={canSend && draft.trim().length > 0} + label={t('session.commandDock.sendCommand', 'Send command')} + /> + )} + + } /> - {liveInputEnabled ? null : ( - void handleSend()} - accessibilityLabel={t('session.commandDock.sendCommand', 'Send command')} - > - - - )} - ) + {keysInRail ? null : } + ) } diff --git a/mobile/src/session/MobileSessionDiffLineRow.tsx b/mobile/src/session/MobileSessionDiffLineRow.tsx index 258c8d783f9..1024376b1a3 100644 --- a/mobile/src/session/MobileSessionDiffLineRow.tsx +++ b/mobile/src/session/MobileSessionDiffLineRow.tsx @@ -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({ ) : null} {isCommenting ? ( - - - + { + if (commentLine !== undefined) { + onSubmitComment(commentLine) + } + }} + canSend={commentDraft.trim().length > 0 && !commentsBusy} + sendAccessibilityLabel={t('session.diffLineRow.saveNote', 'Save note')} + leading={ [ + composerInputStyles.ghostButton, + pressed && composerInputStyles.pressed + ]} disabled={commentsBusy} onPress={onCancelComment} + accessibilityRole="button" + accessibilityLabel={t('common.cancel', 'Cancel')} > - {t('common.cancel', 'Cancel')} + - { - if (commentLine !== undefined) { - onSubmitComment(commentLine) - } - }} - > - - {t('session.diffLineRow.saveNote', 'Save note')} - - - - + } + /> ) : null} ) diff --git a/mobile/src/session/MobileTerminalCommandDictationPreview.tsx b/mobile/src/session/MobileTerminalCommandDictationPreview.tsx index a5371f9f922..a0003a2e1a6 100644 --- a/mobile/src/session/MobileTerminalCommandDictationPreview.tsx +++ b/mobile/src/session/MobileTerminalCommandDictationPreview.tsx @@ -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 ( - + {before} {preview.text} @@ -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: { diff --git a/mobile/src/session/MobileTerminalInputActions.tsx b/mobile/src/session/MobileTerminalInputActions.tsx index db8737a5c95..a83b58b2e9b 100644 --- a/mobile/src/session/MobileTerminalInputActions.tsx +++ b/mobile/src/session/MobileTerminalInputActions.tsx @@ -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 readonly activeButtonStyle: StyleProp readonly disabledButtonStyle: StyleProp - 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 + readonly disabledButtonStyle: StyleProp + readonly onAttachImage: () => void + readonly onAttachFile: () => void +} + +type MobileTerminalInputActionsProps = MobileTerminalDictationButtonProps & + MobileTerminalAttachButtonProps + +export function MobileTerminalInputActions(props: MobileTerminalInputActionsProps) { + return ( + <> + + + + ) +} + +export function MobileTerminalAttachButton({ canSend, isAttaching, + buttonStyle, + disabledButtonStyle, + onAttachImage, + onAttachFile +}: MobileTerminalAttachButtonProps) { + useMobileColorScheme() + const t = useMobileTranslation() + return ( + + {isAttaching ? ( + + ) : ( + + )} + + ) +} + +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({ )} ) - return ( - <> - - {isAttaching ? ( - - ) : ( - - )} - - {dictationEnabled ? ( - meterHalo ? ( - // Always wrapped, so the mic never remounts under a held press. - - {dictation.isRecording ? : null} - {mic} - - ) : ( - mic - ) - ) : null} - + return meterHalo ? ( + // Always wrapped, so the mic never remounts under a held press. + + {dictation.isRecording ? : null} + {mic} + + ) : ( + mic ) } diff --git a/mobile/src/session/QuickCommandEditorForm.tsx b/mobile/src/session/QuickCommandEditorForm.tsx index 2246384ed81..f5e925292da 100644 --- a/mobile/src/session/QuickCommandEditorForm.tsx +++ b/mobile/src/session/QuickCommandEditorForm.tsx @@ -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({ {t('session.quickCommandEditorForm.label', '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')} - 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 }, diff --git a/mobile/src/session/QuickCommandsList.tsx b/mobile/src/session/QuickCommandsList.tsx index 85404564b07..5419e1ba49d 100644 --- a/mobile/src/session/QuickCommandsList.tsx +++ b/mobile/src/session/QuickCommandsList.tsx @@ -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 ( {showSearch ? ( - - - - + } + 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 ? {error} : 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: { diff --git a/mobile/src/session/TerminalArrowPad.tsx b/mobile/src/session/TerminalArrowPad.tsx new file mode 100644 index 00000000000..3b330b9548b --- /dev/null +++ b/mobile/src/session/TerminalArrowPad.tsx @@ -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(null) + const [dragging, setDragging] = useState(false) + const sizeRef = useRef({ width: 66, height: 32 }) + const dragRef = useRef(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 ( + { + 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) + } + }} + > + + + + + + ) +} diff --git a/mobile/src/session/TerminalMoreKeysSheet.tsx b/mobile/src/session/TerminalMoreKeysSheet.tsx new file mode 100644 index 00000000000..853eb17816d --- /dev/null +++ b/mobile/src/session/TerminalMoreKeysSheet.tsx @@ -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 + 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) => ( + [ + styles.sheetAction, + pressed && styles.keyPressed, + !enabled && styles.disabled + ]} + disabled={!enabled} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={label} + > + + {label} + + ) + + const key = (id: string, label: string, accessibilityLabel: string, onPress: () => void) => ( + [ + styles.key, + pressed && styles.keyPressed, + !canSend && styles.disabled + ]} + disabled={!canSend} + onPress={() => { + onPress() + onClose() + }} + accessibilityRole="button" + accessibilityLabel={t('session.terminalMoreKeys.sendKey', 'Send {{label}}', { + label: accessibilityLabel + })} + > + {label} + + ) + + return ( + { + const run = afterCloseRef.current + afterCloseRef.current = null + run?.() + }} + dragContentToDismiss + > + + + {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) + )} + + {t('session.terminalMoreKeys.keys', 'Keys')} + + {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)) + )} + + {combos.length > 0 ? ( + <> + + {t('session.terminalMoreKeys.combos', 'Combos')} + + + {combos.map((k) => + key(k.id, k.label, k.accessibilityLabel ?? k.label, () => onBuiltInKey(k)) + )} + + + ) : null} + + {t('session.terminalMoreKeys.yourKeys', 'Your keys')} + + + {customKeys.map((custom) => ( + [ + 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' + )} + > + {custom.label} + + ))} + [styles.ghostKey, pressed && styles.keyPressed]} + onPress={() => closeThen(onAddCustomKey)} + accessibilityRole="button" + accessibilityLabel={t('session.terminalMoreKeys.addCustomKey', 'Add custom key')} + > + + + {t('session.terminalMoreKeys.addKey', 'Add key')} + + + + + + ) +} diff --git a/mobile/src/session/agent-send-keyboard-dismissal.ts b/mobile/src/session/agent-send-keyboard-dismissal.ts index 5221339f1bd..f52b77f371d 100644 --- a/mobile/src/session/agent-send-keyboard-dismissal.ts +++ b/mobile/src/session/agent-send-keyboard-dismissal.ts @@ -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) } diff --git a/mobile/src/session/mobile-file-review-draft.test.tsx b/mobile/src/session/mobile-file-review-draft.test.tsx index ae6cea8f9ca..43e88d25243 100644 --- a/mobile/src/session/mobile-file-review-draft.test.tsx +++ b/mobile/src/session/mobile-file-review-draft.test.tsx @@ -27,6 +27,7 @@ vi.mock('react-native', async () => { } }) vi.mock('lucide-react-native', () => ({ + ArrowUp: 'ArrowUp', Copy: 'Copy', MessageSquare: 'MessageSquare', Send: 'Send', diff --git a/mobile/src/session/mobile-session-command-input-styles.ts b/mobile/src/session/mobile-session-command-input-styles.ts index c61fc27c960..78f62306ce1 100644 --- a/mobile/src/session/mobile-session-command-input-styles.ts +++ b/mobile/src/session/mobile-session-command-input-styles.ts @@ -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 } })) diff --git a/mobile/src/session/mobile-session-review-comment-styles.ts b/mobile/src/session/mobile-session-review-comment-styles.ts index 7cd52e7d167..7ee86fee4c3 100644 --- a/mobile/src/session/mobile-session-review-comment-styles.ts +++ b/mobile/src/session/mobile-session-review-comment-styles.ts @@ -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 diff --git a/mobile/src/session/mobile-terminal-tab-agent.ts b/mobile/src/session/mobile-terminal-tab-agent.ts index ce0a1e5e546..6fa6b0abf4d 100644 --- a/mobile/src/session/mobile-terminal-tab-agent.ts +++ b/mobile/src/session/mobile-terminal-tab-agent.ts @@ -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, diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx index 92dee1b6ebc..e8563787f84 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx @@ -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" /> - + {attachPress ? ( [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} - + {onMicPress ? ( {t('common.cancel', 'Cancel')} @@ -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. - ) : ( + ) : editSession ? ( [ - styles.sendButton, - !canSend && styles.sendButtonDisabled, - pressed && canSend && styles.pressed + inputStyles.sendButton, + !canSend && inputStyles.sendButtonDisabled, + pressed && canSend && inputStyles.pressed ]} onPress={handleSend} disabled={!canSend} > - {editSession ? ( - - {t('common.save', 'Save')} - - ) : ( - - )} + + {t('common.save', 'Save')} + + ) : ( + )} diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposerField.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposerField.tsx index 1485e88f1c0..01ee99977db 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposerField.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposerField.tsx @@ -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 ? ( - + {skillToken} {value.slice(skillToken.length)} @@ -34,7 +39,11 @@ export function MobileNativeChatComposerField({ diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposerField.web.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposerField.web.tsx index 1f9c8f893a2..ad7d8ef2b83 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposerField.web.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposerField.web.tsx @@ -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({ setScrollTop(scrollTopOf(event)) : undefined} placeholder={dictationPreview ? undefined : placeholder} {...props} diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposerFieldCopy.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposerFieldCopy.tsx index d84a1820142..19e1703416a 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposerFieldCopy.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposerFieldCopy.tsx @@ -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 ( diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposerMic.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposerMic.tsx index d82285d5c9e..b48af05090d 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposerMic.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposerMic.tsx @@ -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} diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-composer-styles.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-composer-styles.ts index f31192cce44..2ad195d8625 100644 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-composer-styles.ts +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-composer-styles.ts @@ -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', diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-input-base-styles.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-input-base-styles.ts deleted file mode 100644 index 5da6ef20735..00000000000 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-input-base-styles.ts +++ /dev/null @@ -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 -}) diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-input-styles.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-input-styles.ts deleted file mode 100644 index 50eeebe73e2..00000000000 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-input-styles.ts +++ /dev/null @@ -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 } -})) diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-input-styles.web.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-input-styles.web.ts deleted file mode 100644 index 78244bc08e8..00000000000 --- a/mobile/src/session/native-chat/composer/mobile-native-chat-input-styles.web.ts +++ /dev/null @@ -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 } -})) diff --git a/mobile/src/session/native-chat/input/MobileNativeChatAsk.tsx b/mobile/src/session/native-chat/input/MobileNativeChatAsk.tsx index 09a10c23aec..50bf869d209 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatAsk.tsx +++ b/mobile/src/session/native-chat/input/MobileNativeChatAsk.tsx @@ -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) ? ( - 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: { diff --git a/mobile/src/session/native-chat/input/MobileNativeChatQuestion.tsx b/mobile/src/session/native-chat/input/MobileNativeChatQuestion.tsx index 76d2e423495..b48a43f22c6 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatQuestion.tsx +++ b/mobile/src/session/native-chat/input/MobileNativeChatQuestion.tsx @@ -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 ? ( - - - [ - styles.freeSend, - !canSendFreeText && styles.freeSendDisabled, - pressed && canSendFreeText && styles.pressed - ]} - onPress={submitFreeText} - disabled={!canSendFreeText} - > - - - + ) : null} ) @@ -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 } diff --git a/mobile/src/session/native-chat/input/MobileNativeChatStopButton.tsx b/mobile/src/session/native-chat/input/MobileNativeChatStopButton.tsx index 9fd6fb421f2..b526c8e4150 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatStopButton.tsx +++ b/mobile/src/session/native-chat/input/MobileNativeChatStopButton.tsx @@ -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({ diff --git a/mobile/src/session/terminal-dock-styles.ts b/mobile/src/session/terminal-dock-styles.ts new file mode 100644 index 00000000000..6a91c16ec13 --- /dev/null +++ b/mobile/src/session/terminal-dock-styles.ts @@ -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 + } +})) diff --git a/mobile/src/session/terminal-field-submit-binding-send.test.tsx b/mobile/src/session/terminal-field-submit-binding-send.test.tsx index 75bf282405f..0863e138c6e 100644 --- a/mobile/src/session/terminal-field-submit-binding-send.test.tsx +++ b/mobile/src/session/terminal-field-submit-binding-send.test.tsx @@ -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> = [] 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['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 }) => { diff --git a/mobile/src/session/use-mobile-session-controller.ts b/mobile/src/session/use-mobile-session-controller.ts index 33aee62c08e..cba3a8fa156 100644 --- a/mobile/src/session/use-mobile-session-controller.ts +++ b/mobile/src/session/use-mobile-session-controller.ts @@ -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) diff --git a/mobile/src/session/use-mobile-session-screen-state.ts b/mobile/src/session/use-mobile-session-screen-state.ts index e7b84fe853a..c9d2776a8ef 100644 --- a/mobile/src/session/use-mobile-session-screen-state.ts +++ b/mobile/src/session/use-mobile-session-screen-state.ts @@ -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('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(null) const [coveredStreamRevision, setCoveredStreamRevision] = useState(0) const [activeSessionTabId, setActiveSessionTabId] = useState(null) diff --git a/mobile/src/session/use-mobile-session-terminal-send-actions.ts b/mobile/src/session/use-mobile-session-terminal-send-actions.ts index fb8ffcf4c44..42a51b6c2b6 100644 --- a/mobile/src/session/use-mobile-session-terminal-send-actions.ts +++ b/mobile/src/session/use-mobile-session-terminal-send-actions.ts @@ -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 } } diff --git a/mobile/src/session/use-terminal-dock-keys.ts b/mobile/src/session/use-terminal-dock-keys.ts new file mode 100644 index 00000000000..388f604b459 --- /dev/null +++ b/mobile/src/session/use-terminal-dock-keys.ts @@ -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 +} + +// 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 + ): 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' + }) +} diff --git a/mobile/src/session/use-terminal-live-input-mode-preference.test.ts b/mobile/src/session/use-terminal-live-input-mode-preference.test.ts index 0a98d737792..52c3d74d1db 100644 --- a/mobile/src/session/use-terminal-live-input-mode-preference.test.ts +++ b/mobile/src/session/use-terminal-live-input-mode-preference.test.ts @@ -15,6 +15,7 @@ vi.mock('../storage/preferences', () => ({ type TerminalLiveInputModePreferenceHarness = { readonly current: ReturnType + readonly setAgentHandles: (handles: readonly string[]) => void readonly unmount: () => void } @@ -37,11 +38,13 @@ function createDeferred(): Deferred { function createTerminalLiveInputModePreferenceHarness(): TerminalLiveInputModePreferenceHarness { let current: ReturnType | null = null let renderer: ReactTestRenderer | null = null + let agentHandles: ReadonlySet = 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() + }) }) diff --git a/mobile/src/session/use-terminal-live-input-mode-preference.ts b/mobile/src/session/use-terminal-live-input-mode-preference.ts index f072a016e26..da7eb410efd 100644 --- a/mobile/src/session/use-terminal-live-input-mode-preference.ts +++ b/mobile/src/session/use-terminal-live-input-mode-preference.ts @@ -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 } +const NO_AGENT_HANDLES: ReadonlySet = new Set() + export function useTerminalLiveInputModePreference({ hostId, - worktreeId + worktreeId, + agentHandles = NO_AGENT_HANDLES }: UseTerminalLiveInputModePreferenceOptions) { - const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState>( + // The shell-level choice: live by default, minus persisted opt-outs. + const [shellLiveInputHandles, setLiveInputTerminalHandles] = useState>( () => new Set() ) - const liveInputTerminalHandlesRef = useRef>(new Set()) + const shellLiveInputHandlesRef = useRef>(new Set()) + // Agent tabs where the user picked live keys; the pick ends when the agent quits. + const [agentLiveInputHandles, setAgentLiveInputHandles] = useState>( + () => 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>(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>(new Set()) const disabledLiveInputTerminalHandlesRef = useRef>(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) => { 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] diff --git a/mobile/src/source-control/MobileSourceControlContent.tsx b/mobile/src/source-control/MobileSourceControlContent.tsx index 5df7d9d7eff..9641fa9f75d 100644 --- a/mobile/src/source-control/MobileSourceControlContent.tsx +++ b/mobile/src/source-control/MobileSourceControlContent.tsx @@ -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 // 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( () => ( ), [ + listBottomInset, sections, openFile, resolveConflict, @@ -196,6 +205,7 @@ export function MobileSourceControlContent({ state, primaryAction }: Props) { )} setCommitBarHeight(event.nativeEvent.layout.height)} style={[ styles.commitBar, { @@ -204,93 +214,95 @@ export function MobileSourceControlContent({ state, primaryAction }: Props) { } ]} > - - {stagedCount === 0 ? ( - - - {t('sourceControl.content.noStagedFiles', 'No staged files')} - - - ) : ( - - )} - {shouldShowGenerateButton ? ( + 0 && !ioBusy} + accessibilityLabel={ + stagedCount === 0 + ? t( + 'sourceControl.content.commitMessageDisabled', + 'Commit message disabled. No staged files.' + ) + : undefined + } + leading={ + shouldShowGenerateButton ? ( + [ + 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 ? ( + + ) : ( + + )} + + ) : null + } + trailing={ [ - 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 ? ( - + {primaryAction.loading ? ( + ) : ( - + + {primaryAction.label} + )} - ) : null} - [ - 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 ? ( - - ) : ( - - {primaryAction.label} - - )} - - + } + /> ) diff --git a/mobile/src/source-control/MobileSourceControlFileList.tsx b/mobile/src/source-control/MobileSourceControlFileList.tsx index b42fe1951ad..23fb42c5777 100644 --- a/mobile/src/source-control/MobileSourceControlFileList.tsx +++ b/mobile/src/source-control/MobileSourceControlFileList.tsx @@ -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 }]} /> ) } diff --git a/mobile/src/source-control/MobileSourceControlFileList.web.test.tsx b/mobile/src/source-control/MobileSourceControlFileList.web.test.tsx index 795f2ddc478..5d81a112527 100644 --- a/mobile/src/source-control/MobileSourceControlFileList.web.test.tsx +++ b/mobile/src/source-control/MobileSourceControlFileList.web.test.tsx @@ -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) diff --git a/mobile/src/source-control/mobile-source-control-list-styles.ts b/mobile/src/source-control/mobile-source-control-list-styles.ts index 6d86bf99811..532e4330241 100644 --- a/mobile/src/source-control/mobile-source-control-list-styles.ts +++ b/mobile/src/source-control/mobile-source-control-list-styles.ts @@ -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, diff --git a/mobile/src/tasks/MobileTasksPrFileDiff.tsx b/mobile/src/tasks/MobileTasksPrFileDiff.tsx index 8269219757c..47bb7a803c1 100644 --- a/mobile/src/tasks/MobileTasksPrFileDiff.tsx +++ b/mobile/src/tasks/MobileTasksPrFileDiff.tsx @@ -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({ {commentLine !== undefined ? ( - <> - onCommentDraftChange(draftKey, next)} - placeholder={t('tasks.prFileDiff.commentPlaceholder', 'Add review comment')} - placeholderTextColor={colors.textMuted} - multiline - textAlignVertical="top" - /> - onSubmitComment(commentLine)} - > - - {t('tasks.prFileDiff.commentOnLine', 'Comment on line {{line}}', { - line: commentLine - })} - - - + 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} ) diff --git a/mobile/src/tasks/mobile-tasks-composer-action-styles.ts b/mobile/src/tasks/mobile-tasks-composer-action-styles.ts index c47c60edcad..9952ebe0e59 100644 --- a/mobile/src/tasks/mobile-tasks-composer-action-styles.ts +++ b/mobile/src/tasks/mobile-tasks-composer-action-styles.ts @@ -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 }, diff --git a/mobile/src/tasks/mobile-tasks-create-drawers.tsx b/mobile/src/tasks/mobile-tasks-create-drawers.tsx index eedda3d5deb..6da551ffde9 100644 --- a/mobile/src/tasks/mobile-tasks-create-drawers.tsx +++ b/mobile/src/tasks/mobile-tasks-create-drawers.tsx @@ -8,7 +8,8 @@ import { Text, Pressable, ChevronDown, - TextInput, + MessageBox, + TextField, ActivityIndicator, PickerModal, Linking, @@ -169,12 +170,10 @@ export function renderMobileTasksCreateDrawer(model: ConnectionPresentationModel {translate('tasks.tasksCreateDrawers.title', 'Title')} - @@ -182,14 +181,10 @@ export function renderMobileTasksCreateDrawer(model: ConnectionPresentationModel {translate('tasks.tasksCreateDrawers.description', 'Description')} - {translate('tasks.tasksCreateDrawers.personalApiKey', 'Personal API key')} - { setLinearApiKeyDraft(next) @@ -305,7 +299,6 @@ export function renderMobileTasksLinearConnectDrawer(model: ConnectionPresentati } }} placeholder="lin_api_..." - placeholderTextColor={colors.textMuted} autoCapitalize="none" autoCorrect={false} secureTextEntry diff --git a/mobile/src/tasks/mobile-tasks-dependencies.ts b/mobile/src/tasks/mobile-tasks-dependencies.ts index a2d9cfeb8dd..f9718a13ed8 100644 --- a/mobile/src/tasks/mobile-tasks-dependencies.ts +++ b/mobile/src/tasks/mobile-tasks-dependencies.ts @@ -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' diff --git a/mobile/src/tasks/mobile-tasks-detail-styles.ts b/mobile/src/tasks/mobile-tasks-detail-styles.ts index 688ad533108..ca77011fc4e 100644 --- a/mobile/src/tasks/mobile-tasks-detail-styles.ts +++ b/mobile/src/tasks/mobile-tasks-detail-styles.ts @@ -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 } })) diff --git a/mobile/src/tasks/mobile-tasks-github-project-pickers.tsx b/mobile/src/tasks/mobile-tasks-github-project-pickers.tsx index 9b574adc042..304f564d03e 100644 --- a/mobile/src/tasks/mobile-tasks-github-project-pickers.tsx +++ b/mobile/src/tasks/mobile-tasks-github-project-pickers.tsx @@ -4,7 +4,8 @@ import { BottomDrawer, View, Text, - TextInput, + TextField, + Search, colors, Pressable, AlertTriangle, @@ -62,18 +63,17 @@ export function renderMobileTasksGitHubProjectPicker(model: ConnectionPresentati - } autoCapitalize="none" autoCorrect={false} /> - { setGithubProjectPasteInput(next) @@ -84,7 +84,6 @@ export function renderMobileTasksGitHubProjectPicker(model: ConnectionPresentati 'tasks.githubProjectPickers.addPlaceholder', 'Add by URL or owner/number' )} - placeholderTextColor={colors.textMuted} autoCapitalize="none" autoCorrect={false} /> diff --git a/mobile/src/tasks/mobile-tasks-item-detail-content.tsx b/mobile/src/tasks/mobile-tasks-item-detail-content.tsx index 4417dca09f3..7ed67bb20b6 100644 --- a/mobile/src/tasks/mobile-tasks-item-detail-content.tsx +++ b/mobile/src/tasks/mobile-tasks-item-detail-content.tsx @@ -9,7 +9,7 @@ import { Pressable, Linking, ExternalLink, - TextInput + TextField } from './mobile-tasks-dependencies' import { styles, getGitLabPipelineStatusStyle } from './mobile-tasks-legacy-styles' import { @@ -285,12 +285,11 @@ export function renderMobileTasksItemDetailContent(model: ConnectionPresentation )) )} - {translate('tasks.itemFieldEditors.title', 'Title')} - - - - - - - { + canSend={!mutatingStatus && itemBodyDraft !== detailPayload.body} + sendAccessibilityLabel={translate( + 'tasks.itemReviewSections.saveDescription', + 'Save description' + )} + onSend={() => { if (actionItem.provider === 'github' && actionItem.source.type === 'pr') { void updateGitHubPullRequestMetadata(actionItem, { body: itemBodyDraft @@ -77,11 +75,7 @@ export function renderMobileTasksItemBodyEditor(model: ConnectionPresentationMod }) } }} - > - - {translate('tasks.itemReviewSections.saveDescription', 'Save description')} - - + /> )} - {isEditingComment ? ( - <> - - + void updateProjectRowComment(projectRowItem, comment)} + canSend={!projectMutating && projectEditingCommentDraft.trim().length > 0} + sendAccessibilityLabel={translate('common.save', 'Save')} + trailing={ void updateProjectRowComment(projectRowItem, comment)} - > - - {translate('common.save', 'Save')} - - - [ + composerInputStyles.ghostButton, + projectMutating && composerInputStyles.disabled, + pressed && composerInputStyles.pressed + ]} onPress={() => { setProjectEditingCommentId(null) setProjectEditingCommentDraft('') }} > - - {translate('common.cancel', 'Cancel')} - + - - + } + /> ) : ( <> {renderCommentReactions(comment)} {SHOW_MOBILE_COMMENT_THREAD_TOOLS ? ( - - {projectRowType(projectRowItem) === 'pr' && comment.threadId ? ( - - void toggleProjectGitHubReviewThread(projectRowItem, comment) - } - > - - {comment.isResolved - ? translate('tasks.projectComments.reopenThread', 'Reopen thread') - : translate( - 'tasks.projectComments.resolveThread', - 'Resolve thread' - )} - - - ) : null} - + setItemReplyDrafts((current) => ({ @@ -209,41 +186,52 @@ export function renderMobileTasksProjectComments(model: ConnectionPresentationMo })) } placeholder={translate('tasks.projectComments.reply', 'Reply')} - placeholderTextColor={colors.textMuted} - multiline - textAlignVertical="top" + onSend={() => void replyToProjectGitHubComment(projectRowItem, comment)} + canSend={!projectMutating && !!(itemReplyDrafts[commentId] ?? '').trim()} + sendAccessibilityLabel={translate('tasks.projectComments.reply', 'Reply')} /> - void replyToProjectGitHubComment(projectRowItem, comment)} - > - - {translate('tasks.projectComments.reply', 'Reply')} - - - { - setProjectEditingCommentId(commentId) - setProjectEditingCommentDraft(comment.body) - }} - > - - {translate('common.edit', 'Edit')} - - - void deleteProjectRowComment(projectRowItem, comment)} - > - - {translate('common.delete', 'Delete')} - - - + + {projectRowType(projectRowItem) === 'pr' && comment.threadId ? ( + + void toggleProjectGitHubReviewThread(projectRowItem, comment) + } + > + + {comment.isResolved + ? translate('tasks.projectComments.reopenThread', 'Reopen thread') + : translate( + 'tasks.projectComments.resolveThread', + 'Resolve thread' + )} + + + ) : null} + { + setProjectEditingCommentId(commentId) + setProjectEditingCommentDraft(comment.body) + }} + > + + {translate('common.edit', 'Edit')} + + + void deleteProjectRowComment(projectRowItem, comment)} + > + + {translate('common.delete', 'Delete')} + + + + ) : null} )} diff --git a/mobile/src/tasks/mobile-tasks-project-field-editors.tsx b/mobile/src/tasks/mobile-tasks-project-field-editors.tsx index a653e8c52ed..d1be26c76cf 100644 --- a/mobile/src/tasks/mobile-tasks-project-field-editors.tsx +++ b/mobile/src/tasks/mobile-tasks-project-field-editors.tsx @@ -5,7 +5,7 @@ import { editableProjectFields, projectFieldValueLabel } from './mobile-tasks-legacy-foundation' -import { View, Text, Pressable, Check, colors, TextInput } from './mobile-tasks-dependencies' +import { View, Text, Pressable, Check, colors, TextField } from './mobile-tasks-dependencies' import { styles } from './mobile-tasks-legacy-styles' export function renderMobileTasksProjectFieldEditors(model: ConnectionPresentationModel) { @@ -159,8 +159,7 @@ export function renderMobileTasksProjectFieldEditors(model: ConnectionPresentati ) : ( <> - setProjectFieldDrafts((current) => ({ @@ -175,7 +174,6 @@ export function renderMobileTasksProjectFieldEditors(model: ConnectionPresentati ? translate('tasks.projectFieldEditors.numberPlaceholder', 'Number') : translate('tasks.projectFieldEditors.textPlaceholder', 'Text') } - placeholderTextColor={colors.textMuted} keyboardType={field.dataType === 'NUMBER' ? 'numeric' : 'default'} autoCapitalize="none" /> diff --git a/mobile/src/tasks/mobile-tasks-project-metadata-editors.tsx b/mobile/src/tasks/mobile-tasks-project-metadata-editors.tsx index 82e1d92f5a7..3e14574e33e 100644 --- a/mobile/src/tasks/mobile-tasks-project-metadata-editors.tsx +++ b/mobile/src/tasks/mobile-tasks-project-metadata-editors.tsx @@ -11,7 +11,8 @@ import { colors, Pressable, Check, - TextInput, + MessageBox, + TextField, MobileMarkdown } from './mobile-tasks-dependencies' import { styles } from './mobile-tasks-legacy-styles' @@ -204,12 +205,10 @@ export function renderMobileTasksProjectIssueMetadata(model: ConnectionPresentat {translate('tasks.projectMetadataEditors.title', 'Title')} - {translate('tasks.projectMetadataEditors.description', 'Description')} - - + sendAccessibilityLabel={translate( + 'tasks.projectMetadataEditors.saveDescription', + 'Save description' + )} + onSend={() => void mutateProjectRowIssueOrPr(projectRowItem, { body: projectBodyDraft }) } - > - - {translate('tasks.projectMetadataEditors.saveDescription', 'Save description')} - - + /> ({ boardContainer: { @@ -158,7 +159,7 @@ export const mobileTasksProjectPickerStyles = themedStyleSheet(() => ({ flex: 1 }, projectPasteButton: { - minHeight: 40, + minHeight: FIELD_MIN_HEIGHT, alignItems: 'center', justifyContent: 'center' }, diff --git a/mobile/src/tasks/mobile-tasks-project-review-panels.tsx b/mobile/src/tasks/mobile-tasks-project-review-panels.tsx index dc0b8ba2b73..d6752cc5a73 100644 --- a/mobile/src/tasks/mobile-tasks-project-review-panels.tsx +++ b/mobile/src/tasks/mobile-tasks-project-review-panels.tsx @@ -16,7 +16,7 @@ import { colors, Pressable, Check, - TextInput, + TextField, Linking, ExternalLink } from './mobile-tasks-dependencies' @@ -120,12 +120,10 @@ export function renderMobileTasksProjectReviewPanels(model: ConnectionPresentati })} )} - diff --git a/mobile/src/tasks/mobile-tasks-workspace-option-pickers.tsx b/mobile/src/tasks/mobile-tasks-workspace-option-pickers.tsx index 5f989fd1b27..5f34bf7306b 100644 --- a/mobile/src/tasks/mobile-tasks-workspace-option-pickers.tsx +++ b/mobile/src/tasks/mobile-tasks-workspace-option-pickers.tsx @@ -3,7 +3,8 @@ import { BottomDrawer, View, Text, - TextInput, + TextField, + Search, colors, Pressable, Check, @@ -48,15 +49,14 @@ export function renderMobileTasksWorkspaceBaseBranchPicker(model: ConnectionPres - } autoCapitalize="none" autoCorrect={false} /> diff --git a/mobile/src/tasks/mobile-tasks-workspace-trust-drawers.tsx b/mobile/src/tasks/mobile-tasks-workspace-trust-drawers.tsx index cb63a298730..092b4899988 100644 --- a/mobile/src/tasks/mobile-tasks-workspace-trust-drawers.tsx +++ b/mobile/src/tasks/mobile-tasks-workspace-trust-drawers.tsx @@ -3,7 +3,8 @@ import { BottomDrawer, View, Text, - TextInput, + MessageBox, + TextField, colors, Pressable, ActivityIndicator, @@ -51,15 +52,13 @@ export function renderMobileTasksWorkspaceSparseDrawer(model: ConnectionPresenta {translate('tasks.workspaceTrustDrawers.nameLabel', 'Name')} - setWorkspaceSparseDraft({ ...workspaceSparseDraft, name })} placeholder={translate( 'tasks.workspaceTrustDrawers.namePlaceholder', 'Renderer UI' )} - placeholderTextColor={colors.textMuted} autoCapitalize="none" autoCorrect={false} maxLength={80} @@ -69,18 +68,15 @@ export function renderMobileTasksWorkspaceSparseDrawer(model: ConnectionPresenta {translate('tasks.workspaceTrustDrawers.directoriesLabel', 'Directories')} - setWorkspaceSparseDraft({ ...workspaceSparseDraft, directoriesText }) } placeholder={'src/renderer\npackages/ui'} - placeholderTextColor={colors.textMuted} autoCapitalize="none" autoCorrect={false} - multiline - textAlignVertical="top" + monospace /> diff --git a/mobile/src/tasks/use-mobile-tasks-detail-comment-renderers.tsx b/mobile/src/tasks/use-mobile-tasks-detail-comment-renderers.tsx index 2b8fc1970bf..39dd0ac809f 100644 --- a/mobile/src/tasks/use-mobile-tasks-detail-comment-renderers.tsx +++ b/mobile/src/tasks/use-mobile-tasks-detail-comment-renderers.tsx @@ -1,13 +1,11 @@ import type { TaskCreateActionsModel } from './use-mobile-tasks-task-create-actions' import { + MessageBox, MobileMarkdown, Pressable, type ReactNode, - Send, Text, - TextInput, - View, - colors + View } from './mobile-tasks-dependencies' import { type DetailComment, @@ -43,39 +41,18 @@ export function useMobileTasksDetailCommentRenderers(model: TaskCreateActionsMod onChangeText: (next: string) => void onSubmit: () => void disabled?: boolean - }): ReactNode => { - const hasText = args.value.trim().length > 0 - return ( - - - {hasText ? ( - [ - styles.commentComposerSend, - pressed && !args.disabled && styles.commentComposerSendPressed, - args.disabled && styles.commentComposerSendDisabled - ]} - disabled={args.disabled} - onPress={args.onSubmit} - > - - - ) : null} - - ) - } + }): ReactNode => ( + 0} + sendAccessibilityLabel={t('tasks.detailCommentRenderers.sendComment', 'Send comment')} + /> + ) const renderDetailComment = ( comment: DetailComment, @@ -119,8 +96,7 @@ export function useMobileTasksDetailCommentRenderers(model: TaskCreateActionsMod ) : null} - setItemReplyDrafts((current) => ({ @@ -129,19 +105,10 @@ export function useMobileTasksDetailCommentRenderers(model: TaskCreateActionsMod })) } placeholder={t('tasks.detailCommentRenderers.reply', 'Reply')} - placeholderTextColor={colors.textMuted} - multiline - textAlignVertical="top" + onSend={() => void replyToGitHubComment(actionItem, comment)} + canSend={!mutatingStatus && !!(itemReplyDrafts[String(comment.id)] ?? '').trim()} + sendAccessibilityLabel={t('tasks.detailCommentRenderers.reply', 'Reply')} /> - void replyToGitHubComment(actionItem, comment)} - > - - {t('tasks.detailCommentRenderers.reply', 'Reply')} - - ) : null} diff --git a/mobile/src/terminal/terminal-sticky-modifiers.ts b/mobile/src/terminal/terminal-sticky-modifiers.ts new file mode 100644 index 00000000000..0fa6db41f58 --- /dev/null +++ b/mobile/src/terminal/terminal-sticky-modifiers.ts @@ -0,0 +1,47 @@ +import type { TerminalShortcutModifier } from './terminal-accessory-keys' + +export type StickyModifierLatch = 'off' | 'once' | 'locked' + +export type StickyModifiers = Readonly> + +export const NO_STICKY_MODIFIERS: StickyModifiers = { ctrl: 'off', alt: 'off', shift: 'off' } + +export const STICKY_MODIFIER_DOUBLE_TAP_MS = 350 + +// A tap arms the modifier for the next key, a quick second tap locks it, and any other tap releases it. +export function tapStickyModifier( + modifiers: StickyModifiers, + modifier: TerminalShortcutModifier, + doubleTap: boolean +): StickyModifiers { + const latch: StickyModifierLatch = doubleTap + ? 'locked' + : modifiers[modifier] === 'off' + ? 'once' + : 'off' + return { ...modifiers, [modifier]: latch } +} + +export function hasStickyModifier(modifiers: StickyModifiers): boolean { + return modifiers.ctrl !== 'off' || modifiers.alt !== 'off' || modifiers.shift !== 'off' +} + +// The next key takes every held modifier; one-shot modifiers then release. +export function consumeStickyModifiers(modifiers: StickyModifiers): StickyModifiers { + const release = (latch: StickyModifierLatch): StickyModifierLatch => + latch === 'once' ? 'off' : latch + return { + ctrl: release(modifiers.ctrl), + alt: release(modifiers.alt), + shift: release(modifiers.shift) + } +} + +/** The one character typed onto the end of `previous`, or null for any other edit. */ +export function appendedCharacter(previous: string, next: string): string | null { + if (!next.startsWith(previous)) { + return null + } + const added = Array.from(next.slice(previous.length)) + return added.length === 1 ? added[0]! : null +} diff --git a/mobile/web-entry/web-overrides.json b/mobile/web-entry/web-overrides.json index f6d1965b888..e96b06048eb 100644 --- a/mobile/web-entry/web-overrides.json +++ b/mobile/web-entry/web-overrides.json @@ -121,14 +121,6 @@ "file": "src/session/native-chat/composer/MobileNativeChatComposerField.web.tsx", "reason": "A picked skill leads the composer's draft and is drawn highlighted in place. Natively that is a styled span of the TextInput's own text, but react-native-web renders TextInput as a textarea, which cannot style part of its text and ignores Text children. This one draws a copy of the draft behind the field with the skill highlighted, clears the field's own text while keeping its caret, and moves the copy with the field's scroll. It also measures the shown text to grow the textarea, whose default two rows do not grow with content, and caps it to the space above the keyboard." }, - { - "file": "src/session/native-chat/composer/mobile-native-chat-input-styles.web.ts", - "reason": "The chat's composer and its question field render one point above the app's body size, which is 15 and under the floor below which iOS zooms the page on focus. keyboard-occlusion.web.ts reads a scale other than 1 as 'no keyboard' and answers 0, and on this screen that lift is the terminal's only feedback that its hidden input has focus, so one focus of the composer would cost the rest of the session. This file puts both fields on TEXT_INPUT_FONT_SIZE; the native sibling keeps the question field at 15 and the composer at 16." - }, - { - "file": "src/browser/browser-address-field-styles.web.ts", - "reason": "The address bar renders at the theme's 12px meta size, and in a browser an input under 16px makes iOS zoom the page on focus and never zoom back. keyboard-occlusion.web.ts reads that scale as 'no keyboard' and answers 0, so one focus would stop the pane lifting for the rest of the typing session. This file puts the input and its overlaid label on TEXT_INPUT_FONT_SIZE with a line box to match; the native sibling keeps 12px, which is what a phone has always rendered and where no page can zoom." - }, { "file": "src/terminal/TerminalWebView.web.tsx", "reason": "react-native-webview has no web build that renders anything: on the page it paints the line \"React Native WebView does not support this platform\" where the terminal was, which is a red line and no terminal rather than a crash. This file mounts the same document the WebView loads — xterm imported from @xterm/xterm with the unicode11 and webgl addons, and the document's own modules imported in the order the generator emits them — behind the identical TerminalWebViewProps and TerminalWebViewHandle, so nothing above the contract can tell the two apart."