mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(mobile): localize sessions, terminal, agent chat, browser and files (#35553)
Moves ~970 strings in session screens, the terminal, agent chat (including the copy mobile used to render straight from shared English records), the browser pane and the file explorer to the translation API, with all ten translations. - Chat tool-run sentences, task lists, receipts, notices and turn status take translated words; the shared sentence joiner accepts an optional continuation so German nouns and Turkish casing survive mid-sentence (desktop unchanged) - "Used the browser" vs "… 21 times" and resolved PR comment headers use explicit keys instead of plural rules, which would drop the count for Russian 21, 31, … - Action sheet rows carry stable ids instead of being keyed by translated labels - Rename inputs keep the raw title rather than a translated placeholder Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -87,6 +87,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"546e369e8e6c": {
|
||||
"crash": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"elements": {
|
||||
"ChevronLeft": 1,
|
||||
"FlatList": 1,
|
||||
"Pressable": 1,
|
||||
"SafeAreaView": 1,
|
||||
"Text": 2,
|
||||
"View": 3
|
||||
},
|
||||
"labels": ["Back to session"],
|
||||
"rows": ["dir:src", "file:README.md"],
|
||||
"text": ["Files", "orca-files - Showing first 5000"]
|
||||
},
|
||||
"59851eb4cbf7": {
|
||||
"name": "files.readDir#1",
|
||||
"ordinal": 1,
|
||||
@@ -124,22 +140,6 @@
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"files.readDir\",\"params\":{\"worktree\":\"id:wt-files\",\"relativePath\":\"\"}}"
|
||||
},
|
||||
"b85511fb8929": {
|
||||
"crash": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"elements": {
|
||||
"ChevronLeft": 1,
|
||||
"FlatList": 1,
|
||||
"Pressable": 1,
|
||||
"SafeAreaView": 1,
|
||||
"Text": 2,
|
||||
"View": 3
|
||||
},
|
||||
"labels": ["Back to session"],
|
||||
"rows": ["dir:src", "file:README.md"],
|
||||
"text": ["Files", "orca-files", " - Showing first 5000"]
|
||||
},
|
||||
"e91880eefe86": {
|
||||
"crash": {
|
||||
"$rpc": "null"
|
||||
@@ -188,7 +188,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "b85511fb8929",
|
||||
"state": "546e369e8e6c",
|
||||
"effects": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +264,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"546e369e8e6c": {
|
||||
"crash": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"elements": {
|
||||
"ChevronLeft": 1,
|
||||
"FlatList": 1,
|
||||
"Pressable": 1,
|
||||
"SafeAreaView": 1,
|
||||
"Text": 2,
|
||||
"View": 3
|
||||
},
|
||||
"labels": ["Back to session"],
|
||||
"rows": ["dir:src", "file:README.md"],
|
||||
"text": ["Files", "orca-files - Showing first 5000"]
|
||||
},
|
||||
"59851eb4cbf7": {
|
||||
"name": "files.readDir#1",
|
||||
"ordinal": 1,
|
||||
@@ -423,22 +439,6 @@
|
||||
"rows": [],
|
||||
"text": ["Files", "orca-files", "No files found"]
|
||||
},
|
||||
"b85511fb8929": {
|
||||
"crash": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"elements": {
|
||||
"ChevronLeft": 1,
|
||||
"FlatList": 1,
|
||||
"Pressable": 1,
|
||||
"SafeAreaView": 1,
|
||||
"Text": 2,
|
||||
"View": 3
|
||||
},
|
||||
"labels": ["Back to session"],
|
||||
"rows": ["dir:src", "file:README.md"],
|
||||
"text": ["Files", "orca-files", " - Showing first 5000"]
|
||||
},
|
||||
"bec439845fd2": {
|
||||
"name": "files.list#1",
|
||||
"ordinal": 3,
|
||||
@@ -608,7 +608,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "b85511fb8929",
|
||||
"state": "546e369e8e6c",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
|
||||
@@ -219,6 +219,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"546e369e8e6c": {
|
||||
"crash": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"elements": {
|
||||
"ChevronLeft": 1,
|
||||
"FlatList": 1,
|
||||
"Pressable": 1,
|
||||
"SafeAreaView": 1,
|
||||
"Text": 2,
|
||||
"View": 3
|
||||
},
|
||||
"labels": ["Back to session"],
|
||||
"rows": ["dir:src", "file:README.md"],
|
||||
"text": ["Files", "orca-files - Showing first 5000"]
|
||||
},
|
||||
"5878b1d32e8e": {
|
||||
"name": "files.readDir#1",
|
||||
"ordinal": 1,
|
||||
@@ -466,22 +482,6 @@
|
||||
"rows": [],
|
||||
"text": ["Files", "orca-files", "No files found"]
|
||||
},
|
||||
"b85511fb8929": {
|
||||
"crash": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"elements": {
|
||||
"ChevronLeft": 1,
|
||||
"FlatList": 1,
|
||||
"Pressable": 1,
|
||||
"SafeAreaView": 1,
|
||||
"Text": 2,
|
||||
"View": 3
|
||||
},
|
||||
"labels": ["Back to session"],
|
||||
"rows": ["dir:src", "file:README.md"],
|
||||
"text": ["Files", "orca-files", " - Showing first 5000"]
|
||||
},
|
||||
"ba7f7b642df7": {
|
||||
"name": "files.readDir#1",
|
||||
"ordinal": 1,
|
||||
@@ -736,7 +736,7 @@
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "b85511fb8929",
|
||||
"state": "546e369e8e6c",
|
||||
"effects": []
|
||||
}
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ export function buildWorktreeNavigationActions(args: Args): ActionSheetAction[]
|
||||
const executionHost = args.executionHost
|
||||
const actions: ActionSheetAction[] = [
|
||||
{
|
||||
id: 'sourceControl',
|
||||
label: translate('agentHistory.worktreeNavigationActions.sourceControl', 'Source Control'),
|
||||
icon: GitBranch,
|
||||
onPress: () => {
|
||||
@@ -42,6 +43,7 @@ export function buildWorktreeNavigationActions(args: Args): ActionSheetAction[]
|
||||
]
|
||||
if (args.hostCapabilities.includes(MOBILE_AI_VAULT_CAPABILITY)) {
|
||||
actions.push({
|
||||
id: 'agentSessionHistory',
|
||||
label: translate(
|
||||
'agentHistory.worktreeNavigationActions.agentSessionHistory',
|
||||
'Agent Session History'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Platform, StyleSheet, Text, TextInput, View } from 'react-native'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import { colors, radii, spacing } from '../theme/mobile-theme'
|
||||
import { themedStyleSheet } from '../theme/themed-styles'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
@@ -29,6 +30,7 @@ export function MobileBrowserAddressField({
|
||||
onSubmit,
|
||||
value
|
||||
}: Props): React.JSX.Element {
|
||||
const t = useMobileTranslation()
|
||||
useMobileColorScheme()
|
||||
const fileLabel = focused ? null : compactMobileBrowserFileAddress(value)
|
||||
const selection = focused ? undefined : { start: 0, end: 0 }
|
||||
@@ -51,7 +53,7 @@ export function MobileBrowserAddressField({
|
||||
inputMode={Platform.OS === 'web' ? 'url' : undefined}
|
||||
numberOfLines={1}
|
||||
returnKeyType="go"
|
||||
placeholder="URL"
|
||||
placeholder={t('browser.addressField.urlPlaceholder', 'URL')}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
editable={!disabled}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { AppState, type View } from 'react-native'
|
||||
import type { BrowserLoadError } from '../../../src/shared/browser-workspace-types'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { BrowserScreencastFrameMetadata } from '../transport/browser-screencast-protocol'
|
||||
import type { MobileBrowserViewMode } from './browser-screencast-request'
|
||||
@@ -73,6 +75,7 @@ export function MobileBrowserPane({
|
||||
bottomInset,
|
||||
onToast
|
||||
}: MobileBrowserPaneProps) {
|
||||
useMobileTranslation()
|
||||
const [browserViewMode, setBrowserViewMode] = useState<MobileBrowserViewMode>(() =>
|
||||
getInitialMobileBrowserViewMode(worktreeId, tab.browserPageId, tab.url)
|
||||
)
|
||||
@@ -219,7 +222,7 @@ export function MobileBrowserPane({
|
||||
const navigateToAddress = useCallback(async () => {
|
||||
const url = normalizeBrowserUrl(addressValue)
|
||||
if (!url) {
|
||||
setError('Enter a valid URL.')
|
||||
setError(translate('browser.pane.invalidUrl', 'Enter a valid URL.'))
|
||||
return
|
||||
}
|
||||
const settled = await sendBrowserRequest(
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type ViewStyle
|
||||
} from 'react-native'
|
||||
import { ArrowUp, ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react-native'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import { MobileBrowserAddressField } from './MobileBrowserAddressField'
|
||||
@@ -78,6 +79,7 @@ type MobileBrowserPaneViewProps = {
|
||||
// Frame cap can leave viewport unpainted despite live stream; oversized frames are dropped, never fatal.
|
||||
// Area budget prevents starvation: C6.6 with budget disabled measured 299 dropped/6 applied and a persistent spinner.
|
||||
export function MobileBrowserPaneView(props: MobileBrowserPaneViewProps) {
|
||||
const t = useMobileTranslation()
|
||||
useMobileColorScheme()
|
||||
const {
|
||||
addressFocused,
|
||||
@@ -118,21 +120,21 @@ export function MobileBrowserPaneView(props: MobileBrowserPaneViewProps) {
|
||||
<View style={styles.toolbar}>
|
||||
<MobileBrowserToolbarIconButton
|
||||
disabled={controlsDisabled || !tab.canGoBack}
|
||||
label="Back"
|
||||
label={t('common.back', 'Back')}
|
||||
onPress={goBack}
|
||||
>
|
||||
<ChevronLeft size={15} color={buttonColor(!controlsDisabled && tab.canGoBack)} />
|
||||
</MobileBrowserToolbarIconButton>
|
||||
<MobileBrowserToolbarIconButton
|
||||
disabled={controlsDisabled || !tab.canGoForward}
|
||||
label="Forward"
|
||||
label={t('browser.paneView.forward', 'Forward')}
|
||||
onPress={goForward}
|
||||
>
|
||||
<ChevronRight size={15} color={buttonColor(!controlsDisabled && tab.canGoForward)} />
|
||||
</MobileBrowserToolbarIconButton>
|
||||
<MobileBrowserToolbarIconButton
|
||||
disabled={controlsDisabled}
|
||||
label="Reload"
|
||||
label={t('browser.paneView.reload', 'Reload')}
|
||||
onPress={reloadPage}
|
||||
>
|
||||
<RefreshCw size={15} color={buttonColor(!controlsDisabled)} />
|
||||
@@ -256,7 +258,9 @@ export function MobileBrowserPaneView(props: MobileBrowserPaneViewProps) {
|
||||
{dialog ? (
|
||||
<View style={styles.dialogOverlay}>
|
||||
<View style={styles.dialogCard}>
|
||||
<Text style={styles.dialogTitle}>Browser Dialog</Text>
|
||||
<Text style={styles.dialogTitle}>
|
||||
{t('browser.paneView.dialogTitle', 'Browser Dialog')}
|
||||
</Text>
|
||||
<Text style={styles.dialogMessage}>{dialog.message}</Text>
|
||||
{/* The page is still blocked, so the buttons stay live and the card says why. */}
|
||||
{dialog.error ? <Text style={styles.dialogError}>{dialog.error}</Text> : null}
|
||||
@@ -271,7 +275,7 @@ export function MobileBrowserPaneView(props: MobileBrowserPaneViewProps) {
|
||||
]}
|
||||
onPress={() => void sendDialogCommand('browser.dialogDismiss')}
|
||||
>
|
||||
<Text style={styles.dialogButtonText}>Cancel</Text>
|
||||
<Text style={styles.dialogButtonText}>{t('common.cancel', 'Cancel')}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable
|
||||
@@ -284,7 +288,9 @@ export function MobileBrowserPaneView(props: MobileBrowserPaneViewProps) {
|
||||
]}
|
||||
onPress={() => void sendDialogCommand('browser.dialogAccept')}
|
||||
>
|
||||
<Text style={[styles.dialogButtonText, styles.dialogButtonPrimaryText]}>OK</Text>
|
||||
<Text style={[styles.dialogButtonText, styles.dialogButtonPrimaryText]}>
|
||||
{t('common.ok', 'OK')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
@@ -312,7 +318,7 @@ export function MobileBrowserPaneView(props: MobileBrowserPaneViewProps) {
|
||||
style={styles.keyboardInput}
|
||||
value={keyboardValue}
|
||||
onChangeText={setKeyboardValue}
|
||||
placeholder="Type on page…"
|
||||
placeholder={t('browser.paneView.keyboardPlaceholder', 'Type on page…')}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -323,7 +329,7 @@ export function MobileBrowserPaneView(props: MobileBrowserPaneViewProps) {
|
||||
style={[styles.sendButton, (controlsDisabled || !keyboardValue) && styles.disabled]}
|
||||
disabled={controlsDisabled || !keyboardValue}
|
||||
onPress={() => void sendKeyboardText()}
|
||||
accessibilityLabel="Send text to browser"
|
||||
accessibilityLabel={t('browser.paneView.sendText', 'Send text to browser')}
|
||||
>
|
||||
<ArrowUp size={18} color={buttonColor(!controlsDisabled && !!keyboardValue)} />
|
||||
</Pressable>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import { themedStyleSheet } from '../theme/themed-styles'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
@@ -23,6 +24,7 @@ export function MobileBrowserPointerModifiers({
|
||||
selectedModifiers,
|
||||
onToggle
|
||||
}: Props): React.JSX.Element {
|
||||
const t = useMobileTranslation()
|
||||
useMobileColorScheme()
|
||||
return (
|
||||
<View style={styles.modifierRow}>
|
||||
@@ -41,7 +43,13 @@ export function MobileBrowserPointerModifiers({
|
||||
onPress={() => onToggle(modifier.id)}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected, disabled }}
|
||||
accessibilityLabel={`${modifier.label} click modifier`}
|
||||
accessibilityLabel={t(
|
||||
'browser.pointerModifiers.clickModifier',
|
||||
'{{key}} click modifier',
|
||||
{
|
||||
key: modifier.label
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Pressable, View } from 'react-native'
|
||||
import { Monitor, Smartphone, type LucideIcon } from 'lucide-react-native'
|
||||
import type { Translate } from '../i18n/mobile-i18n'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import { colors, radii } from '../theme/mobile-theme'
|
||||
import { themedStyleSheet } from '../theme/themed-styles'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
@@ -11,20 +13,31 @@ type Props = {
|
||||
onChange: (mode: MobileBrowserViewMode) => void
|
||||
}
|
||||
|
||||
const VIEW_MODES: { id: MobileBrowserViewMode; label: string; icon: LucideIcon }[] = [
|
||||
{ id: 'web', label: 'Web', icon: Monitor },
|
||||
{ id: 'mobile', label: 'Mobile', icon: Smartphone }
|
||||
]
|
||||
function viewModes(t: Translate): { id: MobileBrowserViewMode; label: string; icon: LucideIcon }[] {
|
||||
return [
|
||||
{
|
||||
id: 'web',
|
||||
label: t('browser.viewModeSwitch.webView', 'Show web website view'),
|
||||
icon: Monitor
|
||||
},
|
||||
{
|
||||
id: 'mobile',
|
||||
label: t('browser.viewModeSwitch.mobileView', 'Show mobile website view'),
|
||||
icon: Smartphone
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function MobileBrowserViewModeSwitch({
|
||||
disabled,
|
||||
value,
|
||||
onChange
|
||||
}: Props): React.JSX.Element {
|
||||
const t = useMobileTranslation()
|
||||
useMobileColorScheme()
|
||||
return (
|
||||
<View style={styles.switch}>
|
||||
{VIEW_MODES.map((mode) => (
|
||||
{viewModes(t).map((mode) => (
|
||||
<ViewModeButton
|
||||
key={mode.id}
|
||||
Icon={mode.icon}
|
||||
@@ -64,7 +77,7 @@ function ViewModeButton({
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected, disabled }}
|
||||
accessibilityLabel={`Show ${label.toLowerCase()} website view`}
|
||||
accessibilityLabel={label}
|
||||
>
|
||||
<Icon size={14} color={selected ? colors.bgBase : colors.textSecondary} />
|
||||
</Pressable>
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
type Dispatch,
|
||||
type SetStateAction
|
||||
} from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import type { BrowserScreencastFrameMetadata } from '../transport/browser-screencast-protocol'
|
||||
@@ -208,12 +203,16 @@ export function useMobileBrowserCommands(args: MobileBrowserCommandArgs) {
|
||||
return
|
||||
}
|
||||
if (result !== null) {
|
||||
onToast('Sent')
|
||||
onToast(translate('browser.useBrowserCommands.sent', 'Sent'))
|
||||
return
|
||||
}
|
||||
setKeyboardValue((current) => text + current)
|
||||
if (unconfirmed) {
|
||||
onToast('Delivery unconfirmed — check page before retrying')
|
||||
const warning = translate(
|
||||
'browser.useBrowserCommands.deliveryUnconfirmed',
|
||||
'Delivery unconfirmed — check page before retrying'
|
||||
)
|
||||
onToast(warning)
|
||||
}
|
||||
}, [onToast, sendBrowserRequest])
|
||||
|
||||
@@ -241,6 +240,10 @@ export function useMobileBrowserCommands(args: MobileBrowserCommandArgs) {
|
||||
async (rpc, page, options) => command.interpret(await command.request(rpc, page, options)),
|
||||
{ suppressError: true, timeoutMs: 5_000 }
|
||||
)
|
||||
const error = translate(
|
||||
'browser.useBrowserCommands.dialogAnswerFailed',
|
||||
'That answer did not reach the page.'
|
||||
)
|
||||
// Refusal/timeout leaves page blocked, so retain card with error explanation.
|
||||
setDialog((current) =>
|
||||
current === null || current.pending !== token
|
||||
@@ -248,7 +251,7 @@ export function useMobileBrowserCommands(args: MobileBrowserCommandArgs) {
|
||||
: {
|
||||
...current,
|
||||
pending: undefined,
|
||||
...(result === null ? { error: 'That answer did not reach the page.' } : {})
|
||||
...(result === null ? { error } : {})
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
|
||||
export type ActionSheetAction = {
|
||||
id?: string
|
||||
label: string
|
||||
icon?: LucideIcon
|
||||
renderIcon?: () => ReactNode
|
||||
@@ -68,7 +69,7 @@ export function ActionSheetContent({ title, message, children, actions, onClose
|
||||
const Icon = iconForAction(action.label, action.destructive, action.icon)
|
||||
const customIcon = action.renderIcon?.()
|
||||
return (
|
||||
<View key={action.label}>
|
||||
<View key={action.id ?? action.label}>
|
||||
{i > 0 && <View style={styles.separator} />}
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { DiffComment } from '../../../src/shared/diff-comment-types'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import type { Translate } from '../i18n/mobile-i18n'
|
||||
import { sendSheetMessage } from './mobile-diff-review-send-copy'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import type { ActionSheetAction } from './ActionSheetModal'
|
||||
import { ActionSheetContent } from './ActionSheetModal'
|
||||
@@ -117,6 +118,7 @@ function useSendActions(
|
||||
const terminalActions =
|
||||
load.kind === 'ready' || load.kind === 'error'
|
||||
? load.terminals.map((terminal) => ({
|
||||
id: `terminal:${terminal.terminal}`,
|
||||
label: t('components.diffReviewDrawers.terminalLabel', '{{title}} ({{id}})', {
|
||||
title: terminal.title || t('components.diffReviewDrawers.terminal', 'Terminal'),
|
||||
id: terminal.terminal.slice(0, 6)
|
||||
@@ -130,6 +132,7 @@ function useSendActions(
|
||||
return [
|
||||
...terminalActions,
|
||||
{
|
||||
id: 'newAgentSession',
|
||||
label: t('components.diffReviewDrawers.newAgentSession', 'New Agent Session'),
|
||||
icon: Plus,
|
||||
disabled: comments.length === 0 || controller.agentLaunchAvailability !== 'available',
|
||||
@@ -149,6 +152,7 @@ function useSendActions(
|
||||
onPress: () => void controller.createTerminalAndSend(comments)
|
||||
},
|
||||
{
|
||||
id: 'copyNotes',
|
||||
label: t('components.diffReviewDrawers.copyNotes', 'Copy Notes'),
|
||||
icon: Copy,
|
||||
disabled:
|
||||
@@ -165,6 +169,7 @@ function useOverflowActions(controller: ReturnType<typeof useMobileDiffReviewCon
|
||||
return useMemo<ActionSheetAction[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'copyNotes',
|
||||
label: t('components.diffReviewDrawers.copyNotes', 'Copy Notes'),
|
||||
icon: Copy,
|
||||
disabled:
|
||||
@@ -172,12 +177,14 @@ function useOverflowActions(controller: ReturnType<typeof useMobileDiffReviewCon
|
||||
onPress: () => void controller.copyNotes()
|
||||
},
|
||||
{
|
||||
id: 'sendUnsentNotes',
|
||||
label: t('components.diffReviewDrawers.sendUnsentNotes', 'Send Unsent Notes'),
|
||||
icon: Send,
|
||||
disabled: controller.unsentComments.length === 0,
|
||||
onPress: () => void controller.openSendSheet()
|
||||
},
|
||||
{
|
||||
id: 'clearSentNotes',
|
||||
label: t('components.diffReviewDrawers.clearSentNotes', 'Clear Sent Notes'),
|
||||
icon: Trash2,
|
||||
disabled:
|
||||
@@ -187,6 +194,7 @@ function useOverflowActions(controller: ReturnType<typeof useMobileDiffReviewCon
|
||||
onPress: () => void controller.clearSentNotes()
|
||||
},
|
||||
{
|
||||
id: 'stageReviewedFiles',
|
||||
label: t('components.diffReviewDrawers.stageReviewedFiles', 'Stage Reviewed Files'),
|
||||
icon: Check,
|
||||
disabled: controller.reviewedUnstagedCount === 0 || controller.busyAction !== null,
|
||||
@@ -194,6 +202,7 @@ function useOverflowActions(controller: ReturnType<typeof useMobileDiffReviewCon
|
||||
onPress: () => void controller.stageReviewedFiles()
|
||||
},
|
||||
{
|
||||
id: 'markUnreviewed',
|
||||
label: t('components.diffReviewDrawers.markUnreviewed', 'Mark Unreviewed'),
|
||||
icon: X,
|
||||
disabled:
|
||||
@@ -206,6 +215,7 @@ function useOverflowActions(controller: ReturnType<typeof useMobileDiffReviewCon
|
||||
...(sessionTabsAvailable
|
||||
? [
|
||||
{
|
||||
id: 'openInSession',
|
||||
label: t('components.diffReviewDrawers.openInSession', 'Open in Session'),
|
||||
icon: FileText,
|
||||
disabled: !controller.currentItem || controller.currentItem.scope === 'branch',
|
||||
@@ -218,21 +228,6 @@ function useOverflowActions(controller: ReturnType<typeof useMobileDiffReviewCon
|
||||
)
|
||||
}
|
||||
|
||||
function sendSheetMessage(
|
||||
t: Translate,
|
||||
controller: ReturnType<typeof useMobileDiffReviewController>,
|
||||
load: SendSheetState
|
||||
): string | undefined {
|
||||
const count = controller.unsentComments.length
|
||||
return load.kind === 'loading'
|
||||
? t('components.diffReviewDrawers.loadingSessions', 'Loading agent sessions...')
|
||||
: load.kind === 'error'
|
||||
? load.message
|
||||
: count === 1
|
||||
? t('components.diffReviewDrawers.unsentNotes_one', '{{count}} unsent notes', { count })
|
||||
: t('components.diffReviewDrawers.unsentNotes_other', '{{count}} unsent notes', { count })
|
||||
}
|
||||
|
||||
function NoteComposerContent({ controller, composer }: Props & { composer: ComposerState }) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Translate } from '../i18n/mobile-i18n'
|
||||
import type { SendSheetState } from '../session/mobile-diff-review-screen-model'
|
||||
import type { useMobileDiffReviewController } from '../session/use-mobile-diff-review-controller'
|
||||
|
||||
export function sendSheetMessage(
|
||||
t: Translate,
|
||||
controller: ReturnType<typeof useMobileDiffReviewController>,
|
||||
load: SendSheetState
|
||||
): string | undefined {
|
||||
const count = controller.unsentComments.length
|
||||
return load.kind === 'loading'
|
||||
? t('components.diffReviewDrawers.loadingSessions', 'Loading agent sessions...')
|
||||
: load.kind === 'error'
|
||||
? load.message
|
||||
: count === 1
|
||||
? t('components.diffReviewDrawers.unsentNotes_one', '{{count}} unsent notes', { count })
|
||||
: t('components.diffReviewDrawers.unsentNotes_other', '{{count}} unsent notes', { count })
|
||||
}
|
||||
@@ -311,23 +311,23 @@ function CommentGroupView({
|
||||
{group.kind === 'thread'
|
||||
? count === 1
|
||||
? t(
|
||||
'pullRequest.commentsSection.resolvedThread_one',
|
||||
'pullRequest.commentsSection.resolvedThreadSingle',
|
||||
'Resolved thread by {{author}}',
|
||||
{ author: root.author, count }
|
||||
{ author: root.author }
|
||||
)
|
||||
: t(
|
||||
'pullRequest.commentsSection.resolvedThread_other',
|
||||
'pullRequest.commentsSection.resolvedThreadCount',
|
||||
'Resolved thread by {{author}} ({{count}})',
|
||||
{ author: root.author, count }
|
||||
)
|
||||
: count === 1
|
||||
? t(
|
||||
'pullRequest.commentsSection.resolvedComment_one',
|
||||
'pullRequest.commentsSection.resolvedCommentSingle',
|
||||
'Resolved comment by {{author}}',
|
||||
{ author: root.author, count }
|
||||
{ author: root.author }
|
||||
)
|
||||
: t(
|
||||
'pullRequest.commentsSection.resolvedComment_other',
|
||||
'pullRequest.commentsSection.resolvedCommentCount',
|
||||
'Resolved comment by {{author}} ({{count}})',
|
||||
{ author: root.author, count }
|
||||
)}
|
||||
|
||||
@@ -10,41 +10,131 @@ import { formatTimeAgo } from '../worktree/agent-row-display'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
|
||||
// Describe only the recorded code; this row has no further evidence about the failure.
|
||||
const REASON_COPY: Record<MobileWebShellUpdateFailureReason, string> = {
|
||||
'no-connection': 'not connected to the host',
|
||||
'connection-lost': 'the connection dropped',
|
||||
'host-refused': 'the host refused the read',
|
||||
'reply-unreadable': 'the host sent a reply this app could not read',
|
||||
'chunk-oversize': 'a chunk was larger than the host allows',
|
||||
'asset-overlong': 'an asset was longer than the manifest declares',
|
||||
'asset-no-progress': 'an asset read made no progress',
|
||||
'asset-short': 'an asset ended short of its declared size',
|
||||
'asset-checksum-mismatch': 'asset checksum mismatch',
|
||||
'build-changed-mid-fetch': "the host's build changed during the download",
|
||||
'chunk-misrouted': 'a chunk answered the wrong asset or offset',
|
||||
'asset-entry-changed': 'an asset no longer matched the manifest',
|
||||
'range-undecodable': 'a compressed read could not be decoded',
|
||||
'fetch-stopped': 'the download was stopped',
|
||||
'cache-write-failed': 'saving the download on this phone failed',
|
||||
'unrecognised-error': 'an unrecognised error'
|
||||
function reasonCopy(): Record<MobileWebShellUpdateFailureReason, string> {
|
||||
return {
|
||||
'no-connection': translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.reasonCopy.noConnection',
|
||||
'not connected to the host'
|
||||
),
|
||||
'connection-lost': translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.reasonCopy.connectionLost',
|
||||
'the connection dropped'
|
||||
),
|
||||
'host-refused': translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.reasonCopy.hostRefused',
|
||||
'the host refused the read'
|
||||
),
|
||||
'reply-unreadable': translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.reasonCopy.replyUnreadable',
|
||||
'the host sent a reply this app could not read'
|
||||
),
|
||||
'chunk-oversize': translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.reasonCopy.chunkOversize',
|
||||
'a chunk was larger than the host allows'
|
||||
),
|
||||
'asset-overlong': translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.reasonCopy.assetOverlong',
|
||||
'an asset was longer than the manifest declares'
|
||||
),
|
||||
'asset-no-progress': translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.reasonCopy.assetNoProgress',
|
||||
'an asset read made no progress'
|
||||
),
|
||||
'asset-short': translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.reasonCopy.assetShort',
|
||||
'an asset ended short of its declared size'
|
||||
),
|
||||
'asset-checksum-mismatch': translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.reasonCopy.assetChecksumMismatch',
|
||||
'asset checksum mismatch'
|
||||
),
|
||||
'build-changed-mid-fetch': translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.reasonCopy.buildChangedMidFetch',
|
||||
"the host's build changed during the download"
|
||||
),
|
||||
'chunk-misrouted': translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.reasonCopy.chunkMisrouted',
|
||||
'a chunk answered the wrong asset or offset'
|
||||
),
|
||||
'asset-entry-changed': translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.reasonCopy.assetEntryChanged',
|
||||
'an asset no longer matched the manifest'
|
||||
),
|
||||
'range-undecodable': translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.reasonCopy.rangeUndecodable',
|
||||
'a compressed read could not be decoded'
|
||||
),
|
||||
'fetch-stopped': translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.reasonCopy.fetchStopped',
|
||||
'the download was stopped'
|
||||
),
|
||||
'cache-write-failed': translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.reasonCopy.cacheWriteFailed',
|
||||
'saving the download on this phone failed'
|
||||
),
|
||||
'unrecognised-error': translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.reasonCopy.unrecognisedError',
|
||||
'an unrecognised error'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const HOST_CODE_COPY: Record<MobileWebBundleErrorCode, string> = {
|
||||
mobile_web_bundle_unavailable: 'the host has no workspace bundle',
|
||||
mobile_web_bundle_build_changed: "the host's build changed during the download",
|
||||
mobile_web_bundle_asset_unknown: 'the host did not recognise an asset',
|
||||
mobile_web_bundle_asset_changed: 'an asset changed on the host',
|
||||
mobile_web_bundle_offset_invalid: 'the host refused a read offset',
|
||||
mobile_web_bundle_read_limited: 'the host limited concurrent reads'
|
||||
function hostCodeCopy(): Record<MobileWebBundleErrorCode, string> {
|
||||
return {
|
||||
mobile_web_bundle_unavailable: translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.hostCodeCopy.unavailable',
|
||||
'the host has no workspace bundle'
|
||||
),
|
||||
mobile_web_bundle_build_changed: translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.hostCodeCopy.buildChanged',
|
||||
"the host's build changed during the download"
|
||||
),
|
||||
mobile_web_bundle_asset_unknown: translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.hostCodeCopy.assetUnknown',
|
||||
'the host did not recognise an asset'
|
||||
),
|
||||
mobile_web_bundle_asset_changed: translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.hostCodeCopy.assetChanged',
|
||||
'an asset changed on the host'
|
||||
),
|
||||
mobile_web_bundle_offset_invalid: translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.hostCodeCopy.offsetInvalid',
|
||||
'the host refused a read offset'
|
||||
),
|
||||
mobile_web_bundle_read_limited: translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.hostCodeCopy.readLimited',
|
||||
'the host limited concurrent reads'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const WALL_COPY: Record<MobileWebShellUpdateFailureWall, string> = {
|
||||
'bundle-unavailable': 'the host has no workspace bundle',
|
||||
'bundle-shell-too-old': 'this app is too old for the saved bundle',
|
||||
'host-too-old-for-bundle': 'the host is too old for the saved bundle',
|
||||
'bundle-too-old-for-host': 'the saved bundle is too old for the host',
|
||||
'page-route-unlisted': "the host's workspace does not include this screen",
|
||||
'page-route-grant-missing': 'this screen needs a newer app'
|
||||
function wallCopy(): Record<MobileWebShellUpdateFailureWall, string> {
|
||||
return {
|
||||
'bundle-unavailable': translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.wallCopy.bundleUnavailable',
|
||||
'the host has no workspace bundle'
|
||||
),
|
||||
'bundle-shell-too-old': translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.wallCopy.bundleShellTooOld',
|
||||
'this app is too old for the saved bundle'
|
||||
),
|
||||
'host-too-old-for-bundle': translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.wallCopy.hostTooOldForBundle',
|
||||
'the host is too old for the saved bundle'
|
||||
),
|
||||
'bundle-too-old-for-host': translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.wallCopy.bundleTooOldForHost',
|
||||
'the saved bundle is too old for the host'
|
||||
),
|
||||
'page-route-unlisted': translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.wallCopy.pageRouteUnlisted',
|
||||
"the host's workspace does not include this screen"
|
||||
),
|
||||
'page-route-grant-missing': translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.wallCopy.pageRouteGrantMissing',
|
||||
'this screen needs a newer app'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function generation(buildId: string): string {
|
||||
@@ -56,20 +146,38 @@ function outcomeCopy(failure: MobileWebShellUpdateFailure): string {
|
||||
switch (outcome) {
|
||||
case 'opened-cached':
|
||||
return failure.cachedBuildId === null
|
||||
? 'Fell back to the saved version.'
|
||||
: `Fell back to the saved version (generation ${generation(failure.cachedBuildId)}).`
|
||||
? translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.openedCached',
|
||||
'Fell back to the saved version.'
|
||||
)
|
||||
: translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.openedCachedGeneration',
|
||||
'Fell back to the saved version (generation {{generation}}).',
|
||||
{ generation: generation(failure.cachedBuildId) }
|
||||
)
|
||||
case 'wall':
|
||||
return failure.wall === null
|
||||
? 'Blocked the workspace.'
|
||||
: `Blocked: ${WALL_COPY[failure.wall]}.`
|
||||
? translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.blockedWorkspace',
|
||||
'Blocked the workspace.'
|
||||
)
|
||||
: translate('diagnostics.webShellUpdateFailureCopy.blockedReason', 'Blocked: {{reason}}.', {
|
||||
reason: wallCopy()[failure.wall]
|
||||
})
|
||||
case 'native-route':
|
||||
return 'Showed the native screen.'
|
||||
return translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.nativeRoute',
|
||||
'Showed the native screen.'
|
||||
)
|
||||
case 'route-unavailable':
|
||||
return 'Showed that the screen is not available.'
|
||||
return translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.routeUnavailable',
|
||||
'Showed that the screen is not available.'
|
||||
)
|
||||
case 'failed':
|
||||
return 'Showed the failure screen.'
|
||||
return translate('diagnostics.webShellUpdateFailureCopy.failed', 'Showed the failure screen.')
|
||||
case 'waiting':
|
||||
return 'Waited for the host.'
|
||||
return translate('diagnostics.webShellUpdateFailureCopy.waiting', 'Waited for the host.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,9 +187,15 @@ export function formatUpdateFailure(
|
||||
now: number
|
||||
): string {
|
||||
const reason =
|
||||
failure.hostCode === null ? REASON_COPY[failure.reason] : HOST_CODE_COPY[failure.hostCode]
|
||||
failure.hostCode === null ? reasonCopy()[failure.reason] : hostCodeCopy()[failure.hostCode]
|
||||
const offered =
|
||||
failure.offeredBuildId === null ? '' : ` (generation ${generation(failure.offeredBuildId)})`
|
||||
failure.offeredBuildId === null
|
||||
? ''
|
||||
: translate(
|
||||
'diagnostics.webShellUpdateFailureCopy.offeredGeneration',
|
||||
' (generation {{generation}})',
|
||||
{ generation: generation(failure.offeredBuildId) }
|
||||
)
|
||||
const options = { hostName, reason, offered, outcome: outcomeCopy(failure) }
|
||||
return now - failure.at < 60_000
|
||||
? translate(
|
||||
|
||||
@@ -37,6 +37,8 @@ import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
|
||||
import { MobileFileExplorerRow } from './mobile-file-explorer-row'
|
||||
import { navigateToMobileFilePreview } from './mobile-file-preview-navigation'
|
||||
import { MobileLoadErrorState } from '../components/MobileLoadErrorState'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
|
||||
export function MobileFileExplorerPanel(props: {
|
||||
hostId: string
|
||||
@@ -46,6 +48,7 @@ export function MobileFileExplorerPanel(props: {
|
||||
onRequestClose?: () => void
|
||||
}) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const { hostId, worktreeId, name, embedded, onRequestClose } = props
|
||||
const router = useWorkspaceRouteHandoff()
|
||||
const executionHost = useWorkspaceExecutionHost()
|
||||
@@ -73,7 +76,9 @@ export function MobileFileExplorerPanel(props: {
|
||||
|
||||
if (!client || connState !== 'connected') {
|
||||
const message =
|
||||
connState === 'connected' ? 'Connecting to desktop...' : 'Waiting for desktop...'
|
||||
connState === 'connected'
|
||||
? translate('files.fileExplorerPanel.connecting', 'Connecting to desktop...')
|
||||
: translate('files.fileExplorerPanel.waiting', 'Waiting for desktop...')
|
||||
if (rootLoad) {
|
||||
const hasLoadedRoot =
|
||||
(getDirectoryCacheState(directoryCacheRef.current, '')?.entries.length ?? 0) > 0
|
||||
@@ -141,10 +146,13 @@ export function MobileFileExplorerPanel(props: {
|
||||
throw new Error(
|
||||
(legacyReply.ok ? undefined : legacyReply.error)?.message ||
|
||||
refusal?.message ||
|
||||
'Unable to load files'
|
||||
translate('files.fileExplorerPanel.loadFailed', 'Unable to load files')
|
||||
)
|
||||
}
|
||||
throw new Error(refusal?.message || 'Unable to load files')
|
||||
throw new Error(
|
||||
refusal?.message ||
|
||||
translate('files.fileExplorerPanel.loadFailed', 'Unable to load files')
|
||||
)
|
||||
}
|
||||
if (
|
||||
!isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
|
||||
@@ -166,7 +174,10 @@ export function MobileFileExplorerPanel(props: {
|
||||
) {
|
||||
return
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unable to load files'
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: translate('files.fileExplorerPanel.loadFailed', 'Unable to load files')
|
||||
if (rootLoad) {
|
||||
// Only cold loads show full-screen errors; failed background refreshes retain the cached tree.
|
||||
setError(hadLoadedRoot ? null : message)
|
||||
@@ -227,7 +238,15 @@ export function MobileFileExplorerPanel(props: {
|
||||
Object.fromEntries(
|
||||
Object.entries(previous).map(([path, state]) => [
|
||||
path,
|
||||
state?.loading ? { entries: state.entries, error: 'Folder load interrupted' } : state
|
||||
state?.loading
|
||||
? {
|
||||
entries: state.entries,
|
||||
error: translate(
|
||||
'files.fileExplorerPanel.loadInterrupted',
|
||||
'Folder load interrupted'
|
||||
)
|
||||
}
|
||||
: state
|
||||
])
|
||||
)
|
||||
)
|
||||
@@ -327,7 +346,7 @@ export function MobileFileExplorerPanel(props: {
|
||||
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
|
||||
onPress={() => onRequestClose?.()}
|
||||
hitSlop={8}
|
||||
accessibilityLabel="Close files"
|
||||
accessibilityLabel={t('files.fileExplorerPanel.closeFiles', 'Close files')}
|
||||
>
|
||||
<X size={20} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
@@ -337,18 +356,21 @@ export function MobileFileExplorerPanel(props: {
|
||||
onPress={() => router.back()}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Back to session"
|
||||
accessibilityLabel={t('files.fileExplorerPanel.backToSession', 'Back to session')}
|
||||
>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
)}
|
||||
<View style={styles.titleBlock}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
Files
|
||||
{t('files.fileExplorerPanel.title', 'Files')}
|
||||
</Text>
|
||||
<Text style={styles.meta} numberOfLines={1}>
|
||||
{worktreeLabel}
|
||||
{legacyListTruncated ? ' - Showing first 5000' : ''}
|
||||
{legacyListTruncated
|
||||
? t('files.fileExplorerPanel.truncatedList', '{{worktreeLabel}} - Showing first 5000', {
|
||||
worktreeLabel
|
||||
})
|
||||
: worktreeLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -369,7 +391,9 @@ export function MobileFileExplorerPanel(props: {
|
||||
<MobileLoadErrorState message={error} onRetry={rootRetry} />
|
||||
) : rows.length === 0 ? (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.emptyText}>No files found</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
{t('files.fileExplorerPanel.emptyState', 'No files found')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
MobileFilePreviewTruncatedNote
|
||||
} from './MobileFilePreviewSourceText'
|
||||
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
|
||||
type Props = {
|
||||
relativePath: string
|
||||
@@ -35,6 +36,7 @@ export function MobileFileMarkdownPreview({
|
||||
onOpenFile
|
||||
}: Props) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const [mode, setMode] = useState<'preview' | 'source'>(() => (initialLine ? 'source' : 'preview'))
|
||||
const [previousRelativePath, setPreviousRelativePath] = useState(relativePath)
|
||||
const [previousInitialLine, setPreviousInitialLine] = useState(initialLine)
|
||||
@@ -55,7 +57,7 @@ export function MobileFileMarkdownPreview({
|
||||
onPress={() => setMode('source')}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: sourceSelected }}
|
||||
accessibilityLabel="View Markdown source"
|
||||
accessibilityLabel={t('files.fileMarkdownPreview.viewSource', 'View Markdown source')}
|
||||
>
|
||||
<Code
|
||||
size={15}
|
||||
@@ -68,7 +70,10 @@ export function MobileFileMarkdownPreview({
|
||||
onPress={() => setMode('preview')}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: previewSelected }}
|
||||
accessibilityLabel="View rendered Markdown preview"
|
||||
accessibilityLabel={t(
|
||||
'files.fileMarkdownPreview.viewPreview',
|
||||
'View rendered Markdown preview'
|
||||
)}
|
||||
>
|
||||
<Pencil
|
||||
size={15}
|
||||
@@ -80,7 +85,11 @@ export function MobileFileMarkdownPreview({
|
||||
key={relativePath}
|
||||
text={content}
|
||||
partial={truncated}
|
||||
accessibilityLabel={truncated ? 'Copy loaded Markdown source' : 'Copy Markdown source'}
|
||||
accessibilityLabel={
|
||||
truncated
|
||||
? t('files.fileMarkdownPreview.copyLoadedSource', 'Copy loaded Markdown source')
|
||||
: t('files.fileMarkdownPreview.copySource', 'Copy Markdown source')
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
{mode === 'preview' ? (
|
||||
|
||||
@@ -11,6 +11,8 @@ import { MobilePdfPreview } from './MobilePdfPreview'
|
||||
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
|
||||
import { formatPreviewByteLength } from './mobile-file-preview-response'
|
||||
import { MobileLoadErrorState } from '../components/MobileLoadErrorState'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
|
||||
type State = { uri: string } | { error: string } | { bytes: number; total: number }
|
||||
type Props = { media: MobileFileMedia; client: MobileFilePreviewRpcSender | null; title: string }
|
||||
@@ -27,6 +29,7 @@ export function MobileFileMediaPreview(props: Props) {
|
||||
|
||||
function MediaDownload({ media, client, title }: Props) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const { worktreeId, relativePath, mimeType } = media
|
||||
const [state, setState] = useState<State>({ bytes: 0, total: 0 })
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
@@ -50,7 +53,12 @@ function MediaDownload({ media, client, title }: Props) {
|
||||
}
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
setState({ error: error instanceof Error ? error.message : 'Unable to load media' })
|
||||
setState({
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translate('files.fileMediaPreview.loadFailed', 'Unable to load media')
|
||||
})
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -63,7 +71,9 @@ function MediaDownload({ media, client, title }: Props) {
|
||||
if (!client) {
|
||||
return (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateText}>Waiting for desktop...</Text>
|
||||
<Text style={styles.stateText}>
|
||||
{t('files.fileMediaPreview.waiting', 'Waiting for desktop...')}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -78,8 +88,11 @@ function MediaDownload({ media, client, title }: Props) {
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
<Text style={styles.stateText}>
|
||||
{state.total
|
||||
? `Downloading ${formatPreviewByteLength(state.bytes)} of ${formatPreviewByteLength(state.total)}`
|
||||
: 'Loading media...'}
|
||||
? t('files.fileMediaPreview.downloading', 'Downloading {{bytes}} of {{total}}', {
|
||||
bytes: formatPreviewByteLength(state.bytes),
|
||||
total: formatPreviewByteLength(state.total)
|
||||
})
|
||||
: t('files.fileMediaPreview.loading', 'Loading media...')}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import { MobileFilePreviewSourceText } from './MobileFilePreviewSourceText'
|
||||
import type { MobileFilePreviewLineColumn } from './mobile-file-preview-line-column'
|
||||
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
|
||||
import { MobileLoadErrorState } from '../components/MobileLoadErrorState'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
|
||||
type Props = {
|
||||
preview: MobileFilePreviewResult
|
||||
@@ -32,6 +33,7 @@ type Props = {
|
||||
|
||||
export function MobileFilePreviewBody({ preview, ...options }: Props) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
if (preview.status === 'loading') {
|
||||
return (
|
||||
<View style={styles.state}>
|
||||
@@ -48,7 +50,7 @@ export function MobileFilePreviewBody({ preview, ...options }: Props) {
|
||||
<EditablePreviewSource {...options} />
|
||||
) : (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateText}>Empty file</Text>
|
||||
<Text style={styles.stateText}>{t('files.filePreviewBody.emptyFile', 'Empty file')}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -76,7 +78,9 @@ export function MobileFilePreviewBody({ preview, ...options }: Props) {
|
||||
style={[styles.image, { width: options.imageWidth, height: options.imageHeight }]}
|
||||
resizeMode="contain"
|
||||
onError={options.onImageError}
|
||||
accessibilityLabel={`${options.title} image`}
|
||||
accessibilityLabel={t('files.filePreviewBody.imageLabel', '{{title}} image', {
|
||||
title: options.title
|
||||
})}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { MobileFilePreviewLineColumn } from './mobile-file-preview-line-col
|
||||
import { textOffsetForLineColumn } from './mobile-file-preview-line-column'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
@@ -21,6 +22,7 @@ export function MobileFilePreviewEditableSource({
|
||||
onDraftChange
|
||||
}: Props) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const selectionTargetKey = lineColumn
|
||||
? `${title}:${lineColumn.line}:${lineColumn.column ?? ''}`
|
||||
: ''
|
||||
@@ -51,7 +53,9 @@ export function MobileFilePreviewEditableSource({
|
||||
autoCorrect={false}
|
||||
selection={selection ?? undefined}
|
||||
onSelectionChange={() => setSelection(null)}
|
||||
accessibilityLabel={`${title} editor`}
|
||||
accessibilityLabel={t('files.filePreviewEditableSource.editorLabel', '{{title}} editor', {
|
||||
title
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Alert, Pressable, Text, View } from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaFrame } from 'react-native-safe-area-context'
|
||||
import { ChevronLeft, Save } from 'lucide-react-native'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import { useWorkspaceExecutionHost } from '../navigation/workspace-execution-host'
|
||||
import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff'
|
||||
import { getWorktreeLabel } from '../session/worktree-label'
|
||||
@@ -51,6 +53,7 @@ export function MobileFilePreviewScreen({ route }: Props) {
|
||||
}
|
||||
|
||||
function MobileFilePreviewDocument({ route }: Props) {
|
||||
const t = useMobileTranslation()
|
||||
useMobileColorScheme()
|
||||
// Use the shared route handoff to keep this screen within the bundle asset budget.
|
||||
const router = useWorkspaceRouteHandoff()
|
||||
@@ -58,7 +61,9 @@ function MobileFilePreviewDocument({ route }: Props) {
|
||||
const { client, state: connState } = useWorkspaceClient(previewParams?.hostId)
|
||||
const forceReconnect = useForceReconnect()
|
||||
const [preview, setPreview] = useState<MobileFilePreviewResult>(() =>
|
||||
route.ok ? { status: 'loading', message: 'Loading preview...' } : previewError(route.message)
|
||||
route.ok
|
||||
? { status: 'loading', message: t('files.filePreviewScreen.loading', 'Loading preview...') }
|
||||
: previewError(route.message)
|
||||
)
|
||||
const [draftContent, setDraftContent] = useState('')
|
||||
const [savedContent, setSavedContent] = useState('')
|
||||
@@ -120,7 +125,7 @@ function MobileFilePreviewDocument({ route }: Props) {
|
||||
const isCurrent = () =>
|
||||
revision === previewLoadRevisionRef.current && previewSourceKeyRef.current === loadSourceKey
|
||||
if (!previewParams || !loadSource || loadSourceKey !== routePreviewSourceKey) {
|
||||
setPreview(previewError(route.ok ? 'Unable to load preview' : route.message))
|
||||
setPreview(previewError(route.ok ? '' : route.message))
|
||||
return
|
||||
}
|
||||
const preserveDirtyDraft =
|
||||
@@ -128,14 +133,21 @@ function MobileFilePreviewDocument({ route }: Props) {
|
||||
draftContentRef.current !== savedContentRef.current
|
||||
if (!client || connState !== 'connected') {
|
||||
if (preserveDirtyDraft) {
|
||||
setSaveError('Waiting for desktop...')
|
||||
setSaveError(translate('files.filePreviewScreen.waiting', 'Waiting for desktop...'))
|
||||
return
|
||||
}
|
||||
setPreview({ status: 'waiting', message: 'Waiting for desktop...', reconnect: true })
|
||||
setPreview({
|
||||
status: 'waiting',
|
||||
message: translate('files.filePreviewScreen.waiting', 'Waiting for desktop...'),
|
||||
reconnect: true
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!preserveDirtyDraft) {
|
||||
setPreview({ status: 'loading', message: 'Loading preview...' })
|
||||
setPreview({
|
||||
status: 'loading',
|
||||
message: translate('files.filePreviewScreen.loading', 'Loading preview...')
|
||||
})
|
||||
}
|
||||
setSaveError('')
|
||||
try {
|
||||
@@ -176,12 +188,14 @@ function MobileFilePreviewDocument({ route }: Props) {
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unable to load preview'
|
||||
const message = err instanceof Error ? err.message : null
|
||||
if (preserveDirtyDraft) {
|
||||
setSaveError(message)
|
||||
setSaveError(
|
||||
message ?? translate('files.filePreviewScreen.loadFailed', 'Unable to load preview')
|
||||
)
|
||||
return
|
||||
}
|
||||
setPreview(previewError(message))
|
||||
setPreview(previewError(message ?? ''))
|
||||
}
|
||||
}, [client, connState, previewParams, previewSourceKey, route, routePreviewSourceKey])
|
||||
|
||||
@@ -215,7 +229,9 @@ function MobileFilePreviewDocument({ route }: Props) {
|
||||
previewParams?.worktreeName,
|
||||
previewParams?.worktreeId ?? ''
|
||||
)
|
||||
const meta = previewParams ? `${worktreeLabel} - ${displayPath}` : 'Preview'
|
||||
const meta = previewParams
|
||||
? `${worktreeLabel} - ${displayPath}`
|
||||
: t('files.filePreviewScreen.title', 'Preview')
|
||||
const isEditableTerminalArtifact =
|
||||
previewSource?.source === 'terminalArtifact' &&
|
||||
isEditableMobileTerminalArtifactPreview(preview, previewSource.readOnly === true)
|
||||
@@ -254,7 +270,10 @@ function MobileFilePreviewDocument({ route }: Props) {
|
||||
setSaveError(saveErrorMessageFromPreviewResult(result))
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unable to save file'
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: translate('files.filePreviewScreen.saveFailed', 'Unable to save file')
|
||||
setSaveError(message)
|
||||
} finally {
|
||||
savingRef.current = false
|
||||
@@ -277,13 +296,13 @@ function MobileFilePreviewDocument({ route }: Props) {
|
||||
onPress={requestBack}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Back to files"
|
||||
accessibilityLabel={t('files.filePreviewScreen.backToFiles', 'Back to files')}
|
||||
>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
<View style={styles.titleBlock}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title || 'Preview'}
|
||||
{title || t('files.filePreviewScreen.title', 'Preview')}
|
||||
</Text>
|
||||
<Text style={styles.meta} numberOfLines={1}>
|
||||
{meta}
|
||||
@@ -294,7 +313,10 @@ function MobileFilePreviewDocument({ route }: Props) {
|
||||
style={[styles.saveButton, (!canSaveArtifact || saving) && styles.saveButtonDisabled]}
|
||||
onPress={() => void saveArtifact()}
|
||||
disabled={!canSaveArtifact || saving}
|
||||
accessibilityLabel="Save terminal artifact"
|
||||
accessibilityLabel={t(
|
||||
'files.filePreviewScreen.saveArtifact',
|
||||
'Save terminal artifact'
|
||||
)}
|
||||
>
|
||||
<Save size={18} color={colors.textPrimary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
@@ -306,7 +328,7 @@ function MobileFilePreviewDocument({ route }: Props) {
|
||||
client={client}
|
||||
worktreeId={previewSource?.source === 'worktree' ? previewSource.worktreeId : undefined}
|
||||
relativePath={displayPath}
|
||||
title={title || 'File'}
|
||||
title={title || t('files.filePreviewScreen.fileTitle', 'File')}
|
||||
editable={isEditableTerminalArtifact}
|
||||
draftContent={draftContent}
|
||||
saveError={saveError}
|
||||
@@ -315,7 +337,11 @@ function MobileFilePreviewDocument({ route }: Props) {
|
||||
imageHeight={Math.max(240, height - 160)}
|
||||
onDraftChange={setDraftContent}
|
||||
onImageError={() =>
|
||||
setPreview({ status: 'error', message: 'Unable to load preview', reconnect: false })
|
||||
setPreview({
|
||||
status: 'error',
|
||||
message: t('files.filePreviewScreen.loadFailed', 'Unable to load preview'),
|
||||
reconnect: false
|
||||
})
|
||||
}
|
||||
onRetry={retry}
|
||||
onOpenFile={(pathText) => {
|
||||
@@ -323,16 +349,22 @@ function MobileFilePreviewDocument({ route }: Props) {
|
||||
if (href) {
|
||||
router.push(href)
|
||||
} else {
|
||||
Alert.alert('Unable to open file', 'Choose a file inside this workspace.')
|
||||
Alert.alert(
|
||||
t('files.filePreviewScreen.openFailed', 'Unable to open file'),
|
||||
t(
|
||||
'files.filePreviewScreen.chooseWorkspaceFile',
|
||||
'Choose a file inside this workspace.'
|
||||
)
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ConfirmModal
|
||||
visible={confirmingDiscard}
|
||||
title="Discard changes?"
|
||||
message="Unsaved edits will be lost."
|
||||
confirmLabel="Discard"
|
||||
cancelLabel="Stay"
|
||||
title={t('files.filePreviewScreen.discardTitle', 'Discard changes?')}
|
||||
message={t('files.filePreviewScreen.discardMessage', 'Unsaved edits will be lost.')}
|
||||
confirmLabel={t('files.filePreviewScreen.discard', 'Discard')}
|
||||
cancelLabel={t('files.filePreviewScreen.stay', 'Stay')}
|
||||
destructive
|
||||
onConfirm={discard}
|
||||
onCancel={stay}
|
||||
@@ -344,5 +376,5 @@ function MobileFilePreviewDocument({ route }: Props) {
|
||||
function saveErrorMessageFromPreviewResult(result: MobileFilePreviewResult): string {
|
||||
return result.status === 'error' || result.status === 'waiting'
|
||||
? result.message
|
||||
: 'Unable to save file'
|
||||
: translate('files.filePreviewScreen.saveFailed', 'Unable to save file')
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { scrollOffsetForPreviewLine } from './mobile-file-preview-line-column'
|
||||
import { buildMobileFilePreviewSyntax } from './mobile-file-preview-syntax'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
|
||||
export function MobileFilePreviewSourceText({
|
||||
relativePath,
|
||||
@@ -21,6 +22,7 @@ export function MobileFilePreviewSourceText({
|
||||
initialLine?: number
|
||||
}) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const scrollRef = useRef<ScrollView>(null)
|
||||
const revealedRef = useRef(false)
|
||||
const syntax = useMemo(
|
||||
@@ -53,7 +55,11 @@ export function MobileFilePreviewSourceText({
|
||||
{truncated ? (
|
||||
<MobileFilePreviewTruncatedNote byteLength={byteLength ?? content.length} />
|
||||
) : null}
|
||||
<Text selectable style={styles.textPreview} accessibilityLabel="File preview">
|
||||
<Text
|
||||
selectable
|
||||
style={styles.textPreview}
|
||||
accessibilityLabel={t('files.filePreviewSourceText.previewLabel', 'File preview')}
|
||||
>
|
||||
<MobileSyntaxSegments segments={syntax.segments} />
|
||||
</Text>
|
||||
</ScrollView>
|
||||
@@ -62,9 +68,12 @@ export function MobileFilePreviewSourceText({
|
||||
|
||||
export function MobileFilePreviewTruncatedNote({ byteLength }: { byteLength: number }) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
return (
|
||||
<Text style={styles.truncatedNote}>
|
||||
Preview truncated. File size: {formatPreviewByteLength(byteLength)}.
|
||||
{t('files.filePreviewSourceText.truncatedNote', 'Preview truncated. File size: {{size}}.', {
|
||||
size: formatPreviewByteLength(byteLength)
|
||||
})}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { MobileLoadErrorState } from '../components/MobileLoadErrorState'
|
||||
import { MobileNativeChatImageViewer } from '../session/native-chat/visual/MobileNativeChatImageViewer'
|
||||
import type { MobileFilePreviewRpcSender } from './mobile-file-preview-operations'
|
||||
import { loadMarkdownPreviewImage } from './mobile-markdown-image-source'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
|
||||
type Props = {
|
||||
client: MobileFilePreviewRpcSender | null
|
||||
@@ -18,6 +20,7 @@ type Props = {
|
||||
|
||||
export function MobileMarkdownPreviewImage({ client, worktreeId, documentPath, href, alt }: Props) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const [load, setLoad] = useState<{ uri?: string; error?: string }>({})
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const [aspectRatio, setAspectRatio] = useState(16 / 9)
|
||||
@@ -41,7 +44,12 @@ export function MobileMarkdownPreviewImage({ client, worktreeId, documentPath, h
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!cancelled) {
|
||||
setLoad({ error: error instanceof Error ? error.message : 'Unable to load image' })
|
||||
setLoad({
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translate('files.markdownPreviewImage.loadFailed', 'Unable to load image')
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -64,14 +72,22 @@ export function MobileMarkdownPreviewImage({ client, worktreeId, documentPath, h
|
||||
<Pressable
|
||||
onPress={() => setExpanded(true)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Expand ${alt || 'image'}`}
|
||||
accessibilityLabel={
|
||||
alt
|
||||
? t('files.markdownPreviewImage.expandImage', 'Expand {{alt}}', { alt })
|
||||
: t('files.markdownPreviewImage.expandFallback', 'Expand image')
|
||||
}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: load.uri }}
|
||||
accessibilityLabel={alt || 'Markdown image'}
|
||||
accessibilityLabel={alt || t('files.markdownPreviewImage.imageLabel', 'Markdown image')}
|
||||
resizeMode="contain"
|
||||
style={{ width: '100%', aspectRatio }}
|
||||
onError={() => setLoad({ error: 'Unable to load image' })}
|
||||
onError={() =>
|
||||
setLoad({
|
||||
error: translate('files.markdownPreviewImage.loadFailed', 'Unable to load image')
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Pressable>
|
||||
) : (
|
||||
@@ -81,7 +97,7 @@ export function MobileMarkdownPreviewImage({ client, worktreeId, documentPath, h
|
||||
{expanded && load.uri ? (
|
||||
<MobileNativeChatImageViewer
|
||||
uri={load.uri}
|
||||
label={alt || 'Markdown image'}
|
||||
label={alt || t('files.markdownPreviewImage.imageLabel', 'Markdown image')}
|
||||
onClose={() => setExpanded(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { PdfOpenRefusedError } from '../platform/pdf-opener-contract'
|
||||
import type { MobileFileMedia } from './mobile-file-media'
|
||||
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
|
||||
import { MobileLoadErrorState, MobileStateButton } from '../components/MobileLoadErrorState'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
|
||||
type State =
|
||||
| { status: 'opening' }
|
||||
@@ -17,6 +19,7 @@ type State =
|
||||
// The shell downloads the PDF and shows it in Quick Look (iOS) or the system viewer (Android).
|
||||
export function MobilePdfPreview({ media }: { media: MobileFileMedia }) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const opener = usePdfOpener()
|
||||
const [state, setState] = useState<State>({ status: 'idle' })
|
||||
const stopPending = useRef<(() => void) | null>(null)
|
||||
@@ -45,7 +48,10 @@ export function MobilePdfPreview({ media }: { media: MobileFileMedia }) {
|
||||
(error: unknown) =>
|
||||
settle({
|
||||
status: 'failed',
|
||||
message: error instanceof Error ? error.message : 'Unable to open this PDF',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translate('files.pdfPreview.openFailed', 'Unable to open this PDF'),
|
||||
retryable: !(error instanceof PdfOpenRefusedError)
|
||||
})
|
||||
)
|
||||
@@ -55,7 +61,9 @@ export function MobilePdfPreview({ media }: { media: MobileFileMedia }) {
|
||||
if (!opener) {
|
||||
return (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateText}>Update Orca Mobile to open PDFs</Text>
|
||||
<Text style={styles.stateText}>
|
||||
{t('files.pdfPreview.updateRequired', 'Update Orca Mobile to open PDFs')}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -66,13 +74,13 @@ export function MobilePdfPreview({ media }: { media: MobileFileMedia }) {
|
||||
return (
|
||||
<View style={styles.state}>
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
<Text style={styles.stateText}>Opening PDF...</Text>
|
||||
<Text style={styles.stateText}>{t('files.pdfPreview.opening', 'Opening PDF...')}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<View style={styles.state}>
|
||||
<MobileStateButton label="Open PDF" onPress={open} />
|
||||
<MobileStateButton label={t('files.pdfPreview.open', 'Open PDF')} onPress={open} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Image as ImageIcon
|
||||
} from 'lucide-react-native'
|
||||
import { triggerSelection } from '../platform/haptics'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import { type FileExplorerRow, isMarkdownPath, type TreeNode } from './file-tree'
|
||||
@@ -25,6 +26,7 @@ type Props = {
|
||||
}
|
||||
|
||||
export function MobileFileExplorerRow(props: Props) {
|
||||
const t = useMobileTranslation()
|
||||
useMobileColorScheme()
|
||||
const { item, maximumIndentation, expanded, onPreviewFile, onRetryDirectory, onToggleDirectory } =
|
||||
props
|
||||
@@ -35,7 +37,9 @@ export function MobileFileExplorerRow(props: Props) {
|
||||
<View style={[styles.inlineStatusRow, { paddingLeft }]}>
|
||||
<View style={styles.chevronSpacer} />
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
<Text style={styles.inlineStatusText}>Loading...</Text>
|
||||
<Text style={styles.inlineStatusText}>
|
||||
{t('files.fileExplorerRow.loading', 'Loading...')}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -45,7 +49,7 @@ export function MobileFileExplorerRow(props: Props) {
|
||||
<View style={[styles.inlineStatusRow, { paddingLeft }]}>
|
||||
<View style={styles.chevronSpacer} />
|
||||
<Text style={styles.inlineErrorText} numberOfLines={1}>
|
||||
{item.message || 'Unable to load folder'}
|
||||
{item.message || t('files.fileExplorerRow.loadFolderFailed', 'Unable to load folder')}
|
||||
</Text>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.inlineRetryButton, pressed && styles.rowPressed]}
|
||||
@@ -53,9 +57,11 @@ export function MobileFileExplorerRow(props: Props) {
|
||||
triggerSelection()
|
||||
onRetryDirectory(item.relativePath)
|
||||
}}
|
||||
accessibilityLabel={`Retry loading ${item.relativePath}`}
|
||||
accessibilityLabel={t('files.fileExplorerRow.retryLoading', 'Retry loading {{path}}', {
|
||||
path: item.relativePath
|
||||
})}
|
||||
>
|
||||
<Text style={styles.inlineRetryText}>Retry</Text>
|
||||
<Text style={styles.inlineRetryText}>{t('common.retry', 'Retry')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
@@ -87,6 +93,7 @@ function TreeRow(props: {
|
||||
onPreviewFile: (relativePath: string, displayName: string) => void
|
||||
onToggleDirectory: (relativePath: string) => void
|
||||
}) {
|
||||
const t = useMobileTranslation()
|
||||
useMobileColorScheme()
|
||||
const { item, paddingLeft, expanded, onPreviewFile, onToggleDirectory } = props
|
||||
const isDirectory = item.kind === 'directory'
|
||||
@@ -117,10 +124,12 @@ function TreeRow(props: {
|
||||
}}
|
||||
accessibilityLabel={
|
||||
isDirectory
|
||||
? `Open folder ${item.name}`
|
||||
? t('files.fileExplorerRow.openFolder', 'Open folder {{name}}', { name: item.name })
|
||||
: disabled
|
||||
? `${item.name} unavailable on mobile`
|
||||
: `Preview file ${item.name}`
|
||||
? t('files.fileExplorerRow.fileUnavailable', '{{name}} unavailable on mobile', {
|
||||
name: item.name
|
||||
})
|
||||
: t('files.fileExplorerRow.previewFile', 'Preview file {{name}}', { name: item.name })
|
||||
}
|
||||
>
|
||||
{isDirectory ? (
|
||||
@@ -145,7 +154,11 @@ function TreeRow(props: {
|
||||
<Text style={[styles.rowTitle, disabled && styles.rowTitleDisabled]} numberOfLines={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
{disabled ? <Text style={styles.rowMeta}>Unavailable on mobile</Text> : null}
|
||||
{disabled ? (
|
||||
<Text style={styles.rowMeta}>
|
||||
{t('files.fileExplorerRow.unavailable', 'Unavailable on mobile')}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { parseExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import { assertFileMutationOwnershipCapability } from '../../../src/shared/file-mutation-ownership'
|
||||
import type { SshConnectionState, SshMutationExpectation } from '../../../src/shared/ssh-types'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
import {
|
||||
fileOwnershipRuntimeStatusRead,
|
||||
fileOwnershipSshStateRead,
|
||||
@@ -9,8 +10,13 @@ import {
|
||||
} from './mobile-file-ownership-operations'
|
||||
|
||||
const FILE_MUTATION_TIMEOUT_MS = 15_000
|
||||
const SSH_OWNER_CHANGED_MESSAGE =
|
||||
"Couldn't verify the SSH connection. Reconnect the host and try again."
|
||||
|
||||
function sshOwnerChangedMessage(): string {
|
||||
return translate(
|
||||
'files.fileMutationOwnership.sshOwnerChanged',
|
||||
"Couldn't verify the SSH connection. Reconnect the host and try again."
|
||||
)
|
||||
}
|
||||
|
||||
export type MobileFileMutationOwnership = SshMutationExpectation & {
|
||||
expectedExecutionHostId: 'local' | `ssh:${string}`
|
||||
@@ -28,13 +34,13 @@ export function buildMobileFileMutationOwnership(
|
||||
): MobileFileMutationOwnership {
|
||||
const host = parseExecutionHostId(worktreeHostId)
|
||||
if (worktreeHostId !== undefined && !host) {
|
||||
throw new Error(SSH_OWNER_CHANGED_MESSAGE)
|
||||
throw new Error(sshOwnerChangedMessage())
|
||||
}
|
||||
if (!host || host.kind === 'local' || host.kind === 'runtime') {
|
||||
return { expectedExecutionHostId: 'local' }
|
||||
}
|
||||
if (sshState?.targetId !== host.targetId || sshState.connectionGeneration === undefined) {
|
||||
throw new Error(SSH_OWNER_CHANGED_MESSAGE)
|
||||
throw new Error(sshOwnerChangedMessage())
|
||||
}
|
||||
return {
|
||||
expectedExecutionHostId: host.id,
|
||||
@@ -60,7 +66,7 @@ export async function captureMobileFileMutationOwnership(
|
||||
)
|
||||
const summary = fileOwnershipWorktreeRead.interpret(worktreeReply)
|
||||
if (!summary) {
|
||||
throw new Error(SSH_OWNER_CHANGED_MESSAGE)
|
||||
throw new Error(sshOwnerChangedMessage())
|
||||
}
|
||||
|
||||
const host = parseExecutionHostId(summary.hostId)
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
normalizeMobileFilePreviewResult,
|
||||
previewError,
|
||||
previewErrorFromRefusal,
|
||||
terminalArtifactPreviewChangedError,
|
||||
type MobileFilePreviewResult
|
||||
} from './mobile-file-preview-response'
|
||||
import {
|
||||
@@ -270,14 +271,7 @@ async function verifyTerminalArtifactBaseContent(
|
||||
return { status: 'error', error: latest }
|
||||
}
|
||||
if (!terminalArtifactPreviewMatchesBase(latest, baseContent)) {
|
||||
return {
|
||||
status: 'error',
|
||||
error: {
|
||||
status: 'error',
|
||||
message: 'File changed on desktop. Reload preview before saving',
|
||||
reconnect: false
|
||||
}
|
||||
}
|
||||
return { status: 'error', error: terminalArtifactPreviewChangedError() }
|
||||
}
|
||||
return { status: 'ok', source: readSource, refreshed }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { MobileFileMedia } from './mobile-file-media'
|
||||
import { classifyMobileArtifact } from '../session/mobile-artifact-kind'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
import { getUiIntlLocale } from '../i18n/ui-intl-locale'
|
||||
import type { RpcFailure } from '../transport/types'
|
||||
import { isMarkdownPath } from './file-tree'
|
||||
import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error'
|
||||
@@ -54,16 +56,48 @@ export function previewErrorFromRefusal(error: RpcFailure['error']): MobileFileP
|
||||
return previewError(error.message || error.code)
|
||||
}
|
||||
|
||||
export function terminalArtifactPreviewChangedError(): MobileFilePreviewResult {
|
||||
return {
|
||||
status: 'error',
|
||||
message: translate(
|
||||
'files.filePreviewResponse.fileChanged',
|
||||
'File changed on desktop. Reload preview before saving'
|
||||
),
|
||||
reconnect: false
|
||||
}
|
||||
}
|
||||
|
||||
export function previewError(message: string): MobileFilePreviewResult {
|
||||
const normalized = message.toLowerCase()
|
||||
if (normalized === 'binary_file' || normalized.includes('binary_file')) {
|
||||
return { status: 'error', message: 'Binary preview unavailable', reconnect: false }
|
||||
return {
|
||||
status: 'error',
|
||||
message: translate(
|
||||
'files.filePreviewResponse.binaryUnavailable',
|
||||
'Binary preview unavailable'
|
||||
),
|
||||
reconnect: false
|
||||
}
|
||||
}
|
||||
if (normalized === 'file_too_large' || normalized.includes('file_too_large')) {
|
||||
return { status: 'error', message: 'File too large for mobile preview', reconnect: false }
|
||||
return {
|
||||
status: 'error',
|
||||
message: translate(
|
||||
'files.filePreviewResponse.fileTooLarge',
|
||||
'File too large for mobile preview'
|
||||
),
|
||||
reconnect: false
|
||||
}
|
||||
}
|
||||
if (isTerminalArtifactGrantError(normalized)) {
|
||||
return { status: 'error', message: 'Reload preview before saving', reconnect: false }
|
||||
return {
|
||||
status: 'error',
|
||||
message: translate(
|
||||
'files.filePreviewResponse.reloadBeforeSaving',
|
||||
'Reload preview before saving'
|
||||
),
|
||||
reconnect: false
|
||||
}
|
||||
}
|
||||
if (
|
||||
normalized.includes('remote connection dropped') ||
|
||||
@@ -71,7 +105,14 @@ export function previewError(message: string): MobileFilePreviewResult {
|
||||
normalized.includes('disconnected') ||
|
||||
normalized.includes('reconnect the ssh target')
|
||||
) {
|
||||
return { status: 'error', message: 'Unable to reach the desktop filesystem', reconnect: true }
|
||||
return {
|
||||
status: 'error',
|
||||
message: translate(
|
||||
'files.filePreviewResponse.filesystemUnreachable',
|
||||
'Unable to reach the desktop filesystem'
|
||||
),
|
||||
reconnect: true
|
||||
}
|
||||
}
|
||||
if (
|
||||
normalized.includes('enoent') ||
|
||||
@@ -79,22 +120,40 @@ export function previewError(message: string): MobileFilePreviewResult {
|
||||
normalized.includes('not found') ||
|
||||
normalized.includes('does not exist')
|
||||
) {
|
||||
return { status: 'error', message: 'File not found', reconnect: false }
|
||||
return {
|
||||
status: 'error',
|
||||
message: translate('files.filePreviewResponse.fileNotFound', 'File not found'),
|
||||
reconnect: false
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'error',
|
||||
message: translate('files.filePreviewResponse.loadFailed', 'Unable to load preview'),
|
||||
reconnect: false
|
||||
}
|
||||
return { status: 'error', message: 'Unable to load preview', reconnect: false }
|
||||
}
|
||||
|
||||
export function formatPreviewByteLength(byteLength: number): string {
|
||||
if (!Number.isFinite(byteLength) || byteLength < 0) {
|
||||
return 'unknown size'
|
||||
return translate('files.filePreviewResponse.unknownSize', 'unknown size')
|
||||
}
|
||||
if (byteLength < 1024) {
|
||||
return `${byteLength} B`
|
||||
return translate('files.filePreviewResponse.bytes', '{{size}} B', {
|
||||
size: byteLength.toLocaleString(getUiIntlLocale(), { useGrouping: false })
|
||||
})
|
||||
}
|
||||
if (byteLength < 1024 * 1024) {
|
||||
return `${Math.round(byteLength / 1024)} KB`
|
||||
return translate('files.filePreviewResponse.kilobytes', '{{size}} KB', {
|
||||
size: Math.round(byteLength / 1024).toLocaleString(getUiIntlLocale(), { useGrouping: false })
|
||||
})
|
||||
}
|
||||
return `${(byteLength / (1024 * 1024)).toFixed(1)} MB`
|
||||
return translate('files.filePreviewResponse.megabytes', '{{size}} MB', {
|
||||
size: (byteLength / (1024 * 1024)).toLocaleString(getUiIntlLocale(), {
|
||||
useGrouping: false,
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeImagePreviewResult(result: unknown): MobileFilePreviewResult {
|
||||
|
||||
@@ -27,6 +27,7 @@ export function getHostListActionSheetActions(args: {
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'connect',
|
||||
label:
|
||||
args.hasEverConnected && isLive
|
||||
? translate('hostScreen.hostListActionSheetActions.reconnect', 'Reconnect')
|
||||
@@ -40,6 +41,7 @@ export function getHostListActionSheetActions(args: {
|
||||
...(isLive
|
||||
? [
|
||||
{
|
||||
id: 'disconnect',
|
||||
label: translate('hostScreen.hostListActionSheetActions.disconnect', 'Disconnect'),
|
||||
icon: PowerOff,
|
||||
onPress: () => {
|
||||
@@ -50,6 +52,7 @@ export function getHostListActionSheetActions(args: {
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'networkDiagnostics',
|
||||
label: translate(
|
||||
'hostScreen.hostListActionSheetActions.networkDiagnostics',
|
||||
'Network diagnostics'
|
||||
@@ -61,6 +64,7 @@ export function getHostListActionSheetActions(args: {
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'editHost',
|
||||
label: translate('hostScreen.hostListActionSheetActions.editHost', 'Edit host'),
|
||||
icon: Edit3,
|
||||
closeBeforePress: true,
|
||||
@@ -70,6 +74,7 @@ export function getHostListActionSheetActions(args: {
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'remove',
|
||||
label: translate('common.remove', 'Remove'),
|
||||
destructive: true,
|
||||
closeBeforePress: true,
|
||||
|
||||
@@ -117,6 +117,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: 'sleep',
|
||||
label: t('hostScreen.hostScreenOverlays.sleep', 'Sleep'),
|
||||
icon: Moon,
|
||||
onPress: () => {
|
||||
@@ -126,6 +127,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
}
|
||||
]),
|
||||
{
|
||||
id: 'togglePin',
|
||||
label: isWorktreePinned(actionTarget, state.pinnedIds)
|
||||
? t('hostScreen.hostScreenOverlays.unpin', 'Unpin')
|
||||
: t('hostScreen.hostScreenOverlays.pin', 'Pin'),
|
||||
@@ -136,6 +138,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
label: t('common.delete', 'Delete'),
|
||||
destructive: true,
|
||||
onPress: () => state.setConfirmDelete(actionTarget),
|
||||
|
||||
@@ -17,6 +17,58 @@
|
||||
"restoreAfterSeconds_other": "After {{count}}s"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"toolActivity": {
|
||||
"count_one": "{{count}} tool call",
|
||||
"count_other": "{{count}} tool calls"
|
||||
},
|
||||
"toolRun": {
|
||||
"liveBrowserTimes_other": "Using the browser {{count}} times",
|
||||
"liveFileChange_one": "Editing {{count}} file",
|
||||
"liveFileChange_other": "Editing {{count}} files",
|
||||
"liveListFiles_one": "Listing {{count}} directory",
|
||||
"liveListFiles_other": "Listing {{count}} directories",
|
||||
"liveMcpToolCall_one": "Using {{count}} integration",
|
||||
"liveMcpToolCall_other": "Using {{count}} integrations",
|
||||
"liveOther_one": "Using {{count}} tool",
|
||||
"liveOther_other": "Using {{count}} tools",
|
||||
"liveRead_one": "Reading {{count}} file",
|
||||
"liveRead_other": "Reading {{count}} files",
|
||||
"liveSearch_one": "Searching {{count}} time",
|
||||
"liveSearch_other": "Searching {{count}} times",
|
||||
"liveSubAgentActivity_one": "Running {{count}} agent",
|
||||
"liveSubAgentActivity_other": "Running {{count}} agents",
|
||||
"liveTodoListTimes_other": "Updating the plan {{count}} times",
|
||||
"liveUnknown_one": "Running {{count}} command",
|
||||
"liveUnknown_other": "Running {{count}} commands",
|
||||
"liveWebSearch_one": "Searching the web {{count}} time",
|
||||
"liveWebSearch_other": "Searching the web {{count}} times",
|
||||
"settledBrowserTimes_other": "Used the browser {{count}} times",
|
||||
"settledFileChange_one": "Edited {{count}} file",
|
||||
"settledFileChange_other": "Edited {{count}} files",
|
||||
"settledListFiles_one": "Listed {{count}} directory",
|
||||
"settledListFiles_other": "Listed {{count}} directories",
|
||||
"settledMcpToolCall_one": "Used {{count}} integration",
|
||||
"settledMcpToolCall_other": "Used {{count}} integrations",
|
||||
"settledOther_one": "Used {{count}} tool",
|
||||
"settledOther_other": "Used {{count}} tools",
|
||||
"settledRead_one": "Read {{count}} file",
|
||||
"settledRead_other": "Read {{count}} files",
|
||||
"settledSearch_one": "Searched {{count}} time",
|
||||
"settledSearch_other": "Searched {{count}} times",
|
||||
"settledSubAgentActivity_one": "Ran {{count}} agent",
|
||||
"settledSubAgentActivity_other": "Ran {{count}} agents",
|
||||
"settledTodoListTimes_other": "Updated the plan {{count}} times",
|
||||
"settledUnknown_one": "Ran {{count}} command",
|
||||
"settledUnknown_other": "Ran {{count}} commands",
|
||||
"settledWebSearch_one": "Searched the web {{count}} time",
|
||||
"settledWebSearch_other": "Searched the web {{count}} times"
|
||||
},
|
||||
"turnStatus": {
|
||||
"seconds_one": "{{count}}s",
|
||||
"seconds_other": "{{count}}s"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"codexResetCredit": {
|
||||
"available_one": "{{count}} reset available",
|
||||
@@ -75,12 +127,6 @@
|
||||
"yearsAgo_one": "{{count}}y ago",
|
||||
"yearsAgo_other": "{{count}}y ago"
|
||||
},
|
||||
"commentsSection": {
|
||||
"resolvedComment_one": "Resolved comment by {{author}}",
|
||||
"resolvedComment_other": "Resolved comment by {{author}} ({{count}})",
|
||||
"resolvedThread_one": "Resolved thread by {{author}}",
|
||||
"resolvedThread_other": "Resolved thread by {{author}} ({{count}})"
|
||||
},
|
||||
"conflictingFilesSection": {
|
||||
"commitsBehind_one": "{{count}} commit behind (base commit: {{baseCommit}})",
|
||||
"commitsBehind_other": "{{count}} commits behind (base commit: {{baseCommit}})"
|
||||
@@ -102,6 +148,42 @@
|
||||
"pending_other": "{{count}} pending"
|
||||
}
|
||||
},
|
||||
"session": {
|
||||
"agentChildRow": {
|
||||
"days_one": "{{count}}d",
|
||||
"days_other": "{{count}}d",
|
||||
"hours_one": "{{count}}h",
|
||||
"hours_other": "{{count}}h",
|
||||
"minutes_one": "{{count}}m",
|
||||
"minutes_other": "{{count}}m"
|
||||
},
|
||||
"backgroundTask": {
|
||||
"countAgents_one": "{{count}} agent",
|
||||
"countAgents_other": "{{count}} agents",
|
||||
"countMonitors_one": "{{count}} monitor",
|
||||
"countMonitors_other": "{{count}} monitors",
|
||||
"countShell_one": "{{count}} shell",
|
||||
"countShell_other": "{{count}} shells",
|
||||
"countTasks_one": "{{count}} task",
|
||||
"countTasks_other": "{{count}} tasks",
|
||||
"countWorkflows_one": "{{count}} workflow",
|
||||
"countWorkflows_other": "{{count}} workflows",
|
||||
"headerTotalCount_one": "{{count}} background task",
|
||||
"headerTotalCount_other": "{{count}} background tasks"
|
||||
},
|
||||
"fileReader": {
|
||||
"reviewNotes_one": "{{count}} review note",
|
||||
"reviewNotes_other": "{{count}} review notes"
|
||||
},
|
||||
"queuedMessageCardRow": {
|
||||
"images_one": "{{count}} image",
|
||||
"images_other": "{{count}} images"
|
||||
},
|
||||
"useDiffReviewGitActions": {
|
||||
"reviewedFilesStaged_one": "{{count}} reviewed file staged",
|
||||
"reviewedFilesStaged_other": "{{count}} reviewed files staged"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"appUpdateSettingsRows": {
|
||||
"lastCheckedDays_one": "Last checked {{count}}d ago",
|
||||
@@ -116,6 +198,24 @@
|
||||
"cleanupUnconfirmed_other": "Couldn't confirm cleanup for {{count}} credentials on this device."
|
||||
}
|
||||
},
|
||||
"sourceControl": {
|
||||
"dropdownCounts": {
|
||||
"fastForwardCount_one": "Fast-forward {{count}} commit",
|
||||
"fastForwardCount_other": "Fast-forward {{count}} commits",
|
||||
"pullCount_one": "Pull {{count}} commit",
|
||||
"pullCount_other": "Pull {{count}} commits",
|
||||
"pushCount_one": "Push {{count}} commit",
|
||||
"pushCount_other": "Push {{count}} commits",
|
||||
"titleForcePushOlderCopiesCount_one": "Remote only has older copies of local commits. Force push {{count}} branch commit with lease to update {{upstreamName}}.",
|
||||
"titleForcePushOlderCopiesCount_other": "Remote only has older copies of local commits. Force push {{count}} branch commits with lease to update {{upstreamName}}.",
|
||||
"titleManualForcePushReplaceRemote_one": "Force push {{count}} local commit with lease to update {{upstreamName}} and replace remote-only commits.",
|
||||
"titleManualForcePushReplaceRemote_other": "Force push {{count}} local commits with lease to update {{upstreamName}} and replace remote-only commits.",
|
||||
"titleManualForcePush_one": "Force push {{count}} local commit with lease to update {{upstreamName}}.",
|
||||
"titleManualForcePush_other": "Force push {{count}} local commits with lease to update {{upstreamName}}.",
|
||||
"titleUnpublishedForcePushCount_one": "Force push {{count}} branch commit with lease and set an upstream if needed.",
|
||||
"titleUnpublishedForcePushCount_other": "Force push {{count}} branch commits with lease and set an upstream if needed."
|
||||
}
|
||||
},
|
||||
"worktree": {
|
||||
"agentRowDisplay": {
|
||||
"days_one": "{{count}}d",
|
||||
|
||||
+1282
-16
File diff suppressed because it is too large
Load Diff
+1267
-6
File diff suppressed because it is too large
Load Diff
+1325
-18
File diff suppressed because it is too large
Load Diff
+1325
-18
File diff suppressed because it is too large
Load Diff
+1278
-14
File diff suppressed because it is too large
Load Diff
+1278
-14
File diff suppressed because it is too large
Load Diff
+1325
-18
File diff suppressed because it is too large
Load Diff
+1368
-20
File diff suppressed because it is too large
Load Diff
+1282
-16
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1278
-14
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react-native'
|
||||
import type { MobileSessionTab } from './mobile-session-route-types'
|
||||
import { ActionSheetModal, type ActionSheetAction } from '../components/ActionSheetModal'
|
||||
import { getMobileSessionTabTitle } from './mobile-terminal-tab-agent'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
|
||||
type BrowserTab = Extract<MobileSessionTab, { type: 'browser' }>
|
||||
export type MobileBrowserNavigationMethod = 'browser.back' | 'browser.forward' | 'browser.reload'
|
||||
@@ -15,16 +16,22 @@ export function MobileBrowserTabActionSheet(props: {
|
||||
* bulk-close builder can resolve the anchor itself. */
|
||||
bulkCloseActions?: (anchorTabId: string | undefined) => ActionSheetAction[]
|
||||
}): React.JSX.Element {
|
||||
const t = useMobileTranslation()
|
||||
const { target, onClose, onNavigate, onCloseTab, bulkCloseActions } = props
|
||||
return (
|
||||
<ActionSheetModal
|
||||
visible={target != null}
|
||||
title={target ? getMobileSessionTabTitle(target) : 'Browser'}
|
||||
title={
|
||||
target
|
||||
? getMobileSessionTabTitle(target)
|
||||
: t('session.browserTabActionSheet.title', 'Browser')
|
||||
}
|
||||
actions={[
|
||||
...(target?.canGoBack
|
||||
? [
|
||||
{
|
||||
label: 'Back',
|
||||
id: 'back',
|
||||
label: t('common.back', 'Back'),
|
||||
icon: ChevronLeft,
|
||||
onPress: () => {
|
||||
const current = target
|
||||
@@ -39,7 +46,8 @@ export function MobileBrowserTabActionSheet(props: {
|
||||
...(target?.canGoForward
|
||||
? [
|
||||
{
|
||||
label: 'Forward',
|
||||
id: 'forward',
|
||||
label: t('session.browserTabActionSheet.forward', 'Forward'),
|
||||
icon: ChevronRight,
|
||||
onPress: () => {
|
||||
const current = target
|
||||
@@ -52,7 +60,8 @@ export function MobileBrowserTabActionSheet(props: {
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Reload',
|
||||
id: 'reload',
|
||||
label: t('session.browserTabActionSheet.reload', 'Reload'),
|
||||
icon: RefreshCw,
|
||||
onPress: () => {
|
||||
const current = target
|
||||
@@ -63,7 +72,8 @@ export function MobileBrowserTabActionSheet(props: {
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Close',
|
||||
id: 'close',
|
||||
label: t('common.close', 'Close'),
|
||||
destructive: true,
|
||||
onPress: () => {
|
||||
const current = target
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { StyleSheet, Text, View } from 'react-native'
|
||||
import type { UseMobileDictationResult } from '../hooks/mobile-dictation-session-state'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
|
||||
export type MobileDictationStatusFlags = Pick<
|
||||
UseMobileDictationResult,
|
||||
@@ -8,11 +10,11 @@ export type MobileDictationStatusFlags = Pick<
|
||||
|
||||
export function mobileDictationStatusTitle(dictation: MobileDictationStatusFlags): string | null {
|
||||
return dictation.isRecording
|
||||
? 'Listening'
|
||||
? translate('session.terminalLiveInputStatus.listening', 'Listening')
|
||||
: dictation.isProcessing
|
||||
? 'Processing'
|
||||
? translate('session.terminalLiveInputStatus.processing', 'Processing')
|
||||
: dictation.isStarting
|
||||
? 'Starting mic'
|
||||
? translate('session.terminalLiveInputStatus.startingMic', 'Starting mic')
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -22,6 +24,7 @@ export function MobileDictationStatusAnnouncement({
|
||||
}: {
|
||||
dictation: MobileDictationStatusFlags
|
||||
}): React.JSX.Element {
|
||||
useMobileTranslation()
|
||||
return (
|
||||
<View style={styles.hidden} pointerEvents="none" accessibilityLiveRegion="polite">
|
||||
<Text>{mobileDictationStatusTitle(dictation) ?? ''}</Text>
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,
|
||||
@@ -35,6 +36,7 @@ export function MobileDiffCommentLineRow({
|
||||
onDeleteComment: (commentId: string) => void
|
||||
}) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const commentLine = line.newLineNumber
|
||||
const isCommenting = commentLine !== undefined && activeCommentLine === commentLine
|
||||
const canComment = commentLine !== undefined
|
||||
@@ -53,7 +55,14 @@ export function MobileDiffCommentLineRow({
|
||||
<Text
|
||||
selectable
|
||||
style={styles.diffText}
|
||||
accessibilityLabel={`${title} diff line ${index + 1}`}
|
||||
accessibilityLabel={t(
|
||||
'session.diffCommentLineRow.diffLine',
|
||||
'{{title}} diff line {{line}}',
|
||||
{
|
||||
title,
|
||||
line: index + 1
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
@@ -79,7 +88,13 @@ export function MobileDiffCommentLineRow({
|
||||
onStartComment(commentLine)
|
||||
}
|
||||
}}
|
||||
accessibilityLabel={`Add note on line ${commentLine}`}
|
||||
accessibilityLabel={t(
|
||||
'session.diffCommentLineRow.addNote',
|
||||
'Add note on line {{line}}',
|
||||
{
|
||||
line: commentLine
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Plus size={12} color={colors.textSecondary} strokeWidth={2.3} />
|
||||
</Pressable>
|
||||
@@ -91,12 +106,20 @@ export function MobileDiffCommentLineRow({
|
||||
<View key={comment.id} style={styles.diffCommentCard}>
|
||||
<View style={styles.diffCommentHeader}>
|
||||
<MessageSquare size={12} color={colors.textMuted} strokeWidth={2.2} />
|
||||
<Text style={styles.diffCommentMeta}>Line {comment.lineNumber}</Text>
|
||||
<Text style={styles.diffCommentMeta}>
|
||||
{t('session.diffCommentLineRow.line', 'Line {{line}}', {
|
||||
line: comment.lineNumber
|
||||
})}
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.diffCommentDeleteButton}
|
||||
disabled={commentsBusy}
|
||||
onPress={() => onDeleteComment(comment.id)}
|
||||
accessibilityLabel={`Delete note on line ${comment.lineNumber}`}
|
||||
accessibilityLabel={t(
|
||||
'session.diffCommentLineRow.deleteNote',
|
||||
'Delete note on line {{line}}',
|
||||
{ line: comment.lineNumber }
|
||||
)}
|
||||
>
|
||||
<X size={12} color={colors.textMuted} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
@@ -112,7 +135,7 @@ export function MobileDiffCommentLineRow({
|
||||
style={[styles.textInput, styles.diffCommentInput]}
|
||||
value={commentDraft}
|
||||
onChangeText={onDraftChange}
|
||||
placeholder="Add review note"
|
||||
placeholder={t('session.diffCommentLineRow.placeholder', 'Add review note')}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
editable={!commentsBusy}
|
||||
multiline
|
||||
@@ -125,7 +148,7 @@ export function MobileDiffCommentLineRow({
|
||||
disabled={commentsBusy}
|
||||
onPress={onCancelComment}
|
||||
>
|
||||
<Text style={styles.diffCommentSecondaryText}>Cancel</Text>
|
||||
<Text style={styles.diffCommentSecondaryText}>{t('common.cancel', 'Cancel')}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[
|
||||
@@ -139,7 +162,9 @@ export function MobileDiffCommentLineRow({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={styles.diffCommentPrimaryText}>Save note</Text>
|
||||
<Text style={styles.diffCommentPrimaryText}>
|
||||
{t('session.diffCommentLineRow.saveNote', 'Save note')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import type { MarkdownDocState } from './mobile-session-route-types'
|
||||
import { styles } from './mobile-session-styles'
|
||||
import { MobileLoadErrorState } from '../components/MobileLoadErrorState'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
|
||||
type Props = {
|
||||
documentId: string
|
||||
@@ -37,6 +38,7 @@ export function MobileMarkdownReader({
|
||||
keyboardLift
|
||||
}: Props) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const editorRef = useRef<MobileRichMarkdownEditorHandle>(null)
|
||||
// Native Keyboard events under-report the WebView editor's covered area, so prefer the larger WebView-measured inset.
|
||||
const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0)
|
||||
@@ -57,9 +59,9 @@ export function MobileMarkdownReader({
|
||||
const statusText = doc.saveError
|
||||
? doc.saveError
|
||||
: doc.readOnlyReason
|
||||
? 'Read only'
|
||||
? t('session.markdownReader.readOnly', 'Read only')
|
||||
: doc.stale
|
||||
? 'Changed on desktop'
|
||||
? t('session.markdownReader.changedOnDesktop', 'Changed on desktop')
|
||||
: null
|
||||
const showRefresh = Boolean((doc.stale && !doc.isDirty) || !doc.editable)
|
||||
const showCopy = Boolean(doc.saveError || !doc.editable)
|
||||
@@ -112,8 +114,11 @@ export function MobileMarkdownReader({
|
||||
onPress={() => editorRef.current?.dismissKeyboard()}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss keyboard"
|
||||
accessibilityHint="Hides the software keyboard and keeps the markdown editor open."
|
||||
accessibilityLabel={t('session.markdownReader.dismissKeyboard', 'Dismiss keyboard')}
|
||||
accessibilityHint={t(
|
||||
'session.markdownReader.dismissKeyboardHint',
|
||||
'Hides the software keyboard and keeps the markdown editor open.'
|
||||
)}
|
||||
>
|
||||
<View style={styles.keyboardDismissGlyph}>
|
||||
<KeyboardIcon size={15} color={colors.textSecondary} strokeWidth={2} />
|
||||
@@ -128,18 +133,22 @@ export function MobileMarkdownReader({
|
||||
) : null}
|
||||
{showCopy ? (
|
||||
<Pressable style={styles.markdownFloatingButton} onPress={onCopy}>
|
||||
<Text style={styles.markdownFloatingButtonText}>Copy</Text>
|
||||
<Text style={styles.markdownFloatingButtonText}>{t('common.copy', 'Copy')}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{showRefresh ? (
|
||||
<Pressable style={styles.markdownFloatingButton} onPress={onRefresh}>
|
||||
<RefreshCw size={13} color={colors.textPrimary} />
|
||||
<Text style={styles.markdownFloatingButtonText}>Refresh</Text>
|
||||
<Text style={styles.markdownFloatingButtonText}>
|
||||
{t('common.refresh', 'Refresh')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{doc.isDirty ? (
|
||||
<Pressable style={styles.markdownFloatingButton} onPress={onDiscard}>
|
||||
<Text style={styles.markdownFloatingButtonText}>Discard</Text>
|
||||
<Text style={styles.markdownFloatingButtonText}>
|
||||
{t('session.markdownReader.discard', 'Discard')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{showSave ? (
|
||||
@@ -155,7 +164,7 @@ export function MobileMarkdownReader({
|
||||
{doc.saving ? (
|
||||
<ActivityIndicator size="small" color={colors.textPrimary} />
|
||||
) : (
|
||||
<Text style={styles.markdownFloatingButtonText}>Save</Text>
|
||||
<Text style={styles.markdownFloatingButtonText}>{t('common.save', 'Save')}</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Keyboard, Pressable, StyleSheet } from 'react-native'
|
||||
import { ChevronDown, 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'
|
||||
|
||||
export function MobileNativeChatCardHeaderAction<Prompt>({
|
||||
prompt,
|
||||
@@ -15,13 +16,18 @@ export function MobileNativeChatCardHeaderAction<Prompt>({
|
||||
disabled?: boolean
|
||||
}): React.JSX.Element | null {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
if (!onCancel && !onCollapse) {
|
||||
return null
|
||||
}
|
||||
const Icon = onCancel ? X : ChevronDown
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityLabel={onCancel ? 'Cancel' : 'Collapse'}
|
||||
accessibilityLabel={
|
||||
onCancel
|
||||
? t('common.cancel', 'Cancel')
|
||||
: t('session.nativeChatCardHeaderAction.collapse', 'Collapse')
|
||||
}
|
||||
accessibilityState={onCancel ? undefined : { expanded: true }}
|
||||
hitSlop={8}
|
||||
style={styles.action}
|
||||
|
||||
@@ -2,10 +2,7 @@ import { useMemo } from 'react'
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import { MobileNativeChatOwnedScrollView } from './MobileNativeChatOwnedScrollView'
|
||||
import { ChevronRight, FileMinus2, FilePen, FilePlus2 } from 'lucide-react-native'
|
||||
import {
|
||||
NATIVE_CHAT_DIFF_CARD_COPY,
|
||||
nativeChatEditFilePatchText
|
||||
} from '../../../src/shared/native-chat/tools/native-chat-edit-cards'
|
||||
import { nativeChatEditFilePatchText } from '../../../src/shared/native-chat/tools/native-chat-edit-cards'
|
||||
import {
|
||||
nativeChatEditFileBaseName,
|
||||
nativeChatEditGutterChars,
|
||||
@@ -13,7 +10,9 @@ import {
|
||||
type NativeChatEditFile,
|
||||
type NativeChatEditLine
|
||||
} from '../../../src/shared/native-chat/tools/native-chat-edit-model'
|
||||
import { NATIVE_CHAT_TOOL_ROW_VERB_COPY } from '../../../src/shared/native-chat/tools/native-chat-tool-line-label'
|
||||
import { nativeChatToolRowVerbCopy } from './native-chat/transcript/native-chat-tool-row-verb-copy'
|
||||
import { nativeChatDiffCardCopy } from './native-chat-diff-card-copy'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import { MobileSourceCopyButton } from '../components/MobileSourceCopyButton'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
@@ -40,9 +39,10 @@ function DiffRow({
|
||||
type: ReturnType<typeof toolType>['code']
|
||||
}): React.JSX.Element {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
if (line.kind === 'gap') {
|
||||
return (
|
||||
<Text style={[styles.diffGap, type]} accessibilityLabel={NATIVE_CHAT_DIFF_CARD_COPY.diffGap}>
|
||||
<Text style={[styles.diffGap, type]} accessibilityLabel={nativeChatDiffCardCopy(t).diffGap}>
|
||||
⋯
|
||||
</Text>
|
||||
)
|
||||
@@ -142,6 +142,9 @@ export function MobileNativeChatDiffCard({
|
||||
onOpenFile?: (path: string) => void
|
||||
}): React.JSX.Element {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const copy = nativeChatDiffCardCopy(t)
|
||||
const verbs = nativeChatToolRowVerbCopy(t)
|
||||
const [expanded, setExpanded] = useMobileNativeChatDisclosure(disclosureKey, false)
|
||||
const hasBody = file.lines.length > 0
|
||||
const copyText = useMemo(() => nativeChatEditFilePatchText(file.lines), [file.lines])
|
||||
@@ -161,9 +164,7 @@ export function MobileNativeChatDiffCard({
|
||||
<View style={[styles.iconSlot, { width: iconSize }]}>
|
||||
<VerbIcon kind={file.changeKind} size={iconSize} />
|
||||
</View>
|
||||
<Text style={[styles.lineWord, type.row]}>
|
||||
{NATIVE_CHAT_TOOL_ROW_VERB_COPY[file.changeKind]}
|
||||
</Text>
|
||||
<Text style={[styles.lineWord, type.row]}>{verbs[file.changeKind]}</Text>
|
||||
{file.oldPath ? (
|
||||
<>
|
||||
<Text
|
||||
@@ -193,9 +194,7 @@ export function MobileNativeChatDiffCard({
|
||||
) : null}
|
||||
</Text>
|
||||
{file.truncated ? (
|
||||
<Text style={[styles.lineMeta, type.meta]}>
|
||||
{NATIVE_CHAT_DIFF_CARD_COPY.diffTruncated}
|
||||
</Text>
|
||||
<Text style={[styles.lineMeta, type.meta]}>{copy.diffTruncated}</Text>
|
||||
) : null}
|
||||
{hasBody ? (
|
||||
<View style={expanded ? styles.caretOpen : undefined}>
|
||||
@@ -203,10 +202,7 @@ export function MobileNativeChatDiffCard({
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
<MobileSourceCopyButton
|
||||
text={copyText}
|
||||
accessibilityLabel={NATIVE_CHAT_DIFF_CARD_COPY.copyDiff}
|
||||
/>
|
||||
<MobileSourceCopyButton text={copyText} accessibilityLabel={copy.copyDiff} />
|
||||
</View>
|
||||
{hasBody && expanded ? (
|
||||
<DiffCardRows
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ArrowDown } from 'lucide-react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import { styles } from './mobile-native-chat-view-styles'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
|
||||
export function MobileNativeChatFloatingControls({
|
||||
showJumpToTail,
|
||||
@@ -12,10 +13,18 @@ export function MobileNativeChatFloatingControls({
|
||||
onJumpToTail: () => void
|
||||
}): React.JSX.Element {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
return (
|
||||
<View pointerEvents="box-none" style={styles.floatingControls}>
|
||||
{showJumpToTail ? (
|
||||
<Pressable accessibilityLabel="Scroll to latest" style={styles.fab} onPress={onJumpToTail}>
|
||||
<Pressable
|
||||
accessibilityLabel={t(
|
||||
'session.nativeChatFloatingControls.scrollToLatest',
|
||||
'Scroll to latest'
|
||||
)}
|
||||
style={styles.fab}
|
||||
onPress={onJumpToTail}
|
||||
>
|
||||
<ArrowDown size={18} color={colors.textPrimary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Pressable, Text, View, type TextStyle } from 'react-native'
|
||||
import { truncateToolDetail } from '../../../src/shared/native-chat/tools/native-chat-tool-summary'
|
||||
import {
|
||||
isNativeChatToolOutputClipped,
|
||||
NATIVE_CHAT_TOOL_OUTPUT_COPY,
|
||||
nativeChatToolFullPayload,
|
||||
nativeChatToolOutputPartialSizes
|
||||
} from '../../../src/shared/native-chat/tools/native-chat-tool-output'
|
||||
@@ -16,6 +15,7 @@ import { INLINE_TEXT_SELECTION } from '../components/inline-text-selection'
|
||||
import { styles } from './native-chat/transcript/mobile-native-chat-message-styles'
|
||||
import { toolStyles } from './native-chat/transcript/mobile-native-chat-tool-styles'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import { MobileNativeChatOwnedScrollView } from './MobileNativeChatOwnedScrollView'
|
||||
import {
|
||||
MobileNativeChatItemSourceContext,
|
||||
@@ -28,10 +28,12 @@ type Load = 'idle' | 'loading' | 'failed'
|
||||
|
||||
function ShowFull({
|
||||
label,
|
||||
failedLabel,
|
||||
load,
|
||||
onPress
|
||||
}: {
|
||||
label: string
|
||||
failedLabel: string
|
||||
load: Load
|
||||
onPress: () => void
|
||||
}): React.JSX.Element {
|
||||
@@ -44,9 +46,7 @@ function ShowFull({
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Text style={styles.showFullText}>
|
||||
{load === 'failed' ? `${label} — couldn't load, tap to retry` : label}
|
||||
</Text>
|
||||
<Text style={styles.showFullText}>{load === 'failed' ? failedLabel : label}</Text>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
@@ -63,6 +63,7 @@ export function MobileNativeChatToolOutput({
|
||||
positionKey: string
|
||||
}): React.JSX.Element {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const source = useContext(MobileNativeChatItemSourceContext)
|
||||
const { output, isError, fullOutputItemId } = body
|
||||
const client = source?.client,
|
||||
@@ -130,13 +131,22 @@ export function MobileNativeChatToolOutput({
|
||||
</MobileNativeChatOwnedScrollView>
|
||||
{partial ? (
|
||||
<Text style={styles.showFullText}>
|
||||
{NATIVE_CHAT_TOOL_OUTPUT_COPY.partial
|
||||
.replace('{{shown}}', partial.shown)
|
||||
.replace('{{total}}', partial.total)}
|
||||
{t('session.nativeChatFullContent.partialOutput', 'Showing {{shown}} of {{total}}.', {
|
||||
shown: partial.shown,
|
||||
total: partial.total
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
{clipped ? (
|
||||
<ShowFull label="Show full output" load={current?.load ?? 'idle'} onPress={showFull} />
|
||||
<ShowFull
|
||||
label={t('session.nativeChatFullContent.showFullOutput', 'Show full output')}
|
||||
failedLabel={t(
|
||||
'session.nativeChatFullContent.loadOutputFailed',
|
||||
"Show full output — couldn't load, tap to retry"
|
||||
)}
|
||||
load={current?.load ?? 'idle'}
|
||||
onPress={showFull}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
@@ -148,6 +158,7 @@ export function useMobileNativeChatFullMessage(message: NativeChatMessage): {
|
||||
recovery: { load: Load; onPress: () => void } | null
|
||||
} {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const source = useContext(MobileNativeChatItemSourceContext)
|
||||
const detail = source?.messageDetail
|
||||
const client = source?.client,
|
||||
@@ -182,9 +193,19 @@ export function useMobileNativeChatFullMessage(message: NativeChatMessage): {
|
||||
recovery: state === 'available' ? { load, onPress: showFull } : null,
|
||||
showFull:
|
||||
state === 'partial' ? (
|
||||
<Text style={styles.showFullText}>Showing part of this message.</Text>
|
||||
<Text style={styles.showFullText}>
|
||||
{t('session.nativeChatFullContent.partialMessage', 'Showing part of this message.')}
|
||||
</Text>
|
||||
) : state === 'available' ? (
|
||||
<ShowFull label="Show full message" load={load} onPress={showFull} />
|
||||
<ShowFull
|
||||
label={t('session.nativeChatFullContent.showFullMessage', 'Show full message')}
|
||||
failedLabel={t(
|
||||
'session.nativeChatFullContent.loadMessageFailed',
|
||||
"Show full message — couldn't load, tap to retry"
|
||||
)}
|
||||
load={load}
|
||||
onPress={showFull}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { StyleSheet, Text, View } from 'react-native'
|
||||
import {
|
||||
NATIVE_CHAT_RECEIPT_COPY,
|
||||
nativeChatReceiptAnswerHeading,
|
||||
nativeChatReceiptAnswers,
|
||||
nativeChatReceiptSubject,
|
||||
type NativeChatResolvedPrompt
|
||||
} from '../../../src/shared/native-chat-resolution-receipt'
|
||||
import { nativeChatReceiptCopy } from './native-chat-receipt-copy'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import { INLINE_TEXT_SELECTION } from '../components/inline-text-selection'
|
||||
import { colors, spacing, typography } from '../theme/mobile-theme'
|
||||
import { MobileNativeChatAwaitingInputRow } from './native-chat/transcript/MobileNativeChatAwaitingInputRow'
|
||||
@@ -25,6 +26,7 @@ export function MobileNativeChatResolutionReceipt({
|
||||
fontScale: number
|
||||
}): React.JSX.Element | null {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const subject = nativeChatReceiptSubject(body)
|
||||
if (body.resolution.state === 'pending') {
|
||||
return body.kind === 'question' ? (
|
||||
@@ -38,16 +40,12 @@ export function MobileNativeChatResolutionReceipt({
|
||||
) : null
|
||||
}
|
||||
const { resolution } = body
|
||||
const copy = nativeChatReceiptCopy(t, resolution.resolvedBy ?? undefined)
|
||||
const cancelled = resolution.state === 'cancelled'
|
||||
const answers = nativeChatReceiptAnswers(body)
|
||||
const outcome = [
|
||||
cancelled ? NATIVE_CHAT_RECEIPT_COPY.cancelled : NATIVE_CHAT_RECEIPT_COPY.resolved,
|
||||
resolution.resolvedBy
|
||||
? (cancelled
|
||||
? NATIVE_CHAT_RECEIPT_COPY.cancelledBy
|
||||
: NATIVE_CHAT_RECEIPT_COPY.resolver
|
||||
).replace('{{device}}', resolution.resolvedBy)
|
||||
: null
|
||||
cancelled ? copy.cancelled : copy.resolved,
|
||||
resolution.resolvedBy ? (cancelled ? copy.cancelledBy : copy.resolver) : null
|
||||
]
|
||||
const text = [styles.text, scaledType(typography.metaSize, fontScale, 5)]
|
||||
return (
|
||||
@@ -77,9 +75,7 @@ export function MobileNativeChatResolutionReceipt({
|
||||
>
|
||||
{heading ? <Text style={text}>{heading}</Text> : null}
|
||||
<Text selectable={INLINE_TEXT_SELECTION} style={text} numberOfLines={3}>
|
||||
{answer.answer === ''
|
||||
? NATIVE_CHAT_RECEIPT_COPY.emptyAnswer
|
||||
: (answer.answer ?? NATIVE_CHAT_RECEIPT_COPY.unavailable)}
|
||||
{answer.answer === '' ? copy.emptyAnswer : (answer.answer ?? copy.unavailable)}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { colors, typography } from '../theme/mobile-theme'
|
||||
import { themedStyleSheet } from '../theme/themed-styles'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import { queuedMessageStyles } from './native-chat/input/mobile-native-chat-queued-message-styles'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
|
||||
// Expand clipped queued text before Steer/Delete; use the queue’s capped scroller rather than nesting another scroll box.
|
||||
export function MobileQueuedCardText({
|
||||
@@ -16,6 +17,7 @@ export function MobileQueuedCardText({
|
||||
onToggle: () => void
|
||||
}): React.JSX.Element {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const [clipped, setClipped] = useState(false)
|
||||
const measure = (
|
||||
<Text
|
||||
@@ -43,7 +45,11 @@ export function MobileQueuedCardText({
|
||||
testID="queued-card-text"
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded }}
|
||||
accessibilityHint={expanded ? 'Shows less of the message' : 'Shows the whole message'}
|
||||
accessibilityHint={
|
||||
expanded
|
||||
? t('session.queuedCardText.showLessHint', 'Shows less of the message')
|
||||
: t('session.queuedCardText.showMoreHint', 'Shows the whole message')
|
||||
}
|
||||
onPress={onToggle}
|
||||
>
|
||||
{expanded ? null : (
|
||||
@@ -51,7 +57,11 @@ export function MobileQueuedCardText({
|
||||
{text}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={styles.caption}>{expanded ? 'Show less' : 'Show more'}</Text>
|
||||
<Text style={styles.caption}>
|
||||
{expanded
|
||||
? t('session.queuedCardText.showLess', 'Show less')
|
||||
: t('session.queuedCardText.showMore', 'Show more')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
return (
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { MobileQueuedMessageInlineEditor } from './use-mobile-structured-qu
|
||||
import { MobileQueuedMessageComposer } from './MobileQueuedMessageComposer'
|
||||
import { MobileQueuedCardText } from './MobileQueuedCardText'
|
||||
import { queuedMessageStyles as styles } from './native-chat/input/mobile-native-chat-queued-message-styles'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
|
||||
type QueuedMessageCardRowProps = {
|
||||
dragHandle?: ReactNode
|
||||
@@ -64,9 +65,11 @@ function QueuedMessageCardContents({
|
||||
dragHandle
|
||||
}: Omit<QueuedMessageCardRowProps, 'editor' | 'onEditorLayout'>): React.JSX.Element {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const returned = card.state === 'returned'
|
||||
const steers = queuedMessageCardSteers(card)
|
||||
const count = card.images?.length ?? 0
|
||||
return (
|
||||
<View testID="queued-card-row" style={[styles.card, styles.divided]}>
|
||||
<View style={styles.row}>
|
||||
@@ -98,7 +101,11 @@ function QueuedMessageCardContents({
|
||||
<View
|
||||
style={styles.actions}
|
||||
accessible
|
||||
accessibilityLabel={`${card.images.length} images`}
|
||||
accessibilityLabel={
|
||||
count === 1
|
||||
? t('session.queuedMessageCardRow.images_one', '{{count}} image', { count })
|
||||
: t('session.queuedMessageCardRow.images_other', '{{count}} images', { count })
|
||||
}
|
||||
>
|
||||
<ImageIcon size={12} color={colors.textMuted} strokeWidth={2} />
|
||||
<Text style={styles.caption}>{card.images.length}</Text>
|
||||
@@ -110,10 +117,13 @@ function QueuedMessageCardContents({
|
||||
accessibilityState={{ disabled: busy || steerHeld }}
|
||||
accessibilityLabel={
|
||||
returned
|
||||
? 'Send this message again'
|
||||
? t('session.queuedMessageCardRow.sendAgainHint', 'Send this message again')
|
||||
: steers
|
||||
? 'Submit without interrupting the model'
|
||||
: 'Send this message'
|
||||
? t(
|
||||
'session.queuedMessageCardRow.steerHint',
|
||||
'Submit without interrupting the model'
|
||||
)
|
||||
: t('session.queuedMessageCardRow.sendHint', 'Send this message')
|
||||
}
|
||||
style={({ pressed }) => [
|
||||
styles.textAction,
|
||||
@@ -128,13 +138,20 @@ function QueuedMessageCardContents({
|
||||
) : (
|
||||
<Send size={12} color={colors.textPrimary} strokeWidth={2} />
|
||||
)}
|
||||
<Text style={styles.actionLabel}>{steers ? 'Steer' : 'Send'}</Text>
|
||||
<Text style={styles.actionLabel}>
|
||||
{steers
|
||||
? t('session.queuedMessageCardRow.steer', 'Steer')
|
||||
: t('session.queuedMessageCardRow.send', 'Send')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ disabled: busy }}
|
||||
accessibilityLabel="Delete this queued message"
|
||||
accessibilityLabel={t(
|
||||
'session.queuedMessageCardRow.deleteHint',
|
||||
'Delete this queued message'
|
||||
)}
|
||||
style={({ pressed }) => [
|
||||
styles.iconAction,
|
||||
card.command && styles.lastIconAction,
|
||||
@@ -150,7 +167,7 @@ function QueuedMessageCardContents({
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ disabled: editBlocked }}
|
||||
accessibilityLabel="More actions"
|
||||
accessibilityLabel={t('session.queuedMessageCardRow.moreActions', 'More actions')}
|
||||
style={({ pressed }) => [
|
||||
styles.iconAction,
|
||||
styles.lastIconAction,
|
||||
@@ -171,7 +188,10 @@ function QueuedMessageCardContents({
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded: true }}
|
||||
accessibilityHint="Shows less of the message"
|
||||
accessibilityHint={t(
|
||||
'session.queuedMessageCardRow.showLessHint',
|
||||
'Shows less of the message'
|
||||
)}
|
||||
onPress={() => setExpanded(false)}
|
||||
>
|
||||
<Text style={styles.bodyText}>{card.line}</Text>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { prepareQueuedMessageEditText } from '../../../src/shared/queued-message
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import { createUuidV4 } from '../../../src/shared/uuid-v4'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
|
||||
export function MobileQueuedMessageComposer({
|
||||
editor,
|
||||
@@ -22,6 +23,7 @@ export function MobileQueuedMessageComposer({
|
||||
attribution: string | null
|
||||
}): React.JSX.Element {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const context = useContext(MobileQueuedMessageComposerContext)
|
||||
const inputRef = useRef<TextInput>(null)
|
||||
const emptyHandle = useRef<string | null>(null)
|
||||
@@ -60,13 +62,26 @@ export function MobileQueuedMessageComposer({
|
||||
if (acceptsText(text)) {
|
||||
return false
|
||||
}
|
||||
context?.onError("Commands can't be saved in a queued message.")
|
||||
context?.onError(
|
||||
t(
|
||||
'session.queuedMessageComposer.commandsNotAllowed',
|
||||
"Commands can't be saved in a queued message."
|
||||
)
|
||||
)
|
||||
return true
|
||||
}
|
||||
return (
|
||||
<View testID="queued-card-editor">
|
||||
<Text style={{ color: colors.textMuted }}>
|
||||
Editing message{attribution ? ` · ${attribution}` : ''}
|
||||
{attribution
|
||||
? t(
|
||||
'session.queuedMessageComposer.editingAttributed',
|
||||
'Editing message · {{attribution}}',
|
||||
{
|
||||
attribution
|
||||
}
|
||||
)
|
||||
: t('session.queuedMessageComposer.editing', 'Editing message')}
|
||||
</Text>
|
||||
<MobileNativeChatComposer
|
||||
inputRef={inputRef}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createTerminalLiveAccessoryInput } from '../terminal/terminal-live-acce
|
||||
import { keepHeldPressThroughLongPress } from './held-press-long-press'
|
||||
import { isTerminalPhoneDisplayMode } from './mobile-session-route-helpers'
|
||||
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'
|
||||
@@ -27,6 +28,7 @@ export function MobileSessionAccessoryKeys({
|
||||
vertical?: boolean
|
||||
}) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const {
|
||||
activeHandle,
|
||||
customKeys,
|
||||
@@ -62,8 +64,11 @@ export function MobileSessionAccessoryKeys({
|
||||
onPress={dismissSoftwareKeyboard}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss keyboard"
|
||||
accessibilityHint="Hides the software keyboard and keeps the current terminal session open."
|
||||
accessibilityLabel={t('session.accessoryKeys.dismissKeyboard', 'Dismiss keyboard')}
|
||||
accessibilityHint={t(
|
||||
'session.accessoryKeys.dismissKeyboardHint',
|
||||
'Hides the software keyboard and keeps the current terminal session open.'
|
||||
)}
|
||||
>
|
||||
<View style={styles.keyboardDismissGlyph}>
|
||||
<KeyboardIcon size={15} color={colors.textSecondary} strokeWidth={2} />
|
||||
@@ -100,8 +105,8 @@ export function MobileSessionAccessoryKeys({
|
||||
}}
|
||||
accessibilityLabel={
|
||||
isTerminalPhoneDisplayMode(activeHandle, terminalModes)
|
||||
? 'Switch to desktop mode'
|
||||
: 'Switch to phone mode'
|
||||
? t('session.accessoryKeys.switchToDesktopMode', 'Switch to desktop mode')
|
||||
: t('session.accessoryKeys.switchToPhoneMode', 'Switch to phone mode')
|
||||
}
|
||||
>
|
||||
{isTerminalPhoneDisplayMode(activeHandle, terminalModes) ? (
|
||||
@@ -122,7 +127,9 @@ export function MobileSessionAccessoryKeys({
|
||||
disabled={!canCompose}
|
||||
onPress={toggleLiveInput}
|
||||
accessibilityLabel={
|
||||
liveInputEnabled ? 'Switch to buffered command input' : 'Switch to live terminal input'
|
||||
liveInputEnabled
|
||||
? t('session.accessoryKeys.switchToBufferedInput', 'Switch to buffered command input')
|
||||
: t('session.accessoryKeys.switchToLiveInput', 'Switch to live terminal input')
|
||||
}
|
||||
>
|
||||
<ChevronsRight
|
||||
@@ -147,10 +154,13 @@ export function MobileSessionAccessoryKeys({
|
||||
]}
|
||||
disabled={!canSend}
|
||||
onPress={() => void handlePaste()}
|
||||
accessibilityLabel="Paste from clipboard"
|
||||
accessibilityLabel={t(
|
||||
'session.accessoryKeys.pasteFromClipboard',
|
||||
'Paste from clipboard'
|
||||
)}
|
||||
>
|
||||
<Text style={[styles.accessoryKeyText, !canSend && styles.accessoryKeyTextDisabled]}>
|
||||
Paste
|
||||
{t('session.accessoryKeys.paste', 'Paste')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
@@ -183,7 +193,10 @@ export function MobileSessionAccessoryKeys({
|
||||
void handleAccessoryKey(createTerminalLiveAccessoryInput(key))
|
||||
}}
|
||||
onLongPress={key.repeatable ? keepHeldPressThroughLongPress : undefined}
|
||||
accessibilityLabel={key.accessibilityLabel ?? `Send ${key.label}`}
|
||||
accessibilityLabel={
|
||||
key.accessibilityLabel ??
|
||||
t('session.accessoryKeys.sendKey', 'Send {{label}}', { label: key.label })
|
||||
}
|
||||
>
|
||||
<Text style={[styles.accessoryKeyText, !canSend && styles.accessoryKeyTextDisabled]}>
|
||||
{key.label}
|
||||
@@ -208,7 +221,9 @@ export function MobileSessionAccessoryKeys({
|
||||
setDeleteKeyTarget(key)
|
||||
}}
|
||||
delayLongPress={400}
|
||||
accessibilityLabel={`Send ${key.label}`}
|
||||
accessibilityLabel={t('session.accessoryKeys.sendKey', 'Send {{label}}', {
|
||||
label: key.label
|
||||
})}
|
||||
>
|
||||
<Text style={[styles.accessoryKeyText, !canSend && styles.accessoryKeyTextDisabled]}>
|
||||
{key.label}
|
||||
@@ -222,7 +237,7 @@ export function MobileSessionAccessoryKeys({
|
||||
pressed && styles.accessoryKeyPressed
|
||||
]}
|
||||
onPress={() => setShowCustomKeyModal(true)}
|
||||
accessibilityLabel="Add custom shortcut"
|
||||
accessibilityLabel={t('session.accessoryKeys.addCustomShortcut', 'Add custom shortcut')}
|
||||
>
|
||||
<Plus size={14} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { MobileNativeChatDictationPreviewContext } from './native-chat/composer/
|
||||
import { resolveMobileNativeChatSessionId } from './native-chat/mobile-native-chat-eligibility'
|
||||
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'
|
||||
import { useWorkspaceExecutionHost } from '../navigation/workspace-execution-host'
|
||||
@@ -22,6 +23,7 @@ export function MobileSessionActiveContent({
|
||||
controller: MobileSessionController
|
||||
}) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const executionHost = useWorkspaceExecutionHost()
|
||||
const {
|
||||
hostId,
|
||||
@@ -109,7 +111,9 @@ export function MobileSessionActiveContent({
|
||||
</View>
|
||||
) : showEmptyState ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyText}>No tabs in this session</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
{t('session.activeContent.noTabs', 'No tabs in this session')}
|
||||
</Text>
|
||||
{createError ? <Text style={styles.createError}>{createError}</Text> : null}
|
||||
<View style={styles.emptyActions}>
|
||||
<Pressable
|
||||
@@ -124,7 +128,9 @@ export function MobileSessionActiveContent({
|
||||
}}
|
||||
>
|
||||
<Text style={styles.createButtonText}>
|
||||
{createTabBusy ? 'Creating...' : 'Create Tab'}
|
||||
{createTabBusy
|
||||
? t('session.activeContent.creatingTab', 'Creating...')
|
||||
: t('session.activeContent.createTab', 'Create Tab')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -154,7 +160,7 @@ export function MobileSessionActiveContent({
|
||||
documentId={JSON.stringify([hostId, executionHost, worktreeId, activeFileTab.id])}
|
||||
client={client}
|
||||
doc={fileDocs.get(activeFileTab.id)}
|
||||
title={activeFileTab.title || 'File'}
|
||||
title={activeFileTab.title || t('session.activeContent.fileTitle', 'File')}
|
||||
relativePath={activeFileTab.relativePath}
|
||||
language={activeFileTab.language}
|
||||
diffCommentActions={
|
||||
@@ -203,17 +209,24 @@ export function MobileSessionActiveContent({
|
||||
)}
|
||||
<Text style={styles.emptyText}>
|
||||
{isPendingTerminalRecoveryParked
|
||||
? 'Terminal is taking longer than expected'
|
||||
: activePendingTerminalTab.title || 'Loading terminal'}
|
||||
? t(
|
||||
'session.activeContent.terminalTakingLonger',
|
||||
'Terminal is taking longer than expected'
|
||||
)
|
||||
: activePendingTerminalTab.title ||
|
||||
t('session.activeContent.loadingTerminal', 'Loading terminal')}
|
||||
</Text>
|
||||
{isPendingTerminalRecoveryParked && (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Retry loading terminal"
|
||||
accessibilityLabel={t(
|
||||
'session.activeContent.retryLoadingTerminal',
|
||||
'Retry loading terminal'
|
||||
)}
|
||||
style={({ pressed }) => [styles.createButton, pressed && styles.newTerminalButtonPressed]}
|
||||
onPress={() => void retryPendingTerminalRecovery()}
|
||||
>
|
||||
<Text style={styles.createButtonText}>Retry</Text>
|
||||
<Text style={styles.createButtonText}>{t('common.retry', 'Retry')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { MobileTerminalInputActions } from './MobileTerminalInputActions'
|
||||
import { MobileSessionAccessoryKeys } from './MobileSessionAccessoryKeys'
|
||||
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'
|
||||
|
||||
@@ -33,6 +34,7 @@ export function MobileSessionCommandDock({
|
||||
keysInRail: boolean
|
||||
}) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const {
|
||||
insets,
|
||||
bufferedTerminalDraftState,
|
||||
@@ -87,11 +89,14 @@ export function MobileSessionCommandDock({
|
||||
onChange={handleLiveInputChange}
|
||||
onKeyPress={handleLiveInputKeyPress}
|
||||
onSubmitEditing={submitLiveInput}
|
||||
placeholder="Live input"
|
||||
placeholder={t('session.terminalLiveInputStatus.liveInput', 'Live input')}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
selectionColor={colors.accentBlue}
|
||||
accessibilityLabel="Live terminal input"
|
||||
accessibilityHint="Typed text is sent directly to the active terminal"
|
||||
accessibilityLabel={t('session.commandDock.liveInputLabel', 'Live terminal input')}
|
||||
accessibilityHint={t(
|
||||
'session.commandDock.liveInputHint',
|
||||
'Typed text is sent directly to the active terminal'
|
||||
)}
|
||||
showSoftInputOnFocus
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -157,7 +162,11 @@ export function MobileSessionCommandDock({
|
||||
}
|
||||
}}
|
||||
// The preview stands in for an empty draft, so its placeholder must not show through.
|
||||
placeholder={commandDictationPreview ? undefined : 'Type a command…'}
|
||||
placeholder={
|
||||
commandDictationPreview
|
||||
? undefined
|
||||
: t('session.commandDock.commandPlaceholder', 'Type a command…')
|
||||
}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={autocompleteEnabled}
|
||||
@@ -199,7 +208,7 @@ export function MobileSessionCommandDock({
|
||||
style={[styles.sendButton, !canSend && styles.sendButtonDisabled]}
|
||||
disabled={!canSend}
|
||||
onPress={() => void handleSend()}
|
||||
accessibilityLabel="Send command"
|
||||
accessibilityLabel={t('session.commandDock.sendCommand', 'Send command')}
|
||||
>
|
||||
<ArrowUp size={18} color={colors.textSecondary} strokeWidth={2.5} />
|
||||
</Pressable>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SessionDockColumn } from './SessionDockColumn'
|
||||
import { dismissMobileSessionCreateWarningState } from './mobile-session-create-warning-state'
|
||||
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'
|
||||
import { MobileSessionActiveContent } from './MobileSessionActiveContent'
|
||||
@@ -18,6 +19,7 @@ export function MobileSessionContentRow({
|
||||
keysInRail: boolean
|
||||
}) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const {
|
||||
hostId,
|
||||
worktreeId,
|
||||
@@ -43,7 +45,10 @@ export function MobileSessionContentRow({
|
||||
<Pressable
|
||||
style={styles.createWarningDismiss}
|
||||
onPress={() => setCreateWarningState(dismissMobileSessionCreateWarningState)}
|
||||
accessibilityLabel="Dismiss workspace creation warning"
|
||||
accessibilityLabel={t(
|
||||
'session.contentRow.dismissCreationWarning',
|
||||
'Dismiss workspace creation warning'
|
||||
)}
|
||||
hitSlop={8}
|
||||
>
|
||||
<X size={16} color={colors.textMuted} strokeWidth={2.2} />
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import { styles } from './mobile-session-styles'
|
||||
import type { DiffComment } from '../../../src/shared/diff-comment-types'
|
||||
import type { RenderableDiffLine } from './mobile-session-route-types'
|
||||
@@ -35,6 +36,7 @@ export function DiffLineRow({
|
||||
onDeleteComment: (commentId: string) => void
|
||||
}) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const commentLine = line.newLineNumber
|
||||
const isCommenting = commentLine !== undefined && activeCommentLine === commentLine
|
||||
const canComment = commentLine !== undefined
|
||||
@@ -53,7 +55,14 @@ export function DiffLineRow({
|
||||
<Text
|
||||
selectable
|
||||
style={styles.diffText}
|
||||
accessibilityLabel={`${title} diff line ${index + 1}`}
|
||||
accessibilityLabel={t(
|
||||
'session.diffLineRow.diffLineLabel',
|
||||
'{{title}} diff line {{line}}',
|
||||
{
|
||||
title,
|
||||
line: index + 1
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
@@ -79,7 +88,9 @@ export function DiffLineRow({
|
||||
onStartComment(commentLine)
|
||||
}
|
||||
}}
|
||||
accessibilityLabel={`Add note on line ${commentLine}`}
|
||||
accessibilityLabel={t('session.diffLineRow.addNoteLabel', 'Add note on line {{line}}', {
|
||||
line: commentLine
|
||||
})}
|
||||
>
|
||||
<Plus size={12} color={colors.textSecondary} strokeWidth={2.3} />
|
||||
</Pressable>
|
||||
@@ -91,12 +102,22 @@ export function DiffLineRow({
|
||||
<View key={comment.id} style={styles.diffCommentCard}>
|
||||
<View style={styles.diffCommentHeader}>
|
||||
<MessageSquare size={12} color={colors.textMuted} strokeWidth={2.2} />
|
||||
<Text style={styles.diffCommentMeta}>Line {comment.lineNumber}</Text>
|
||||
<Text style={styles.diffCommentMeta}>
|
||||
{t('session.diffLineRow.lineNumber', 'Line {{line}}', {
|
||||
line: comment.lineNumber
|
||||
})}
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.diffCommentDeleteButton}
|
||||
disabled={commentsBusy}
|
||||
onPress={() => onDeleteComment(comment.id)}
|
||||
accessibilityLabel={`Delete note on line ${comment.lineNumber}`}
|
||||
accessibilityLabel={t(
|
||||
'session.diffLineRow.deleteNoteLabel',
|
||||
'Delete note on line {{line}}',
|
||||
{
|
||||
line: comment.lineNumber
|
||||
}
|
||||
)}
|
||||
>
|
||||
<X size={12} color={colors.textMuted} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
@@ -112,7 +133,7 @@ export function DiffLineRow({
|
||||
style={[styles.textInput, styles.diffCommentInput]}
|
||||
value={commentDraft}
|
||||
onChangeText={onDraftChange}
|
||||
placeholder="Add review note"
|
||||
placeholder={t('session.diffLineRow.notePlaceholder', 'Add review note')}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
editable={!commentsBusy}
|
||||
multiline
|
||||
@@ -125,7 +146,7 @@ export function DiffLineRow({
|
||||
disabled={commentsBusy}
|
||||
onPress={onCancelComment}
|
||||
>
|
||||
<Text style={styles.diffCommentSecondaryText}>Cancel</Text>
|
||||
<Text style={styles.diffCommentSecondaryText}>{t('common.cancel', 'Cancel')}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[
|
||||
@@ -139,7 +160,9 @@ export function DiffLineRow({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={styles.diffCommentPrimaryText}>Save note</Text>
|
||||
<Text style={styles.diffCommentPrimaryText}>
|
||||
{t('session.diffLineRow.saveNote', 'Save note')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { MobileHtmlPreview } from '../components/MobileHtmlPreview'
|
||||
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 { DiffComment } from '../../../src/shared/diff-comment-types'
|
||||
import type {
|
||||
@@ -61,6 +62,7 @@ function FileReaderDocument({
|
||||
diffCommentActions
|
||||
}: FileReaderProps) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const syntaxLanguage = useMemo(
|
||||
() => resolveMobileSyntaxLanguage(relativePath || title, language),
|
||||
[language, relativePath, title]
|
||||
@@ -197,7 +199,7 @@ function FileReaderDocument({
|
||||
if (doc.kind === 'diff') {
|
||||
const activeDiffSyntax =
|
||||
diffSyntax?.doc === doc && diffSyntax.language === syntaxLanguage ? diffSyntax.lines : null
|
||||
const commentCount = diffCommentActions?.comments.length ?? 0
|
||||
const count = diffCommentActions?.comments.length ?? 0
|
||||
const unsentCommentCount = diffCommentActions
|
||||
? sendableMobileDiffComments(
|
||||
diffCommentActions.comments,
|
||||
@@ -205,7 +207,7 @@ function FileReaderDocument({
|
||||
).length
|
||||
: 0
|
||||
const commentsBusy = diffCommentActions?.busy === true
|
||||
const canCopyNotes = commentCount > 0 && !commentsBusy
|
||||
const canCopyNotes = count > 0 && !commentsBusy
|
||||
const canSendNotes = unsentCommentCount > 0 && !commentsBusy
|
||||
return (
|
||||
<View style={styles.markdownEditor}>
|
||||
@@ -214,9 +216,13 @@ function FileReaderDocument({
|
||||
<View style={styles.diffNotesTitleRow}>
|
||||
<MessageSquare size={14} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
<Text style={styles.diffNotesTitle}>
|
||||
{commentCount === 0
|
||||
? 'No review notes'
|
||||
: `${commentCount} review ${commentCount === 1 ? 'note' : 'notes'}`}
|
||||
{count === 0
|
||||
? t('session.fileReader.noReviewNotes', 'No review notes')
|
||||
: count === 1
|
||||
? t('session.fileReader.reviewNotes_one', '{{count}} review note', { count })
|
||||
: t('session.fileReader.reviewNotes_other', '{{count}} review notes', {
|
||||
count
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.diffNotesActions}>
|
||||
@@ -227,10 +233,10 @@ function FileReaderDocument({
|
||||
]}
|
||||
disabled={!canCopyNotes}
|
||||
onPress={() => void diffCommentActions.onCopyAll()}
|
||||
accessibilityLabel="Copy review notes"
|
||||
accessibilityLabel={t('session.fileReader.copyReviewNotes', 'Copy review notes')}
|
||||
>
|
||||
<Copy size={13} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
<Text style={styles.diffNotesActionText}>Copy</Text>
|
||||
<Text style={styles.diffNotesActionText}>{t('common.copy', 'Copy')}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[
|
||||
@@ -239,10 +245,15 @@ function FileReaderDocument({
|
||||
]}
|
||||
disabled={!canSendNotes}
|
||||
onPress={diffCommentActions.onSendAll}
|
||||
accessibilityLabel="Send review notes to AI"
|
||||
accessibilityLabel={t(
|
||||
'session.fileReader.sendReviewNotes',
|
||||
'Send review notes to AI'
|
||||
)}
|
||||
>
|
||||
<Send size={13} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
<Text style={styles.diffNotesActionText}>Send</Text>
|
||||
<Text style={styles.diffNotesActionText}>
|
||||
{t('session.fileReader.send', 'Send')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
@@ -282,7 +293,7 @@ function FileReaderDocument({
|
||||
source={{ uri: doc.dataUri }}
|
||||
style={styles.imagePreview}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel={`${title} image`}
|
||||
accessibilityLabel={t('session.fileReader.imageLabel', '{{title}} image', { title })}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
@@ -295,7 +306,11 @@ function FileReaderDocument({
|
||||
style={styles.filePreviewScroll}
|
||||
contentContainerStyle={styles.filePreviewContent}
|
||||
>
|
||||
<Text selectable style={styles.filePreviewText} accessibilityLabel={`${title} preview`}>
|
||||
<Text
|
||||
selectable
|
||||
style={styles.filePreviewText}
|
||||
accessibilityLabel={t('session.fileReader.previewLabel', '{{title}} preview', { title })}
|
||||
>
|
||||
<MobileSyntaxSegments
|
||||
segments={
|
||||
fileSyntax?.doc === doc && fileSyntax.language === syntaxLanguage
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MobileSessionTabStrip } from './MobileSessionTabStrip'
|
||||
import { StatusDot } from '../components/StatusDot'
|
||||
import { styles } from './mobile-session-styles'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import { useHostSidebarReveal } from '../layout/host-sidebar-reveal'
|
||||
import { useResponsiveLayout } from '../layout/responsive-layout'
|
||||
import type { MobileSessionController } from './use-mobile-session-controller'
|
||||
@@ -18,6 +19,7 @@ export function MobileSessionHeader({
|
||||
backInRail: boolean
|
||||
}) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const {
|
||||
hostId,
|
||||
isFolderWorkspaceRoute,
|
||||
@@ -46,14 +48,14 @@ export function MobileSessionHeader({
|
||||
<View style={styles.sessionTopBar}>
|
||||
{revealSidebar ? (
|
||||
<MobileSessionHeaderIconButton
|
||||
accessibilityLabel="Show sidebar"
|
||||
accessibilityLabel={t('session.header.showSidebar', 'Show sidebar')}
|
||||
icon={PanelLeftOpen}
|
||||
onPress={revealSidebar}
|
||||
/>
|
||||
) : null}
|
||||
{backInRail ? null : (
|
||||
<MobileSessionHeaderIconButton
|
||||
accessibilityLabel="Back to worktrees"
|
||||
accessibilityLabel={t('session.header.backToWorktrees', 'Back to worktrees')}
|
||||
icon={ChevronLeft}
|
||||
onPress={requestLeaveSession}
|
||||
/>
|
||||
@@ -63,7 +65,7 @@ export function MobileSessionHeader({
|
||||
style={[styles.sessionTitleBlock, tabsInTopBar && styles.sessionTitleBlockBesideTabs]}
|
||||
>
|
||||
<Text style={styles.sessionTitle} numberOfLines={1}>
|
||||
{worktreeName || 'Terminal'}
|
||||
{worktreeName || t('session.header.terminalTitle', 'Terminal')}
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.sessionMetaRow}
|
||||
@@ -74,7 +76,11 @@ export function MobileSessionHeader({
|
||||
}
|
||||
}}
|
||||
accessibilityRole={showConnectionRetry ? 'button' : undefined}
|
||||
accessibilityLabel={showConnectionRetry ? 'Reconnect to desktop' : undefined}
|
||||
accessibilityLabel={
|
||||
showConnectionRetry
|
||||
? t('session.header.reconnectToDesktop', 'Reconnect to desktop')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<StatusDot state={presentedConnState} />
|
||||
<Text style={styles.sessionMetaText} numberOfLines={1}>
|
||||
@@ -86,7 +92,7 @@ export function MobileSessionHeader({
|
||||
{!isFloatingWorkspaceRoute && (
|
||||
<MobileSessionHeaderIconButton
|
||||
active={activePanel === 'files'}
|
||||
accessibilityLabel="Open file explorer"
|
||||
accessibilityLabel={t('session.header.openFileExplorer', 'Open file explorer')}
|
||||
icon={Folder}
|
||||
onPress={() => handlePanelTap('files')}
|
||||
/>
|
||||
@@ -94,7 +100,7 @@ export function MobileSessionHeader({
|
||||
{!isFolderWorkspaceRoute && !isFloatingWorkspaceRoute && (
|
||||
<MobileSessionHeaderIconButton
|
||||
active={activePanel === 'sourceControl'}
|
||||
accessibilityLabel="Open source control"
|
||||
accessibilityLabel={t('session.header.openSourceControl', 'Open source control')}
|
||||
icon={GitBranch}
|
||||
onPress={() => handlePanelTap('sourceControl')}
|
||||
/>
|
||||
@@ -102,7 +108,7 @@ export function MobileSessionHeader({
|
||||
{showHeaderMoreButton ? (
|
||||
<MobileSessionHeaderIconButton
|
||||
active={activePanel === 'pr'}
|
||||
accessibilityLabel="More session actions"
|
||||
accessibilityLabel={t('session.header.moreActions', 'More session actions')}
|
||||
icon={MoreHorizontal}
|
||||
onPress={() => setShowHeaderMoreActions(true)}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { MobileAgentSessionHistoryIcon } from '../agent-history/MobileAgentSessi
|
||||
import { ActionSheetModal } from '../components/ActionSheetModal'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
|
||||
type Props = {
|
||||
visible: boolean
|
||||
@@ -22,6 +23,7 @@ export function MobileSessionHeaderMoreActionsSheet({
|
||||
onClose
|
||||
}: Props) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
return (
|
||||
<ActionSheetModal
|
||||
visible={visible}
|
||||
@@ -29,8 +31,12 @@ export function MobileSessionHeaderMoreActionsSheet({
|
||||
...(showAgentSessionHistory
|
||||
? [
|
||||
{
|
||||
label: 'Agent History',
|
||||
hint: 'Browse and resume agent sessions',
|
||||
id: 'agentHistory',
|
||||
label: t('session.headerMoreActionsSheet.agentHistory', 'Agent History'),
|
||||
hint: t(
|
||||
'session.headerMoreActionsSheet.agentHistoryHint',
|
||||
'Browse and resume agent sessions'
|
||||
),
|
||||
renderIcon: () => (
|
||||
<MobileAgentSessionHistoryIcon
|
||||
size={16}
|
||||
@@ -45,8 +51,9 @@ export function MobileSessionHeaderMoreActionsSheet({
|
||||
...(showChecks
|
||||
? [
|
||||
{
|
||||
label: 'Checks',
|
||||
hint: 'Open pull request checks',
|
||||
id: 'checks',
|
||||
label: t('session.headerMoreActionsSheet.checks', 'Checks'),
|
||||
hint: t('session.headerMoreActionsSheet.checksHint', 'Open pull request checks'),
|
||||
icon: ListChecks,
|
||||
onPress: onOpenChecks
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { resolveMarkdownFloatingActionsBottom } from './markdown-floating-action
|
||||
import { markdownReaderStatusText } from './mobile-markdown-reader-status'
|
||||
import { colors, spacing } 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 { MarkdownDocState } from './mobile-session-route-types'
|
||||
import { MobileLoadErrorState } from '../components/MobileLoadErrorState'
|
||||
@@ -32,6 +33,7 @@ export function MarkdownReader({
|
||||
keyboardLift: number
|
||||
}) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
// Native Keyboard events under-report the WebView editor's covered area, so prefer the larger WebView-measured inset.
|
||||
const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0)
|
||||
const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset)
|
||||
@@ -87,30 +89,40 @@ export function MarkdownReader({
|
||||
onPress={onCopy}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
doc.truncated ? 'Copy loaded Markdown source' : 'Copy Markdown source'
|
||||
doc.truncated
|
||||
? t('session.markdownReader.copyLoadedSource', 'Copy loaded Markdown source')
|
||||
: t('session.markdownReader.copySource', 'Copy Markdown source')
|
||||
}
|
||||
>
|
||||
<Text style={styles.markdownFloatingButtonText}>
|
||||
{doc.truncated ? 'Copy loaded' : 'Copy'}
|
||||
{doc.truncated
|
||||
? t('session.markdownReader.copyLoaded', 'Copy loaded')
|
||||
: t('common.copy', 'Copy')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.markdownFloatingButton, styles.markdownCopyButton]}
|
||||
onPress={onCopyPath}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Copy Markdown path"
|
||||
accessibilityLabel={t('session.markdownReader.copyPathLabel', 'Copy Markdown path')}
|
||||
>
|
||||
<Text style={styles.markdownFloatingButtonText}>Copy path</Text>
|
||||
<Text style={styles.markdownFloatingButtonText}>
|
||||
{t('session.markdownReader.copyPath', 'Copy path')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{showRefresh ? (
|
||||
<Pressable style={styles.markdownFloatingButton} onPress={onRefresh}>
|
||||
<RefreshCw size={13} color={colors.textPrimary} />
|
||||
<Text style={styles.markdownFloatingButtonText}>Refresh</Text>
|
||||
<Text style={styles.markdownFloatingButtonText}>
|
||||
{t('common.refresh', 'Refresh')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{doc.isDirty ? (
|
||||
<Pressable style={styles.markdownFloatingButton} onPress={onDiscard}>
|
||||
<Text style={styles.markdownFloatingButtonText}>Discard</Text>
|
||||
<Text style={styles.markdownFloatingButtonText}>
|
||||
{t('session.markdownReader.discard', 'Discard')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{showSave ? (
|
||||
@@ -126,7 +138,7 @@ export function MarkdownReader({
|
||||
{doc.saving ? (
|
||||
<ActivityIndicator size="small" color={colors.textPrimary} />
|
||||
) : (
|
||||
<Text style={styles.markdownFloatingButtonText}>Save</Text>
|
||||
<Text style={styles.markdownFloatingButtonText}>{t('common.save', 'Save')}</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
@@ -3,11 +3,13 @@ import { useMobileSessionController } from './use-mobile-session-controller'
|
||||
import { ActivityIndicator, Text, View } from 'react-native'
|
||||
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'
|
||||
|
||||
// Keep the screen importable by the page; native route modules mount the shell.
|
||||
export function MobileSessionRouteScreen() {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const controller = useMobileSessionController()
|
||||
// Readiness stays true on refocus so returning from Settings preserves mounted panes.
|
||||
if (
|
||||
@@ -19,7 +21,9 @@ export function MobileSessionRouteScreen() {
|
||||
<View style={styles.container}>
|
||||
<View style={styles.emptyState}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
<Text style={styles.emptyText}>Opening workspace</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
{t('session.routeScreen.openingWorkspace', 'Opening workspace')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Platform } from 'react-native'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import { getRepoIdFromWorktreeId } from '../../../src/shared/worktree/id'
|
||||
import { useClipboardWriter } from '../platform/clipboard'
|
||||
import { Copy, FileText, Globe, RefreshCw, SquareTerminal } from 'lucide-react-native'
|
||||
@@ -7,6 +8,7 @@ import { QuickCommandsSheet } from './QuickCommandsSheet'
|
||||
import { triggerSuccess, triggerError } from '../platform/haptics'
|
||||
import { ActionSheetModal } from '../components/ActionSheetModal'
|
||||
import { TextInputModal } from '../components/TextInputModal'
|
||||
import { MobileTerminalRenameModal } from './MobileTerminalRenameModal'
|
||||
import { ConfirmModal } from '../components/ConfirmModal'
|
||||
import { CustomKeyModal } from '../components/CustomKeyModal'
|
||||
import { MobileDictationSetupSheet } from '../components/MobileDictationSetupSheet'
|
||||
@@ -18,6 +20,7 @@ import { isTerminalPhoneDisplayMode } from './mobile-session-route-helpers'
|
||||
import type { MobileSessionController } from './use-mobile-session-controller'
|
||||
|
||||
export function MobileSessionSheets({ controller }: { controller: MobileSessionController }) {
|
||||
const t = useMobileTranslation()
|
||||
const {
|
||||
worktreeId,
|
||||
isFolderWorkspaceRoute,
|
||||
@@ -50,7 +53,6 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
|
||||
leaveDrafts,
|
||||
setLeaveDrafts,
|
||||
setRenameTarget,
|
||||
renameTarget,
|
||||
setCustomKeys,
|
||||
showCustomKeyModal,
|
||||
setShowCustomKeyModal,
|
||||
@@ -61,9 +63,7 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
|
||||
closeDictationSetup,
|
||||
browserScreencastSupported,
|
||||
quickCommandsSupported,
|
||||
quickCommandAgentPromptMaxLength,
|
||||
showToast,
|
||||
nativeChatTranscriptIsLocalReadable,
|
||||
nativeChatController,
|
||||
toggleTabChatView,
|
||||
toggleDisplayMode,
|
||||
@@ -80,7 +80,6 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
|
||||
handleCreateMarkdownNote,
|
||||
handleCreateBrowser,
|
||||
handleBrowserNavigationCommand,
|
||||
handleRenameTerminal,
|
||||
handleCloseTerminal,
|
||||
handleCloseSessionTab,
|
||||
bulkCloseActions,
|
||||
@@ -115,16 +114,17 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
|
||||
: getRepoIdFromWorktreeId(worktreeId) || null
|
||||
}
|
||||
repoName={worktreeName || null}
|
||||
agentPromptMaxLength={quickCommandAgentPromptMaxLength}
|
||||
agentPromptMaxLength={controller.quickCommandAgentPromptMaxLength}
|
||||
onLaunch={launchQuickCommand}
|
||||
/>
|
||||
<ActionSheetModal
|
||||
visible={showCreateTabDrawer}
|
||||
title="New Tab"
|
||||
title={t('session.sheets.newTab', 'New Tab')}
|
||||
actions={[
|
||||
...createTabAgentActions,
|
||||
{
|
||||
label: 'Terminal',
|
||||
id: 'terminal',
|
||||
label: t('session.sheets.terminal', 'Terminal'),
|
||||
icon: SquareTerminal,
|
||||
onPress: () => {
|
||||
setShowCreateTabDrawer(false)
|
||||
@@ -138,12 +138,19 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: 'Browser',
|
||||
id: 'browser',
|
||||
label: t('session.sheets.browser', 'Browser'),
|
||||
icon: Globe,
|
||||
closeBeforePress: true,
|
||||
onPress: () => {
|
||||
if (browserScreencastSupported !== true) {
|
||||
showToast('Desktop update required for mobile browser streaming', 1600)
|
||||
showToast(
|
||||
t(
|
||||
'session.sheets.browserUpdateRequired',
|
||||
'Desktop update required for mobile browser streaming'
|
||||
),
|
||||
1600
|
||||
)
|
||||
return
|
||||
}
|
||||
setShowCreateBrowserModal(true)
|
||||
@@ -152,7 +159,8 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
|
||||
...(onPairedHost
|
||||
? [
|
||||
{
|
||||
label: 'Markdown Note',
|
||||
id: 'markdownNote',
|
||||
label: t('session.sheets.markdownNote', 'Markdown Note'),
|
||||
icon: FileText,
|
||||
onPress: () => {
|
||||
setShowCreateTabDrawer(false)
|
||||
@@ -167,12 +175,13 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
|
||||
/>
|
||||
<ActionSheetModal
|
||||
visible={pendingDiffNotesDelivery !== null}
|
||||
title="Send Review Notes"
|
||||
message="Choose an agent session for the current notes."
|
||||
title={t('session.sheets.sendReviewNotes', 'Send Review Notes')}
|
||||
message={t('session.sheets.notesHint', 'Choose an agent session for the current notes.')}
|
||||
actions={[
|
||||
...sendDiffNotesAgentActions,
|
||||
{
|
||||
label: 'Copy Notes',
|
||||
id: 'copyNotes',
|
||||
label: t('session.sheets.copyNotes', 'Copy Notes'),
|
||||
icon: Copy,
|
||||
onPress: () => {
|
||||
const delivery = pendingDiffNotesDelivery
|
||||
@@ -184,11 +193,11 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
|
||||
.writeText(delivery.prompt)
|
||||
.then(() => {
|
||||
triggerSuccess()
|
||||
showToast('Notes copied')
|
||||
showToast(t('session.sheets.notesCopied', 'Notes copied'))
|
||||
})
|
||||
.catch(() => {
|
||||
triggerError()
|
||||
showToast("Couldn't copy notes", 1500)
|
||||
showToast(t('session.sheets.copyNotesFailed', "Couldn't copy notes"), 1500)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -197,12 +206,12 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
|
||||
/>
|
||||
<ActionSheetModal
|
||||
visible={actionTarget != null}
|
||||
title={actionTarget?.title || 'Terminal'}
|
||||
title={actionTarget?.title || t('session.sheets.terminal', 'Terminal')}
|
||||
actions={getMobileTerminalActionSheetActions({
|
||||
target: actionTarget,
|
||||
tabs: sessionTabs.filter((tab) => tab.type === 'terminal'),
|
||||
isTabChatView: nativeChatController.isTabChatView,
|
||||
nativeChatTranscriptIsLocalReadable,
|
||||
nativeChatTranscriptIsLocalReadable: controller.nativeChatTranscriptIsLocalReadable,
|
||||
onDismiss: () => setActionTarget(null),
|
||||
onToggleChat: toggleTabChatView,
|
||||
isPhoneMode: (handle) => isTerminalPhoneDisplayMode(handle, terminalModes),
|
||||
@@ -217,10 +226,11 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
|
||||
/>
|
||||
<ActionSheetModal
|
||||
visible={markdownActionTarget != null}
|
||||
title={markdownActionTarget?.title || 'Markdown'}
|
||||
title={markdownActionTarget?.title || t('session.sheets.markdown', 'Markdown')}
|
||||
actions={[
|
||||
{
|
||||
label: 'Refresh',
|
||||
id: 'refresh',
|
||||
label: t('common.refresh', 'Refresh'),
|
||||
icon: RefreshCw,
|
||||
// Why: dirty refresh opens ConfirmModal; wait for this sheet's native
|
||||
// Modal to unmount first (same dual-Modal race as tab Rename, #10331).
|
||||
@@ -233,7 +243,8 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Copy Path',
|
||||
id: 'copyPath',
|
||||
label: t('session.sheets.copyPath', 'Copy Path'),
|
||||
icon: FileText,
|
||||
onPress: () => {
|
||||
const target = markdownActionTarget
|
||||
@@ -249,10 +260,11 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
|
||||
/>
|
||||
<ActionSheetModal
|
||||
visible={fileActionTarget != null}
|
||||
title={fileActionTarget?.title || 'File'}
|
||||
title={fileActionTarget?.title || t('session.sheets.file', 'File')}
|
||||
actions={[
|
||||
{
|
||||
label: 'Refresh',
|
||||
id: 'refresh',
|
||||
label: t('common.refresh', 'Refresh'),
|
||||
icon: RefreshCw,
|
||||
onPress: () => {
|
||||
const target = fileActionTarget
|
||||
@@ -275,7 +287,7 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
|
||||
/>
|
||||
<ActionSheetModal
|
||||
visible={agentSessionActionTarget != null}
|
||||
title={agentSessionActionTarget?.title || 'Chat'}
|
||||
title={agentSessionActionTarget?.title || t('session.sheets.chat', 'Chat')}
|
||||
actions={[
|
||||
...getMobileNativeChatCliForkActions({
|
||||
controller,
|
||||
@@ -289,11 +301,12 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
|
||||
/>
|
||||
<ActionSheetModal
|
||||
visible={leaveDrafts != null}
|
||||
title="Unsaved markdown changes"
|
||||
message="Copy or discard phone drafts before leaving."
|
||||
title={t('session.sheets.unsavedMarkdown', 'Unsaved markdown changes')}
|
||||
message={t('session.sheets.draftsHint', 'Copy or discard phone drafts before leaving.')}
|
||||
actions={[
|
||||
{
|
||||
label: 'Copy All & Leave',
|
||||
id: 'copyAllAndLeave',
|
||||
label: t('session.sheets.copyAllAndLeave', 'Copy All & Leave'),
|
||||
icon: FileText,
|
||||
onPress: () => {
|
||||
const drafts = leaveDrafts ?? []
|
||||
@@ -308,12 +321,13 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
|
||||
})
|
||||
.catch(() => {
|
||||
triggerError()
|
||||
showToast("Couldn't copy drafts", 1500)
|
||||
showToast(t('session.sheets.copyDraftsFailed', "Couldn't copy drafts"), 1500)
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Discard & Leave',
|
||||
id: 'discardAndLeave',
|
||||
label: t('session.sheets.discardAndLeave', 'Discard & Leave'),
|
||||
destructive: true,
|
||||
onPress: () => {
|
||||
setLeaveDrafts(null)
|
||||
@@ -325,29 +339,25 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
|
||||
/>
|
||||
<ConfirmModal
|
||||
visible={discardMarkdownTarget != null}
|
||||
title="Discard Changes"
|
||||
message="Replace the phone draft with the latest desktop file?"
|
||||
confirmLabel="Discard"
|
||||
title={t('session.sheets.discardChanges', 'Discard Changes')}
|
||||
message={t(
|
||||
'session.sheets.discardMessage',
|
||||
'Replace the phone draft with the latest desktop file?'
|
||||
)}
|
||||
confirmLabel={t('session.sheets.discard', 'Discard')}
|
||||
destructive
|
||||
onConfirm={confirmDiscardMarkdown}
|
||||
onCancel={() => setDiscardMarkdownTarget(null)}
|
||||
/>
|
||||
<ConfirmModal {...bulkCloseConfirm} />
|
||||
<TextInputModal
|
||||
visible={renameTarget != null}
|
||||
title="Rename Terminal"
|
||||
defaultValue={renameTarget?.title || 'Terminal'}
|
||||
placeholder="Terminal name"
|
||||
onSubmit={(value) => void handleRenameTerminal(value)}
|
||||
onCancel={() => setRenameTarget(null)}
|
||||
/>
|
||||
<MobileTerminalRenameModal controller={controller} />
|
||||
<TextInputModal
|
||||
visible={showCreateBrowserModal}
|
||||
title="New Browser"
|
||||
message="Enter a URL, or leave blank for a new tab."
|
||||
title={t('session.sheets.newBrowser', 'New Browser')}
|
||||
message={t('session.sheets.urlPrompt', 'Enter a URL, or leave blank for a new tab.')}
|
||||
defaultValue=""
|
||||
placeholder="https://example.com"
|
||||
submitLabel="Open"
|
||||
submitLabel={t('common.open', 'Open')}
|
||||
allowEmpty
|
||||
selectTextOnFocus
|
||||
keyboardType={Platform.OS === 'ios' ? 'url' : 'default'}
|
||||
@@ -374,11 +384,12 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
|
||||
/>
|
||||
<ActionSheetModal
|
||||
visible={deleteKeyTarget != null}
|
||||
title={deleteKeyTarget?.label ?? 'Shortcut'}
|
||||
message="Remove this custom shortcut?"
|
||||
title={deleteKeyTarget?.label ?? t('session.sheets.shortcut', 'Shortcut')}
|
||||
message={t('session.sheets.removeShortcutMessage', 'Remove this custom shortcut?')}
|
||||
actions={[
|
||||
{
|
||||
label: 'Remove',
|
||||
id: 'remove',
|
||||
label: t('common.remove', 'Remove'),
|
||||
destructive: true,
|
||||
onPress: () => {
|
||||
if (deleteKeyTarget) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { View, Text, ScrollView, Pressable } from 'react-native'
|
||||
import { File, FileText, Globe, Plus, SquareTerminal } from 'lucide-react-native'
|
||||
import { triggerMediumImpact } from '../platform/haptics'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import { MobileAgentIcon } from '../components/MobileAgentIcon'
|
||||
import { AgentStateDot } from '../components/AgentStateDot'
|
||||
import { useNow } from '../hooks/use-now'
|
||||
@@ -35,6 +36,7 @@ export function MobileSessionTabStrip({
|
||||
inTopBar: boolean
|
||||
}) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const {
|
||||
connState,
|
||||
activeSessionTabId,
|
||||
@@ -189,54 +191,61 @@ export function MobileSessionTabStrip({
|
||||
refreshHiddenTabs()
|
||||
}}
|
||||
>
|
||||
{visibleTabs.map((t, index) => {
|
||||
const tabState = tabStates.get(t.id)
|
||||
const hostAgentIdentity = getMobileTerminalTabHostAgentIdentity(t, terminals)
|
||||
const title = getMobileSessionTabTitle(t, hostAgentIdentity)
|
||||
{visibleTabs.map((tab, index) => {
|
||||
const tabState = tabStates.get(tab.id)
|
||||
const hostAgentIdentity = getMobileTerminalTabHostAgentIdentity(tab, terminals)
|
||||
const title = getMobileSessionTabTitle(tab, hostAgentIdentity)
|
||||
return (
|
||||
<Pressable
|
||||
key={t.id}
|
||||
key={tab.id}
|
||||
style={[
|
||||
styles.tab,
|
||||
index === 0 && !inTopBar && styles.tabFirst,
|
||||
t.id === activeSessionTabId && styles.tabActive
|
||||
tab.id === activeSessionTabId && styles.tabActive
|
||||
]}
|
||||
onLayout={(e) => {
|
||||
const { x, width } = e.nativeEvent.layout
|
||||
tabLayoutsRef.current.set(t.id, { x, width })
|
||||
if (t.id === activeSessionTabIdRef.current) {
|
||||
scrollActiveTabIntoView(t.id, false)
|
||||
tabLayoutsRef.current.set(tab.id, { x, width })
|
||||
if (tab.id === activeSessionTabIdRef.current) {
|
||||
scrollActiveTabIntoView(tab.id, false)
|
||||
}
|
||||
refreshHiddenTabs()
|
||||
}}
|
||||
onPress={() => pressTab(t)}
|
||||
onPress={() => pressTab(tab)}
|
||||
accessibilityRole="tab"
|
||||
accessibilityLabel={
|
||||
tabState && tabState !== 'idle' ? `${title}, ${agentStateLabel(tabState)}` : title
|
||||
tabState && tabState !== 'idle'
|
||||
? t('session.tabStrip.tabStatus', '{{title}}, {{status}}', {
|
||||
title,
|
||||
status: agentStateLabel(tabState)
|
||||
})
|
||||
: title
|
||||
}
|
||||
accessibilityState={{ selected: t.id === activeSessionTabId }}
|
||||
accessibilityState={{ selected: tab.id === activeSessionTabId }}
|
||||
onLongPress={() => {
|
||||
triggerMediumImpact()
|
||||
openSessionTabActionSheetAfterKeyboardDismiss(t)
|
||||
openSessionTabActionSheetAfterKeyboardDismiss(tab)
|
||||
}}
|
||||
delayLongPress={400}
|
||||
>
|
||||
<View style={styles.tabLabelRow}>
|
||||
{tabState && tabState !== 'idle' ? <AgentStateDot state={tabState} /> : null}
|
||||
<View style={styles.tabIcon}>
|
||||
{t.type === 'browser' && (
|
||||
{tab.type === 'browser' && (
|
||||
<Globe size={13} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
)}
|
||||
{t.type === 'markdown' && (
|
||||
{tab.type === 'markdown' && (
|
||||
<FileText size={13} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
)}
|
||||
{t.type === 'file' && (
|
||||
{tab.type === 'file' && (
|
||||
<File size={13} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
)}
|
||||
{t.type === 'agent-session' && <MobileAgentIcon agentId={t.agent} size={13} />}
|
||||
{t.type === 'terminal' &&
|
||||
{tab.type === 'agent-session' && (
|
||||
<MobileAgentIcon agentId={tab.agent} size={13} />
|
||||
)}
|
||||
{tab.type === 'terminal' &&
|
||||
(() => {
|
||||
const agentId = resolveMobileTerminalTabAgentId(t, hostAgentIdentity)
|
||||
const agentId = resolveMobileTerminalTabAgentId(tab, hostAgentIdentity)
|
||||
return agentId ? (
|
||||
<MobileAgentIcon agentId={agentId} size={13} />
|
||||
) : (
|
||||
@@ -249,7 +258,7 @@ export function MobileSessionTabStrip({
|
||||
})()}
|
||||
</View>
|
||||
<Text
|
||||
style={[styles.tabText, t.id === activeSessionTabId && styles.tabTextActive]}
|
||||
style={[styles.tabText, tab.id === activeSessionTabId && styles.tabTextActive]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{title}
|
||||
@@ -277,7 +286,7 @@ export function MobileSessionTabStrip({
|
||||
setCreateError('')
|
||||
setShowCreateTabDrawer(true)
|
||||
}}
|
||||
accessibilityLabel="New tab"
|
||||
accessibilityLabel={t('session.tabStrip.newTab', 'New tab')}
|
||||
icon={Plus}
|
||||
/>
|
||||
{/* Why: stable placement matters, while old hosts must stay gated because they strip agentPrompt. */}
|
||||
@@ -290,8 +299,14 @@ export function MobileSessionTabStrip({
|
||||
}
|
||||
showToast(
|
||||
quickCommandsSupported === false
|
||||
? 'Desktop update required for quick commands'
|
||||
: 'Checking desktop capabilities — try again in a moment',
|
||||
? t(
|
||||
'session.tabStrip.quickCommandsUpdateRequired',
|
||||
'Desktop update required for quick commands'
|
||||
)
|
||||
: t(
|
||||
'session.tabStrip.checkingCapabilities',
|
||||
'Checking desktop capabilities — try again in a moment'
|
||||
),
|
||||
1600
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ActivityIndicator, Pressable, type StyleProp, type ViewStyle } from 'react-native'
|
||||
import { ImagePlus, Mic } from 'lucide-react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import { keepHeldPressThroughLongPress } from './held-press-long-press'
|
||||
|
||||
@@ -44,6 +45,7 @@ export function MobileTerminalInputActions({
|
||||
onDictationCancel
|
||||
}: MobileTerminalInputActionsProps) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
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
|
||||
@@ -56,8 +58,15 @@ export function MobileTerminalInputActions({
|
||||
onPress={onAttachImage}
|
||||
onLongPress={onAttachFile}
|
||||
delayLongPress={350}
|
||||
accessibilityLabel={isAttaching ? 'Sending image' : 'Attach a photo'}
|
||||
accessibilityHint="Long press to attach a file instead"
|
||||
accessibilityLabel={
|
||||
isAttaching
|
||||
? t('session.terminalInputActions.sendingImage', 'Sending image')
|
||||
: t('session.terminalInputActions.attachPhoto', 'Attach a photo')
|
||||
}
|
||||
accessibilityHint={t(
|
||||
'session.terminalInputActions.attachFileHint',
|
||||
'Long press to attach a file instead'
|
||||
)}
|
||||
>
|
||||
{isAttaching ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
@@ -89,12 +98,15 @@ export function MobileTerminalInputActions({
|
||||
aria-busy={dictation.isStarting || dictation.isProcessing}
|
||||
accessibilityLabel={
|
||||
dictation.isRecording
|
||||
? 'Stop voice dictation'
|
||||
? t('session.terminalInputActions.stopDictation', 'Stop voice dictation')
|
||||
: dictation.isProcessing
|
||||
? 'Transcribing voice dictation'
|
||||
? t(
|
||||
'session.terminalInputActions.transcribingDictation',
|
||||
'Transcribing voice dictation'
|
||||
)
|
||||
: dictation.isStarting
|
||||
? 'Starting voice dictation'
|
||||
: 'Start voice dictation'
|
||||
? t('session.terminalInputActions.startingDictation', 'Starting voice dictation')
|
||||
: t('session.terminalInputActions.startDictation', 'Start voice dictation')
|
||||
}
|
||||
>
|
||||
{dictation.isProcessing ? (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Text, View } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import { themedStyleSheet } from '../theme/themed-styles'
|
||||
import { TailClippedText } from '../components/TailClippedText'
|
||||
import {
|
||||
@@ -24,23 +25,25 @@ export function MobileTerminalLiveInputStatus({
|
||||
heard
|
||||
}: MobileTerminalLiveInputStatusProps) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const dictationTitle = mobileDictationStatusTitle(dictation)
|
||||
if (!dictationTitle && !isAttaching) {
|
||||
return null
|
||||
}
|
||||
const title = dictationTitle ?? 'Uploading image'
|
||||
const title =
|
||||
dictationTitle ?? t('session.terminalLiveInputStatus.uploadingImageTitle', 'Uploading image')
|
||||
// Live input can't type into the terminal until the text is final, so the newest words show here.
|
||||
const detail =
|
||||
heard ||
|
||||
(dictation.isRecording
|
||||
? dictationMode === 'hold'
|
||||
? 'Release mic to stop'
|
||||
: 'Tap mic to stop'
|
||||
? t('session.terminalLiveInputStatus.releaseMicHint', 'Release mic to stop')
|
||||
: t('session.terminalLiveInputStatus.stopHint', 'Tap mic to stop')
|
||||
: dictation.isProcessing
|
||||
? 'Transcribing on desktop'
|
||||
? t('session.terminalLiveInputStatus.transcribing', 'Transcribing on desktop')
|
||||
: dictation.isStarting
|
||||
? 'Preparing microphone'
|
||||
: 'Uploading image to host')
|
||||
? t('session.terminalLiveInputStatus.preparingMic', 'Preparing microphone')
|
||||
: t('session.terminalLiveInputStatus.uploadingImage', 'Uploading image to host'))
|
||||
|
||||
return (
|
||||
<View style={styles.status} pointerEvents="none">
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { TextInputModal } from '../components/TextInputModal'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import type { MobileSessionController } from './use-mobile-session-controller'
|
||||
|
||||
export function MobileTerminalRenameModal({
|
||||
controller
|
||||
}: {
|
||||
controller: Pick<
|
||||
MobileSessionController,
|
||||
'renameTarget' | 'setRenameTarget' | 'handleRenameTerminal'
|
||||
>
|
||||
}) {
|
||||
const t = useMobileTranslation()
|
||||
const { renameTarget, setRenameTarget, handleRenameTerminal } = controller
|
||||
return (
|
||||
<TextInputModal
|
||||
visible={renameTarget != null}
|
||||
title={t('session.sheets.renameTerminal', 'Rename Terminal')}
|
||||
defaultValue={renameTarget?.title ?? ''}
|
||||
placeholder={t('session.sheets.terminalName', 'Terminal name')}
|
||||
onSubmit={(value) => void handleRenameTerminal(value)}
|
||||
onCancel={() => setRenameTarget(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -8,13 +8,14 @@ import { MobileAgentIcon } from '../components/MobileAgentIcon'
|
||||
import {
|
||||
getQuickCommandAgentLabel,
|
||||
getTerminalQuickCommandBodySize,
|
||||
formatKilobytes,
|
||||
MAX_QUICK_COMMAND_LABEL_LENGTH,
|
||||
MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH
|
||||
} from '../terminal/quick-commands'
|
||||
import type { QuickCommandDraft } from './quick-command-draft'
|
||||
import { isQuickCommandDraftPromptTooLong, isQuickCommandDraftValid } from './quick-command-draft'
|
||||
import { themedStyleSheet } from '../theme/themed-styles'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import { getUiIntlLocale } from '../i18n/ui-intl-locale'
|
||||
|
||||
type Props = {
|
||||
draft: QuickCommandDraft
|
||||
@@ -40,6 +41,7 @@ function ActionToggle({
|
||||
onChange: (action: QuickCommandDraft['action']) => void
|
||||
}) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
return (
|
||||
<View style={styles.toggleGroup}>
|
||||
{(['terminal-command', 'agent-prompt'] as const).map((action) => {
|
||||
@@ -57,7 +59,9 @@ function ActionToggle({
|
||||
accessibilityState={{ selected }}
|
||||
>
|
||||
<Text style={[styles.toggleText, selected && styles.toggleTextSelected]}>
|
||||
{action === 'terminal-command' ? 'Terminal Command' : 'Agent Prompt'}
|
||||
{action === 'terminal-command'
|
||||
? t('session.quickCommandEditorForm.terminalCommand', 'Terminal Command')
|
||||
: t('session.quickCommandEditorForm.agentPrompt', 'Agent Prompt')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
@@ -80,6 +84,7 @@ export function QuickCommandEditorForm({
|
||||
onSave
|
||||
}: Props) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const hasRepoScope = repoId !== null
|
||||
const [advancedOpen, setAdvancedOpen] = useState(draft.scope.type === 'repo')
|
||||
const isAgent = draft.action === 'agent-prompt'
|
||||
@@ -94,12 +99,12 @@ export function QuickCommandEditorForm({
|
||||
return (
|
||||
<View style={styles.form}>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Label</Text>
|
||||
<Text style={styles.label}>{t('session.quickCommandEditorForm.label', 'Label')}</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={draft.label}
|
||||
onChangeText={(label) => onChange({ label })}
|
||||
placeholder="Start dev server"
|
||||
placeholder={t('session.quickCommandEditorForm.labelPlaceholder', 'Start dev server')}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -108,13 +113,13 @@ export function QuickCommandEditorForm({
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Action</Text>
|
||||
<Text style={styles.label}>{t('session.quickCommandEditorForm.action', 'Action')}</Text>
|
||||
<ActionToggle value={draft.action} onChange={(action) => onChange({ action })} />
|
||||
</View>
|
||||
|
||||
{isAgent ? (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Agent</Text>
|
||||
<Text style={styles.label}>{t('session.quickCommandEditorForm.agent', 'Agent')}</Text>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.select, pressed && styles.pressed]}
|
||||
onPress={onOpenAgentPicker}
|
||||
@@ -126,7 +131,9 @@ export function QuickCommandEditorForm({
|
||||
<Text style={styles.selectValueText}>{getQuickCommandAgentLabel(draft.agent)}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.selectPlaceholder}>Choose agent</Text>
|
||||
<Text style={styles.selectPlaceholder}>
|
||||
{t('session.quickCommandEditorForm.chooseAgent', 'Choose agent')}
|
||||
</Text>
|
||||
)}
|
||||
<ChevronDown size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
@@ -134,12 +141,23 @@ export function QuickCommandEditorForm({
|
||||
) : null}
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>{isAgent ? 'Prompt' : 'Command Text'}</Text>
|
||||
<Text style={styles.label}>
|
||||
{isAgent
|
||||
? t('session.quickCommandEditorForm.prompt', 'Prompt')
|
||||
: t('session.quickCommandEditorForm.commandText', 'Command Text')}
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.textarea, !isAgent && styles.mono]}
|
||||
value={isAgent ? draft.prompt : draft.command}
|
||||
onChangeText={(text) => onChange(isAgent ? { prompt: text } : { command: text })}
|
||||
placeholder={isAgent ? 'Ask the agent to investigate this workspace' : 'npm run dev'}
|
||||
placeholder={
|
||||
isAgent
|
||||
? t(
|
||||
'session.quickCommandEditorForm.promptPlaceholder',
|
||||
'Ask the agent to investigate this workspace'
|
||||
)
|
||||
: 'npm run dev'
|
||||
}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -152,17 +170,39 @@ export function QuickCommandEditorForm({
|
||||
{/* Why static text in the alert: a live count would re-announce on every keystroke. */}
|
||||
<Text style={styles.error} accessibilityRole="alert" accessibilityLiveRegion="polite">
|
||||
{promptSize.olderHostCap
|
||||
? 'Too long for this host. Update Orca on the host to save longer prompts.'
|
||||
: 'Too long to save.'}
|
||||
? t(
|
||||
'session.quickCommandEditorForm.olderHostPromptTooLong',
|
||||
'Too long for this host. Update Orca on the host to save longer prompts.'
|
||||
)
|
||||
: t('session.quickCommandEditorForm.promptTooLong', 'Too long to save.')}
|
||||
</Text>
|
||||
<Text style={styles.hint}>
|
||||
{promptSize.unit === 'bytes'
|
||||
? `${formatKilobytes(promptSize.used)} / ${formatKilobytes(promptSize.max)}`
|
||||
: `${promptSize.used.toLocaleString()} / ${promptSize.max.toLocaleString()} characters`}
|
||||
? t(
|
||||
'session.quickCommandEditorForm.promptSizeKilobytes',
|
||||
'{{used}} KB / {{max}} KB',
|
||||
{
|
||||
used: Math.ceil(promptSize.used / 1024).toLocaleString(getUiIntlLocale()),
|
||||
max: Math.ceil(promptSize.max / 1024).toLocaleString(getUiIntlLocale())
|
||||
}
|
||||
)
|
||||
: t(
|
||||
'session.quickCommandEditorForm.promptSizeCharacters',
|
||||
'{{used}} / {{max}} characters',
|
||||
{
|
||||
used: promptSize.used.toLocaleString(getUiIntlLocale()),
|
||||
max: promptSize.max.toLocaleString(getUiIntlLocale())
|
||||
}
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
) : isAgent ? (
|
||||
<Text style={styles.hint}>Supports skills, file paths, and built-in commands.</Text>
|
||||
<Text style={styles.hint}>
|
||||
{t(
|
||||
'session.quickCommandEditorForm.promptHint',
|
||||
'Supports skills, file paths, and built-in commands.'
|
||||
)}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -178,7 +218,9 @@ export function QuickCommandEditorForm({
|
||||
) : (
|
||||
<ChevronRight size={16} color={colors.textSecondary} />
|
||||
)}
|
||||
<Text style={styles.advancedText}>Advanced</Text>
|
||||
<Text style={styles.advancedText}>
|
||||
{t('session.quickCommandEditorForm.advanced', 'Advanced')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{advancedOpen ? (
|
||||
@@ -186,9 +228,14 @@ export function QuickCommandEditorForm({
|
||||
{!isAgent ? (
|
||||
<View style={styles.switchRow}>
|
||||
<View style={styles.switchText}>
|
||||
<Text style={styles.switchTitle}>Append Enter</Text>
|
||||
<Text style={styles.switchTitle}>
|
||||
{t('session.quickCommandEditorForm.appendEnter', 'Append Enter')}
|
||||
</Text>
|
||||
<Text style={styles.switchDesc}>
|
||||
Submit immediately instead of only inserting text.
|
||||
{t(
|
||||
'session.quickCommandEditorForm.appendEnterDescription',
|
||||
'Submit immediately instead of only inserting text.'
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
@@ -201,7 +248,7 @@ export function QuickCommandEditorForm({
|
||||
) : null}
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Scope</Text>
|
||||
<Text style={styles.label}>{t('session.quickCommandEditorForm.scope', 'Scope')}</Text>
|
||||
<View style={styles.toggleGroup}>
|
||||
{(['global', 'repo'] as const).map((scopeType) => {
|
||||
const selected = draft.scope.type === scopeType
|
||||
@@ -228,7 +275,9 @@ export function QuickCommandEditorForm({
|
||||
accessibilityState={{ selected, disabled }}
|
||||
>
|
||||
<Text style={[styles.toggleText, selected && styles.toggleTextSelected]}>
|
||||
{scopeType === 'global' ? 'Global' : 'Project'}
|
||||
{scopeType === 'global'
|
||||
? t('session.quickCommandEditorForm.globalScope', 'Global')
|
||||
: t('session.quickCommandEditorForm.projectScope', 'Project')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
@@ -250,7 +299,7 @@ export function QuickCommandEditorForm({
|
||||
onPress={onCancel}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Text style={styles.cancelText}>Cancel</Text>
|
||||
<Text style={styles.cancelText}>{t('common.cancel', 'Cancel')}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.button, styles.saveButton, !canSave && styles.saveButtonDisabled]}
|
||||
@@ -259,7 +308,9 @@ export function QuickCommandEditorForm({
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Text style={[styles.saveText, !canSave && styles.saveTextDisabled]}>
|
||||
{mode === 'edit' ? 'Save' : 'Add Quick Command'}
|
||||
{mode === 'edit'
|
||||
? t('common.save', 'Save')
|
||||
: t('session.quickCommandEditorForm.addQuickCommand', 'Add Quick Command')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
isAgentQuickCommand
|
||||
} from '../terminal/quick-commands'
|
||||
import { themedStyleSheet } from '../theme/themed-styles'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
|
||||
type QuickCommandRowProps = {
|
||||
command: TerminalQuickCommand
|
||||
@@ -37,6 +38,7 @@ export function QuickCommandRow({
|
||||
disabled
|
||||
}: QuickCommandRowProps) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const clipboard = useClipboardWriter()
|
||||
const isAgent = isAgentQuickCommand(command)
|
||||
const body = getTerminalQuickCommandBody(command)
|
||||
@@ -99,12 +101,12 @@ export function QuickCommandRow({
|
||||
const copyDisabled = disabled || !canCopy
|
||||
const copyLabel =
|
||||
copyStatus === 'copied'
|
||||
? 'Copied'
|
||||
? t('common.copied', 'Copied')
|
||||
: copyStatus === 'failed'
|
||||
? "Couldn't copy"
|
||||
? t('session.quickCommandRow.copyFailed', "Couldn't copy")
|
||||
: canCopy
|
||||
? `Copy ${command.label}`
|
||||
: 'Nothing to copy'
|
||||
? t('session.quickCommandRow.copyCommand', 'Copy {{label}}', { label: command.label })
|
||||
: t('session.quickCommandRow.nothingToCopy', 'Nothing to copy')
|
||||
const copyIconColor =
|
||||
copyStatus === 'copied'
|
||||
? colors.statusGreen
|
||||
@@ -119,7 +121,9 @@ export function QuickCommandRow({
|
||||
disabled={disabled}
|
||||
onPress={() => onLaunch(command)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Run ${command.label}`}
|
||||
accessibilityLabel={t('session.quickCommandRow.runCommand', 'Run {{label}}', {
|
||||
label: command.label
|
||||
})}
|
||||
>
|
||||
<View style={styles.rowIcon}>
|
||||
{isAgent ? (
|
||||
@@ -160,7 +164,9 @@ export function QuickCommandRow({
|
||||
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
|
||||
disabled={disabled}
|
||||
onPress={() => onEdit(command)}
|
||||
accessibilityLabel={`Edit ${command.label}`}
|
||||
accessibilityLabel={t('session.quickCommandRow.editCommand', 'Edit {{label}}', {
|
||||
label: command.label
|
||||
})}
|
||||
>
|
||||
<Pencil size={15} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
@@ -168,7 +174,9 @@ export function QuickCommandRow({
|
||||
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
|
||||
disabled={disabled}
|
||||
onPress={() => onDelete(command)}
|
||||
accessibilityLabel={`Delete ${command.label}`}
|
||||
accessibilityLabel={t('session.quickCommandRow.deleteCommand', 'Delete {{label}}', {
|
||||
label: command.label
|
||||
})}
|
||||
>
|
||||
<Trash2 size={15} color={colors.statusRed} />
|
||||
</Pressable>
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-co
|
||||
import type { TuiAgent } from '../../../src/shared/tui-agent'
|
||||
import { supportsTerminalAgentQuickCommand } from '../terminal/quick-commands'
|
||||
import { QuickCommandRow } from './QuickCommandRow'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
|
||||
export const QUICK_COMMAND_SUPPORTED_AGENTS = MOBILE_AGENT_CATALOG.filter((entry) =>
|
||||
supportsTerminalAgentQuickCommand(entry.id)
|
||||
@@ -47,6 +48,7 @@ export function QuickCommandsList({
|
||||
onAdd
|
||||
}: ListProps) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const hasVisible = repoCommands.length + globalCommands.length > 0
|
||||
// Why: keep an active filter clearable if a delete or paired desktop edit
|
||||
// leaves only one command while the sheet is open.
|
||||
@@ -60,7 +62,10 @@ export function QuickCommandsList({
|
||||
style={styles.searchInput}
|
||||
value={query}
|
||||
onChangeText={onQueryChange}
|
||||
placeholder="Search quick commands..."
|
||||
placeholder={t(
|
||||
'session.quickCommandsList.searchPlaceholder',
|
||||
'Search quick commands...'
|
||||
)}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -78,16 +83,20 @@ export function QuickCommandsList({
|
||||
) : null}
|
||||
|
||||
{!loading && totalCount === 0 ? (
|
||||
<Text style={styles.empty}>No quick commands yet.</Text>
|
||||
<Text style={styles.empty}>
|
||||
{t('session.quickCommandsList.emptyState', 'No quick commands yet.')}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{!loading && totalCount > 0 && !hasVisible ? (
|
||||
<Text style={styles.empty}>No matching quick commands.</Text>
|
||||
<Text style={styles.empty}>
|
||||
{t('session.quickCommandsList.noMatches', 'No matching quick commands.')}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{repoCommands.length > 0 ? (
|
||||
<QuickCommandGroup
|
||||
label="This project"
|
||||
label={t('session.quickCommandsList.projectGroup', 'This project')}
|
||||
commands={repoCommands}
|
||||
onLaunch={onLaunch}
|
||||
onEdit={onEdit}
|
||||
@@ -98,7 +107,7 @@ export function QuickCommandsList({
|
||||
|
||||
{globalCommands.length > 0 ? (
|
||||
<QuickCommandGroup
|
||||
label="Global"
|
||||
label={t('session.quickCommandsList.globalGroup', 'Global')}
|
||||
commands={globalCommands}
|
||||
onLaunch={onLaunch}
|
||||
onEdit={onEdit}
|
||||
@@ -118,7 +127,9 @@ export function QuickCommandsList({
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Plus size={18} color={colors.textSecondary} />
|
||||
<Text style={styles.addText}>New quick command</Text>
|
||||
<Text style={styles.addText}>
|
||||
{t('session.quickCommandsList.newCommand', 'New quick command')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
quickCommandToDraft,
|
||||
type QuickCommandDraft
|
||||
} from './quick-command-draft'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
|
||||
type Props = {
|
||||
visible: boolean
|
||||
@@ -40,6 +41,7 @@ export function QuickCommandsSheet({
|
||||
onLaunch
|
||||
}: Props) {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const { commands, loading, ready, error, persist } = useQuickCommands({
|
||||
client,
|
||||
enabled: visible
|
||||
@@ -114,12 +116,17 @@ export function QuickCommandsSheet({
|
||||
// Why: quick commands sync with desktop, so an accidental one-tap delete
|
||||
// removes shared data rather than only dismissing a local row.
|
||||
Alert.alert(
|
||||
`Delete "${command.label || 'Untitled'}"?`,
|
||||
'This quick command will be removed from your saved list.',
|
||||
t('session.quickCommandsSheet.deleteConfirmTitle', 'Delete "{{label}}"?', {
|
||||
label: command.label || t('session.quickCommandsSheet.untitled', 'Untitled')
|
||||
}),
|
||||
t(
|
||||
'session.quickCommandsSheet.deleteConfirmMessage',
|
||||
'This quick command will be removed from your saved list.'
|
||||
),
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
text: t('common.delete', 'Delete'),
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
void persist({ type: 'delete', id: command.id })
|
||||
@@ -162,11 +169,11 @@ export function QuickCommandsSheet({
|
||||
const title =
|
||||
view === 'editor'
|
||||
? draft?.id
|
||||
? 'Edit Quick Command'
|
||||
: 'Add Quick Command'
|
||||
? t('session.quickCommandsSheet.editTitle', 'Edit Quick Command')
|
||||
: t('session.quickCommandsSheet.addTitle', 'Add Quick Command')
|
||||
: view === 'agent'
|
||||
? 'Choose Agent'
|
||||
: 'Quick Commands'
|
||||
? t('session.quickCommandsSheet.chooseAgentTitle', 'Choose Agent')
|
||||
: t('session.quickCommandsSheet.title', 'Quick Commands')
|
||||
|
||||
return (
|
||||
<BottomDrawer visible={visible} onClose={onClose}>
|
||||
@@ -181,7 +188,7 @@ export function QuickCommandsSheet({
|
||||
setView(view === 'agent' ? 'editor' : 'list')
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Back"
|
||||
accessibilityLabel={t('common.back', 'Back')}
|
||||
>
|
||||
<ChevronLeft size={18} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
@@ -193,7 +200,10 @@ export function QuickCommandsSheet({
|
||||
{view === 'editor' && draft ? (
|
||||
<View style={styles.editorDesc}>
|
||||
<Text style={styles.descText}>
|
||||
Save terminal commands or agent prompts for quick access.
|
||||
{t(
|
||||
'session.quickCommandsSheet.editorDescription',
|
||||
'Save terminal commands or agent prompts for quick access.'
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SquareChevronRight } from 'lucide-react-native'
|
||||
import { MobileSessionHeaderIconButton } from './MobileSessionHeaderIconButton'
|
||||
import { styles } from './mobile-session-styles'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
|
||||
type Props = {
|
||||
disabled: boolean
|
||||
@@ -11,6 +12,7 @@ type Props = {
|
||||
}
|
||||
|
||||
export function QuickCommandsTabButton({ disabled, onPress }: Props) {
|
||||
const t = useMobileTranslation()
|
||||
useMobileColorScheme()
|
||||
return (
|
||||
<>
|
||||
@@ -18,7 +20,7 @@ export function QuickCommandsTabButton({ disabled, onPress }: Props) {
|
||||
<MobileSessionHeaderIconButton
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
accessibilityLabel="Quick commands"
|
||||
accessibilityLabel={t('session.quickCommandsTabButton.quickCommands', 'Quick commands')}
|
||||
icon={SquareChevronRight}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { AiVaultResumeTargetStatus } from '../../../src/shared/ai-vault-res
|
||||
import { QODER_OWNED_TERMINAL_CREATE_CAPABILITY } from '../../../src/shared/qoder-terminal-create-capability'
|
||||
import { agentHistoryHostStatusSchema } from '../agent-history/agent-history-reply-schema'
|
||||
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
|
||||
export function readMobileAiVaultResumeHost(statusResult: unknown) {
|
||||
const status = agentHistoryHostStatusSchema.safeParse(statusResult)
|
||||
@@ -112,7 +113,7 @@ export async function resumeAiVaultSessionInTerminal(
|
||||
let terminalTab
|
||||
terminalTab = interpretOrThrowRefusalMessage(
|
||||
() => reviewTerminalCreateRun.interpret(created),
|
||||
'Failed to create terminal'
|
||||
translate('session.aiVaultResumeLaunch.createFailed', 'Failed to create terminal')
|
||||
)
|
||||
if (launchAtCreate) {
|
||||
return terminalTab
|
||||
@@ -130,10 +131,12 @@ export async function resumeAiVaultSessionInTerminal(
|
||||
let accepted
|
||||
accepted = interpretOrThrowRefusalMessage(
|
||||
() => reviewTerminalSendRun.interpret(sent),
|
||||
'Failed to send resume command'
|
||||
translate('session.aiVaultResumeLaunch.sendFailed', 'Failed to send resume command')
|
||||
)
|
||||
if (!accepted) {
|
||||
throw new Error('Terminal input is locked')
|
||||
throw new Error(
|
||||
translate('session.aiVaultResumeLaunch.inputLocked', 'Terminal input is locked')
|
||||
)
|
||||
}
|
||||
return terminalTab
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../src/shared/execution-host'
|
||||
import { aiVaultResumePreparationRun } from './mobile-session-launch-operations'
|
||||
import type { RpcOperationSender } from '../transport/rpc-operation-sender'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
|
||||
// Why: without an explicit timeout, a socket drop mid-resume parks the request
|
||||
// on the reconnect waiter for the full reconnect budget, pinning the spinner.
|
||||
@@ -43,7 +44,11 @@ export async function prepareMobileAiVaultSessionResume(
|
||||
return session
|
||||
}
|
||||
throw new Error(
|
||||
response.error?.message || 'Could not prepare this legacy Codex session. Retry resume.'
|
||||
response.error?.message ||
|
||||
translate(
|
||||
'session.aiVaultResumePreparation.prepareFailed',
|
||||
'Could not prepare this legacy Codex session. Retry resume.'
|
||||
)
|
||||
)
|
||||
}
|
||||
const result = aiVaultResumePreparationRun.interpret(response)
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
|
||||
// Match desktop’s synthetic id: Floating Workspace has no backing repo/worktree and always uses the local runtime.
|
||||
export const FLOATING_WORKSPACE_WORKTREE_ID = 'global-floating-terminal'
|
||||
|
||||
export const FLOATING_WORKSPACE_TITLE = 'Floating Workspace'
|
||||
|
||||
export function getFloatingWorkspaceTitle(): string {
|
||||
return translate('session.floatingWorkspace.title', 'Floating Workspace')
|
||||
}
|
||||
|
||||
export function isFloatingWorkspaceWorktreeId(worktreeId: string | null | undefined): boolean {
|
||||
return worktreeId === FLOATING_WORKSPACE_WORKTREE_ID
|
||||
}
|
||||
|
||||
// Seed name in the route before session tabs load.
|
||||
export function floatingWorkspaceSessionPath(hostId: string | undefined): string {
|
||||
return `/h/${hostId}/session/${FLOATING_WORKSPACE_WORKTREE_ID}?name=${encodeURIComponent(FLOATING_WORKSPACE_TITLE)}`
|
||||
return `/h/${hostId}/session/${FLOATING_WORKSPACE_WORKTREE_ID}?name=${encodeURIComponent(getFloatingWorkspaceTitle())}`
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type GitHubPrRepoSlug
|
||||
} from './github-pr-repo-slug'
|
||||
import type { RpcOperationSender } from '../transport/rpc-operation-sender'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
|
||||
export type { GitHubPrMutationOutcome } from './github-pr-mutation-outcome'
|
||||
|
||||
@@ -62,7 +63,7 @@ export function fetchUpdatePRTitle(
|
||||
prRepo: args.prRepo
|
||||
})
|
||||
),
|
||||
'Failed to update title.'
|
||||
translate('session.githubPrMutations.titleUpdateFailed', 'Failed to update title.')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -264,7 +265,10 @@ export function fetchResolveReviewThread(
|
||||
{ prRepo: args.prRepo }
|
||||
)
|
||||
),
|
||||
'Failed to update review thread.'
|
||||
translate(
|
||||
'session.githubPrMutations.reviewThreadUpdateFailed',
|
||||
'Failed to update review thread.'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import type { GitHubPrSettleableOperation } from './github-pr-mutation-outcome'
|
||||
import { githubPrRequestParams, type GitHubPrRepoSlug } from './github-pr-repo-slug'
|
||||
import type { RpcOperationSender } from '../transport/rpc-operation-sender'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
|
||||
export {
|
||||
buildGithubPrParams,
|
||||
@@ -39,7 +40,9 @@ async function settleGithubPrRead<Value>(
|
||||
read: GitHubPrSettleableOperation<Value>,
|
||||
send: () => Promise<RpcResponse>
|
||||
): Promise<GitHubPrReadOutcome<Value>> {
|
||||
const fallback = `Request failed: ${read.operation.method}`
|
||||
const fallback = translate('session.githubPrRpc.requestFailed', 'Request failed: {{method}}', {
|
||||
method: read.operation.method
|
||||
})
|
||||
let reply: RpcResponse
|
||||
try {
|
||||
reply = await send()
|
||||
|
||||
@@ -5,17 +5,43 @@ import {
|
||||
} from '../../../src/shared/agent-child-row-lead-trail'
|
||||
import type { AgentChildRowModel } from '../../../src/shared/agent-child-row-model'
|
||||
import type { AgentChildDisplayState } from '../../../src/shared/agent-status-child-work-display'
|
||||
import { sayBackgroundTaskEnglish } from '../../../src/shared/background-task-copy'
|
||||
import { sayMobileBackgroundTask } from './mobile-background-task-copy'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
import { agentStateLabel } from '../worktree/agent-row-display'
|
||||
|
||||
export function mobileAgentChildStateLabel(state: AgentChildDisplayState): string {
|
||||
return state === 'unverifiable' ? 'No recent update' : agentStateLabel(state)
|
||||
return state === 'unverifiable'
|
||||
? translate('session.agentChildRow.noRecentUpdate', 'No recent update')
|
||||
: agentStateLabel(state)
|
||||
}
|
||||
|
||||
const MOBILE_AGENT_CHILD_ROW_COPY = {
|
||||
stateLabel: mobileAgentChildStateLabel,
|
||||
ended: () => 'Ended',
|
||||
sayBackgroundTask: sayBackgroundTaskEnglish
|
||||
function mobileAgentChildRowCopy() {
|
||||
return {
|
||||
stateLabel: mobileAgentChildStateLabel,
|
||||
ended: () => translate('session.agentChildRow.ended', 'Ended'),
|
||||
sayBackgroundTask: sayMobileBackgroundTask
|
||||
}
|
||||
}
|
||||
|
||||
function noUpdateDuration(deltaMs: number): string {
|
||||
const minutes = Math.max(0, Math.floor(deltaMs / 60_000))
|
||||
if (minutes < 60) {
|
||||
const count = minutes
|
||||
return count === 1
|
||||
? translate('session.agentChildRow.minutes_one', '{{count}}m', { count })
|
||||
: translate('session.agentChildRow.minutes_other', '{{count}}m', { count })
|
||||
}
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) {
|
||||
const count = hours
|
||||
return count === 1
|
||||
? translate('session.agentChildRow.hours_one', '{{count}}h', { count })
|
||||
: translate('session.agentChildRow.hours_other', '{{count}}h', { count })
|
||||
}
|
||||
const count = Math.floor(hours / 24)
|
||||
return count === 1
|
||||
? translate('session.agentChildRow.days_one', '{{count}}d', { count })
|
||||
: translate('session.agentChildRow.days_other', '{{count}}d', { count })
|
||||
}
|
||||
|
||||
export function mobileAgentChildRowName(row: AgentChildRowModel): string {
|
||||
@@ -26,9 +52,13 @@ export function mobileAgentChildRowText(
|
||||
row: AgentChildRowModel,
|
||||
now: number
|
||||
): { lead: string; trail: string } {
|
||||
return agentChildRowLeadTrail(
|
||||
row,
|
||||
mobileAgentChildRowName(row),
|
||||
agentChildRowDetailText(row, now, MOBILE_AGENT_CHILD_ROW_COPY)
|
||||
)
|
||||
const detail =
|
||||
row.detail?.kind === 'no-update'
|
||||
? translate('session.agentChildRow.noUpdate', 'No update in {{value0}}', {
|
||||
value0: noUpdateDuration(now - row.recencyAt)
|
||||
})
|
||||
: row.detail?.kind === 'role' && (!row.detail.agentType || row.detail.agentType === 'unknown')
|
||||
? translate('session.agentChildRow.agent', 'Agent')
|
||||
: agentChildRowDetailText(row, now, mobileAgentChildRowCopy())
|
||||
return agentChildRowLeadTrail(row, mobileAgentChildRowName(row), detail)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
import {
|
||||
agentMessageSendersShown,
|
||||
type AgentMessageSource
|
||||
} from '../../../src/shared/agent-session/agent-session-message-source'
|
||||
|
||||
const UNNAMED_SENDER = 'an agent'
|
||||
|
||||
export function agentMessageAttribution(
|
||||
lead: 'From' | 'Message from',
|
||||
source: AgentMessageSource | undefined
|
||||
@@ -13,7 +12,33 @@ export function agentMessageAttribution(
|
||||
return null
|
||||
}
|
||||
const { shown, more } = agentMessageSendersShown(source)
|
||||
if (shown.length === 0 || (shown.length === 1 && shown[0].name === null && more === 0)) {
|
||||
return lead === 'Message from'
|
||||
? translate('session.agentMessageAttribution.messageFromUnnamed', 'Message from an agent')
|
||||
: translate('session.agentMessageAttribution.fromUnnamed', 'From an agent')
|
||||
}
|
||||
// One name per sender, repeats included: two senders that share a name are still two.
|
||||
const names = shown.map((sender) => sender.name ?? UNNAMED_SENDER)
|
||||
return `${lead} ${names.length > 0 ? names.join(', ') : UNNAMED_SENDER}${more > 0 ? ` +${more}` : ''}`
|
||||
const sender = shown
|
||||
.map(
|
||||
(sender) =>
|
||||
sender.name ?? translate('session.agentMessageAttribution.unnamedSender', 'an agent')
|
||||
)
|
||||
.join(', ')
|
||||
if (more > 0) {
|
||||
return lead === 'Message from'
|
||||
? translate(
|
||||
'session.agentMessageAttribution.messageFromMore',
|
||||
'Message from {{sender}} +{{count}}',
|
||||
{ sender, count: more }
|
||||
)
|
||||
: translate('session.agentMessageAttribution.fromMore', 'From {{sender}} +{{count}}', {
|
||||
sender,
|
||||
count: more
|
||||
})
|
||||
}
|
||||
return lead === 'Message from'
|
||||
? translate('session.agentMessageAttribution.messageFrom', 'Message from {{sender}}', {
|
||||
sender
|
||||
})
|
||||
: translate('session.agentMessageAttribution.from', 'From {{sender}}', { sender })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
BackgroundTaskCopyId,
|
||||
BackgroundTaskSay
|
||||
} from '../../../src/shared/background-task-copy'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
|
||||
export const sayMobileBackgroundTask: BackgroundTaskSay = (id, values) => {
|
||||
const count = values?.count
|
||||
const countValues = count === undefined ? undefined : { count }
|
||||
const phraseValues = values?.value0 === undefined ? undefined : { value0: values.value0 }
|
||||
const copy = {
|
||||
monitoring: () => translate('session.backgroundTask.monitoring', 'Monitoring background tasks'),
|
||||
stop: () => translate('session.backgroundTask.stop', 'Stop'),
|
||||
stopTask: () => translate('session.backgroundTask.stopTask', 'Stop {{value0}}', phraseValues),
|
||||
stopAll: () => translate('session.backgroundTask.stopAll', 'Stop background tasks'),
|
||||
agent: () => translate('session.backgroundTask.agent', 'Background agent'),
|
||||
workflow: () => translate('session.backgroundTask.workflow', 'Background workflow'),
|
||||
command: () => translate('session.backgroundTask.command', 'Background command'),
|
||||
monitor: () => translate('session.backgroundTask.monitor', 'Background monitor'),
|
||||
task: () => translate('session.backgroundTask.task', 'Background task'),
|
||||
detailsUnavailable: () =>
|
||||
translate(
|
||||
'session.backgroundTask.detailsUnavailable',
|
||||
'Task details are unavailable for this session.'
|
||||
),
|
||||
countAgents: () =>
|
||||
count === 1
|
||||
? translate('session.backgroundTask.countAgents_one', '{{count}} agent', countValues)
|
||||
: translate('session.backgroundTask.countAgents_other', '{{count}} agents', countValues),
|
||||
countShell: () =>
|
||||
count === 1
|
||||
? translate('session.backgroundTask.countShell_one', '{{count}} shell', countValues)
|
||||
: translate('session.backgroundTask.countShell_other', '{{count}} shells', countValues),
|
||||
countShellCommandOne: () =>
|
||||
translate('session.backgroundTask.countShellCommandOne', '1 shell command'),
|
||||
countMonitors: () =>
|
||||
count === 1
|
||||
? translate('session.backgroundTask.countMonitors_one', '{{count}} monitor', countValues)
|
||||
: translate(
|
||||
'session.backgroundTask.countMonitors_other',
|
||||
'{{count}} monitors',
|
||||
countValues
|
||||
),
|
||||
countWorkflows: () =>
|
||||
count === 1
|
||||
? translate('session.backgroundTask.countWorkflows_one', '{{count}} workflow', countValues)
|
||||
: translate(
|
||||
'session.backgroundTask.countWorkflows_other',
|
||||
'{{count}} workflows',
|
||||
countValues
|
||||
),
|
||||
countTasks: () =>
|
||||
count === 1
|
||||
? translate('session.backgroundTask.countTasks_one', '{{count}} task', countValues)
|
||||
: translate('session.backgroundTask.countTasks_other', '{{count}} tasks', countValues),
|
||||
headerTotal: () =>
|
||||
count === 1
|
||||
? translate(
|
||||
'session.backgroundTask.headerTotalCount_one',
|
||||
'{{count}} background task',
|
||||
countValues
|
||||
)
|
||||
: translate(
|
||||
'session.backgroundTask.headerTotalCount_other',
|
||||
'{{count}} background tasks',
|
||||
countValues
|
||||
),
|
||||
stateWorking: () => translate('session.backgroundTask.stateWorking', 'working'),
|
||||
stateWorkingCount: () =>
|
||||
translate('session.backgroundTask.stateWorkingCount', '{{value0}} working', phraseValues),
|
||||
stateMonitoring: () => translate('session.backgroundTask.stateMonitoring', 'monitoring'),
|
||||
stateMonitoringCount: () =>
|
||||
translate(
|
||||
'session.backgroundTask.stateMonitoringCount',
|
||||
'{{value0}} monitoring',
|
||||
phraseValues
|
||||
),
|
||||
stateWaiting: () => translate('session.backgroundTask.stateWaiting', 'waiting'),
|
||||
stateWaitingCount: () =>
|
||||
translate('session.backgroundTask.stateWaitingCount', '{{value0}} waiting', phraseValues),
|
||||
stateBlocked: () => translate('session.backgroundTask.stateBlocked', 'blocked'),
|
||||
stateBlockedCount: () =>
|
||||
translate('session.backgroundTask.stateBlockedCount', '{{value0}} blocked', phraseValues),
|
||||
stateDone: () => translate('session.backgroundTask.stateDone', 'done'),
|
||||
stateDoneCount: () =>
|
||||
translate('session.backgroundTask.stateDoneCount', '{{value0}} done', phraseValues),
|
||||
stateIdle: () => translate('session.backgroundTask.stateIdle', 'stopped'),
|
||||
stateIdleCount: () =>
|
||||
translate('session.backgroundTask.stateIdleCount', '{{value0}} stopped', phraseValues),
|
||||
stateUnverifiable: () =>
|
||||
translate('session.backgroundTask.stateUnverifiable', 'status unavailable'),
|
||||
stateUnverifiableCount: () =>
|
||||
translate(
|
||||
'session.backgroundTask.stateUnverifiableCount',
|
||||
'{{value0}} with status unavailable',
|
||||
phraseValues
|
||||
),
|
||||
reasonWaiting: () => translate('session.backgroundTask.reasonWaiting', 'needs approval'),
|
||||
reasonBlocked: () => translate('session.backgroundTask.reasonBlocked', 'failed'),
|
||||
groupAgents: () => translate('session.backgroundTask.groupAgents', 'Agents'),
|
||||
groupShell: () => translate('session.backgroundTask.groupShell', 'Shell'),
|
||||
groupMonitors: () => translate('session.backgroundTask.groupMonitors', 'Monitors'),
|
||||
groupWorkflows: () => translate('session.backgroundTask.groupWorkflows', 'Workflows'),
|
||||
groupTasks: () => translate('session.backgroundTask.groupTasks', 'Tasks')
|
||||
} satisfies Record<BackgroundTaskCopyId, () => string>
|
||||
return copy[id]()
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { MarkdownDocState, MobileSessionTab } from './mobile-session-route-
|
||||
import type { ActionSheetAction } from '../components/ActionSheetModal'
|
||||
import { selectBulkCloseTabs } from './mobile-tab-close-selection'
|
||||
import { closeTabsAfterConfirmation } from '../../../src/shared/bulk-tab-close-confirmation'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
|
||||
type BulkCloseSheetDeps = {
|
||||
sessionTabsRef: { readonly current: readonly MobileSessionTab[] }
|
||||
@@ -50,7 +51,8 @@ export function createBulkCloseSheetActions(deps: BulkCloseSheetDeps) {
|
||||
}
|
||||
return [
|
||||
{
|
||||
label: 'Close Other Tabs',
|
||||
id: 'closeOtherTabs',
|
||||
label: translate('session.bulkCloseSheetActions.closeOtherTabs', 'Close Other Tabs'),
|
||||
destructive: true,
|
||||
// Why: the confirm is its own native Modal; wait for this sheet to unmount (#10331).
|
||||
closeBeforePress: true,
|
||||
@@ -72,7 +74,8 @@ export function createCloseWithBulkActions(
|
||||
) {
|
||||
return (target: MobileSessionTab | null, dismiss: () => void): ActionSheetAction[] => [
|
||||
{
|
||||
label: 'Close',
|
||||
id: 'close',
|
||||
label: translate('common.close', 'Close'),
|
||||
destructive: true,
|
||||
onPress: () => {
|
||||
dismiss()
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Translate } from '../i18n/mobile-i18n'
|
||||
|
||||
export function mobileBulkTabCloseCopy(t: Translate) {
|
||||
return {
|
||||
title: {
|
||||
others: t('session.bulkTabClose.titleOthers', 'Close other tabs?'),
|
||||
right: t('session.bulkTabClose.titleRight', 'Close tabs to the right?'),
|
||||
left: t('session.bulkTabClose.titleLeft', 'Close tabs to the left?'),
|
||||
editors: t('session.bulkTabClose.titleEditors', 'Close all editor tabs?')
|
||||
},
|
||||
description: t(
|
||||
'session.bulkTabClose.description',
|
||||
'The selected tabs will be closed. Pinned tabs will stay open.'
|
||||
),
|
||||
confirm: t('session.bulkTabClose.confirm', 'Close tabs')
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import type { RpcResponse } from '../transport/types'
|
||||
import type { MobileDiffReviewQueueItem } from './mobile-diff-review-queue'
|
||||
import type { ReviewDiffState, ReviewScreenState } from './mobile-diff-review-screen-model'
|
||||
import { reviewDescriptorFromItem } from './mobile-diff-review-screen-model'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
|
||||
type BranchCompareLoadResult = {
|
||||
result: MobileGitBranchCompareResult | null
|
||||
@@ -73,12 +74,27 @@ export async function loadMobileDiffReviewBranchCompare(
|
||||
} catch (error) {
|
||||
return {
|
||||
result: null,
|
||||
error: refusedRpcMessageOrFallback(error, 'Committed changes unavailable')
|
||||
error: refusedRpcMessageOrFallback(
|
||||
error,
|
||||
translate(
|
||||
'session.diffReviewLoaders.committedChangesUnavailable',
|
||||
'Committed changes unavailable'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// A transport drop surfaces its own message verbatim; only a refusal falls back above.
|
||||
return { result: null, error: err instanceof Error ? err.message : 'Committed changes failed' }
|
||||
return {
|
||||
result: null,
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: translate(
|
||||
'session.diffReviewLoaders.committedChangesFailed',
|
||||
'Committed changes failed'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,16 +111,32 @@ export async function loadMobileDiffReviewSnapshot(
|
||||
!statusReply.ok &&
|
||||
isMobileGitUnavailable(statusReply.error?.code, statusReply.error?.message)
|
||||
) {
|
||||
return { kind: 'unavailable', message: 'Update Orca desktop to review changes on mobile.' }
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
message: translate(
|
||||
'session.diffReviewLoaders.desktopUpdateRequired',
|
||||
'Update Orca desktop to review changes on mobile.'
|
||||
)
|
||||
}
|
||||
}
|
||||
let status
|
||||
try {
|
||||
status = gitStatusProjectionRead.interpret(statusReply)
|
||||
} catch (error) {
|
||||
throw new Error(refusedRpcMessageOrFallback(error, 'Unable to load changes'))
|
||||
throw new Error(
|
||||
refusedRpcMessageOrFallback(
|
||||
error,
|
||||
translate('session.diffReviewLoaders.changesLoadFailed', 'Unable to load changes')
|
||||
)
|
||||
)
|
||||
}
|
||||
if (!status) {
|
||||
throw new Error('Source control response was invalid')
|
||||
throw new Error(
|
||||
translate(
|
||||
'session.diffReviewLoaders.invalidSourceControlResponse',
|
||||
'Source control response was invalid'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Interpret after both reads settle so worktree refusal cannot preempt the compare failure.
|
||||
@@ -119,7 +151,12 @@ export async function loadMobileDiffReviewSnapshot(
|
||||
try {
|
||||
metadata = reviewWorktreeMetadataRead.interpret(worktreeReply)
|
||||
} catch (error) {
|
||||
throw new Error(refusedRpcMessageOrFallback(error, 'Unable to load review notes'))
|
||||
throw new Error(
|
||||
refusedRpcMessageOrFallback(
|
||||
error,
|
||||
translate('session.diffReviewLoaders.reviewNotesLoadFailed', 'Unable to load review notes')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const comments = normalizeMobileDiffComments(metadata.diffComments, worktreeId)
|
||||
@@ -174,7 +211,12 @@ export async function loadMobileDiffReviewDiff(input: DiffLoadInput): Promise<Re
|
||||
try {
|
||||
result = pending.interpret(pending.reply)
|
||||
} catch (error) {
|
||||
throw new Error(refusedRpcMessageOrFallback(error, 'Unable to load diff'))
|
||||
throw new Error(
|
||||
refusedRpcMessageOrFallback(
|
||||
error,
|
||||
translate('session.diffReviewLoaders.diffLoadFailed', 'Unable to load diff')
|
||||
)
|
||||
)
|
||||
}
|
||||
if (result.kind === 'binary') {
|
||||
return { kind: 'binary', itemKey: item.key }
|
||||
@@ -216,7 +258,12 @@ async function requestBranchFileDiff(
|
||||
): Promise<PendingFileDiff> {
|
||||
const summary = branchCompare?.summary
|
||||
if (!summary || !summary.headOid || !summary.mergeBase) {
|
||||
throw new Error('Committed diff is unavailable')
|
||||
throw new Error(
|
||||
translate(
|
||||
'session.diffReviewLoaders.committedDiffUnavailable',
|
||||
'Committed diff is unavailable'
|
||||
)
|
||||
)
|
||||
}
|
||||
const reply = await reviewBranchFileDiffRead.request(client, {
|
||||
worktree: `id:${worktreeId}`,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { compareFileNames } from '../../../src/shared/file-name-sort'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
import type {
|
||||
DiffComment,
|
||||
DiffReviewScope,
|
||||
@@ -191,7 +192,11 @@ function statusEntryToQueueItem(
|
||||
oldPath: entry.oldPath,
|
||||
status: entry.status,
|
||||
title: entry.path,
|
||||
subtitle: scope === 'staged' ? 'Staged' : 'Unstaged',
|
||||
get subtitle() {
|
||||
return scope === 'staged'
|
||||
? translate('session.diffReviewQueue.staged', 'Staged')
|
||||
: translate('session.diffReviewQueue.unstaged', 'Unstaged')
|
||||
},
|
||||
added: entry.added,
|
||||
removed: entry.removed,
|
||||
canStage: isMobileGitStageableEntry(entry),
|
||||
@@ -227,7 +232,9 @@ function branchEntryToQueueItem(
|
||||
oldPath: entry.oldPath,
|
||||
status: entry.status,
|
||||
title: entry.path,
|
||||
subtitle: 'Committed on branch',
|
||||
get subtitle() {
|
||||
return translate('session.diffReviewQueue.committedOnBranch', 'Committed on branch')
|
||||
},
|
||||
added: entry.added,
|
||||
removed: entry.removed,
|
||||
canStage: false,
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import type { MobileDiffReviewFileDescriptor } from './mobile-diff-review-state'
|
||||
import type { MobileHighlightedDiffLine } from './mobile-file-syntax'
|
||||
import type { MobileReviewTerminalTab } from './review-terminal-reply-schema'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
|
||||
export type ReviewScreenState =
|
||||
| { kind: 'loading' }
|
||||
@@ -109,9 +110,11 @@ export function nextReviewIndexAfterMarkReviewed({
|
||||
|
||||
export function mobileReviewScopeLabel(item: MobileDiffReviewQueueItem): string {
|
||||
if (item.scope === 'branch') {
|
||||
return 'Branch'
|
||||
return translate('session.diffReviewScreenModel.branch', 'Branch')
|
||||
}
|
||||
return item.scope === 'staged' ? 'Staged' : 'Unstaged'
|
||||
return item.scope === 'staged'
|
||||
? translate('session.diffReviewScreenModel.staged', 'Staged')
|
||||
: translate('session.diffReviewScreenModel.unstaged', 'Unstaged')
|
||||
}
|
||||
|
||||
export function mobileReviewCountLabel(count: number, singular: string, plural: string): string {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AtSign, Image, Package, Paperclip, Terminal } from 'lucide-react-native'
|
||||
import type { ActionSheetAction } from '../components/ActionSheetModal'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
|
||||
// Dismiss the sheet before opening pickers because iOS cannot present them over its Modal.
|
||||
export function mobileNativeChatAttachActions(args: {
|
||||
@@ -12,12 +13,19 @@ export function mobileNativeChatAttachActions(args: {
|
||||
}): ActionSheetAction[] {
|
||||
const actions: ActionSheetAction[] = []
|
||||
if (args.onPhotos) {
|
||||
actions.push({ label: 'Photos', icon: Image, closeBeforePress: true, onPress: args.onPhotos })
|
||||
actions.push({
|
||||
id: 'photos',
|
||||
label: translate('session.nativeChatAttachActions.photos', 'Photos'),
|
||||
icon: Image,
|
||||
closeBeforePress: true,
|
||||
onPress: args.onPhotos
|
||||
})
|
||||
}
|
||||
if (args.onFiles) {
|
||||
actions.push({
|
||||
label: 'Files',
|
||||
hint: 'PDF, text, code…',
|
||||
id: 'files',
|
||||
label: translate('session.nativeChatAttachActions.files', 'Files'),
|
||||
hint: translate('session.nativeChatAttachActions.fileTypes', 'PDF, text, code…'),
|
||||
icon: Paperclip,
|
||||
closeBeforePress: true,
|
||||
onPress: args.onFiles
|
||||
@@ -25,19 +33,37 @@ export function mobileNativeChatAttachActions(args: {
|
||||
}
|
||||
actions.push(
|
||||
{
|
||||
label: 'Workspace file',
|
||||
id: 'workspaceFile',
|
||||
label: translate('session.nativeChatAttachActions.workspaceFile', 'Workspace file'),
|
||||
icon: AtSign,
|
||||
closeBeforePress: true,
|
||||
onPress: args.onWorkspaceFile
|
||||
},
|
||||
{
|
||||
label: 'Skills',
|
||||
...(args.skillCount > 0 ? { hint: `${args.skillCount} available` } : {}),
|
||||
id: 'skills',
|
||||
label: translate('session.nativeChatAttachActions.skills', 'Skills'),
|
||||
...(args.skillCount > 0
|
||||
? {
|
||||
hint: translate(
|
||||
'session.nativeChatAttachActions.availableSkills',
|
||||
'{{count}} available',
|
||||
{
|
||||
count: args.skillCount
|
||||
}
|
||||
)
|
||||
}
|
||||
: {}),
|
||||
icon: Package,
|
||||
closeBeforePress: true,
|
||||
onPress: args.onSkills
|
||||
},
|
||||
{ label: 'Commands', icon: Terminal, closeBeforePress: true, onPress: args.onCommands }
|
||||
{
|
||||
id: 'commands',
|
||||
label: translate('session.nativeChatAttachActions.commands', 'Commands'),
|
||||
icon: Terminal,
|
||||
closeBeforePress: true,
|
||||
onPress: args.onCommands
|
||||
}
|
||||
)
|
||||
return actions
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SquareTerminal } from 'lucide-react-native'
|
||||
import type { ActionSheetAction } from '../components/ActionSheetModal'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
import type { MobileSessionController } from './use-mobile-session-controller'
|
||||
import { agentForksConversations } from '../../../src/shared/agent-resume-argv'
|
||||
import { SESSION_TABS_CHAT_FORK_CLI_RUNTIME_CAPABILITY } from '../../../src/shared/session-tabs-runtime-capabilities'
|
||||
@@ -37,7 +38,8 @@ export function getMobileNativeChatCliForkActions(args: {
|
||||
}
|
||||
return [
|
||||
{
|
||||
label: 'Resume in New CLI',
|
||||
id: 'resumeInNewCli',
|
||||
label: translate('session.nativeChatCliFork.resumeInNewCli', 'Resume in New CLI'),
|
||||
icon: SquareTerminal,
|
||||
onPress: () => {
|
||||
args.onDismiss()
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { formatAgentTypeLabel } from '../../../src/shared/agent-type-label'
|
||||
import {
|
||||
formatNativeChatEmptyStateCopy,
|
||||
type NativeChatEmptyStateCopy
|
||||
} from '../../../src/shared/native-chat/native-chat-empty-state'
|
||||
import type { NativeChatEmptyStateCopy } from '../../../src/shared/native-chat/native-chat-empty-state'
|
||||
import { isRootAgentJournalItem } from '../../../src/shared/agent-session-journal-producer'
|
||||
import { stripNoiseMessages } from '../../../src/shared/native-chat-noise'
|
||||
import { nativeChatStreamingMessage } from '../../../src/shared/native-chat/native-chat-streaming'
|
||||
@@ -20,6 +17,46 @@ import {
|
||||
type NativeChatMessage
|
||||
} from '../../../src/shared/native-chat-types'
|
||||
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
|
||||
function mobileNativeChatEmptyStateCopy(
|
||||
kind: 'notFound' | 'empty' | 'error',
|
||||
agentLabel: string
|
||||
): NativeChatEmptyStateCopy {
|
||||
switch (kind) {
|
||||
case 'notFound':
|
||||
return {
|
||||
title: translate(
|
||||
'session.nativeChatRenderData.notFoundTitle',
|
||||
"Can't find this conversation yet"
|
||||
),
|
||||
subtitle: translate(
|
||||
'session.nativeChatRenderData.notFoundSubtitle',
|
||||
'Send a message to start one, or open the terminal view to see what {{agent}} is doing.',
|
||||
{ agent: agentLabel }
|
||||
)
|
||||
}
|
||||
case 'empty':
|
||||
return {
|
||||
title: translate('session.nativeChatRenderData.emptyTitle', 'Start a chat with {{agent}}', {
|
||||
agent: agentLabel
|
||||
}),
|
||||
subtitle: translate(
|
||||
'session.nativeChatRenderData.emptySubtitle',
|
||||
'Ask {{agent}} to inspect code, explain output, or make a change.',
|
||||
{ agent: agentLabel }
|
||||
)
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
title: translate('session.nativeChatRenderData.errorTitle', 'Could not load conversation'),
|
||||
subtitle: translate(
|
||||
'session.nativeChatRenderData.errorSubtitle',
|
||||
'The transcript could not be read. Toggle back to the terminal to keep working.'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function mobileNativeChatEmptyState(
|
||||
status: MobileNativeChatStatus,
|
||||
@@ -27,18 +64,20 @@ export function mobileNativeChatEmptyState(
|
||||
error?: string,
|
||||
sessionLive = false
|
||||
): NativeChatEmptyStateCopy | null {
|
||||
const agentLabel = agent ? formatAgentTypeLabel(agent) : 'the agent'
|
||||
const agentLabel = agent
|
||||
? formatAgentTypeLabel(agent)
|
||||
: translate('session.nativeChatRenderData.defaultAgentLabel', 'the agent')
|
||||
switch (status) {
|
||||
// Missing history may mean new or unreadable; promise neither in the waiting copy.
|
||||
case 'waiting-session':
|
||||
return formatNativeChatEmptyStateCopy('notFound', agentLabel)
|
||||
return mobileNativeChatEmptyStateCopy('notFound', agentLabel)
|
||||
// A session the running agent just reported has no file until its first prompt.
|
||||
case 'awaiting-transcript':
|
||||
return formatNativeChatEmptyStateCopy(sessionLive ? 'empty' : 'notFound', agentLabel)
|
||||
return mobileNativeChatEmptyStateCopy(sessionLive ? 'empty' : 'notFound', agentLabel)
|
||||
case 'ready':
|
||||
return formatNativeChatEmptyStateCopy('empty', agentLabel)
|
||||
return mobileNativeChatEmptyStateCopy('empty', agentLabel)
|
||||
case 'error': {
|
||||
const copy = formatNativeChatEmptyStateCopy('error', agentLabel)
|
||||
const copy = mobileNativeChatEmptyStateCopy('error', agentLabel)
|
||||
return error ? { ...copy, subtitle: error } : copy
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MessageSquare, SquareTerminal } from 'lucide-react-native'
|
||||
import type { ActionSheetAction } from '../components/ActionSheetModal'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
import {
|
||||
resolveMobileNativeChat,
|
||||
type MobileNativeChatTab
|
||||
@@ -28,7 +29,13 @@ export function getMobileNativeChatToggleActions(args: {
|
||||
const isChat = isTabChatView(tab.id)
|
||||
return [
|
||||
{
|
||||
label: isChat ? 'Switch to terminal view' : 'Switch to chat view',
|
||||
id: 'toggleChatView',
|
||||
label: isChat
|
||||
? translate(
|
||||
'session.nativeChatToggleAction.switchToTerminalView',
|
||||
'Switch to terminal view'
|
||||
)
|
||||
: translate('session.nativeChatToggleAction.switchToChatView', 'Switch to chat view'),
|
||||
icon: isChat ? SquareTerminal : MessageSquare,
|
||||
onPress: () => {
|
||||
onClose()
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '../../../src/shared/github/linked-pr-staleness'
|
||||
import type { MobileWorktreeSummary } from '../source-control/mobile-worktree-metadata-operations'
|
||||
import type { WorktreeGitHubPRLink } from '../source-control/mobile-pr-link'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
|
||||
export type PrSidebarData = {
|
||||
pr: PRInfo
|
||||
@@ -197,7 +198,12 @@ export async function loadPrSidebarData(
|
||||
}
|
||||
} catch (err) {
|
||||
// Rejected dependencies must settle as UI errors rather than unhandled rejections.
|
||||
return failureState(err instanceof Error ? err.message : 'Unable to load pull request')
|
||||
return err instanceof Error
|
||||
? failureState(err.message)
|
||||
: {
|
||||
kind: 'error',
|
||||
message: translate('session.prSidebarState.loadFailed', 'Unable to load pull request')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
|
||||
export const nativeChatDictationCopy = {
|
||||
insertFailedCopied: () =>
|
||||
translate(
|
||||
'session.useNativeChatDictation.insertFailedCopied',
|
||||
"Couldn't insert dictation, so it was copied to the clipboard"
|
||||
),
|
||||
insertFailed: () =>
|
||||
translate('session.useNativeChatDictation.insertFailed', "Couldn't insert dictation"),
|
||||
inserted: () => translate('session.useNativeChatDictation.inserted', 'Dictation inserted')
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { buildMobileQuickCommandLaunch } from '../terminal/quick-commands'
|
||||
import type { MobileQuickCommandLaunch } from '../terminal/quick-commands'
|
||||
import { triggerError } from '../platform/haptics'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types'
|
||||
import type { MobileNewTabAgentOption } from './mobile-new-tab-agent-options'
|
||||
import type { MobileSessionAttachmentsModel } from './use-mobile-session-attachments'
|
||||
@@ -33,10 +34,22 @@ export function launchMobileSessionQuickCommand(
|
||||
const launch = buildMobileQuickCommandLaunch(command)
|
||||
if (!launch) {
|
||||
triggerError()
|
||||
scope.showToast('Edit this quick command before running it', 1800)
|
||||
scope.showToast(
|
||||
translate(
|
||||
'session.quickCommandLaunch.editBeforeRunning',
|
||||
'Edit this quick command before running it'
|
||||
),
|
||||
1800
|
||||
)
|
||||
return false
|
||||
}
|
||||
const label = command.label.trim() || 'Quick command'
|
||||
void createTerminal(launch.agent, { ...launch.options, errorToast: `Couldn't run ${label}` })
|
||||
const label =
|
||||
command.label.trim() || translate('session.quickCommandLaunch.defaultLabel', 'Quick command')
|
||||
void createTerminal(launch.agent, {
|
||||
...launch.options,
|
||||
errorToast: translate('session.quickCommandLaunch.runFailed', "Couldn't run {{label}}", {
|
||||
label
|
||||
})
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import type { TerminalModes } from '../terminal/terminal-webview-contract'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
|
||||
export const MOBILE_SESSION_STATUS_LABELS: Record<ConnectionState, string> = {
|
||||
connecting: 'Connecting',
|
||||
handshaking: 'Securing',
|
||||
connected: 'Connected',
|
||||
disconnected: 'Disconnected',
|
||||
reconnecting: 'Reconnecting',
|
||||
'auth-failed': 'Pairing invalid'
|
||||
get connecting() {
|
||||
return translate('session.routeHelpers.connecting', 'Connecting')
|
||||
},
|
||||
get handshaking() {
|
||||
return translate('session.routeHelpers.securing', 'Securing')
|
||||
},
|
||||
get connected() {
|
||||
return translate('session.routeHelpers.connected', 'Connected')
|
||||
},
|
||||
get disconnected() {
|
||||
return translate('session.routeHelpers.disconnected', 'Disconnected')
|
||||
},
|
||||
get reconnecting() {
|
||||
return translate('session.routeHelpers.reconnecting', 'Reconnecting')
|
||||
},
|
||||
get 'auth-failed'() {
|
||||
return translate('session.routeHelpers.pairingInvalid', 'Pairing invalid')
|
||||
}
|
||||
}
|
||||
|
||||
export const TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY = 64
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
|
||||
export const terminalCreateCopy = {
|
||||
forkUnavailable: (agentName: string) =>
|
||||
translate(
|
||||
'session.useTerminalCreateActions.forkUnavailable',
|
||||
'Open the {{agentName}} chat on a connected, updated host and try again.',
|
||||
{ agentName }
|
||||
),
|
||||
forkBusy: (agentName: string) =>
|
||||
translate(
|
||||
'session.useTerminalCreateActions.forkBusy',
|
||||
'Another terminal is opening. Wait, then retry the {{agentName}} chat.',
|
||||
{ agentName }
|
||||
),
|
||||
createFailed: () =>
|
||||
translate('session.useTerminalCreateActions.createFailed', 'Failed to create terminal'),
|
||||
terminalTitle: () => translate('session.useTerminalCreateActions.terminalTitle', 'Terminal'),
|
||||
sendNotesFailed: () =>
|
||||
translate('session.useTerminalCreateActions.sendNotesFailed', 'Failed to send notes'),
|
||||
inputLocked: () =>
|
||||
translate(
|
||||
'session.useTerminalCreateActions.inputLocked',
|
||||
'Terminal input is locked by another client.'
|
||||
),
|
||||
notesSent: () => translate('session.useTerminalCreateActions.notesSent', 'Notes sent'),
|
||||
sendNotesError: () =>
|
||||
translate('session.useTerminalCreateActions.sendNotesError', "Couldn't send notes")
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { filterEnabledTuiAgents } from '../../../src/shared/tui-agent-selection'
|
||||
import type { TuiAgent } from '../../../src/shared/tui-agent'
|
||||
import { isMobileTuiAgent } from '../tasks/mobile-tui-agents'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
import type { MobileAgentLaunchContext } from './mobile-new-tab-agent-loader'
|
||||
|
||||
export type MobileSourceControlLaunchAgent =
|
||||
@@ -38,7 +39,10 @@ export function resolveMobileSourceControlLaunchAgent(
|
||||
) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
message: 'The saved agent for this action is not available on this workspace host.'
|
||||
message: translate(
|
||||
'session.sourceControlLaunchAgent.savedAgentUnavailable',
|
||||
'The saved agent for this action is not available on this workspace host.'
|
||||
)
|
||||
}
|
||||
}
|
||||
const agent = pickSourceControlLaunchAgent({
|
||||
@@ -49,7 +53,13 @@ export function resolveMobileSourceControlLaunchAgent(
|
||||
})
|
||||
return agent
|
||||
? { kind: 'agent', agent, recipe }
|
||||
: { kind: 'unavailable', message: 'No enabled AI agent was detected on this workspace host.' }
|
||||
: {
|
||||
kind: 'unavailable',
|
||||
message: translate(
|
||||
'session.sourceControlLaunchAgent.noEnabledAgent',
|
||||
'No enabled AI agent was detected on this workspace host.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function readLaunchAgentSettings(settings: unknown): LaunchAgentSettings | null {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '../../../src/shared/structured-conversation-command-hold'
|
||||
import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
import type { MobileNativeChatSendOutcome } from './native-chat/mobile-native-chat-send'
|
||||
import { requestStructuredAgentSessionMutation } from './mobile-structured-agent-session-rpc'
|
||||
import type { MobileNativeChatSendErrorReporter } from './native-chat/use-mobile-native-chat-send-error'
|
||||
@@ -55,7 +56,12 @@ export async function dispatchMobileStructuredCommand(input: {
|
||||
return input.pending.current ? 'rejected' : null
|
||||
}
|
||||
if (input.hasAttachments) {
|
||||
input.onError('Remove attachments before using a chat-session command.')
|
||||
input.onError(
|
||||
translate(
|
||||
'session.structuredComposerCommand.removeAttachments',
|
||||
'Remove attachments before using a chat-session command.'
|
||||
)
|
||||
)
|
||||
return 'rejected'
|
||||
}
|
||||
let unknown = false
|
||||
@@ -107,7 +113,10 @@ export async function dispatchMobileStructuredCommand(input: {
|
||||
unknown = true
|
||||
return {
|
||||
accepted: false,
|
||||
error: 'Conversation operation was not confirmed.'
|
||||
error: translate(
|
||||
'session.structuredComposerCommand.operationUnconfirmed',
|
||||
'Conversation operation was not confirmed.'
|
||||
)
|
||||
}
|
||||
}
|
||||
if (result.status !== 'accepted') {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AgentSessionSendResult } from '../../../src/shared/agent-session-w
|
||||
import { agentSessionRefusalOperationState } from '../../../src/shared/agent-session-refusal-retry'
|
||||
import type { MobileNativeChatSendOutcome } from './native-chat/mobile-native-chat-send'
|
||||
import type { StructuredAgentSessionMutationCallResult } from './mobile-structured-agent-session-rpc'
|
||||
import { translate } from '../i18n/mobile-i18n'
|
||||
|
||||
export type MobileStructuredSendDelivery = {
|
||||
outcome: MobileNativeChatSendOutcome
|
||||
@@ -24,7 +25,10 @@ export function mobileStructuredSendDelivery(
|
||||
}
|
||||
if ('queued' in result.value && result.value.queued) {
|
||||
return result.value.queued.state === 'withdrawn'
|
||||
? { outcome: 'rejected', error: 'Message not sent' }
|
||||
? {
|
||||
outcome: 'rejected',
|
||||
error: translate('session.structuredSendDelivery.messageNotSent', 'Message not sent')
|
||||
}
|
||||
: { outcome: 'queued', error: null }
|
||||
}
|
||||
const submission = 'submission' in result.value ? result.value.submission : undefined
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user