Merge branch 'main' of https://github.com/stablyai/orca into bug-regression-gitlab-merge-request-creation-opt

This commit is contained in:
Jinjing
2026-06-11 19:41:05 -07:00
118 changed files with 5474 additions and 1827 deletions
+71 -1
View File
@@ -153,5 +153,75 @@ export const JA_VALUE_OVERRIDES = {
'Enter a commit message to commit': 'コミットメッセージを入力してコミット',
'Choose parent folder...': '親フォルダーを選択...',
'Check for stuck work, stale generated files, failing validation, and anything that needs human attention. Report only actionable issues.':
'スタックした作業、古い生成ファイル、検証の失敗、および人間の対応が必要なものがないか確認してください。対応が必要なイシューのみを報告してください。'
'スタックした作業、古い生成ファイル、検証の失敗、および人間の対応が必要なものがないか確認してください。対応が必要なイシューのみを報告してください。',
// Round 6: homograph/term mistranslations surfaced by a full ja.json audit.
// Why: machine translation rendered UI terms as unrelated everyday words.
Smart: 'スマート',
Jobs: 'ジョブ',
Bold: '太字',
Strike: '取り消し線',
thread: 'スレッド',
Lead: 'リード',
Assignees: '担当者',
'Assignees:': '担当者:',
Origin: 'オリジン',
Force: '強制',
Kind: '種類',
Address: 'アドレス',
Mouse: 'マウス',
Home: 'ホーム',
Move: '移動',
Change: '変更',
Conflicts: '競合',
conflict: '競合',
'unresolved conflict': '未解決の競合',
'Next match': '次の一致',
'Previous match': '前の一致',
'No matches': '一致なし',
'New Issue': '新規イシュー',
'Issue title': 'イシューのタイトル',
'Sub-issue title': 'サブイシューのタイトル',
Merged: 'マージ済み',
Behind: '遅れ',
'Head branch': 'ヘッドブランチ',
Import: 'インポート',
'Import…': 'インポート…',
'Import...': 'インポート...',
'Re-import': '再インポート',
Local: 'ローカル',
local: 'ローカル',
'Scanning...': 'スキャン中...',
Sparse: 'スパース',
sparse: 'スパース',
connection: '接続',
'Est. cost': '推定コスト',
'Est. API-equivalent cost': 'API 相当の推定コスト',
'Est. spend': '推定費用',
Spend: '費用',
'new markdown': '新規 markdown',
working: '実行中',
'Working…': '処理中…',
On: 'オン',
Hold: 'ホールド',
'Keep alive until reset': 'リセットされるまで維持',
Forward: '進む',
Fresh: '新規',
Gone: '削除済み',
Ding: 'ディン',
'Take back': '操作を取り戻す',
'Mobile driving': 'モバイルで操作中',
'Mobile is driving this browser': 'モバイルがこのブラウザを操作しています',
'Wants to run': '実行をリクエスト中',
'No states found': 'ステータスが見つかりません',
'Loading states': 'ステータスを読み込み中',
vibrancy: '透過効果',
ligature: '合字',
Zinc: 'ジンク',
Rose: 'ローズ',
import: 'インポート',
mouse: 'マウス',
'Open job in GitLab': 'GitLab でジョブを開く',
'Showing first 100 jobs': '最初の 100 件のジョブを表示しています',
'Imported from Ghostty.': 'Ghostty からインポートしました。',
'Claude Accounts': 'Claude アカウント'
}
+2
View File
@@ -590,5 +590,7 @@ export const LOCALE_KEY_OVERRIDES = {
zh: '已添加评审评论。',
ja: 'レビューコメントを追加しました。'
},
// Port forwarding "Forward" is 転送, not the browser-navigation 進む.
'auto.components.right.sidebar.PortsPanel.c9d106547a': { ja: '転送' },
...KO_KEY_OVERRIDES
}
+69 -1
View File
@@ -82,7 +82,75 @@ export const NEVER_TRANSLATE_VALUES = new Set([
'Nautilus',
'GitHub',
'no_proxy',
'Beta'
'Beta',
// Round 6: product/tool names, language names, and code tokens that machine
// translation wrongly localized (e.g. tailscale→尾鱗, Swift→迅速, yarn→糸).
'Tailscale',
'tailscale',
'Ghostty',
'ghostty',
'pwsh',
'yarn',
'Kagi',
'kagi',
'Bitbucket',
'bitbucket',
'GNOME',
'gnome',
'iCloud',
'icloud',
'ripgrep',
'PowerShell',
'powershell',
'Mermaid',
'mermaid',
'Swift',
'swift',
'Rust',
'rust',
'Java',
'java',
'Go',
'Python',
'python',
'Kotlin',
'kotlin',
'Ruby',
'ruby',
'Bash',
'bash',
'GraphQL',
'graphql',
'ide',
'IDE',
'ui',
'UI',
'otlp',
'OTLP',
'calt',
'ai',
'AI',
'ci',
'REST',
'rest',
'yaml',
'yml',
'true',
'false',
'/home/user',
'/home/user/project',
'/path/to/destination',
'.orca/issue-command',
'PLAN.md',
'feat/mobile-page',
'sk-...',
'main',
'master',
'HEAD',
'lint',
'MD',
'/home/user/projects',
'Claude Code'
])
export const BRAND_MISTRANSLATIONS = {
+2 -2
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.12",
"version": "0.0.13",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -16,7 +16,7 @@
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.stably.orca.mobile",
"buildNumber": "1",
"buildNumber": "2",
"infoPlist": {
"NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your local network.",
"NSMicrophoneUsageDescription": "Allow Orca to record voice dictation and transcribe it on your paired desktop.",
+27 -9
View File
@@ -81,6 +81,7 @@ import {
isTerminalLiveInputWithinByteLimit,
scheduleTerminalLiveInputFocus
} from '../../../../src/terminal/terminal-live-input'
import { normalizeTerminalTextInput } from '../../../../src/terminal/terminal-text-input-normalization'
import { countTerminalGestureInputSequences } from '../../../../src/terminal/terminal-gesture-input'
import { MobileBrowserPane, type MobileBrowserTab } from '../../../../src/browser/MobileBrowserPane'
import { isBlankBrowserUrl, normalizeBrowserUrl } from '../../../../src/browser/browser-url'
@@ -2663,7 +2664,7 @@ export default function SessionScreen() {
}
sendingRef.current = true
const text = input
const text = normalizeTerminalTextInput(input)
setInput('')
try {
@@ -2706,10 +2707,11 @@ export default function SessionScreen() {
const sendLiveTerminalInput = useCallback(
(handle: string, bytes: string) => {
if (bytes.length === 0) {
const text = normalizeTerminalTextInput(bytes)
if (text.length === 0) {
return
}
if (!isTerminalLiveInputWithinByteLimit(bytes)) {
if (!isTerminalLiveInputWithinByteLimit(text)) {
triggerError()
showToast('Input too large (max 256 KiB)', 1500)
return
@@ -2726,7 +2728,7 @@ export default function SessionScreen() {
void rpc
.sendRequest('terminal.send', {
terminal: handle,
text: bytes,
text,
enter: false,
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
@@ -2791,8 +2793,9 @@ export default function SessionScreen() {
liveInputRef.current?.setNativeProps({ text: '' })
return
}
if (text.length > 0) {
sendLiveTerminalInput(activeHandle, text)
const normalizedText = normalizeTerminalTextInput(text)
if (normalizedText.length > 0) {
sendLiveTerminalInput(activeHandle, normalizedText)
}
setLiveInputCapture('')
// Why: the field is only a keyboard capture surface. Clearing the
@@ -3859,11 +3862,16 @@ export default function SessionScreen() {
{visibleTabs.length > 0 && (
<View style={styles.tabBar}>
{/* Why: tab taps must register on the first press while the live
keyboard is open instead of being eaten by keyboard dismissal
(#5106); leaving a non-live tab still closes the keyboard
because the live input unmounts. */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.tabScroll}
contentContainerStyle={styles.tabContent}
keyboardShouldPersistTaps="handled"
>
{visibleTabs.map((t) => (
<Pressable
@@ -4099,10 +4107,14 @@ export default function SessionScreen() {
>
{/* Accessory keys */}
<View style={styles.accessoryBar}>
{/* Why: with default tap handling the first tap on any accessory
key dismisses the open keyboard and is swallowed, so live
input lost its keyboard on every Esc/Tab press (#5106). */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.accessoryContent}
keyboardShouldPersistTaps="always"
>
<Pressable
style={({ pressed }) => [
@@ -4270,6 +4282,7 @@ export default function SessionScreen() {
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
smartInsertDelete={false}
keyboardType={Platform.OS === 'ios' ? 'ascii-capable' : 'visible-password'}
returnKeyType="default"
blurOnSubmit={false}
@@ -4283,11 +4296,16 @@ export default function SessionScreen() {
<TextInput
style={styles.textInput}
value={input}
onChangeText={setInput}
onChangeText={(text) =>
setInput((previousText) => normalizeTerminalTextInput(text, previousText))
}
placeholder="Type a command…"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
smartInsertDelete={false}
keyboardType={Platform.OS === 'ios' ? 'ascii-capable' : 'visible-password'}
returnKeyType="send"
editable={canSend}
onSubmitEditing={() => void handleSend()}
@@ -4354,6 +4372,7 @@ export default function SessionScreen() {
visible={showCreateTabDrawer}
title="New Tab"
actions={[
...createTabAgentActions,
{
label: 'Terminal',
icon: SquareTerminal,
@@ -4381,8 +4400,7 @@ export default function SessionScreen() {
setShowCreateTabDrawer(false)
void handleCreateMarkdownNote()
}
},
...createTabAgentActions
}
]}
onClose={() => setShowCreateTabDrawer(false)}
/>
+49 -249
View File
@@ -1,40 +1,21 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
AppState,
View,
Text,
StyleSheet,
Pressable,
ScrollView,
Switch,
type AppStateStatus
} from 'react-native'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { View, Text, StyleSheet, Pressable } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useFocusEffect, useRouter } from 'expo-router'
import { ChevronLeft, ChevronRight, Smartphone, X } from 'lucide-react-native'
import {
CustomKeyModal,
loadCustomKeys,
saveCustomKeys,
type CustomKey
} from '../src/components/CustomKeyModal'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import Animated, {
useAnimatedRef,
useAnimatedScrollHandler,
useSharedValue
} from 'react-native-reanimated'
import { useRouter } from 'expo-router'
import { ChevronLeft, ChevronRight, Smartphone } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
import type { HostProfile } from '../src/transport/types'
import { useAllHostClients } from '../src/transport/client-context'
import type { RpcClient } from '../src/transport/rpc-client'
import { PickerModal, type PickerOption } from '../src/components/PickerModal'
import {
TERMINAL_ACCESSORY_KEYS,
type TerminalAccessoryKey
} from '../src/terminal/terminal-accessory-keys'
import {
getDefaultTerminalAccessoryBuiltInIds,
loadTerminalAccessoryLayout,
resetTerminalAccessoryBuiltInIds,
saveTerminalAccessoryLayout,
setTerminalAccessoryBuiltInVisible
} from '../src/terminal/terminal-accessory-layout'
import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings'
import { setTerminalAutoRestoreFitMsForHost } from '../src/terminal/terminal-auto-restore-fit-state'
type RestoreValue = 'indefinite' | '60s' | '5m' | '30m'
@@ -111,33 +92,6 @@ function HostFitRow({
)
}
function ShortcutBarRow({
shortcutKey,
visible,
onToggle
}: {
shortcutKey: TerminalAccessoryKey
visible: boolean
onToggle: (visible: boolean) => void
}): React.JSX.Element {
return (
<View style={styles.row}>
<View style={styles.keycap}>
<Text style={styles.keycapText}>{shortcutKey.label}</Text>
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>{shortcutKey.accessibilityLabel ?? shortcutKey.label}</Text>
</View>
<Switch
value={visible}
onValueChange={onToggle}
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
)
}
export default function TerminalSettingsScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
@@ -152,9 +106,6 @@ export default function TerminalSettingsScreen() {
[hostClients]
)
const [customKeys, setCustomKeys] = useState<CustomKey[]>([])
const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
// Why: per-host current value, lazily fetched. We keep state at the
// screen level rather than per-row so the picker can render at root
// level — embedding PickerModal inside a row clipped its BottomDrawer
@@ -162,81 +113,6 @@ export default function TerminalSettingsScreen() {
// drawer appear cut-off.
const [hostMs, setHostMs] = useState<Record<string, number | null | undefined>>({})
const [pickerHostId, setPickerHostId] = useState<string | null>(null)
const [visibleBuiltInIds, setVisibleBuiltInIds] = useState<string[]>(
getDefaultTerminalAccessoryBuiltInIds
)
const layoutWriteChainRef = useRef<Promise<void>>(Promise.resolve())
const layoutWriteSeqRef = useRef(0)
const pendingLayoutWritesRef = useRef(0)
const persistLayout = useCallback((nextIds: string[]) => {
layoutWriteSeqRef.current += 1
pendingLayoutWritesRef.current += 1
layoutWriteChainRef.current = layoutWriteChainRef.current
.catch(() => {})
.then(() => saveTerminalAccessoryLayout(nextIds))
.catch(() => {})
.finally(() => {
pendingLayoutWritesRef.current -= 1
})
}, [])
const refreshShortcutLayout = useCallback(() => {
const refreshSeq = layoutWriteSeqRef.current
void loadTerminalAccessoryLayout().then((layout) => {
if (pendingLayoutWritesRef.current > 0 || refreshSeq !== layoutWriteSeqRef.current) {
return
}
setVisibleBuiltInIds(layout.visibleBuiltInIds)
})
}, [])
const refreshCustomKeys = useCallback(() => {
void loadCustomKeys().then(setCustomKeys)
}, [])
const handleDeleteCustomKey = useCallback(
async (key: CustomKey) => {
const updated = customKeys.filter((k) => k.id !== key.id)
setCustomKeys(updated)
await saveCustomKeys(updated)
},
[customKeys]
)
useFocusEffect(
useCallback(() => {
refreshShortcutLayout()
refreshCustomKeys()
}, [refreshShortcutLayout, refreshCustomKeys])
)
useEffect(() => {
const sub = AppState.addEventListener('change', (s: AppStateStatus) => {
if (s === 'active') {
refreshShortcutLayout()
refreshCustomKeys()
}
})
return () => sub.remove()
}, [refreshShortcutLayout, refreshCustomKeys])
const toggleBuiltInKey = useCallback(
(id: string, visible: boolean) => {
setVisibleBuiltInIds((current) => {
const next = setTerminalAccessoryBuiltInVisible(current, id, visible)
persistLayout(next)
return next
})
},
[persistLayout]
)
const resetBuiltInKeys = useCallback(() => {
const next = resetTerminalAccessoryBuiltInIds()
setVisibleBuiltInIds(next)
persistLayout(next)
}, [persistLayout])
useEffect(() => {
let cancelled = false
@@ -295,10 +171,28 @@ export default function TerminalSettingsScreen() {
}
const pickerHost = pickerHostId ? hosts.find((h) => h.id === pickerHostId) : null
const visibleBuiltInSet = useMemo(() => new Set(visibleBuiltInIds), [visibleBuiltInIds])
const scrollRef = useAnimatedRef<Animated.ScrollView>()
const scrollOffsetY = useSharedValue(0)
const scrollContentHeight = useSharedValue(0)
const scrollHandler = useAnimatedScrollHandler((event) => {
scrollOffsetY.value = event.contentOffset.y
})
// Why: imperative toggle instead of state — a re-render while a drag gesture
// is active would rebuild the row gestures and could cancel the drag.
const setScrollEnabled = useCallback(
(enabled: boolean) => {
scrollRef.current?.setNativeProps({ scrollEnabled: enabled })
},
[scrollRef]
)
const handleDragActiveChange = useCallback(
(active: boolean) => setScrollEnabled(!active),
[setScrollEnabled]
)
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<GestureHandlerRootView style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
@@ -306,7 +200,16 @@ export default function TerminalSettingsScreen() {
<Text style={styles.heading}>Terminal</Text>
</View>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<Animated.ScrollView
ref={scrollRef}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
scrollEventThrottle={16}
onContentSizeChange={(_width, height) => {
scrollContentHeight.value = height
}}
>
<Text style={styles.groupHeading}>WHEN YOU LEAVE THE APP</Text>
<Text style={styles.groupDescription}>
While you&apos;re using a terminal on your phone, Orca shrinks it to fit your screen. When
@@ -340,76 +243,13 @@ export default function TerminalSettingsScreen() {
</View>
)}
<Text style={[styles.groupHeading, styles.groupTopGap]}>SHORTCUT BAR</Text>
<View style={[styles.section, styles.sectionTopGap]}>
{TERMINAL_ACCESSORY_KEYS.map((shortcutKey, idx) => (
<View key={shortcutKey.id}>
{idx > 0 && <View style={styles.separator} />}
<ShortcutBarRow
shortcutKey={shortcutKey}
visible={visibleBuiltInSet.has(shortcutKey.id)}
onToggle={(visible) => toggleBuiltInKey(shortcutKey.id, visible)}
/>
</View>
))}
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={resetBuiltInKeys}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Reset Defaults</Text>
<Text style={styles.rowSublabel}>Show every built-in shortcut key</Text>
</View>
</Pressable>
</View>
<Text style={[styles.groupHeading, styles.groupTopGap]}>CUSTOM SHORTCUTS</Text>
<View style={[styles.section, styles.sectionTopGap]}>
{customKeys.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No custom shortcuts defined yet.</Text>
</View>
) : (
customKeys.map((key, idx) => (
<View key={key.id}>
{idx > 0 && <View style={styles.separator} />}
<View style={styles.row}>
<View style={styles.keycap}>
<Text style={styles.keycapText}>{key.label}</Text>
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>{key.label}</Text>
<Text style={styles.rowSublabel} numberOfLines={1} ellipsizeMode="tail">
{key.bytes.replace(/\r/g, ' ↵')}
</Text>
</View>
<Pressable
style={({ pressed }) => [
styles.deleteButton,
pressed && styles.deleteButtonPressed
]}
onPress={() => handleDeleteCustomKey(key)}
>
<X size={16} color={colors.statusRed} />
</Pressable>
</View>
</View>
))
)}
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => setShowCustomKeyModal(true)}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Add Custom Shortcut…</Text>
<Text style={styles.rowSublabel}>Create key combo or text macro</Text>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</View>
</ScrollView>
<TerminalShortcutSettings
scrollRef={scrollRef}
scrollOffsetY={scrollOffsetY}
scrollContentHeight={scrollContentHeight}
onDragActiveChange={handleDragActiveChange}
/>
</Animated.ScrollView>
<PickerModal<RestoreValue>
visible={pickerHost != null}
@@ -423,15 +263,7 @@ export default function TerminalSettingsScreen() {
}}
onClose={() => setPickerHostId(null)}
/>
<CustomKeyModal
visible={showCustomKeyModal}
onClose={() => setShowCustomKeyModal(false)}
onKeysChanged={(keys) => {
setCustomKeys(keys)
}}
/>
</View>
</GestureHandlerRootView>
)
}
@@ -472,9 +304,6 @@ const styles = StyleSheet.create({
marginBottom: spacing.xs,
paddingHorizontal: spacing.xs
},
groupTopGap: {
marginTop: spacing.xl
},
groupDescription: {
fontSize: typography.bodySize - 1,
color: colors.textSecondary,
@@ -517,38 +346,9 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
marginTop: 2
},
keycap: {
minWidth: 62,
alignItems: 'center',
backgroundColor: colors.bgRaised,
borderRadius: radii.button,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
keycapText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
emptyContainer: {
padding: spacing.md,
alignItems: 'center',
justifyContent: 'center'
},
deleteButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(239, 68, 68, 0.1)'
},
deleteButtonPressed: {
backgroundColor: 'rgba(239, 68, 68, 0.2)'
}
})
+1 -1
View File
@@ -231,7 +231,7 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
onPress={onManageShortcuts}
>
<Text style={styles.rowLabel}>Manage Shortcuts</Text>
<Text style={styles.rowHint}>Show or hide default shortcut keys</Text>
<Text style={styles.rowHint}>Show, hide, or reorder shortcut keys</Text>
</Pressable>
</>
) : null}
+349
View File
@@ -0,0 +1,349 @@
import { useCallback, useEffect, type ReactNode } from 'react'
import { StyleSheet, View } from 'react-native'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { GripVertical } from 'lucide-react-native'
import Animated, {
measure,
runOnJS,
scrollTo,
useAnimatedStyle,
useFrameCallback,
useSharedValue,
withSpring,
type AnimatedRef,
type SharedValue
} from 'react-native-reanimated'
import { colors, spacing } from '../theme/mobile-theme'
import { triggerMediumImpact, triggerSelection } from '../platform/haptics'
import {
clampDragReorderIndex,
dragReorderPositionsFromKeys,
moveDragReorderKey,
orderedKeysFromDragReorderPositions,
type DragReorderPositions
} from './drag-reorder-positions'
const ROW_SPRING = { damping: 28, stiffness: 350 }
const LONG_PRESS_ACTIVATION_MS = 200
// Why: joins row keys into a change-detection signature; NUL cannot occur in
// a key, so the joined string is unambiguous.
const KEY_SEPARATOR = '\u0000'
// Why: drags near the viewport edges scroll the outer ScrollView so rows can
// travel further than one screen; speed ramps up the closer the finger gets.
const AUTO_SCROLL_EDGE = 72
const AUTO_SCROLL_MAX_SPEED = 560
type DragSharedState = {
positions: SharedValue<DragReorderPositions>
activeKey: SharedValue<string | null>
activeTop: SharedValue<number>
dragStartTop: SharedValue<number>
dragStartScrollY: SharedValue<number>
dragTranslationY: SharedValue<number>
dragPointerAbsY: SharedValue<number>
}
export type DragReorderListProps<ItemT> = {
items: ItemT[]
itemKey: (item: ItemT) => string
rowHeight: number
renderRow: (item: ItemT) => ReactNode
/** Called with every item key in the new order after a drop changes it. */
onReorder: (orderedKeys: string[]) => void
/** Lets the owning screen disable its ScrollView while a row is held. */
onDragActiveChange?: (active: boolean) => void
scrollRef: AnimatedRef<Animated.ScrollView>
scrollOffsetY: SharedValue<number>
scrollContentHeight: SharedValue<number>
}
export function DragReorderList<ItemT>({
items,
itemKey,
rowHeight,
renderRow,
onReorder,
onDragActiveChange,
scrollRef,
scrollOffsetY,
scrollContentHeight
}: DragReorderListProps<ItemT>): React.JSX.Element {
const keys = items.map(itemKey)
const count = keys.length
const positions = useSharedValue<DragReorderPositions>(dragReorderPositionsFromKeys(keys))
const activeKey = useSharedValue<string | null>(null)
const activeTop = useSharedValue(0)
const dragStartTop = useSharedValue(0)
const dragStartScrollY = useSharedValue(0)
const dragTranslationY = useSharedValue(0)
const dragPointerAbsY = useSharedValue(0)
// Why: rows can be added, removed, or reordered by the owning screen;
// rebuild the position map whenever the rendered key order changes.
const keySignature = keys.join(KEY_SEPARATOR)
useEffect(() => {
positions.value = dragReorderPositionsFromKeys(
keySignature ? keySignature.split(KEY_SEPARATOR) : []
)
}, [keySignature, positions])
const updateDragPosition = (key: string): void => {
'worklet'
const rawTop =
dragStartTop.value + dragTranslationY.value + (scrollOffsetY.value - dragStartScrollY.value)
const top = Math.min(Math.max(rawTop, 0), Math.max(0, (count - 1) * rowHeight))
activeTop.value = top
const target = clampDragReorderIndex(Math.round(top / rowHeight), count)
if (positions.value[key] !== target) {
positions.value = moveDragReorderKey(positions.value, key, target)
runOnJS(triggerSelection)()
}
}
// Why: pan updates stop while the finger holds still at a screen edge, so a
// frame callback keeps scrolling (and re-slotting the row) until it moves.
const autoScroll = useFrameCallback((frame) => {
const key = activeKey.value
if (key === null) {
return
}
const viewport = measure(scrollRef)
if (viewport) {
const topEdge = viewport.pageY + AUTO_SCROLL_EDGE
const bottomEdge = viewport.pageY + viewport.height - AUTO_SCROLL_EDGE
let velocity = 0
if (dragPointerAbsY.value < topEdge) {
velocity =
-AUTO_SCROLL_MAX_SPEED * Math.min(1, (topEdge - dragPointerAbsY.value) / AUTO_SCROLL_EDGE)
} else if (dragPointerAbsY.value > bottomEdge) {
velocity =
AUTO_SCROLL_MAX_SPEED *
Math.min(1, (dragPointerAbsY.value - bottomEdge) / AUTO_SCROLL_EDGE)
}
if (velocity !== 0) {
const maxOffset = Math.max(0, scrollContentHeight.value - viewport.height)
const dtMs = frame.timeSincePreviousFrame ?? 16
const next = Math.min(
Math.max(scrollOffsetY.value + (velocity * dtMs) / 1000, 0),
maxOffset
)
if (next !== scrollOffsetY.value) {
scrollOffsetY.value = next
scrollTo(scrollRef, 0, next, false)
}
}
}
updateDragPosition(key)
}, false)
const setAutoScrollActive = autoScroll.setActive
const handleDragActiveChange = useCallback(
(active: boolean) => {
setAutoScrollActive(active)
onDragActiveChange?.(active)
},
[setAutoScrollActive, onDragActiveChange]
)
const commitReorder = useCallback(
(orderedKeys: string[]) => {
// Why: a cancelled or no-op drag should not trigger a persisted write.
if (orderedKeys.join(KEY_SEPARATOR) !== keySignature) {
onReorder(orderedKeys)
}
},
[onReorder, keySignature]
)
// Why: screen-reader users can't long-press-drag; the handle exposes
// move up/down accessibility actions that commit the same reorder.
const moveRowByAccessibilityAction = useCallback(
(key: string, delta: number) => {
const fromIndex = keys.indexOf(key)
if (fromIndex === -1) {
return
}
const toIndex = Math.min(Math.max(fromIndex + delta, 0), keys.length - 1)
if (toIndex === fromIndex) {
return
}
const next = [...keys]
next.splice(fromIndex, 1)
next.splice(toIndex, 0, key)
onReorder(next)
},
[keys, onReorder]
)
const shared: DragSharedState = {
positions,
activeKey,
activeTop,
dragStartTop,
dragStartScrollY,
dragTranslationY,
dragPointerAbsY
}
return (
<View style={{ height: count * rowHeight }}>
{items.map((item) => (
<DragReorderRow
key={itemKey(item)}
rowKey={itemKey(item)}
rowHeight={rowHeight}
shared={shared}
scrollOffsetY={scrollOffsetY}
updateDragPosition={updateDragPosition}
onDragActiveChange={handleDragActiveChange}
onCommit={commitReorder}
onAccessibilityMove={moveRowByAccessibilityAction}
>
{renderRow(item)}
</DragReorderRow>
))}
</View>
)
}
function DragReorderRow({
rowKey,
rowHeight,
shared,
scrollOffsetY,
updateDragPosition,
onDragActiveChange,
onCommit,
onAccessibilityMove,
children
}: {
rowKey: string
rowHeight: number
shared: DragSharedState
scrollOffsetY: SharedValue<number>
updateDragPosition: (key: string) => void
onDragActiveChange: (active: boolean) => void
onCommit: (orderedKeys: string[]) => void
onAccessibilityMove: (key: string, delta: number) => void
children: ReactNode
}): React.JSX.Element {
const {
positions,
activeKey,
activeTop,
dragStartTop,
dragStartScrollY,
dragTranslationY,
dragPointerAbsY
} = shared
const pan = Gesture.Pan()
.activateAfterLongPress(LONG_PRESS_ACTIVATION_MS)
.shouldCancelWhenOutside(false)
.onStart((event) => {
const index = positions.value[rowKey] ?? 0
dragStartTop.value = index * rowHeight
dragStartScrollY.value = scrollOffsetY.value
dragTranslationY.value = 0
dragPointerAbsY.value = event.absoluteY
activeTop.value = dragStartTop.value
activeKey.value = rowKey
runOnJS(onDragActiveChange)(true)
runOnJS(triggerMediumImpact)()
})
.onUpdate((event) => {
dragTranslationY.value = event.translationY
dragPointerAbsY.value = event.absoluteY
updateDragPosition(rowKey)
})
.onFinalize(() => {
if (activeKey.value !== rowKey) {
return
}
activeKey.value = null
const orderedKeys = orderedKeysFromDragReorderPositions(positions.value)
runOnJS(onCommit)(orderedKeys)
runOnJS(onDragActiveChange)(false)
})
const rowStyle = useAnimatedStyle(() => {
const index = positions.value[rowKey] ?? 0
if (activeKey.value === rowKey) {
return {
top: activeTop.value,
zIndex: 2,
elevation: 4,
shadowOpacity: 0.3,
backgroundColor: colors.bgRaised,
transform: [{ scale: 1.02 }]
}
}
return {
top: withSpring(index * rowHeight, ROW_SPRING),
zIndex: 0,
elevation: 0,
shadowOpacity: 0,
backgroundColor: colors.bgPanel,
transform: [{ scale: 1 }]
}
})
return (
<Animated.View style={[styles.row, { height: rowHeight }, rowStyle]}>
<View style={styles.rowContent}>{children}</View>
<GestureDetector gesture={pan}>
<Animated.View
style={styles.handle}
accessible
accessibilityRole="button"
accessibilityLabel="Drag to reorder"
accessibilityHint="Use the move up and move down actions to reorder without dragging"
accessibilityActions={[
{ name: 'moveUp', label: 'Move up' },
{ name: 'moveDown', label: 'Move down' }
]}
onAccessibilityAction={(event) => {
if (event.nativeEvent.actionName === 'moveUp') {
onAccessibilityMove(rowKey, -1)
} else if (event.nativeEvent.actionName === 'moveDown') {
onAccessibilityMove(rowKey, 1)
}
}}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<GripVertical size={18} color={colors.textMuted} />
</Animated.View>
</GestureDetector>
<View style={styles.rowSeparator} />
</Animated.View>
)
}
const styles = StyleSheet.create({
row: {
position: 'absolute',
left: 0,
right: 0,
flexDirection: 'row',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowRadius: 8
},
rowContent: {
flex: 1
},
handle: {
alignSelf: 'stretch',
justifyContent: 'center',
paddingHorizontal: spacing.md
},
rowSeparator: {
position: 'absolute',
bottom: 0,
left: spacing.md,
right: spacing.md,
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle
}
})
+1 -1
View File
@@ -79,7 +79,7 @@ function AgentLetterIcon({ letter, size = 16 }: { letter: string; size?: number
}
export function MobileAgentIcon({ agentId, size = 16 }: { agentId: string; size?: number }) {
if (agentId === 'claude') {
if (agentId === 'claude' || agentId === 'claude-agent-teams') {
return <ClaudeIcon size={size} />
}
if (agentId === 'codex') {
@@ -0,0 +1,428 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
AppState,
View,
Text,
StyleSheet,
Pressable,
Switch,
type AppStateStatus
} from 'react-native'
import { useFocusEffect } from 'expo-router'
import { ChevronRight, X } from 'lucide-react-native'
import type Animated from 'react-native-reanimated'
import type { AnimatedRef, SharedValue } from 'react-native-reanimated'
import { CustomKeyModal, loadCustomKeys, saveCustomKeys, type CustomKey } from './CustomKeyModal'
import { DragReorderList } from './DragReorderList'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import {
TERMINAL_ACCESSORY_KEYS,
type TerminalAccessoryKey
} from '../terminal/terminal-accessory-keys'
import {
getDefaultTerminalAccessoryLayout,
loadTerminalAccessoryLayout,
reorderTerminalAccessoryBuiltInIds,
saveTerminalAccessoryLayout,
setTerminalAccessoryBuiltInVisible,
type TerminalAccessoryLayout
} from '../terminal/terminal-accessory-layout'
// Why: DragReorderList absolutely positions rows, so every row in a
// reorderable section must share one fixed height.
const REORDER_ROW_HEIGHT = 56
function ShortcutBarRow({
shortcutKey,
visible,
onToggle
}: {
shortcutKey: TerminalAccessoryKey
visible: boolean
onToggle: (visible: boolean) => void
}): React.JSX.Element {
return (
<View style={styles.reorderRowContent}>
<View style={styles.keycap}>
<Text style={styles.keycapText}>{shortcutKey.label}</Text>
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>{shortcutKey.accessibilityLabel ?? shortcutKey.label}</Text>
</View>
<Switch
value={visible}
onValueChange={onToggle}
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
)
}
type Props = {
scrollRef: AnimatedRef<Animated.ScrollView>
scrollOffsetY: SharedValue<number>
scrollContentHeight: SharedValue<number>
onDragActiveChange: (active: boolean) => void
}
export function TerminalShortcutSettings({
scrollRef,
scrollOffsetY,
scrollContentHeight,
onDragActiveChange
}: Props): React.JSX.Element {
const [customKeys, setCustomKeys] = useState<CustomKey[]>([])
const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
const [shortcutLayout, setShortcutLayout] = useState<TerminalAccessoryLayout>(
getDefaultTerminalAccessoryLayout
)
const layoutWriteChainRef = useRef<Promise<void>>(Promise.resolve())
const layoutWriteSeqRef = useRef(0)
const pendingLayoutWritesRef = useRef(0)
const persistLayout = useCallback((next: TerminalAccessoryLayout) => {
layoutWriteSeqRef.current += 1
pendingLayoutWritesRef.current += 1
layoutWriteChainRef.current = layoutWriteChainRef.current
.catch(() => {})
.then(() => saveTerminalAccessoryLayout(next))
.catch(() => {})
.finally(() => {
pendingLayoutWritesRef.current -= 1
})
}, [])
const refreshShortcutLayout = useCallback(() => {
const refreshSeq = layoutWriteSeqRef.current
void loadTerminalAccessoryLayout().then((layout) => {
if (pendingLayoutWritesRef.current > 0 || refreshSeq !== layoutWriteSeqRef.current) {
return
}
setShortcutLayout({
orderedBuiltInIds: layout.orderedBuiltInIds,
visibleBuiltInIds: layout.visibleBuiltInIds
})
})
}, [])
const customKeysWriteChainRef = useRef<Promise<void>>(Promise.resolve())
const customKeysWriteSeqRef = useRef(0)
const pendingCustomKeysWritesRef = useRef(0)
// Why: same stale-snapshot guard as persistLayout — a focus/AppState refresh
// racing an in-flight save must not overwrite the optimistic state.
const persistCustomKeys = useCallback((next: CustomKey[]) => {
customKeysWriteSeqRef.current += 1
pendingCustomKeysWritesRef.current += 1
customKeysWriteChainRef.current = customKeysWriteChainRef.current
.catch(() => {})
.then(() => saveCustomKeys(next))
.catch(() => {})
.finally(() => {
pendingCustomKeysWritesRef.current -= 1
})
}, [])
const refreshCustomKeys = useCallback(() => {
const refreshSeq = customKeysWriteSeqRef.current
void loadCustomKeys().then((keys) => {
if (pendingCustomKeysWritesRef.current > 0 || refreshSeq !== customKeysWriteSeqRef.current) {
return
}
setCustomKeys(keys)
})
}, [])
const handleDeleteCustomKey = useCallback(
(key: CustomKey) => {
setCustomKeys((current) => {
const updated = current.filter((k) => k.id !== key.id)
persistCustomKeys(updated)
return updated
})
},
[persistCustomKeys]
)
useFocusEffect(
useCallback(() => {
refreshShortcutLayout()
refreshCustomKeys()
}, [refreshShortcutLayout, refreshCustomKeys])
)
useEffect(() => {
const sub = AppState.addEventListener('change', (s: AppStateStatus) => {
if (s === 'active') {
refreshShortcutLayout()
refreshCustomKeys()
}
})
return () => sub.remove()
}, [refreshShortcutLayout, refreshCustomKeys])
const toggleBuiltInKey = useCallback(
(id: string, visible: boolean) => {
setShortcutLayout((current) => {
const next = setTerminalAccessoryBuiltInVisible(current, id, visible)
persistLayout(next)
return next
})
},
[persistLayout]
)
const reorderBuiltInKeys = useCallback(
(orderedKeys: string[]) => {
setShortcutLayout((current) => {
const next = reorderTerminalAccessoryBuiltInIds(current, orderedKeys)
persistLayout(next)
return next
})
},
[persistLayout]
)
const resetBuiltInKeys = useCallback(() => {
const next = getDefaultTerminalAccessoryLayout()
setShortcutLayout(next)
persistLayout(next)
}, [persistLayout])
const reorderCustomKeys = useCallback(
(orderedKeys: string[]) => {
setCustomKeys((current) => {
const byId = new Map(current.map((key) => [key.id, key]))
const reordered = orderedKeys.flatMap((id) => {
const key = byId.get(id)
return key ? [key] : []
})
if (reordered.length !== current.length) {
return current
}
persistCustomKeys(reordered)
return reordered
})
},
[persistCustomKeys]
)
const visibleBuiltInSet = useMemo(
() => new Set(shortcutLayout.visibleBuiltInIds),
[shortcutLayout.visibleBuiltInIds]
)
const orderedAccessoryKeys = useMemo(() => {
const byId = new Map(TERMINAL_ACCESSORY_KEYS.map((key) => [key.id, key]))
return shortcutLayout.orderedBuiltInIds.flatMap((id) => {
const key = byId.get(id)
return key ? [key] : []
})
}, [shortcutLayout.orderedBuiltInIds])
return (
<>
<Text style={[styles.groupHeading, styles.groupTopGap]}>SHORTCUT BAR</Text>
<Text style={styles.groupDescription}>
Toggle keys to show or hide them, and hold the grip to drag a key into the order you want on
the terminal shortcut bar.
</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<DragReorderList
items={orderedAccessoryKeys}
itemKey={(shortcutKey) => shortcutKey.id}
rowHeight={REORDER_ROW_HEIGHT}
scrollRef={scrollRef}
scrollOffsetY={scrollOffsetY}
scrollContentHeight={scrollContentHeight}
onDragActiveChange={onDragActiveChange}
onReorder={reorderBuiltInKeys}
renderRow={(shortcutKey) => (
<ShortcutBarRow
shortcutKey={shortcutKey}
visible={visibleBuiltInSet.has(shortcutKey.id)}
onToggle={(visible) => toggleBuiltInKey(shortcutKey.id, visible)}
/>
)}
/>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={resetBuiltInKeys}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Reset Defaults</Text>
<Text style={styles.rowSublabel}>
Show every built-in shortcut key in the original order
</Text>
</View>
</Pressable>
</View>
<Text style={[styles.groupHeading, styles.groupTopGap]}>CUSTOM SHORTCUTS</Text>
<View style={[styles.section, styles.sectionTopGap]}>
{customKeys.length === 0 ? (
<>
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No custom shortcuts defined yet.</Text>
</View>
<View style={styles.separator} />
</>
) : (
<DragReorderList
items={customKeys}
itemKey={(key) => key.id}
rowHeight={REORDER_ROW_HEIGHT}
scrollRef={scrollRef}
scrollOffsetY={scrollOffsetY}
scrollContentHeight={scrollContentHeight}
onDragActiveChange={onDragActiveChange}
onReorder={reorderCustomKeys}
renderRow={(key) => (
<View style={styles.reorderRowContent}>
<View style={styles.keycap}>
<Text style={styles.keycapText}>{key.label}</Text>
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>{key.label}</Text>
<Text style={styles.rowSublabel} numberOfLines={1} ellipsizeMode="tail">
{key.bytes.replace(/\r/g, ' ↵')}
</Text>
</View>
<Pressable
style={({ pressed }) => [
styles.deleteButton,
pressed && styles.deleteButtonPressed
]}
onPress={() => handleDeleteCustomKey(key)}
>
<X size={16} color={colors.statusRed} />
</Pressable>
</View>
)}
/>
)}
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => setShowCustomKeyModal(true)}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Add Custom Shortcut…</Text>
<Text style={styles.rowSublabel}>Create key combo or text macro</Text>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</View>
<CustomKeyModal
visible={showCustomKeyModal}
onClose={() => setShowCustomKeyModal(false)}
onKeysChanged={(keys) => {
// Why: the modal already persisted this list; bumping the sequence
// discards refreshes that read storage before its save landed.
customKeysWriteSeqRef.current += 1
setCustomKeys(keys)
}}
/>
</>
)
}
const styles = StyleSheet.create({
groupHeading: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
letterSpacing: 0.5,
marginBottom: spacing.xs,
paddingHorizontal: spacing.xs
},
groupTopGap: {
marginTop: spacing.xl
},
groupDescription: {
fontSize: typography.bodySize - 1,
color: colors.textSecondary,
lineHeight: 20,
paddingHorizontal: spacing.xs
},
section: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden'
},
sectionTopGap: {
marginTop: spacing.sm
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
// Why: rows inside DragReorderList get a fixed height and a trailing grip
// handle from the list itself, so content only pads on the left.
reorderRowContent: {
flex: 1,
height: '100%',
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingLeft: spacing.md + 2
},
rowContent: {
flex: 1
},
rowLabel: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
rowSublabel: {
fontSize: typography.bodySize - 2,
color: colors.textSecondary,
marginTop: 2
},
keycap: {
minWidth: 62,
alignItems: 'center',
backgroundColor: colors.bgRaised,
borderRadius: radii.button,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
keycapText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
emptyContainer: {
padding: spacing.md,
alignItems: 'center',
justifyContent: 'center'
},
emptyText: {
fontSize: typography.bodySize,
color: colors.textSecondary,
padding: spacing.md
},
deleteButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(239, 68, 68, 0.1)'
},
deleteButtonPressed: {
backgroundColor: 'rgba(239, 68, 68, 0.2)'
}
})
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import {
clampDragReorderIndex,
dragReorderPositionsFromKeys,
moveDragReorderKey,
orderedKeysFromDragReorderPositions
} from './drag-reorder-positions'
describe('drag reorder positions', () => {
it('round-trips keys through positions', () => {
const keys = ['escape', 'tab', 'enter']
expect(orderedKeysFromDragReorderPositions(dragReorderPositionsFromKeys(keys))).toEqual(keys)
})
it('clamps drag indexes to the list bounds', () => {
expect(clampDragReorderIndex(-2, 3)).toBe(0)
expect(clampDragReorderIndex(1, 3)).toBe(1)
expect(clampDragReorderIndex(7, 3)).toBe(2)
expect(clampDragReorderIndex(0, 0)).toBe(0)
})
it('shifts intermediate rows down when dragging a row later', () => {
const positions = dragReorderPositionsFromKeys(['a', 'b', 'c', 'd'])
expect(orderedKeysFromDragReorderPositions(moveDragReorderKey(positions, 'a', 2))).toEqual([
'b',
'c',
'a',
'd'
])
})
it('shifts intermediate rows up when dragging a row earlier', () => {
const positions = dragReorderPositionsFromKeys(['a', 'b', 'c', 'd'])
expect(orderedKeysFromDragReorderPositions(moveDragReorderKey(positions, 'd', 1))).toEqual([
'a',
'd',
'b',
'c'
])
})
it('returns the same positions for no-op or unknown moves', () => {
const positions = dragReorderPositionsFromKeys(['a', 'b'])
expect(moveDragReorderKey(positions, 'a', 0)).toBe(positions)
expect(moveDragReorderKey(positions, 'missing', 1)).toBe(positions)
})
})
@@ -0,0 +1,54 @@
// Index math for DragReorderList. Kept worklet-safe (no captures, plain
// objects) because moveDragReorderKey runs on the UI thread during a drag.
export type DragReorderPositions = Record<string, number>
export function dragReorderPositionsFromKeys(keys: string[]): DragReorderPositions {
'worklet'
const positions: DragReorderPositions = {}
for (let i = 0; i < keys.length; i++) {
positions[keys[i]!] = i
}
return positions
}
export function orderedKeysFromDragReorderPositions(positions: DragReorderPositions): string[] {
'worklet'
const keys = Object.keys(positions)
keys.sort((a, b) => positions[a]! - positions[b]!)
return keys
}
export function clampDragReorderIndex(index: number, count: number): number {
'worklet'
if (count <= 0) {
return 0
}
return Math.min(Math.max(index, 0), count - 1)
}
export function moveDragReorderKey(
positions: DragReorderPositions,
key: string,
toIndex: number
): DragReorderPositions {
'worklet'
const fromIndex = positions[key]
if (fromIndex === undefined || fromIndex === toIndex) {
return positions
}
const next: DragReorderPositions = {}
for (const currentKey of Object.keys(positions)) {
const position = positions[currentKey]!
if (currentKey === key) {
next[currentKey] = toIndex
} else if (fromIndex < toIndex && position > fromIndex && position <= toIndex) {
next[currentKey] = position - 1
} else if (toIndex < fromIndex && position >= toIndex && position < fromIndex) {
next[currentKey] = position + 1
} else {
next[currentKey] = position
}
}
return next
}
@@ -32,6 +32,14 @@ describe('subscribeToDesktopNotifications', () => {
}
}
function makeDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((next) => {
resolve = next
})
return { promise, resolve }
}
it('drops the local stream when disposed before the desktop returns ready', () => {
const unsubscribeStream = vi.fn()
const client = {
@@ -106,6 +114,142 @@ describe('subscribeToDesktopNotifications', () => {
expect(Notifications.dismissNotificationAsync).toHaveBeenNthCalledWith(2, 'scheduled-2')
})
it('dedupes concurrent notification events with the same desktop notification id', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
let onEvent: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
onEvent = callback
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn()
} as unknown as RpcClient
subscribeToDesktopNotifications(client, 'host-concurrent')
onEvent?.({
type: 'notification',
source: 'agent-task-complete',
title: 'Done',
body: 'Finished.',
notificationId: 'agent:concurrent'
})
onEvent?.({
type: 'notification',
source: 'agent-task-complete',
title: 'Done',
body: 'Finished.',
notificationId: 'agent:concurrent'
})
await flushAsync()
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(1)
})
it('dismisses a notification when dismiss arrives while scheduling is pending', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
let resolveSchedule!: (identifier: string) => void
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(
() =>
new Promise<string>((resolve) => {
resolveSchedule = resolve
})
)
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
let onEvent: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
onEvent = callback
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn()
} as unknown as RpcClient
subscribeToDesktopNotifications(client, 'host-dismiss-race')
onEvent?.({
type: 'notification',
source: 'agent-task-complete',
title: 'Done',
body: 'Finished.',
notificationId: 'agent:pending'
})
await flushAsync()
onEvent?.({ type: 'dismiss', notificationId: 'agent:pending' })
resolveSchedule('scheduled-pending')
await flushAsync()
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-pending')
})
it('does not carry a failed pending dismiss into a future schedule', async () => {
const secondEnabled = makeDeferred<boolean>()
vi.mocked(loadPushNotificationsEnabled)
.mockResolvedValueOnce(true)
.mockReturnValueOnce(secondEnabled.promise)
.mockResolvedValueOnce(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync)
.mockResolvedValueOnce('scheduled-1')
.mockResolvedValueOnce('scheduled-2')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
let onEvent: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
onEvent = callback
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn()
} as unknown as RpcClient
subscribeToDesktopNotifications(client, 'host-dismiss-failed-replacement')
onEvent?.({
type: 'notification',
source: 'agent-task-complete',
title: 'Done',
body: 'Finished.',
notificationId: 'agent:stale-dismiss'
})
await flushAsync()
onEvent?.({
type: 'notification',
source: 'agent-task-complete',
title: 'Done again',
body: 'Finished again.',
notificationId: 'agent:stale-dismiss'
})
await flushAsync()
onEvent?.({ type: 'dismiss', notificationId: 'agent:stale-dismiss' })
secondEnabled.resolve(false)
await flushAsync()
onEvent?.({
type: 'notification',
source: 'agent-task-complete',
title: 'Done later',
body: 'Finished later.',
notificationId: 'agent:stale-dismiss'
})
await flushAsync()
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2)
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledTimes(1)
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-1')
})
it('treats unknown dismiss events as no-ops', async () => {
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
let onEvent: ((data: unknown) => void) | null = null
+100 -32
View File
@@ -23,7 +23,13 @@ type SubscribeResult = {
subscriptionId: string
}
const scheduledNotificationIdsByHostAndNotificationId = new Map<string, string>()
type ScheduledNotificationState = {
identifier?: string
pending?: Promise<string | null>
dismissAfterSchedule?: boolean
}
const scheduledNotificationsByHostAndNotificationId = new Map<string, ScheduledNotificationState>()
function getStoredNotificationKey(hostId: string, notificationId: string): string {
return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}`
@@ -69,38 +75,91 @@ function configureNotificationChannel(): void {
}
async function showLocalNotification(event: NotificationEvent, hostId: string): Promise<void> {
const enabled = await loadPushNotificationsEnabled()
if (!enabled) {
return
}
const granted = await ensureNotificationPermissions()
if (!granted) {
return
}
const storedKey = event.notificationId
? getStoredNotificationKey(hostId, event.notificationId)
: null
const previousIdentifier = storedKey
? scheduledNotificationIdsByHostAndNotificationId.get(storedKey)
: undefined
if (storedKey && previousIdentifier) {
await Notifications.dismissNotificationAsync(previousIdentifier).catch(() => {})
scheduledNotificationIdsByHostAndNotificationId.delete(storedKey)
if (!storedKey) {
const enabled = await loadPushNotificationsEnabled()
if (!enabled) {
return
}
const granted = await ensureNotificationPermissions()
if (!granted) {
return
}
await Notifications.scheduleNotificationAsync({
content: {
title: event.title,
body: event.body,
data: buildLocalNotificationData(event, hostId),
...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
},
trigger: null
})
return
}
const scheduledIdentifier = await Notifications.scheduleNotificationAsync({
content: {
title: event.title,
body: event.body,
data: buildLocalNotificationData(event, hostId),
...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
},
trigger: null
})
if (storedKey) {
scheduledNotificationIdsByHostAndNotificationId.set(storedKey, scheduledIdentifier)
let state = scheduledNotificationsByHostAndNotificationId.get(storedKey)
if (state?.pending) {
return
}
if (!state) {
state = {}
scheduledNotificationsByHostAndNotificationId.set(storedKey, state)
}
const notificationState = state
const pending = (async () => {
const enabled = await loadPushNotificationsEnabled()
if (!enabled) {
return null
}
const granted = await ensureNotificationPermissions()
if (!granted) {
return null
}
if (notificationState.identifier) {
await Notifications.dismissNotificationAsync(notificationState.identifier).catch(() => {})
notificationState.identifier = undefined
}
return Notifications.scheduleNotificationAsync({
content: {
title: event.title,
body: event.body,
data: buildLocalNotificationData(event, hostId),
...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
},
trigger: null
})
})()
notificationState.pending = pending
try {
const scheduledIdentifier = await pending
if (!scheduledIdentifier) {
if (!notificationState.identifier) {
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
}
return
}
if (notificationState.dismissAfterSchedule) {
notificationState.dismissAfterSchedule = false
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
await Notifications.dismissNotificationAsync(scheduledIdentifier).catch(() => {})
return
}
notificationState.identifier = scheduledIdentifier
} finally {
if (notificationState.pending === pending) {
notificationState.pending = undefined
notificationState.dismissAfterSchedule = false
}
}
}
@@ -112,12 +171,21 @@ async function dismissLocalNotification(
return
}
const storedKey = getStoredNotificationKey(hostId, event.notificationId)
const identifier = scheduledNotificationIdsByHostAndNotificationId.get(storedKey)
if (!identifier) {
const state = scheduledNotificationsByHostAndNotificationId.get(storedKey)
if (!state) {
return
}
scheduledNotificationIdsByHostAndNotificationId.delete(storedKey)
await Notifications.dismissNotificationAsync(identifier).catch(() => {})
if (state.pending) {
// Why: desktop can send dismiss while iOS/Android is still scheduling the
// matching local notification. Remember it so no stale banner survives.
state.dismissAfterSchedule = true
return
}
if (!state.identifier) {
return
}
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
await Notifications.dismissNotificationAsync(state.identifier).catch(() => {})
}
// Why: each host connection gets its own notification subscription. When the
@@ -28,4 +28,18 @@ describe('mobile session startup', () => {
expect(autoCreateEffect).toContain("setCreateError('')")
expect(autoCreateEffect).toContain('void handleCreateTerminal()')
})
it('keeps dynamic agent rows above fixed New Tab actions', () => {
const newTabActions = sliceBetween('title="New Tab"', 'onClose={() => setShowCreateTabDrawer')
expect(newTabActions.indexOf('...createTabAgentActions')).toBeLessThan(
newTabActions.indexOf("label: 'Terminal'")
)
expect(newTabActions.indexOf("label: 'Terminal'")).toBeLessThan(
newTabActions.indexOf("label: 'Browser'")
)
expect(newTabActions.indexOf("label: 'Browser'")).toBeLessThan(
newTabActions.indexOf("label: 'Markdown Note'")
)
})
})
@@ -38,4 +38,10 @@ describe('mobile agent catalog', () => {
new Set(parseDesktopConfiguredAgents())
)
})
it('uses the bundled Claude icon path for Claude Agent Teams', () => {
expect(MOBILE_AGENT_CATALOG.find((agent) => agent.id === 'claude-agent-teams')).toEqual(
expect.not.objectContaining({ faviconDomain: expect.any(String) })
)
})
})
-1
View File
@@ -72,7 +72,6 @@ export const MOBILE_TUI_AGENT_LABELS: Record<TuiAgent, string> = {
}
export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial<Record<TuiAgent, string>> = {
'claude-agent-teams': 'anthropic.com',
openclaude: 'openclaude.gitlawb.com',
grok: 'x.ai',
copilot: 'github.com',
@@ -4,10 +4,11 @@ import {
TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY,
createTerminalAccessoryLayoutPreference,
getDefaultTerminalAccessoryBuiltInIds,
getDefaultTerminalAccessoryLayout,
getVisibleTerminalAccessoryKeys,
loadTerminalAccessoryLayout,
normalizeTerminalAccessoryLayoutPreference,
resetTerminalAccessoryBuiltInIds,
reorderTerminalAccessoryBuiltInIds,
saveTerminalAccessoryLayout,
setTerminalAccessoryBuiltInVisible
} from './terminal-accessory-layout'
@@ -45,6 +46,13 @@ describe('terminal accessory layout', () => {
)
})
it('default layout shows every built-in in canonical order', () => {
expect(getDefaultTerminalAccessoryLayout()).toEqual({
orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
visibleBuiltInIds: getDefaultTerminalAccessoryBuiltInIds()
})
})
it('normalizes invalid storage to defaults', () => {
expect(normalizeTerminalAccessoryLayoutPreference(null).visibleBuiltInIds).toEqual(
getDefaultTerminalAccessoryBuiltInIds()
@@ -55,31 +63,112 @@ describe('terminal accessory layout', () => {
visibleBuiltInIds: ['escape']
}).visibleBuiltInIds
).toEqual(getDefaultTerminalAccessoryBuiltInIds())
expect(
normalizeTerminalAccessoryLayoutPreference({
version: 2,
visibleBuiltInIds: ['escape']
}).visibleBuiltInIds
).toEqual(getDefaultTerminalAccessoryBuiltInIds())
})
it('returns defaults for corrupt or unreadable storage', async () => {
asyncStorageMock.getItem.mockResolvedValueOnce('{')
await expect(loadTerminalAccessoryLayout()).resolves.toEqual(
createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryBuiltInIds())
createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryLayout())
)
asyncStorageMock.getItem.mockRejectedValueOnce(new Error('unreadable'))
await expect(loadTerminalAccessoryLayout()).resolves.toEqual(
createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryBuiltInIds())
createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryLayout())
)
})
it('ignores removed ids and de-dupes visible ids', () => {
it('preserves a custom v2 order and its visible subset', () => {
const reversed = [...getDefaultTerminalAccessoryBuiltInIds()].reverse()
expect(
normalizeTerminalAccessoryLayoutPreference({
version: 2,
orderedBuiltInIds: reversed,
visibleBuiltInIds: ['tab', 'escape']
})
).toEqual({
version: 2,
orderedBuiltInIds: reversed,
visibleBuiltInIds: ['tab', 'escape']
})
})
it('ignores removed ids and de-dupes ids in v2 storage', () => {
const current = ['escape', 'tab', 'enter']
expect(
normalizeTerminalAccessoryLayoutPreference(
{
version: 2,
orderedBuiltInIds: ['tab', 'removed', 'tab', 'escape', 'enter'],
visibleBuiltInIds: ['escape', 'removed', 'escape', 'tab']
},
current
)
).toEqual({
version: 2,
orderedBuiltInIds: ['tab', 'escape', 'enter'],
visibleBuiltInIds: ['tab', 'escape']
})
})
it('inserts new built-ins next to their canonical neighbors in a custom order', () => {
const current = ['escape', 'tab', 'space', 'enter']
expect(
normalizeTerminalAccessoryLayoutPreference(
{
version: 2,
orderedBuiltInIds: ['enter', 'tab', 'escape'],
visibleBuiltInIds: ['enter', 'escape']
},
current
)
).toEqual({
version: 2,
// Why asserted: 'space' follows its canonical predecessor 'tab' even
// though the user moved 'tab' into the middle of the bar.
orderedBuiltInIds: ['enter', 'tab', 'space', 'escape'],
visibleBuiltInIds: ['enter', 'space', 'escape']
})
})
it('puts a new built-in with no surviving predecessor at the front', () => {
const current = ['escape', 'tab', 'enter']
expect(
normalizeTerminalAccessoryLayoutPreference(
{
version: 2,
orderedBuiltInIds: ['enter', 'tab'],
visibleBuiltInIds: ['enter']
},
current
).orderedBuiltInIds
).toEqual(['escape', 'enter', 'tab'])
})
it('migrates v1 layouts to canonical order', () => {
expect(
normalizeTerminalAccessoryLayoutPreference({
version: 1,
visibleBuiltInIds: ['escape', 'removed', 'escape', 'tab'],
visibleBuiltInIds: ['tab', 'escape'],
knownBuiltInIds: getDefaultTerminalAccessoryBuiltInIds()
}).visibleBuiltInIds
).toEqual(['escape', 'tab'])
})
).toEqual({
version: 2,
orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
visibleBuiltInIds: ['escape', 'tab']
})
})
it('appends new defaults only when absent from known ids', () => {
it('appends new defaults only when absent from v1 known ids', () => {
const current = ['escape', 'tab', 'enter']
expect(
@@ -129,11 +218,14 @@ describe('terminal accessory layout', () => {
).toEqual(['space'])
})
it('keeps Space hidden after that choice is persisted with current known ids', () => {
it('keeps hidden built-ins hidden across v2 round-trips', () => {
const visibleBuiltInIds = getDefaultTerminalAccessoryBuiltInIds().filter((id) => id !== 'space')
const persisted = createTerminalAccessoryLayoutPreference(visibleBuiltInIds)
const persisted = createTerminalAccessoryLayoutPreference({
orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
visibleBuiltInIds
})
expect(persisted.knownBuiltInIds).toContain('space')
expect(persisted.orderedBuiltInIds).toContain('space')
expect(normalizeTerminalAccessoryLayoutPreference(persisted).visibleBuiltInIds).not.toContain(
'space'
)
@@ -145,43 +237,100 @@ describe('terminal accessory layout', () => {
expect(
normalizeTerminalAccessoryLayoutPreference(
{
version: 1,
visibleBuiltInIds: [],
knownBuiltInIds: current
version: 2,
orderedBuiltInIds: current,
visibleBuiltInIds: []
},
current
).visibleBuiltInIds
).toEqual([])
})
it('toggle and reset helpers preserve built-in order', () => {
expect(setTerminalAccessoryBuiltInVisible(['tab'], 'escape', true, ['escape', 'tab'])).toEqual([
'escape',
'tab'
])
it('toggles visibility while preserving the custom order', () => {
const layout = { orderedBuiltInIds: ['tab', 'escape'], visibleBuiltInIds: ['tab'] }
expect(setTerminalAccessoryBuiltInVisible(layout, 'escape', true, ['escape', 'tab'])).toEqual({
orderedBuiltInIds: ['tab', 'escape'],
visibleBuiltInIds: ['tab', 'escape']
})
expect(
setTerminalAccessoryBuiltInVisible(['escape', 'tab'], 'escape', false, ['escape', 'tab'])
).toEqual(['tab'])
expect(resetTerminalAccessoryBuiltInIds()).toEqual(getDefaultTerminalAccessoryBuiltInIds())
setTerminalAccessoryBuiltInVisible(
{ orderedBuiltInIds: ['tab', 'escape'], visibleBuiltInIds: ['tab', 'escape'] },
'tab',
false,
['escape', 'tab']
).visibleBuiltInIds
).toEqual(['escape'])
expect(setTerminalAccessoryBuiltInVisible(layout, 'unknown', true, ['escape', 'tab'])).toEqual({
orderedBuiltInIds: ['tab', 'escape'],
visibleBuiltInIds: ['tab']
})
})
it('saves visible ids with current known built-in ids', async () => {
it('reorders built-ins and keeps the visible subset in the new order', () => {
const layout = {
orderedBuiltInIds: ['escape', 'tab', 'enter'],
visibleBuiltInIds: ['escape', 'enter']
}
expect(
reorderTerminalAccessoryBuiltInIds(
layout,
['enter', 'escape', 'tab'],
['escape', 'tab', 'enter']
)
).toEqual({
orderedBuiltInIds: ['enter', 'escape', 'tab'],
visibleBuiltInIds: ['enter', 'escape']
})
// Why asserted: a stale drag result missing an id must not drop that key.
expect(
reorderTerminalAccessoryBuiltInIds(layout, ['enter', 'escape'], ['escape', 'tab', 'enter'])
.orderedBuiltInIds
).toEqual(['enter', 'escape', 'tab'])
})
it('keeps visible terminal keys in the order of their ids', () => {
expect(getVisibleTerminalAccessoryKeys(['enter', 'escape']).map((key) => key.id)).toEqual([
'enter',
'escape'
])
})
it('saves the sanitized v2 preference', async () => {
asyncStorageMock.setItem.mockResolvedValueOnce(undefined)
await saveTerminalAccessoryLayout(['tab', 'tab', 'missing'])
await saveTerminalAccessoryLayout({
orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
visibleBuiltInIds: ['tab', 'tab', 'missing']
})
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY,
JSON.stringify(createTerminalAccessoryLayoutPreference(['tab']))
JSON.stringify(
createTerminalAccessoryLayoutPreference({
orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
visibleBuiltInIds: ['tab']
})
)
)
})
it('rejects write failures without mutating helper output', async () => {
asyncStorageMock.setItem.mockRejectedValueOnce(new Error('nope'))
await expect(saveTerminalAccessoryLayout(['escape'])).rejects.toThrow('nope')
expect(createTerminalAccessoryLayoutPreference(['escape']).visibleBuiltInIds).toEqual([
'escape'
])
await expect(
saveTerminalAccessoryLayout({
orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
visibleBuiltInIds: ['escape']
})
).rejects.toThrow('nope')
expect(
createTerminalAccessoryLayoutPreference({
orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
visibleBuiltInIds: ['escape']
}).visibleBuiltInIds
).toEqual(['escape'])
})
})
+121 -43
View File
@@ -4,10 +4,13 @@ import { TERMINAL_ACCESSORY_KEYS, type TerminalAccessoryKey } from './terminal-a
export const TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY = 'orca:terminal-accessory-layout'
export type TerminalAccessoryLayoutPreference = {
version: 1
export type TerminalAccessoryLayout = {
orderedBuiltInIds: string[]
visibleBuiltInIds: string[]
knownBuiltInIds: string[]
}
export type TerminalAccessoryLayoutPreference = TerminalAccessoryLayout & {
version: 2
}
function builtInIds(): string[] {
@@ -16,9 +19,9 @@ function builtInIds(): string[] {
function defaultPreference(ids = builtInIds()): TerminalAccessoryLayoutPreference {
return {
version: 1,
visibleBuiltInIds: [...ids],
knownBuiltInIds: [...ids]
version: 2,
orderedBuiltInIds: [...ids],
visibleBuiltInIds: [...ids]
}
}
@@ -42,15 +45,44 @@ function dedupeKnownIds(ids: string[], builtInSet: Set<string>): string[] {
return out
}
function orderBuiltInIds(ids: Set<string>, currentBuiltInIds: string[]): string[] {
// Why: migrated terminal bars should match the Settings -> Terminal order.
return currentBuiltInIds.filter((id) => ids.has(id))
// Why: built-ins added after the user saved a custom order should land next
// to their canonical neighbors, not dangle at the end of the bar.
function insertMissingBuiltInIds(
ordered: string[],
currentBuiltInIds: string[]
): { ordered: string[]; inserted: string[] } {
const present = new Set(ordered)
const out = [...ordered]
const inserted: string[] = []
for (let i = 0; i < currentBuiltInIds.length; i++) {
const id = currentBuiltInIds[i]!
if (present.has(id)) {
continue
}
let insertAt = 0
for (let j = i - 1; j >= 0; j--) {
const at = out.indexOf(currentBuiltInIds[j]!)
if (at !== -1) {
insertAt = at + 1
break
}
}
out.splice(insertAt, 0, id)
present.add(id)
inserted.push(id)
}
return { ordered: out, inserted }
}
export function getDefaultTerminalAccessoryBuiltInIds(): string[] {
return builtInIds()
}
export function getDefaultTerminalAccessoryLayout(): TerminalAccessoryLayout {
const ids = builtInIds()
return { orderedBuiltInIds: ids, visibleBuiltInIds: [...ids] }
}
export function normalizeTerminalAccessoryLayoutPreference(
value: unknown,
currentBuiltInIds = builtInIds()
@@ -62,66 +94,112 @@ export function normalizeTerminalAccessoryLayoutPreference(
const candidate = value as {
version?: unknown
orderedBuiltInIds?: unknown
visibleBuiltInIds?: unknown
knownBuiltInIds?: unknown
}
const visibleInput = stringArray(candidate.visibleBuiltInIds)
const knownInput = stringArray(candidate.knownBuiltInIds)
if (candidate.version !== 1 || !visibleInput || !knownInput) {
return fallback
}
const builtInSet = new Set(currentBuiltInIds)
const knownInputSet = new Set(knownInput.filter((id) => builtInSet.has(id)))
const visibleBuiltInSet = new Set(dedupeKnownIds(visibleInput, builtInSet))
for (const id of currentBuiltInIds) {
if (!knownInputSet.has(id)) {
visibleBuiltInSet.add(id)
if (candidate.version === 2) {
const orderedInput = stringArray(candidate.orderedBuiltInIds)
const visibleInput = stringArray(candidate.visibleBuiltInIds)
if (!orderedInput || !visibleInput) {
return fallback
}
const { ordered, inserted } = insertMissingBuiltInIds(
dedupeKnownIds(orderedInput, builtInSet),
currentBuiltInIds
)
const visibleSet = new Set(dedupeKnownIds(visibleInput, builtInSet))
for (const id of inserted) {
visibleSet.add(id)
}
return {
version: 2,
orderedBuiltInIds: ordered,
visibleBuiltInIds: ordered.filter((id) => visibleSet.has(id))
}
}
return {
version: 1,
visibleBuiltInIds: orderBuiltInIds(visibleBuiltInSet, currentBuiltInIds),
knownBuiltInIds: [...currentBuiltInIds]
if (candidate.version === 1) {
const visibleInput = stringArray(candidate.visibleBuiltInIds)
const knownInput = stringArray(candidate.knownBuiltInIds)
if (!visibleInput || !knownInput) {
return fallback
}
const knownInputSet = new Set(knownInput.filter((id) => builtInSet.has(id)))
const visibleSet = new Set(dedupeKnownIds(visibleInput, builtInSet))
for (const id of currentBuiltInIds) {
if (!knownInputSet.has(id)) {
visibleSet.add(id)
}
}
// Why: v1 layouts never had a custom order, so migrate to canonical order.
return {
version: 2,
orderedBuiltInIds: [...currentBuiltInIds],
visibleBuiltInIds: currentBuiltInIds.filter((id) => visibleSet.has(id))
}
}
return fallback
}
export function createTerminalAccessoryLayoutPreference(
visibleBuiltInIds: string[],
layout: TerminalAccessoryLayout,
currentBuiltInIds = builtInIds()
): TerminalAccessoryLayoutPreference {
const builtInSet = new Set(currentBuiltInIds)
const { ordered } = insertMissingBuiltInIds(
dedupeKnownIds(layout.orderedBuiltInIds, builtInSet),
currentBuiltInIds
)
const visibleSet = new Set(dedupeKnownIds(layout.visibleBuiltInIds, builtInSet))
return {
version: 1,
visibleBuiltInIds: dedupeKnownIds(visibleBuiltInIds, new Set(currentBuiltInIds)),
knownBuiltInIds: [...currentBuiltInIds]
version: 2,
orderedBuiltInIds: ordered,
visibleBuiltInIds: ordered.filter((id) => visibleSet.has(id))
}
}
export function setTerminalAccessoryBuiltInVisible(
visibleBuiltInIds: string[],
layout: TerminalAccessoryLayout,
id: string,
visible: boolean,
currentBuiltInIds = builtInIds()
): string[] {
const builtInSet = new Set(currentBuiltInIds)
if (!builtInSet.has(id)) {
return createTerminalAccessoryLayoutPreference(visibleBuiltInIds, currentBuiltInIds)
.visibleBuiltInIds
): TerminalAccessoryLayout {
const preference = createTerminalAccessoryLayoutPreference(layout, currentBuiltInIds)
if (!new Set(currentBuiltInIds).has(id)) {
return {
orderedBuiltInIds: preference.orderedBuiltInIds,
visibleBuiltInIds: preference.visibleBuiltInIds
}
}
const selected = new Set(dedupeKnownIds(visibleBuiltInIds, builtInSet))
const visibleSet = new Set(preference.visibleBuiltInIds)
if (visible) {
selected.add(id)
visibleSet.add(id)
} else {
selected.delete(id)
visibleSet.delete(id)
}
return {
orderedBuiltInIds: preference.orderedBuiltInIds,
visibleBuiltInIds: preference.orderedBuiltInIds.filter((builtInId) => visibleSet.has(builtInId))
}
return currentBuiltInIds.filter((builtInId) => selected.has(builtInId))
}
export function resetTerminalAccessoryBuiltInIds(): string[] {
return builtInIds()
export function reorderTerminalAccessoryBuiltInIds(
layout: TerminalAccessoryLayout,
orderedBuiltInIds: string[],
currentBuiltInIds = builtInIds()
): TerminalAccessoryLayout {
const preference = createTerminalAccessoryLayoutPreference(
{ orderedBuiltInIds, visibleBuiltInIds: layout.visibleBuiltInIds },
currentBuiltInIds
)
return {
orderedBuiltInIds: preference.orderedBuiltInIds,
visibleBuiltInIds: preference.visibleBuiltInIds
}
}
export function getVisibleTerminalAccessoryKeys(
@@ -146,7 +224,7 @@ export async function loadTerminalAccessoryLayout(): Promise<TerminalAccessoryLa
}
}
export async function saveTerminalAccessoryLayout(visibleBuiltInIds: string[]): Promise<void> {
const preference = createTerminalAccessoryLayoutPreference(visibleBuiltInIds)
export async function saveTerminalAccessoryLayout(layout: TerminalAccessoryLayout): Promise<void> {
const preference = createTerminalAccessoryLayoutPreference(layout)
await AsyncStorage.setItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, JSON.stringify(preference))
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { normalizeTerminalTextInput } from './terminal-text-input-normalization'
describe('normalizeTerminalTextInput', () => {
it('converts iOS smart dash replacements back to terminal hyphens', () => {
expect(normalizeTerminalTextInput('git checkout – file')).toBe('git checkout -- file')
expect(normalizeTerminalTextInput('git checkout — file')).toBe('git checkout -- file')
})
it('keeps ASCII hyphens unchanged', () => {
expect(normalizeTerminalTextInput('git checkout -- file')).toBe('git checkout -- file')
})
it('preserves longer trailing hyphen runs when iOS re-collapses the controlled value', () => {
expect(normalizeTerminalTextInput('—', '--')).toBe('---')
expect(normalizeTerminalTextInput('—', '---')).toBe('----')
expect(normalizeTerminalTextInput('git checkout —', 'git checkout --')).toBe('git checkout ---')
})
})
@@ -0,0 +1,18 @@
// Why: iOS smart punctuation can rewrite two ASCII hyphens into a single
// Unicode dash before React Native delivers terminal text input.
const IOS_SMART_DASH_REPLACEMENT_PATTERN = /[\u2013\u2014]/g
const IOS_SMART_DASH_REPLACEMENT_TEST = /[\u2013\u2014]/
export function normalizeTerminalTextInput(text: string, previousText = ''): string {
const normalizedText = text.replace(IOS_SMART_DASH_REPLACEMENT_PATTERN, '--')
const previousTrailingHyphens = /-+$/.exec(previousText)?.[0] ?? ''
const previousPrefix = previousText.slice(0, previousText.length - previousTrailingHyphens.length)
const collapsedPreviousHyphenRun =
previousTrailingHyphens.length >= 2 &&
IOS_SMART_DASH_REPLACEMENT_TEST.test(text) &&
(text === `${previousPrefix}\u2013` || text === `${previousPrefix}\u2014`)
if (collapsedPreviousHyphenRun) {
return `${previousText}-`
}
return normalizedText
}
+17
View File
@@ -217,6 +217,23 @@ describe('mobile rpc-client connection timeout', () => {
client.close()
})
it('does not resend a stream subscribed from the connected-state listener', () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key', (state) => {
if (state === 'connected') {
client.subscribe('notifications.subscribe', {}, () => {})
}
})
const socket = mockSockets[0]!
socket.open()
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
socket.receive('encrypted:{"type":"e2ee_authenticated"}')
expect(sentRequests(socket, 'notifications.subscribe')).toHaveLength(1)
client.close()
})
it('routes browser screencast binary frames to the browser subscriber', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const socket = mockSockets[0]!
+9
View File
@@ -475,6 +475,12 @@ export function connect(
removeStreamListener(id)
continue
}
// Why: setState('connected') notifies UI listeners synchronously;
// a listener may subscribe and send immediately before this
// reconnect replay loop resumes.
if (stream.sent) {
continue
}
if (stream.method === 'browser.screencast') {
pendingBrowserScreencastRequestId = id
activeBrowserScreencastRequestId = null
@@ -704,6 +710,9 @@ export function connect(
sharedKey = null
activeBrowserScreencastRequestId = null
pendingBrowserScreencastRequestId = null
for (const stream of streamListeners.values()) {
stream.sent = false
}
if (handshakeTimer) {
clearTimeout(handshakeTimer)
handshakeTimer = null
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.61-rc.2",
"version": "1.4.64-rc.1",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -2872,7 +2872,7 @@ describe('ClaudeRuntimeAuthService', () => {
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials)
})
it('does not clobber unverified live runtime credentials when switching accounts', async () => {
it('switches accounts without persisting unverified live runtime credentials', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original', 'org-a')
const unverifiedLiveCredentials = createClaudeCredentialsWithoutEmail('one-live', 'org-b')
@@ -2912,15 +2912,16 @@ describe('ClaudeRuntimeAuthService', () => {
writeFileSync(runtimeCredentialsPath, unverifiedLiveCredentials, 'utf-8')
settings.activeClaudeManagedAccountId = 'account-2'
await expect(service.syncForCurrentSelection()).rejects.toThrow(
'live Claude terminal has unverified refreshed auth'
)
await service.syncForCurrentSelection()
} finally {
markClaudePtyExited('live-claude-pty')
}
expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Original)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(unverifiedLiveCredentials)
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials)
if (process.platform === 'darwin') {
expect(testState.scopedKeychainCredentials).toBe(account2Credentials)
}
})
it('routes refreshed Claude credentials to the matching managed account', async () => {
@@ -211,8 +211,12 @@ export class ClaudeRuntimeAuthService {
outgoingReadBackResult.runtimeCredentialsJson
)
} else {
throw new Error(
'Claude account switch paused because a live Claude terminal has unverified refreshed auth.'
// Why: Claude's runtime credential blob can lack enough identity
// proof to attribute a live-session refresh. Do not persist that
// unverified blob, but also do not block the user from moving new
// terminals to the selected managed account.
console.warn(
'[claude-runtime-auth] Skipping unverified live Claude auth read-back while switching accounts'
)
}
}
+2 -2
View File
@@ -803,7 +803,7 @@ describe('ClaudeAccountService credential capture', () => {
}
const runtimeAuth = {
syncForCurrentSelection: vi.fn(async () => {
throw new Error('unverified live auth')
throw new Error('runtime sync failed')
}),
forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {})
}
@@ -817,7 +817,7 @@ describe('ClaudeAccountService credential capture', () => {
runtimeAuth as never
)
await expect(service.selectAccount('account-2')).rejects.toThrow('unverified live auth')
await expect(service.selectAccount('account-2')).rejects.toThrow('runtime sync failed')
expect(settings.activeClaudeManagedAccountId).toBe('account-1')
expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({
@@ -42,12 +42,7 @@ describe('daemon health socket listener cleanup', () => {
const result = healthCheckDaemon(socketPath, tokenPath)
socket.emit('connect')
socket.emit(
'data',
Buffer.from(
'{"type":"hello","ok":true}\n{"id":"health-1","ok":true}\n{"id":"health-2","ok":true}\n'
)
)
socket.emit('data', Buffer.from('{"type":"hello","ok":true}\n{"id":"health-1","ok":true}\n'))
await expect(result).resolves.toBe(true)
expect(socket.listenerCount('connect')).toBe(0)
-21
View File
@@ -74,36 +74,15 @@ describe('daemon health', () => {
})
it('passes when a daemon answers ping', async () => {
const ptySpawnHealthCheck = vi.fn(async () => {})
const server = new DaemonServer({
socketPath,
tokenPath,
ptySpawnHealthCheck,
spawnSubprocess: () => createMockSubprocess()
})
await server.start()
try {
await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(true)
expect(ptySpawnHealthCheck).toHaveBeenCalledOnce()
} finally {
await server.shutdown()
}
})
it('fails when a protocol-healthy daemon cannot spawn PTYs', async () => {
const server = new DaemonServer({
socketPath,
tokenPath,
ptySpawnHealthCheck: vi.fn(async () => {
throw new Error('stale node-pty helper')
}),
spawnSubprocess: () => createMockSubprocess()
})
await server.start()
try {
await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(false)
} finally {
await server.shutdown()
}
+9 -30
View File
@@ -21,8 +21,6 @@ const KILL_WAIT_MS = 3_000
const KILL_POLL_MS = 100
const START_TIME_TOLERANCE_MS = 1_500
export type DaemonHealthCheckResult = 'healthy' | 'unhealthy' | 'pty-spawn-unhealthy'
type ParsedDaemonPid = {
pid: number
startedAtMs: number | null
@@ -67,13 +65,10 @@ function canConnectSocket(socketPath: string): Promise<boolean> {
})
}
export function checkDaemonHealth(
socketPath: string,
tokenPath: string
): Promise<DaemonHealthCheckResult> {
export function healthCheckDaemon(socketPath: string, tokenPath: string): Promise<boolean> {
return new Promise((resolve) => {
if (process.platform !== 'win32' && !existsSync(socketPath)) {
resolve('unhealthy')
resolve(false)
return
}
@@ -81,13 +76,13 @@ export function checkDaemonHealth(
try {
token = readFileSync(tokenPath, 'utf8').trim()
} catch {
resolve('unhealthy')
resolve(false)
return
}
let settled = false
let sock: Socket | null = null
const settle = (result: DaemonHealthCheckResult): void => {
const settle = (result: boolean): void => {
if (settled) {
return
}
@@ -102,7 +97,7 @@ export function checkDaemonHealth(
sock?.off('connect', onConnect)
sock?.off('data', onData)
}
const onError = (): void => settle('unhealthy')
const onError = (): void => settle(false)
const onConnect = (): void => {
const hello: HelloMessage = {
type: 'hello',
@@ -133,13 +128,13 @@ export function checkDaemonHealth(
try {
message = JSON.parse(line) as Record<string, unknown>
} catch {
settle('unhealthy')
settle(false)
return
}
if (message.type === 'hello') {
if (!(message as HelloResponse).ok) {
settle('unhealthy')
settle(false)
return
}
sock?.write(encodeNdjson({ id: 'health-1', type: 'ping' }))
@@ -147,24 +142,12 @@ export function checkDaemonHealth(
}
if (message.id === 'health-1') {
if (!message.ok) {
settle('unhealthy')
return
}
// Why: protocol ping only proves the socket loop is alive. New
// terminals also depend on node-pty's native helper state inside
// the daemon process, which can go stale after dev rebuilds.
sock?.write(encodeNdjson({ id: 'health-2', type: 'ptySpawnHealth' }))
continue
}
if (message.id === 'health-2') {
settle(message.ok ? 'healthy' : 'pty-spawn-unhealthy')
settle(message.ok === true)
return
}
}
}
const timer = setTimeout(() => settle('unhealthy'), HEALTH_CHECK_TIMEOUT_MS)
const timer = setTimeout(() => settle(false), HEALTH_CHECK_TIMEOUT_MS)
sock = connect({ path: socketPath })
sock.on('error', onError)
@@ -175,10 +158,6 @@ export function checkDaemonHealth(
})
}
export async function healthCheckDaemon(socketPath: string, tokenPath: string): Promise<boolean> {
return (await checkDaemonHealth(socketPath, tokenPath)) === 'healthy'
}
function isSystemResolverHealth(value: unknown): value is SystemResolverHealth {
return value === 'healthy' || value === 'unhealthy' || value === 'unknown'
}
+52 -13
View File
@@ -20,7 +20,6 @@ const {
writeFileSyncMock,
netConnectMock,
forkMock,
checkDaemonHealthMock,
healthCheckDaemonMock,
getMacDaemonSystemResolverHealthMock,
getDaemonLaunchIdentityMock,
@@ -67,7 +66,6 @@ const {
}
})
const checkDaemonHealthMock = vi.fn(async () => 'healthy')
const healthCheckDaemonMock = vi.fn(async () => true)
const getMacDaemonSystemResolverHealthMock = vi.fn(() => 'healthy')
const getDaemonLaunchIdentityMock = vi.fn(() => 'match')
@@ -103,7 +101,6 @@ const {
writeFileSyncMock,
netConnectMock,
forkMock,
checkDaemonHealthMock,
healthCheckDaemonMock,
getMacDaemonSystemResolverHealthMock,
getDaemonLaunchIdentityMock,
@@ -175,7 +172,6 @@ vi.mock('child_process', () => ({ fork: forkMock }))
vi.mock('net', () => ({ connect: netConnectMock }))
vi.mock('./daemon-health', () => ({
checkDaemonHealth: checkDaemonHealthMock,
getDaemonLaunchIdentity: getDaemonLaunchIdentityMock,
getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock,
healthCheckDaemon: healthCheckDaemonMock,
@@ -272,9 +268,8 @@ async function importFresh() {
setLocalPtyProviderMock.mockClear()
unbindLocalProviderListenersMock.mockClear()
rebindLocalProviderListenersMock.mockClear()
checkDaemonHealthMock.mockClear()
checkDaemonHealthMock.mockResolvedValue('healthy')
healthCheckDaemonMock.mockClear()
healthCheckDaemonMock.mockResolvedValue(true)
getMacDaemonSystemResolverHealthMock.mockReset()
getMacDaemonSystemResolverHealthMock.mockReturnValue('healthy')
getDaemonLaunchIdentityMock.mockClear()
@@ -1031,7 +1026,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
probeSocketExistsMock.mockImplementation(
(p?: string) => p === '/fake/app/out/main/daemon-entry.js'
)
checkDaemonHealthMock.mockResolvedValueOnce('unhealthy')
healthCheckDaemonMock.mockResolvedValueOnce(false)
const mod = await importFresh()
getAppPathMock.mockReturnValue('/fake/app/out/main')
await mod.initDaemonPtyProvider()
@@ -1074,7 +1069,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
})
it('removes detached daemon startup listeners after readiness', async () => {
checkDaemonHealthMock.mockResolvedValueOnce('unhealthy')
healthCheckDaemonMock.mockResolvedValueOnce(false)
const mod = await importFresh()
await mod.initDaemonPtyProvider()
@@ -1129,7 +1124,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
})
it('removes detached daemon startup listeners after startup error', async () => {
checkDaemonHealthMock.mockResolvedValueOnce('unhealthy')
healthCheckDaemonMock.mockResolvedValueOnce(false)
const mod = await importFresh()
await mod.initDaemonPtyProvider()
@@ -1173,7 +1168,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(child.unref).not.toHaveBeenCalled()
})
it('preserves a spawn-unhealthy daemon when it owns live sessions', async () => {
it('preserves a health-check-failing daemon when it owns live sessions', async () => {
const mod = await importFresh()
await mod.initDaemonPtyProvider()
@@ -1198,7 +1193,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
socketPath: string,
tokenPath: string
) => Promise<{ shutdown(): Promise<void> }>
checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy')
healthCheckDaemonMock.mockResolvedValueOnce(false)
await launcher('/fake/socket', '/fake/token')
@@ -1208,7 +1203,51 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(forkMock).not.toHaveBeenCalled()
})
it('replaces a spawn-unhealthy daemon when no live sessions would be lost', async () => {
it('replaces a health-check-failing daemon when live sessions cannot be verified', async () => {
const mod = await importFresh()
await mod.initDaemonPtyProvider()
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
return {
ensureConnected: vi.fn(async () => {
throw new Error('daemon is wedged')
}),
request: vi.fn(),
disconnect: vi.fn()
}
})
const launcher = spawnerInstances[0].launcher as (
socketPath: string,
tokenPath: string
) => Promise<{ shutdown(): Promise<void> }>
healthCheckDaemonMock.mockResolvedValueOnce(false)
forkMock.mockImplementationOnce(() => ({
pid: 12345,
on(event: string, cb: (arg?: unknown) => void) {
if (event === 'message') {
queueMicrotask(() => cb({ type: 'ready' }))
}
return this
},
off() {
return this
},
disconnect: vi.fn(),
unref: vi.fn()
}))
await launcher('/fake/socket', '/fake/token')
expect(killStaleDaemonMock).toHaveBeenCalledWith(
'/fake/userData/daemon',
'/fake/socket',
'/fake/token'
)
expect(forkMock).toHaveBeenCalled()
})
it('replaces a health-check-failing daemon when no live sessions would be lost', async () => {
const mod = await importFresh()
await mod.initDaemonPtyProvider()
@@ -1216,7 +1255,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
socketPath: string,
tokenPath: string
) => Promise<{ shutdown(): Promise<void> }>
checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy')
healthCheckDaemonMock.mockResolvedValueOnce(false)
forkMock.mockImplementationOnce(() => {
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
message: [],
+49 -61
View File
@@ -28,10 +28,10 @@ import {
type ListSessionsResult
} from './types'
import {
checkDaemonHealth,
getMacDaemonSystemResolverHealth,
getDaemonLaunchIdentity,
getProcessStartedAtMs,
healthCheckDaemon,
isDaemonStaleForCurrentBundle,
killStaleDaemon
} from './daemon-health'
@@ -174,77 +174,65 @@ async function shouldPreserveDaemonWithLiveSessions(
function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
return async (socketPath, tokenPath) => {
const entryPath = getDaemonEntryPath()
const health = await checkDaemonHealth(socketPath, tokenPath)
if (health !== 'unhealthy') {
if (health === 'pty-spawn-unhealthy') {
if (
await shouldPreserveDaemonWithLiveSessions(
socketPath,
tokenPath,
'that cannot spawn new PTYs'
const healthy = await healthCheckDaemon(socketPath, tokenPath)
if (healthy) {
const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath)
if (resolverHealth === 'unhealthy') {
const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
if (liveSessionCount !== 0) {
console.warn(
liveSessionCount === null
? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified'
: `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
)
) {
return createPreservedDaemonHandle(runtimeDir)
}
console.warn('[daemon] Replacing daemon that cannot spawn new PTYs')
console.warn('[daemon] Replacing daemon with unavailable macOS system resolver')
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
} else {
const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath)
if (resolverHealth === 'unhealthy') {
const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
if (liveSessionCount !== 0) {
console.warn(
liveSessionCount === null
? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified'
: `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
)
// Why: a protocol-healthy daemon can outlive the app bundle that
// launched it. In dev this happens after deleting/rebuilding a
// worktree; in packaged apps it happens when the stable
// /Applications/Orca.app path is replaced during update.
const identity = await getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath)
const stalePackagedBundle =
app.isPackaged &&
(await isDaemonStaleForCurrentBundle(runtimeDir, socketPath, tokenPath, app.getVersion()))
if (identity === 'mismatch' || stalePackagedBundle) {
// Why: replacing a healthy daemon kills its child PTYs; defer code
// freshness until no live terminal sessions would be lost.
const replacementLabel = stalePackagedBundle
? 'launched before the current app bundle was installed'
: 'launched from a different app path'
if (await shouldPreserveDaemonWithLiveSessions(socketPath, tokenPath, replacementLabel)) {
return createPreservedDaemonHandle(runtimeDir)
}
console.warn('[daemon] Replacing daemon with unavailable macOS system resolver')
console.warn(
stalePackagedBundle
? '[daemon] Replacing daemon launched before the current app bundle was installed'
: '[daemon] Replacing daemon launched from a different app path'
)
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
} else {
// Why: a protocol-healthy daemon can outlive the app bundle that
// launched it. In dev this happens after deleting/rebuilding a
// worktree; in packaged apps it happens when the stable
// /Applications/Orca.app path is replaced during update.
const identity = await getDaemonLaunchIdentity(
runtimeDir,
socketPath,
tokenPath,
entryPath
)
const stalePackagedBundle =
app.isPackaged &&
(await isDaemonStaleForCurrentBundle(
runtimeDir,
socketPath,
tokenPath,
app.getVersion()
))
if (identity === 'mismatch' || stalePackagedBundle) {
// Why: replacing a healthy daemon kills its child PTYs; defer code
// freshness until no live terminal sessions would be lost.
const replacementLabel = stalePackagedBundle
? 'launched before the current app bundle was installed'
: 'launched from a different app path'
if (
await shouldPreserveDaemonWithLiveSessions(socketPath, tokenPath, replacementLabel)
) {
return createPreservedDaemonHandle(runtimeDir)
}
console.warn(
stalePackagedBundle
? '[daemon] Replacing daemon launched before the current app bundle was installed'
: '[daemon] Replacing daemon launched from a different app path'
)
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
} else {
// Why: daemon is already running from a previous app session and
// responded to a protocol-level ping. Safe to reuse.
return createPreservedDaemonHandle(runtimeDir)
}
// Why: daemon is already running from a previous app session and
// responded to a protocol-level ping. Safe to reuse.
return createPreservedDaemonHandle(runtimeDir)
}
}
} else {
// Why: a busy machine (e.g. right after an update) can time out the
// health check while the daemon is alive and owning terminals. Killing
// it would destroy every live session, so re-verify with a session list
// first. Only a verified non-empty list preserves: a daemon that cannot
// even list sessions cannot serve terminals, and replacing it is the
// only recovery.
const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
if (liveSessionCount !== null && liveSessionCount > 0) {
console.warn(
`[daemon] Preserving daemon that failed the health check because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
)
return createPreservedDaemonHandle(runtimeDir)
}
}
// Why: a raw socket can outlive a broken or wedged daemon. Kill by PID
+11 -11
View File
@@ -198,19 +198,19 @@ describe('DaemonServer', () => {
expect(result).toEqual({ pong: true })
})
it('handles ptySpawnHealth through the daemon process', async () => {
const ptySpawnHealthCheck = vi.fn(async () => {})
server = new DaemonServer({
socketPath,
tokenPath,
ptySpawnHealthCheck,
spawnSubprocess: () => createMockSubprocess()
})
await server.start()
it('replies with an error to unknown request types and keeps serving', async () => {
await startServer()
const c = await connectClient()
await expect(c.request('ptySpawnHealth', undefined)).resolves.toEqual({ healthy: true })
expect(ptySpawnHealthCheck).toHaveBeenCalledOnce()
// Why: older app builds still send the removed ptySpawnHealth probe.
// The daemon must reject it gracefully so a downgraded client lands on
// its session-preserving branch instead of crashing the daemon.
await expect(c.request('ptySpawnHealth', undefined)).rejects.toThrow(
'Unknown request type: ptySpawnHealth'
)
await expect(c.request<{ pong: boolean }>('ping', undefined)).resolves.toEqual({
pong: true
})
})
it('handles systemResolverHealth', async () => {
-8
View File
@@ -11,7 +11,6 @@ import { TerminalHost } from './terminal-host'
import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health'
import type { SubprocessHandle } from './session'
import { checkPtySpawnHealth } from './pty-subprocess'
import {
PROTOCOL_VERSION,
NOTIFY_PREFIX,
@@ -23,7 +22,6 @@ import {
export type DaemonServerOptions = {
socketPath: string
tokenPath: string
ptySpawnHealthCheck?: () => Promise<void>
spawnSubprocess: (opts: {
sessionId: string
cols: number
@@ -47,7 +45,6 @@ export class DaemonServer {
private host: TerminalHost
private socketPath: string
private tokenPath: string
private ptySpawnHealthCheck: () => Promise<void>
private clients = new Map<string, ConnectedClient>()
private streamDataBatcher = new DaemonStreamDataBatcher((clientId) => this.clients.get(clientId))
@@ -65,7 +62,6 @@ export class DaemonServer {
this.tokenPath = opts.tokenPath
this.token = randomUUID()
this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess })
this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth
}
async start(): Promise<void> {
@@ -379,10 +375,6 @@ export class DaemonServer {
case 'systemResolverHealth':
return { health: await readCurrentProcessMacSystemResolverHealth() }
case 'ptySpawnHealth':
await this.ptySpawnHealthCheck()
return { healthy: true }
case 'shutdown':
if (request.payload.killSessions) {
this.host.dispose()
+3 -62
View File
@@ -1,19 +1,13 @@
/* oxlint-disable max-lines -- Why: exercises full PTY subprocess surface (spawn setup, signal routing, data events, platform-specific shell configs, and Windows PowerShell implementations) with co-located test scenarios to prevent fixture drift. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'fs'
import { mkdtempSync, realpathSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import type * as LocalPtyUtils from '../providers/local-pty-utils'
const {
spawnMock,
isPwshAvailableMock,
validateWorkingDirectoryMock,
getNodePtySpawnHelperCandidatesMock
} = vi.hoisted(() => ({
const { spawnMock, isPwshAvailableMock, validateWorkingDirectoryMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
isPwshAvailableMock: vi.fn(),
getNodePtySpawnHelperCandidatesMock: vi.fn(),
validateWorkingDirectoryMock: vi.fn((cwd: string) => {
if (cwd.includes('definitely-missing')) {
throw new Error(
@@ -35,12 +29,11 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => {
const actual = await importOriginal<typeof LocalPtyUtils>()
return {
...actual,
getNodePtySpawnHelperCandidates: getNodePtySpawnHelperCandidatesMock,
validateWorkingDirectory: validateWorkingDirectoryMock
}
})
import { checkPtySpawnHealth, createPtySubprocess } from './pty-subprocess'
import { createPtySubprocess } from './pty-subprocess'
const ORCA_SHELL_WRAPPER_ENV = [
'ORCA_ATTRIBUTION_SHIM_DIR',
@@ -86,10 +79,6 @@ describe('createPtySubprocess', () => {
isPwshAvailableMock.mockReturnValue(false)
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
userDataPath = mkdtempSync(join(tmpdir(), 'daemon-pty-subprocess-test-'))
const spawnHelperPath = join(userDataPath, 'spawn-helper')
writeFileSync(spawnHelperPath, '')
getNodePtySpawnHelperCandidatesMock.mockReset()
getNodePtySpawnHelperCandidatesMock.mockReturnValue([spawnHelperPath])
process.env.ORCA_USER_DATA_PATH = userDataPath
for (const key of ORCA_SHELL_WRAPPER_ENV) {
savedWrapperEnv[key] = process.env[key]
@@ -180,54 +169,6 @@ describe('createPtySubprocess', () => {
)
})
it('checks macOS PTY spawn health with a short-lived shell', async () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'darwin' })
try {
const result = checkPtySpawnHealth()
proc._simulateExit(0)
await expect(result).resolves.toBeUndefined()
expect(proc.kill).not.toHaveBeenCalled()
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
expect(spawnMock).toHaveBeenCalledWith(
'/bin/sh',
['-c', 'exit 0'],
expect.objectContaining({
cols: 2,
rows: 1,
cwd: userDataPath,
name: 'xterm-256color'
})
)
})
it('surfaces stale node-pty helper failures during macOS PTY spawn health', async () => {
spawnMock.mockImplementation(() => {
throw new Error(
"node-pty: posix_spawn failed: ENOENT (errno 2, No such file or directory) - helper='/tmp/deleted/spawn-helper'"
)
})
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'darwin' })
try {
await expect(checkPtySpawnHealth()).rejects.toThrow('Daemon failed to spawn shell "/bin/sh"')
await expect(checkPtySpawnHealth()).rejects.toThrow('posix_spawn failed: ENOENT')
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
it('returns a SubprocessHandle with correct pid', () => {
const proc = mockPtyProcess(42)
spawnMock.mockReturnValue(proc)
-70
View File
@@ -29,7 +29,6 @@ import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../gi
import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell'
const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const
const PTY_SPAWN_HEALTH_TIMEOUT_MS = 2_000
export type PtySubprocessOptions = {
sessionId: string
@@ -232,75 +231,6 @@ function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string):
return formatted
}
export async function checkPtySpawnHealth(): Promise<void> {
if (process.platform !== 'darwin') {
return
}
ensureNodePtySpawnHelperExecutable()
preflightMacNodePtySpawnEnvironment()
const cwd = isExistingDirectory(process.env.ORCA_USER_DATA_PATH)
? process.env.ORCA_USER_DATA_PATH
: getDefaultCwd()
let proc: pty.IPty
try {
proc = pty.spawn('/bin/sh', ['-c', 'exit 0'], {
name: 'xterm-256color',
cols: 2,
rows: 1,
cwd,
env: {
...process.env,
TERM: 'xterm-256color'
}
})
} catch (err) {
throw formatPtySpawnError(err, '/bin/sh', cwd)
}
await new Promise<void>((resolve, reject) => {
let settled = false
let exitDisposable: { dispose(): void } | undefined
const finish = (error?: Error, opts?: { kill?: boolean }): void => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
exitDisposable?.dispose()
if (opts?.kill) {
try {
proc.kill()
} catch {
// Best-effort cleanup for a short-lived health probe.
}
}
if (error) {
reject(error)
return
}
resolve()
}
const timer = setTimeout(() => {
finish(new Error(`PTY spawn health check timed out after ${PTY_SPAWN_HEALTH_TIMEOUT_MS}ms`), {
kill: true
})
}, PTY_SPAWN_HEALTH_TIMEOUT_MS)
// Why: ping only proves the daemon protocol is alive. A real short-lived
// PTY spawn catches stale node-pty helper paths captured by this process.
exitDisposable = proc.onExit(({ exitCode }) => {
if (exitCode === 0) {
finish()
return
}
finish(new Error(`PTY spawn health check exited with code ${exitCode}`))
})
})
}
function normalizeForegroundProcessName(processName: string | null | undefined): string | null {
const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? ''
if (!trimmed || trimmed === 'xterm-256color') {
@@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { connect, createServer, type Server, type Socket } from 'net'
import { tmpdir } from 'os'
import { join } from 'path'
import { mkdtempSync, rmSync } from 'fs'
import { DaemonServer } from './daemon-server'
import { DaemonClient } from './client'
import { healthCheckDaemon } from './daemon-health'
import type { ListSessionsResult } from './types'
import type { SubprocessHandle } from './session'
// Why: terminals were lost after app updates because a busy machine could
// time out the 3s startup health check against a daemon that was alive and
// owning sessions, and the unhealthy path killed it. The fix re-verifies
// with listSessions, which has far larger budgets (5s hello, 30s request).
// This test reproduces the production asymmetry against a REAL daemon by
// inserting a response delay that exceeds the health-check budget but fits
// the verification budgets, and asserts the guard's two inputs disagree the
// way the fix depends on.
const RESPONSE_DELAY_MS = 3_500
function createMockSubprocess(): SubprocessHandle {
return {
pid: 55555,
getForegroundProcess: vi.fn(() => null),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
forceKill: vi.fn(),
signal: vi.fn(),
onData: vi.fn(),
onExit: vi.fn(),
dispose: vi.fn()
}
}
/** Forwards client bytes to the daemon immediately, but delays every daemon
* response so each round-trip looks like a daemon under heavy load. */
function startDelayProxy(listenPath: string, upstreamPath: string): Server {
const proxy = createServer((clientSocket: Socket) => {
const upstream = connect(upstreamPath)
clientSocket.on('data', (chunk) => upstream.write(chunk))
upstream.on('data', (chunk) => {
setTimeout(() => {
if (!clientSocket.destroyed) {
clientSocket.write(chunk)
}
}, RESPONSE_DELAY_MS)
})
const teardown = (): void => {
clientSocket.destroy()
upstream.destroy()
}
clientSocket.on('close', teardown)
clientSocket.on('error', teardown)
upstream.on('close', () => {
setTimeout(teardown, RESPONSE_DELAY_MS)
})
upstream.on('error', teardown)
})
proxy.listen(listenPath)
return proxy
}
describe('slow daemon session verification', () => {
let dir: string
let daemonSocketPath: string
let proxySocketPath: string
let tokenPath: string
let server: DaemonServer
let proxy: Server
const clients: DaemonClient[] = []
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'daemon-slow-verification-test-'))
daemonSocketPath = join(dir, 'daemon.sock')
proxySocketPath = join(dir, 'proxy.sock')
tokenPath = join(dir, 'daemon.token')
})
afterEach(async () => {
for (const client of clients.splice(0)) {
client.disconnect()
}
await new Promise<void>((resolve) => proxy?.close(() => resolve()))
await server?.shutdown()
rmSync(dir, { recursive: true, force: true })
})
it(
'fails the 3s health check against a slow daemon while listSessions still verifies its live session',
{ timeout: 60_000 },
async () => {
server = new DaemonServer({
socketPath: daemonSocketPath,
tokenPath,
spawnSubprocess: () => createMockSubprocess()
})
await server.start()
const directClient = new DaemonClient({ socketPath: daemonSocketPath, tokenPath })
clients.push(directClient)
await directClient.ensureConnected()
await directClient.request('createOrAttach', {
sessionId: 'wt-1@@live-session',
cols: 80,
rows: 24
})
proxy = startDelayProxy(proxySocketPath, daemonSocketPath)
// The exact pre-fix kill trigger: the daemon is alive but too slow for
// the health-check budget.
await expect(healthCheckDaemon(proxySocketPath, tokenPath)).resolves.toBe(false)
// The fix's re-verification against the SAME slow daemon: the larger
// client budgets absorb the latency and prove the session is alive.
const verificationClient = new DaemonClient({ socketPath: proxySocketPath, tokenPath })
clients.push(verificationClient)
await verificationClient.ensureConnected()
const result = await verificationClient.request<ListSessionsResult>('listSessions', undefined)
const liveSessionCount = result.sessions.filter((session) => session.isAlive).length
expect(liveSessionCount).toBe(1)
}
)
})
-6
View File
@@ -182,11 +182,6 @@ export type SystemResolverHealthRequest = {
type: 'systemResolverHealth'
}
export type PtySpawnHealthRequest = {
id: string
type: 'ptySpawnHealth'
}
export type GetSnapshotRequest = {
id: string
type: 'getSnapshot'
@@ -210,7 +205,6 @@ export type DaemonRequest =
| ShutdownRequest
| PingRequest
| SystemResolverHealthRequest
| PtySpawnHealthRequest
| GetSnapshotRequest
// ─── RPC Responses (Daemon → Client, on control socket) ────────────
+17
View File
@@ -95,6 +95,23 @@ describe('git remote operations', () => {
)
})
it('maps recursive submodule push failures to submodule-specific guidance', async () => {
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error('no branch'))
.mockRejectedValueOnce(
new Error(
"Command failed: git push\nPushing submodule 'find-cmux-followers'\n" +
' ! [rejected] master -> master (fetch first)\n' +
"Unable to push submodule 'find-cmux-followers'\n" +
'fatal: failed to push all needed submodules'
)
)
await expect(gitPush('/repo', false)).rejects.toThrow(
"Submodule 'find-cmux-followers' has remote changes. Pull inside the submodule, then try again."
)
})
it('passes through clean tail line when push error does not match known patterns', async () => {
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error('no branch'))
+43 -40
View File
@@ -546,14 +546,14 @@ describe('registerPtyHandlers', () => {
}
}
function spawnAndGetCall(args?: {
async function spawnAndGetCall(args?: {
cwd?: string
env?: Record<string, string>
command?: string
}): [string, string[], { cwd: string; env: Record<string, string> }] {
}): Promise<[string, string[], { cwd: string; env: Record<string, string> }]> {
handlers.clear()
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, {
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
...args
@@ -3811,11 +3811,11 @@ describe('registerPtyHandlers', () => {
delete process.env.PYTHONUTF8
})
it('passes chcp 65001 to cmd.exe for UTF-8 console output', () => {
it('passes chcp 65001 to cmd.exe for UTF-8 console output', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Windows\\system32\\cmd.exe',
@@ -3824,11 +3824,11 @@ describe('registerPtyHandlers', () => {
)
})
it('sets Console encoding for powershell.exe', () => {
it('sets Console encoding for powershell.exe', async () => {
process.env.COMSPEC = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
@@ -3837,11 +3837,11 @@ describe('registerPtyHandlers', () => {
)
})
it('sets Console encoding for pwsh.exe', () => {
it('sets Console encoding for pwsh.exe', async () => {
process.env.COMSPEC = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
@@ -3850,34 +3850,34 @@ describe('registerPtyHandlers', () => {
)
})
it('sets PYTHONUTF8=1 in the spawn environment on Windows', () => {
it('sets PYTHONUTF8=1 in the spawn environment on Windows', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
const spawnCall = spawnMock.mock.calls.at(-1)!
const env = spawnCall[2].env as Record<string, string>
expect(env.PYTHONUTF8).toBe('1')
})
it('does not override an existing PYTHONUTF8 value', () => {
it('does not override an existing PYTHONUTF8 value', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
process.env.PYTHONUTF8 = '0'
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
const spawnCall = spawnMock.mock.calls.at(-1)!
const env = spawnCall[2].env as Record<string, string>
expect(env.PYTHONUTF8).toBe('0')
})
it('launches Git Bash from COMSPEC as an interactive login shell', () => {
it('launches Git Bash from COMSPEC as an interactive login shell', async () => {
process.env.COMSPEC = 'C:\\Program Files\\Git\\bin\\bash.exe'
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Program Files\\Git\\bin\\bash.exe',
@@ -3888,7 +3888,7 @@ describe('registerPtyHandlers', () => {
)
})
it('uses terminalWindowsShell setting over COMSPEC when provided', () => {
it('uses terminalWindowsShell setting over COMSPEC when provided', async () => {
// Why: COMSPEC always points to cmd.exe on stock Windows, so without the
// setting the terminal would ignore the user's shell preference.
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
@@ -3902,7 +3902,7 @@ describe('registerPtyHandlers', () => {
terminalWindowsShell: 'powershell.exe'
}) as never
)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
@@ -3911,7 +3911,7 @@ describe('registerPtyHandlers', () => {
)
})
it('spawns powershell.exe when PowerShell family keeps the inbox implementation', () => {
it('spawns powershell.exe when PowerShell family keeps the inbox implementation', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
registerPtyHandlers(
@@ -3924,7 +3924,7 @@ describe('registerPtyHandlers', () => {
terminalWindowsPowerShellImplementation: 'powershell.exe'
}) as never
)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
@@ -3933,7 +3933,7 @@ describe('registerPtyHandlers', () => {
)
})
it('spawns pwsh.exe when PowerShell 7 is selected and available', () => {
it('spawns pwsh.exe when PowerShell 7 is selected and available', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
isPwshAvailableMock.mockReturnValue(true)
@@ -3947,12 +3947,12 @@ describe('registerPtyHandlers', () => {
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith('pwsh.exe', POWERSHELL_OSC133_ARGS, expect.any(Object))
})
it('falls back to powershell.exe when PowerShell 7 is selected but unavailable', () => {
it('falls back to powershell.exe when PowerShell 7 is selected but unavailable', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
isPwshAvailableMock.mockReturnValue(false)
@@ -3966,7 +3966,7 @@ describe('registerPtyHandlers', () => {
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
@@ -3975,7 +3975,7 @@ describe('registerPtyHandlers', () => {
)
})
it('falls back to powershell.exe when shellOverride requests pwsh.exe but pwsh is unavailable', () => {
it('falls back to powershell.exe when shellOverride requests pwsh.exe but pwsh is unavailable', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
isPwshAvailableMock.mockReturnValue(false)
@@ -3989,7 +3989,7 @@ describe('registerPtyHandlers', () => {
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, shellOverride: 'pwsh.exe' })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, shellOverride: 'pwsh.exe' })
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
@@ -3998,7 +3998,7 @@ describe('registerPtyHandlers', () => {
)
})
it('ignores the PowerShell implementation setting for cmd.exe', () => {
it('ignores the PowerShell implementation setting for cmd.exe', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\powershell.exe'
isPwshAvailableMock.mockReturnValue(true)
@@ -4012,7 +4012,7 @@ describe('registerPtyHandlers', () => {
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledWith(
'cmd.exe',
@@ -4021,7 +4021,7 @@ describe('registerPtyHandlers', () => {
)
})
it('ignores the PowerShell implementation setting for wsl.exe', () => {
it('ignores the PowerShell implementation setting for wsl.exe', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\powershell.exe'
isPwshAvailableMock.mockReturnValue(true)
@@ -4035,7 +4035,7 @@ describe('registerPtyHandlers', () => {
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
const spawnOptions = spawnMock.mock.calls.at(-1)?.[2] as { env: Record<string, string> }
expect(spawnMock).toHaveBeenCalledWith('wsl.exe', expect.any(Array), expect.any(Object))
@@ -4043,7 +4043,7 @@ describe('registerPtyHandlers', () => {
expect(spawnOptions.env.ORCA_CODEX_HOME).toBeUndefined()
})
it('keeps shellOverride priority for one-off tabs', () => {
it('keeps shellOverride priority for one-off tabs', async () => {
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
isPwshAvailableMock.mockReturnValue(false)
@@ -4057,7 +4057,7 @@ describe('registerPtyHandlers', () => {
terminalWindowsPowerShellImplementation: 'pwsh.exe'
}) as never
)
handlers.get('pty:spawn')!(null, {
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
shellOverride: 'wsl.exe'
@@ -4113,7 +4113,7 @@ describe('registerPtyHandlers', () => {
}
})
it('spawns a plain POSIX login shell and queues startup commands for the live session', () => {
it('spawns a plain POSIX login shell and queues startup commands for the live session', async () => {
const originalPlatform = process.platform
const originalShell = process.env.SHELL
const originalZdotdir = process.env.ZDOTDIR
@@ -4126,7 +4126,10 @@ describe('registerPtyHandlers', () => {
delete process.env.ZDOTDIR
try {
const [shell, args, options] = spawnAndGetCall({ cwd: '/tmp', command: 'printf "hello"' })
const [shell, args, options] = await spawnAndGetCall({
cwd: '/tmp',
command: 'printf "hello"'
})
expect(shell).toBe('/bin/zsh')
expect(args).toEqual(['-l'])
expect(options.env.ZDOTDIR).toBe('/tmp/orca-user-data/shell-ready/zsh')
@@ -4149,7 +4152,7 @@ describe('registerPtyHandlers', () => {
}
})
it('uses the POSIX shell wrapper so OpenCode config survives shell startup files', () => {
it('uses the POSIX shell wrapper so OpenCode config survives shell startup files', async () => {
const originalPlatform = process.platform
const originalShell = process.env.SHELL
@@ -4160,7 +4163,7 @@ describe('registerPtyHandlers', () => {
process.env.SHELL = '/bin/zsh'
try {
const [shell, args, options] = spawnAndGetCall({ cwd: '/tmp' })
const [shell, args, options] = await spawnAndGetCall({ cwd: '/tmp' })
expect(shell).toBe('/bin/zsh')
expect(args).toEqual(['-l'])
expect(options.env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config')
@@ -4180,7 +4183,7 @@ describe('registerPtyHandlers', () => {
}
})
it('uses the POSIX shell wrapper so Pi config survives shell startup files', () => {
it('uses the POSIX shell wrapper so Pi config survives shell startup files', async () => {
const originalPlatform = process.platform
const originalShell = process.env.SHELL
@@ -4196,7 +4199,7 @@ describe('registerPtyHandlers', () => {
}))
try {
const [shell, args, options] = spawnAndGetCall({
const [shell, args, options] = await spawnAndGetCall({
cwd: '/tmp',
env: { PI_CODING_AGENT_DIR: '/tmp/user-pi-agent' }
})
@@ -4233,7 +4236,7 @@ describe('registerPtyHandlers', () => {
process.env.SHELL = '/bin/bash'
try {
spawnAndGetCall({ cwd: '/tmp', command: 'echo hello' })
await spawnAndGetCall({ cwd: '/tmp', command: 'echo hello' })
const { getBashShellReadyRcfileContent } = await import('./pty')
const bashRcContent = getBashShellReadyRcfileContent()
@@ -4259,7 +4262,7 @@ describe('registerPtyHandlers', () => {
try {
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, {
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
@@ -4288,7 +4291,7 @@ describe('registerPtyHandlers', () => {
try {
registerPtyHandlers(mainWindow as never)
handlers.get('pty:spawn')!(null, {
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
@@ -5,24 +5,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SPEECH_MODEL_CATALOG } from './model-catalog'
import { ModelManager } from './model-manager'
const { httpsGetMock } = vi.hoisted(() => ({
httpsGetMock: vi.fn()
const { netRequestMock } = vi.hoisted(() => ({
netRequestMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/orca-speech-models-test'
},
net: {
request: netRequestMock
}
}))
vi.mock('https', async () => {
const actual = await vi.importActual('https')
return { ...(actual as Record<string, unknown>), get: httpsGetMock }
})
describe('ModelManager download failures', () => {
beforeEach(() => {
httpsGetMock.mockReset()
netRequestMock.mockReset()
})
it('rejects failed model downloads so the caller can surface the error', async () => {
@@ -31,8 +29,15 @@ describe('ModelManager download failures', () => {
const manifest = SPEECH_MODEL_CATALOG[0]
const errorHandlers: ((err: Error) => void)[] = []
const request = {
destroy: vi.fn(() => request),
setTimeout: vi.fn(() => request),
abort: vi.fn(() => request),
end: vi.fn(() => {
queueMicrotask(() => {
for (const handler of errorHandlers) {
handler(new Error('network down'))
}
})
return request
}),
on: vi.fn((event: string, cb: (err: Error) => void) => {
if (event === 'error') {
errorHandlers.push(cb)
@@ -49,14 +54,7 @@ describe('ModelManager download failures', () => {
return request
})
}
httpsGetMock.mockImplementation(() => {
queueMicrotask(() => {
for (const handler of errorHandlers) {
handler(new Error('network down'))
}
})
return request
})
netRequestMock.mockReturnValue(request)
const manager = new ModelManager(dir)
await expect(manager.downloadModel(manifest.id)).rejects.toThrow('network down')
@@ -5,21 +5,19 @@ import { PassThrough } from 'stream'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ModelManager } from './model-manager'
const { httpsGetMock } = vi.hoisted(() => ({
httpsGetMock: vi.fn()
const { netRequestMock } = vi.hoisted(() => ({
netRequestMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/orca-speech-models-test'
},
net: {
request: netRequestMock
}
}))
vi.mock('https', async () => {
const actual = await vi.importActual('https')
return { ...(actual as Record<string, unknown>), get: httpsGetMock }
})
type ModelManagerInternals = {
downloadFile: (
url: string,
@@ -33,7 +31,7 @@ type ModelManagerInternals = {
describe('ModelManager stream cleanup', () => {
beforeEach(() => {
httpsGetMock.mockReset()
netRequestMock.mockReset()
})
it('removes response progress listeners after a model download finishes', async () => {
@@ -45,16 +43,24 @@ describe('ModelManager stream cleanup', () => {
}
response.statusCode = 200
response.headers = { 'content-length': '4' }
const responseHandlers: ((response: unknown) => void)[] = []
const request = {
destroy: vi.fn(() => request),
setTimeout: vi.fn(() => request),
on: vi.fn(() => request),
abort: vi.fn(() => request),
end: vi.fn(() => {
for (const handler of responseHandlers) {
handler(response)
}
return request
}),
on: vi.fn((event: string, cb: (response: unknown) => void) => {
if (event === 'response') {
responseHandlers.push(cb)
}
return request
}),
off: vi.fn(() => request)
}
httpsGetMock.mockImplementation((_url: URL, cb: (response: unknown) => void) => {
cb(response)
return request
})
netRequestMock.mockReturnValue(request)
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const download = manager.downloadFile(
+159 -64
View File
@@ -6,15 +6,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SPEECH_MODEL_CATALOG } from './model-catalog'
import { ModelManager } from './model-manager'
const { hasOpenAiSpeechApiKeyMock, httpsGetMock, spawnMock } = vi.hoisted(() => ({
const { hasOpenAiSpeechApiKeyMock, netRequestMock, spawnMock } = vi.hoisted(() => ({
hasOpenAiSpeechApiKeyMock: vi.fn(),
httpsGetMock: vi.fn(),
netRequestMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/orca-speech-models-test'
},
net: {
request: netRequestMock
}
}))
@@ -23,11 +26,6 @@ vi.mock('child_process', async () => {
return { ...(actual as Record<string, unknown>), spawn: spawnMock }
})
vi.mock('https', async () => {
const actual = await vi.importActual('https')
return { ...(actual as Record<string, unknown>), get: httpsGetMock }
})
vi.mock('./openai-api-key-store', () => ({
hasOpenAiSpeechApiKey: hasOpenAiSpeechApiKeyMock
}))
@@ -52,7 +50,7 @@ type ModelManagerInternals = {
describe('ModelManager', () => {
beforeEach(() => {
httpsGetMock.mockReset()
netRequestMock.mockReset()
hasOpenAiSpeechApiKeyMock.mockReset()
hasOpenAiSpeechApiKeyMock.mockReturnValue(false)
spawnMock.mockReset()
@@ -129,23 +127,30 @@ describe('ModelManager', () => {
try {
const manifest = SPEECH_MODEL_CATALOG[0]
const errorHandlers: ((err: Error) => void)[] = []
const timeoutHandlers: (() => void)[] = []
const responseHandlers: ((response: unknown) => void)[] = []
const redirectHandlers: ((
statusCode: number,
method: string,
redirectUrl: string
) => void)[] = []
const request = {
destroy: vi.fn((err?: Error) => {
abort: vi.fn(() => {
queueMicrotask(() => {
for (const handler of errorHandlers) {
handler(err ?? new Error('destroyed'))
handler(new Error('Aborted'))
}
})
return request
}),
setTimeout: vi.fn((_ms: number, cb: () => void) => {
timeoutHandlers.push(cb)
return request
}),
on: vi.fn((event: string, cb: (err: Error) => void) => {
if (event === 'error') {
errorHandlers.push(cb)
} else if (event === 'response') {
responseHandlers.push(cb as unknown as (response: unknown) => void)
} else if (event === 'redirect') {
redirectHandlers.push(
cb as unknown as (statusCode: number, method: string, redirectUrl: string) => void
)
}
return request
}),
@@ -156,75 +161,154 @@ describe('ModelManager', () => {
errorHandlers.splice(index, 1)
}
}
if (event === 'timeout') {
const index = timeoutHandlers.indexOf(cb as () => void)
if (event === 'response') {
const index = responseHandlers.indexOf(cb as (response: unknown) => void)
if (index !== -1) {
timeoutHandlers.splice(index, 1)
responseHandlers.splice(index, 1)
}
}
if (event === 'redirect') {
const index = redirectHandlers.indexOf(
cb as (statusCode: number, method: string, redirectUrl: string) => void
)
if (index !== -1) {
redirectHandlers.splice(index, 1)
}
}
return request
})
}),
end: vi.fn(() => request)
}
httpsGetMock.mockImplementation(
(
_url: URL,
options: { signal?: AbortSignal } | ((response: unknown) => void),
_cb?: (response: unknown) => void
) => {
if (typeof options !== 'function') {
options.signal?.addEventListener('abort', () => request.destroy(new Error('Aborted')), {
once: true
})
}
return request
}
)
netRequestMock.mockReturnValue(request)
const manager = new ModelManager(dir)
const download = manager.downloadModel(manifest.id)
manager.cancelDownload(manifest.id)
await expect(download).resolves.toBeUndefined()
expect(request.destroy).toHaveBeenCalledWith(expect.any(Error))
expect(netRequestMock).toHaveBeenCalledWith({
method: 'GET',
url: expect.stringMatching(/^https:\/\//)
})
expect(request.end).toHaveBeenCalled()
expect(request.abort).toHaveBeenCalled()
expect(request.off).toHaveBeenCalledWith('error', expect.any(Function))
expect(request.off).toHaveBeenCalledWith('timeout', expect.any(Function))
expect(request.off).toHaveBeenCalledWith('response', expect.any(Function))
expect(request.off).toHaveBeenCalledWith('redirect', expect.any(Function))
expect(errorHandlers).toHaveLength(0)
expect(timeoutHandlers).toHaveLength(0)
expect(responseHandlers).toHaveLength(0)
expect(redirectHandlers).toHaveLength(0)
expect((await manager.getModelState(manifest.id)).status).toBe('not-downloaded')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('settles immediately when the abort signal fires before a response', async () => {
vi.useFakeTimers()
const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-'))
try {
const errorHandlers: ((err: Error) => void)[] = []
const responseHandlers: ((response: unknown) => void)[] = []
const redirectHandlers: ((
statusCode: number,
method: string,
redirectUrl: string
) => void)[] = []
const request = {
abort: vi.fn(() => request),
on: vi.fn((event: string, cb: (err: Error) => void) => {
if (event === 'error') {
errorHandlers.push(cb)
} else if (event === 'response') {
responseHandlers.push(cb as unknown as (response: unknown) => void)
} else if (event === 'redirect') {
redirectHandlers.push(
cb as unknown as (statusCode: number, method: string, redirectUrl: string) => void
)
}
return request
}),
off: vi.fn((event: string, cb: ((err: Error) => void) | (() => void)) => {
if (event === 'error') {
const index = errorHandlers.indexOf(cb as (err: Error) => void)
if (index !== -1) {
errorHandlers.splice(index, 1)
}
}
if (event === 'response') {
const index = responseHandlers.indexOf(cb as (response: unknown) => void)
if (index !== -1) {
responseHandlers.splice(index, 1)
}
}
if (event === 'redirect') {
const index = redirectHandlers.indexOf(
cb as (statusCode: number, method: string, redirectUrl: string) => void
)
if (index !== -1) {
redirectHandlers.splice(index, 1)
}
}
return request
}),
end: vi.fn(() => request)
}
netRequestMock.mockReturnValue(request)
const controller = new AbortController()
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const download = manager.downloadFile(
'https://example.com/model.tar.bz2',
join(dir, 'model.tar.bz2'),
1,
'm',
() => true,
controller.signal
)
const outcomePromise = download.then(
() => 'resolved',
(error) => (error instanceof Error ? error.message : String(error))
)
controller.abort()
await vi.advanceTimersByTimeAsync(0)
await expect(outcomePromise).resolves.toBe('Aborted')
expect(request.abort).toHaveBeenCalled()
expect(request.off).toHaveBeenCalledWith('error', expect.any(Function))
expect(request.off).toHaveBeenCalledWith('response', expect.any(Function))
expect(request.off).toHaveBeenCalledWith('redirect', expect.any(Function))
expect(errorHandlers).toHaveLength(0)
expect(responseHandlers).toHaveLength(0)
expect(redirectHandlers).toHaveLength(0)
} finally {
vi.useRealTimers()
rmSync(dir, { recursive: true, force: true })
}
})
it('times out a model download request that never responds', async () => {
vi.useFakeTimers()
const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-'))
try {
const errorHandlers: ((err: Error) => void)[] = []
const timeoutHandlers: (() => void)[] = []
const responseHandlers: ((response: unknown) => void)[] = []
const redirectHandlers: ((
statusCode: number,
method: string,
redirectUrl: string
) => void)[] = []
const request = {
destroy: vi.fn((err?: Error) => {
if (err) {
queueMicrotask(() => {
for (const handler of errorHandlers) {
handler(err)
}
})
}
return request
}),
setTimeout: vi.fn((ms: number, cb: () => void) => {
timeoutHandlers.push(cb)
setTimeout(() => {
for (const handler of timeoutHandlers) {
handler()
}
}, ms)
return request
}),
abort: vi.fn(() => request),
on: vi.fn((event: string, cb: (err: Error) => void) => {
if (event === 'error') {
errorHandlers.push(cb)
} else if (event === 'response') {
responseHandlers.push(cb as unknown as (response: unknown) => void)
} else if (event === 'redirect') {
redirectHandlers.push(
cb as unknown as (statusCode: number, method: string, redirectUrl: string) => void
)
}
return request
}),
@@ -235,16 +319,25 @@ describe('ModelManager', () => {
errorHandlers.splice(index, 1)
}
}
if (event === 'timeout') {
const index = timeoutHandlers.indexOf(cb as () => void)
if (event === 'response') {
const index = responseHandlers.indexOf(cb as (response: unknown) => void)
if (index !== -1) {
timeoutHandlers.splice(index, 1)
responseHandlers.splice(index, 1)
}
}
if (event === 'redirect') {
const index = redirectHandlers.indexOf(
cb as (statusCode: number, method: string, redirectUrl: string) => void
)
if (index !== -1) {
redirectHandlers.splice(index, 1)
}
}
return request
})
}),
end: vi.fn(() => request)
}
httpsGetMock.mockReturnValue(request)
netRequestMock.mockReturnValue(request)
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const download = manager.downloadFile(
@@ -263,11 +356,13 @@ describe('ModelManager', () => {
const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')])
expect(outcome).toBe('Model download timed out after 120 seconds without network activity')
expect(request.destroy).toHaveBeenCalledWith()
expect(request.abort).toHaveBeenCalledWith()
expect(request.off).toHaveBeenCalledWith('error', expect.any(Function))
expect(request.off).toHaveBeenCalledWith('timeout', expect.any(Function))
expect(request.off).toHaveBeenCalledWith('response', expect.any(Function))
expect(request.off).toHaveBeenCalledWith('redirect', expect.any(Function))
expect(errorHandlers).toHaveLength(0)
expect(timeoutHandlers).toHaveLength(0)
expect(responseHandlers).toHaveLength(0)
expect(redirectHandlers).toHaveLength(0)
} finally {
vi.useRealTimers()
rmSync(dir, { recursive: true, force: true })
+83 -61
View File
@@ -1,11 +1,9 @@
/* eslint-disable max-lines -- Why: model download, checksum, extraction, and cleanup share one state machine so progress/error transitions stay coupled. */
import { app } from 'electron'
import { app, net } from 'electron'
import { join, resolve, relative } from 'path'
import { existsSync, mkdirSync, createWriteStream, createReadStream, rmSync } from 'fs'
import { readdir, rm } from 'fs/promises'
import { createHash } from 'crypto'
import { get as httpsGet } from 'https'
import type { IncomingMessage } from 'http'
import { pipeline } from 'stream/promises'
import { spawn } from 'child_process'
import type {
@@ -22,6 +20,12 @@ type DownloadHandle = {
}
type ProgressCallback = (modelId: string, progress: number) => void
type DownloadIncomingMessage = Electron.IncomingMessage &
NodeJS.ReadableStream & {
headers: Record<string, string | string[] | undefined>
resume: () => void
destroy?: () => void
}
const DOWNLOAD_IDLE_TIMEOUT_MS = 120_000
@@ -287,16 +291,35 @@ export class ModelManager {
}
let settled = false
let request: ReturnType<typeof httpsGet> | null = null
let request: Electron.ClientRequest | null = null
let idleTimeout: ReturnType<typeof setTimeout> | null = null
const onSignalAbort = (): void => {
const activeRequest = request
rejectOnce(new Error('Aborted'))
activeRequest?.abort()
}
const clearIdleTimeout = (): void => {
if (idleTimeout) {
clearTimeout(idleTimeout)
idleTimeout = null
}
}
const cleanupRequestListeners = (): void => {
const activeRequest = request
clearIdleTimeout()
if (!activeRequest) {
return
}
activeRequest.off('error', onRequestError)
activeRequest.off('timeout', onRequestTimeout)
activeRequest.off('response', onResponse)
activeRequest.off('redirect', onRedirect)
signal?.removeEventListener('abort', onSignalAbort)
request = null
}
const resetIdleTimeout = (): void => {
clearIdleTimeout()
idleTimeout = setTimeout(onRequestTimeout, DOWNLOAD_IDLE_TIMEOUT_MS)
}
const resolveOnce = (): void => {
if (settled) {
return
@@ -321,62 +344,57 @@ export class ModelManager {
`Model download timed out after ${DOWNLOAD_IDLE_TIMEOUT_MS / 1000} seconds without network activity`
)
)
activeRequest?.destroy()
activeRequest?.abort()
}
const onResponse = (response: IncomingMessage): void => {
if (
response.statusCode === 301 ||
response.statusCode === 302 ||
response.statusCode === 303 ||
response.statusCode === 307 ||
response.statusCode === 308
) {
const redirectUrl = response.headers.location
if (!redirectUrl) {
response.resume()
rejectOnce(new Error('Redirect without location'))
return
}
if (redirectCount >= 5) {
response.resume()
rejectOnce(new Error('Too many redirects'))
return
}
let resolvedRedirect: URL
try {
resolvedRedirect = new URL(redirectUrl, parsedUrl)
} catch {
response.resume()
rejectOnce(new Error('Invalid redirect URL'))
return
}
if (resolvedRedirect.protocol !== 'https:') {
response.resume()
rejectOnce(new Error('Model download redirect must use HTTPS'))
return
}
response.resume()
this.downloadFile(
resolvedRedirect.toString(),
dest,
expectedSize,
modelId,
isAborted,
signal,
redirectCount + 1
)
.then(resolveOnce)
.catch(rejectOnce)
const onRedirect = (_statusCode: number, _method: string, redirectUrl: string): void => {
if (redirectCount >= 5) {
const activeRequest = request
rejectOnce(new Error('Too many redirects'))
activeRequest?.abort()
return
}
let resolvedRedirect: URL
try {
resolvedRedirect = new URL(redirectUrl, parsedUrl)
} catch {
const activeRequest = request
rejectOnce(new Error('Invalid redirect URL'))
activeRequest?.abort()
return
}
if (resolvedRedirect.protocol !== 'https:') {
const activeRequest = request
rejectOnce(new Error('Model download redirect must use HTTPS'))
activeRequest?.abort()
return
}
const activeRequest = request
cleanupRequestListeners()
activeRequest?.abort()
this.downloadFile(
resolvedRedirect.toString(),
dest,
expectedSize,
modelId,
isAborted,
signal,
redirectCount + 1
)
.then(resolveOnce)
.catch(rejectOnce)
}
const onResponse = (incoming: Electron.IncomingMessage): void => {
const response = incoming as DownloadIncomingMessage
if (response.statusCode !== 200) {
response.resume()
rejectOnce(new Error(`HTTP ${response.statusCode}`))
return
}
const totalSize = parseInt(response.headers['content-length'] || '0', 10) || expectedSize
const contentLength = response.headers['content-length']
const totalSize =
parseInt(Array.isArray(contentLength) ? contentLength[0] : contentLength || '0', 10) ||
expectedSize
let downloaded = 0
const fileStream = createWriteStream(dest)
@@ -385,9 +403,10 @@ export class ModelManager {
response.off('data', onResponseData)
}
const onResponseData = (chunk: Buffer): void => {
resetIdleTimeout()
if (isAborted()) {
request?.destroy(new Error('Aborted'))
response.destroy()
request?.abort()
response.destroy?.()
fileStream.destroy()
return
}
@@ -412,15 +431,18 @@ export class ModelManager {
})
}
request = signal
? httpsGet(parsedUrl, { signal }, onResponse)
: httpsGet(parsedUrl, onResponse)
request = net.request({ method: 'GET', url: parsedUrl.toString() })
// Why: cancellation only helps after the user presses cancel; a peer
// that accepts the socket and goes silent must not leave the model stuck
// in "downloading" forever.
request.setTimeout(DOWNLOAD_IDLE_TIMEOUT_MS, onRequestTimeout)
// Why: Electron's net stack honors app proxy settings, unlike Node's
// https client, but it does not expose request.setTimeout().
resetIdleTimeout()
request.on('error', onRequestError)
request.on('response', onResponse)
request.on('redirect', onRedirect)
if (signal) {
signal.addEventListener('abort', onSignalAbort, { once: true })
}
request.end()
})
}
@@ -94,9 +94,9 @@ describe('contextual tour floating position', () => {
expect(Number.isFinite(Number(position.panelPosition.left))).toBe(true)
expect(Number.isFinite(Number(position.panelPosition.top))).toBe(true)
// Why: -(height - 1) overlaps the panel by its 1px border so the arrow
// fill covers the border segment beneath its base.
expect(position.arrowPosition[expectedStaticArrowSide(position.panelPlacement)]).toBe(-7)
// Why: the arrow starts just outside the panel border so the card outline
// remains visually clean.
expect(position.arrowPosition[expectedStaticArrowSide(position.panelPlacement)]).toBe(-8)
})
// Regression: a tall step panel inside a small dialog host fit no placement,
@@ -37,9 +37,8 @@ export const CONTEXTUAL_TOUR_ARROW_SIZE = {
height: ARROW_HEIGHT
} as const
// Why: the arrow overlaps the panel by exactly the border width so its fill
// covers the border segment beneath it — the panel outline then flows around
// the arrow tip instead of cutting across its base.
// Why: keep the arrow just outside the panel border. Letting it overlap the
// border makes the callout look like it is colliding with the tip card outline.
export const CONTEXTUAL_TOUR_PANEL_BORDER_WIDTH = 1
export async function getContextualTourFloatingPosition(args: {
@@ -141,6 +140,6 @@ function getContextualTourArrowPosition(args: {
return {
left: args.arrowX,
top: args.arrowY,
[staticSide]: -(ARROW_HEIGHT - CONTEXTUAL_TOUR_PANEL_BORDER_WIDTH)
[staticSide]: -ARROW_HEIGHT
}
}
@@ -67,13 +67,43 @@ describe('presentGitHubPRMergeState', () => {
it('labels unresolved GitHub mergeability as checking', () => {
expect(
presentGitHubPRMergeState(pr({ mergeable: 'UNKNOWN', mergeStateStatus: null }))
presentGitHubPRMergeState(
pr({
mergeable: 'UNKNOWN',
mergeStateStatus: null,
checksSummary: { state: 'pending', total: 1, passed: 0, failed: 0, pending: 1 }
})
)
).toMatchObject({
label: 'Checking',
directMergeAvailable: false
})
})
it('allows direct merge when GitHub mergeability is unavailable but checks have passed', () => {
expect(
presentGitHubPRMergeState({
state: 'open',
checksSummary: { state: 'success', total: 3, passed: 3, failed: 0, pending: 0 }
})
).toMatchObject({
label: 'Checks passed',
directMergeAvailable: true
})
expect(
presentGitHubPRMergeState(
pr({
mergeable: 'UNKNOWN',
mergeStateStatus: null,
checksSummary: { state: 'success', total: 3, passed: 3, failed: 0, pending: 0 }
})
)
).toMatchObject({
label: 'Checks passed',
directMergeAvailable: true
})
})
it('suppresses auto-merge actions for non-open PR states', () => {
expect(
presentGitHubPRMergeState(pr({ state: 'closed', mergeQueueRequired: true })).autoMergeAction
@@ -45,10 +45,29 @@ function checksState(item: GitHubPRMergeStateInput): CheckStatus | 'none' | unde
return item.checksStatus
}
function checksPassed(item: GitHubPRMergeStateInput): boolean {
return checksState(item) === 'success'
}
function hasFullMergeMetadata(item: GitHubPRMergeStateInput): boolean {
return item.mergeable !== undefined || item.mergeStateStatus !== undefined
}
function passedChecksMergePresentation(
autoMergeAction: GitHubPRAutoMergeAction | null
): GitHubPRMergeStatePresentation {
return {
label: translate('auto.components.github.pr.merge.state.a5b66afb58', 'Checks passed'),
tone: SUCCESS_TONE,
tooltip: translate(
'auto.components.github.pr.merge.state.fbd4f57f0a',
'Checks passed. Merge eligibility will be checked again before merging.'
),
directMergeAvailable: true,
autoMergeAction
}
}
export function presentGitHubPRMergeState(
item: GitHubPRMergeStateInput
): GitHubPRMergeStatePresentation {
@@ -154,6 +173,11 @@ export function presentGitHubPRMergeState(
}
}
if (!hasFullMergeMetadata(item)) {
// Why: GitHub can omit merge metadata while checks are already green; let
// users attempt merge and rely on the main-process preflight for blockers.
if (checksPassed(item)) {
return passedChecksMergePresentation(autoMergeAction)
}
return {
label: translate('auto.components.github.pr.merge.state.bd4f27b50e', 'Merge'),
tone: MUTED_TONE,
@@ -238,6 +262,11 @@ export function presentGitHubPRMergeState(
autoMergeAction
}
}
// Why: GitHub may still report intermediate mergeability while checks are
// green; the merge command re-checks authoritative blockers before merging.
if (checksPassed(item)) {
return passedChecksMergePresentation(autoMergeAction)
}
return {
label: translate('auto.components.github.pr.merge.state.f958920f3a', 'Checking'),
tone: MUTED_TONE,
@@ -0,0 +1,16 @@
// Why: these are exact filled brand paths, not generic line approximations.
export function IosBrandIcon(): React.JSX.Element {
return (
<svg className="mp-platform-brand-icon" viewBox="0 0 24 24" aria-hidden>
<path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701" />
</svg>
)
}
export function AndroidLogo(): React.JSX.Element {
return (
<svg className="mp-platform-brand-icon" viewBox="0 0 24 24" aria-hidden>
<path d="M18.4395 5.5586c-.675 1.1664-1.352 2.3318-2.0274 3.498-.0366-.0155-.0742-.0286-.1113-.043-1.8249-.6957-3.484-.8-4.42-.787-1.8551.0185-3.3544.4643-4.2597.8203-.084-.1494-1.7526-3.021-2.0215-3.4864a1.1451 1.1451 0 0 0-.1406-.1914c-.3312-.364-.9054-.4859-1.379-.203-.475.282-.7136.9361-.3886 1.5019 1.9466 3.3696-.0966-.2158 1.9473 3.3593.0172.031-.4946.2642-1.3926 1.0177C2.8987 12.176.452 14.772 0 18.9902h24c-.119-1.1108-.3686-2.099-.7461-3.0683-.7438-1.9118-1.8435-3.2928-2.7402-4.1836a12.1048 12.1048 0 0 0-2.1309-1.6875c.6594-1.122 1.312-2.2559 1.9649-3.3848.2077-.3615.1886-.7956-.0079-1.1191a1.1001 1.1001 0 0 0-.8515-.5332c-.5225-.0536-.9392.3128-1.0488.5449zm-.0391 8.461c.3944.5926.324 1.3306-.1563 1.6503-.4799.3197-1.188.0985-1.582-.4941-.3944-.5927-.324-1.3307.1563-1.6504.4727-.315 1.1812-.1086 1.582.4941zM7.207 13.5273c.4803.3197.5506 1.0577.1563 1.6504-.394.5926-1.1038.8138-1.584.4941-.48-.3197-.5503-1.0577-.1563-1.6504.4008-.6021 1.1087-.8106 1.584-.4941z" />
</svg>
)
}
@@ -2,9 +2,10 @@ import { ArrowLeft, ArrowRight, Copy, RefreshCw } from 'lucide-react'
import { cn } from '../../lib/utils'
import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { translate } from '@/i18n/i18n'
import { AndroidLogo, IosBrandIcon } from './MobileBrandIcons'
export { HeroIntro } from './MobileHeroIntro'
export { HeroPaired, type PairedDevice } from './MobileHeroPairedDevices'
import { translate } from '@/i18n/i18n'
export type Platform = 'ios' | 'android'
export type StepIndex = 0 | 1
@@ -21,59 +22,6 @@ function getDeviceLabel(): string {
return 'computer'
}
export function HeroIntro({ onStart }: { onStart: () => void }): React.JSX.Element {
return (
<div className="mp-intro-shell">
<div className="mp-eyebrow-row">
<span className="mp-eyebrow">
{translate('auto.components.mobile.MobileHero.5410d55d79', 'Orca Mobile')}
</span>
</div>
<h1 className="mp-h1">
{translate(
'auto.components.mobile.MobileHero.cd4e5e816f',
'Your workspaces, in your pocket.'
)}
</h1>
<p className="mp-lead">
{translate(
'auto.components.mobile.MobileHero.b4ccce5cb7',
"Control Orca from your phone. Check on agents, review changes, and kick off tasks while you're away from your desk."
)}
</p>
<div
className="mp-platform-badges"
aria-label={translate(
'auto.components.mobile.MobileHero.ec0607bf66',
'Supported mobile platforms'
)}
>
<span className="mp-platform-label">
{translate('auto.components.mobile.MobileHero.da1d5e5ed0', 'Available on')}
</span>
<span className="mp-platform-badge">
<IosBrandIcon />
{translate('auto.components.mobile.MobileHero.711e6f4b47', 'iOS')}
</span>
<span className="mp-platform-badge">
<AndroidLogo />
{translate('auto.components.mobile.MobileHero.ac1eb64952', 'Android')}
</span>
</div>
<div className="mp-cta-row">
<button
type="button"
className="mp-primary-action mp-flow-primary-action"
onClick={onStart}
>
{translate('auto.components.mobile.MobileHero.10d27b4cba', 'Get started')}
<ArrowRight className="size-3.5" />
</button>
</div>
</div>
)
}
type HeroFlowProps = {
stepIdx: StepIndex
platform: Platform
@@ -344,20 +292,3 @@ export function HeroFlow({
</div>
)
}
// Why: these are exact filled brand paths, not generic line approximations.
function IosBrandIcon(): React.JSX.Element {
return (
<svg className="mp-platform-brand-icon" viewBox="0 0 24 24" aria-hidden>
<path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701" />
</svg>
)
}
function AndroidLogo(): React.JSX.Element {
return (
<svg className="mp-platform-brand-icon" viewBox="0 0 24 24" aria-hidden>
<path d="M18.4395 5.5586c-.675 1.1664-1.352 2.3318-2.0274 3.498-.0366-.0155-.0742-.0286-.1113-.043-1.8249-.6957-3.484-.8-4.42-.787-1.8551.0185-3.3544.4643-4.2597.8203-.084-.1494-1.7526-3.021-2.0215-3.4864a1.1451 1.1451 0 0 0-.1406-.1914c-.3312-.364-.9054-.4859-1.379-.203-.475.282-.7136.9361-.3886 1.5019 1.9466 3.3696-.0966-.2158 1.9473 3.3593.0172.031-.4946.2642-1.3926 1.0177C2.8987 12.176.452 14.772 0 18.9902h24c-.119-1.1108-.3686-2.099-.7461-3.0683-.7438-1.9118-1.8435-3.2928-2.7402-4.1836a12.1048 12.1048 0 0 0-2.1309-1.6875c.6594-1.122 1.312-2.2559 1.9649-3.3848.2077-.3615.1886-.7956-.0079-1.1191a1.1001 1.1001 0 0 0-.8515-.5332c-.5225-.0536-.9392.3128-1.0488.5449zm-.0391 8.461c.3944.5926.324 1.3306-.1563 1.6503-.4799.3197-1.188.0985-1.582-.4941-.3944-.5927-.324-1.3307.1563-1.6504.4727-.315 1.1812-.1086 1.582.4941zM7.207 13.5273c.4803.3197.5506 1.0577.1563 1.6504-.394.5926-1.1038.8138-1.584.4941-.48-.3197-.5503-1.0577-.1563-1.6504.4008-.6021 1.1087-.8106 1.584-.4941z" />
</svg>
)
}
@@ -0,0 +1,56 @@
import { ArrowRight } from 'lucide-react'
import { AndroidLogo, IosBrandIcon } from './MobileBrandIcons'
import { translate } from '@/i18n/i18n'
export function HeroIntro({ onStart }: { onStart: () => void }): React.JSX.Element {
return (
<div className="mp-intro-shell">
<div className="mp-eyebrow-row">
<span className="mp-eyebrow">
{translate('auto.components.mobile.MobileHero.5410d55d79', 'Orca Mobile')}
</span>
</div>
<h1 className="mp-h1">
{translate(
'auto.components.mobile.MobileHero.cd4e5e816f',
'Your workspaces, in your pocket.'
)}
</h1>
<p className="mp-lead">
{translate(
'auto.components.mobile.MobileHero.b4ccce5cb7',
"Control Orca from your phone. Check on agents, review changes, and kick off tasks while you're away from your desk."
)}
</p>
<div
className="mp-platform-badges"
aria-label={translate(
'auto.components.mobile.MobileHero.ec0607bf66',
'Supported mobile platforms'
)}
>
<span className="mp-platform-label">
{translate('auto.components.mobile.MobileHero.da1d5e5ed0', 'Available on')}
</span>
<span className="mp-platform-badge">
<IosBrandIcon />
{translate('auto.components.mobile.MobileHero.711e6f4b47', 'iOS')}
</span>
<span className="mp-platform-badge">
<AndroidLogo />
{translate('auto.components.mobile.MobileHero.ac1eb64952', 'Android')}
</span>
</div>
<div className="mp-cta-row">
<button
type="button"
className="mp-primary-action mp-flow-primary-action"
onClick={onStart}
>
{translate('auto.components.mobile.MobileHero.10d27b4cba', 'Get started')}
<ArrowRight className="size-3.5" />
</button>
</div>
</div>
)
}
@@ -0,0 +1,93 @@
import { Check } from 'lucide-react'
import type { GhosttyImportPreview } from '../../../../shared/types'
import ghosttyIcon from '../../../../../resources/ghostty.svg'
import { translate } from '@/i18n/i18n'
import type { DiscoveryState } from './ThemeStep'
export function GhosttyDiscoveryRow({
discovery,
importing,
disabled,
onImport
}: {
discovery: DiscoveryState
importing: boolean
disabled: boolean
onImport: (preview: GhosttyImportPreview) => void
}) {
// Why: 'idle' is the pre-effect state that persists on non-Mac (the
// discovery effect short-circuits there), so render nothing instead of
// showing the dashed-border "Looking for a Ghostty config..." placeholder.
if (discovery.status === 'absent' || discovery.status === 'idle') {
return null
}
if (discovery.status === 'detecting') {
return (
<div className="flex items-center gap-2.5 rounded-lg border border-dashed border-border bg-transparent px-3.5 py-2.5 text-[12px] text-muted-foreground">
<span className="size-1.5 animate-pulse rounded-full bg-muted-foreground/60" />
{translate(
'auto.components.onboarding.ThemeStep.2c3aa538f8',
'Looking for a Ghostty config...'
)}
</div>
)
}
if (discovery.status === 'imported') {
return (
<div className="flex items-center gap-2.5 rounded-lg border border-emerald-500/30 bg-emerald-500/[0.07] px-3.5 py-2.5 text-[12px] text-foreground">
<Check className="size-3.5 text-emerald-600 dark:text-emerald-400" strokeWidth={3} />
<span className="flex-1">
<span className="font-medium">
{translate('auto.components.onboarding.ThemeStep.78b6386140', 'Imported from Ghostty.')}
</span>
{discovery.fields.length > 0 && (
<span className="text-muted-foreground"> {discovery.fields.join(' · ')}</span>
)}
</span>
</div>
)
}
const { preview, fields } = discovery
return (
<div className="flex items-center gap-3 rounded-lg border border-violet-500/30 bg-violet-500/[0.06] px-3.5 py-2.5">
<img src={ghosttyIcon} alt="" className="size-4 shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-[12px] text-foreground">
<span className="font-medium">
{translate(
'auto.components.onboarding.ThemeStep.7ee9234e54',
'Ghostty config detected.'
)}
</span>{' '}
<span className="text-muted-foreground">
{translate('auto.components.onboarding.ThemeStep.248c812283', 'Import')}{' '}
{fields.length > 0
? fields.map((f) => f.toLowerCase()).join(', ')
: translate('auto.components.onboarding.ThemeStep.906c4373fe', 'settings')}
?
</span>
</div>
{preview.configPath && (
<div
className="mt-0.5 truncate font-mono text-[10.5px] text-muted-foreground"
title={preview.configPath}
>
{preview.configPath}
</div>
)}
</div>
<button
className="shrink-0 rounded-md bg-foreground px-3 py-1.5 text-[11.5px] font-semibold text-background hover:bg-foreground/90 disabled:opacity-50"
disabled={importing || disabled}
onClick={() => onImport(preview)}
>
{importing
? translate('auto.components.onboarding.ThemeStep.ad19e5c916', 'Importing...')
: translate('auto.components.onboarding.ThemeStep.248c812283', 'Import')}
</button>
</div>
)
}
@@ -4,12 +4,12 @@ import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { track } from '@/lib/telemetry'
import { useMountedRef } from '@/hooks/useMountedRef'
import { GhosttyDiscoveryRow } from './GhosttyDiscoveryRow'
import type {
DiscoveryStatusEmitted,
GhosttyImportPreview,
GlobalSettings
} from '../../../../shared/types'
import ghosttyIcon from '../../../../../resources/ghostty.svg'
import { translate } from '@/i18n/i18n'
import { ChromePreview } from './theme-chrome-preview'
@@ -35,7 +35,7 @@ export function applyOnboardingThemeSelection(
// remaining states are exactly `DiscoveryStatusEmitted`, which is the
// schema-side enum the compile-time guard in
// `src/shared/telemetry-events.ts` locks against.
type DiscoveryState =
export type DiscoveryState =
| { status: 'idle' }
| { status: 'detecting' }
| { status: 'found'; preview: GhosttyImportPreview; fields: string[] }
@@ -274,94 +274,6 @@ export function ThemeStep({ theme, onThemeChange, settings, updateSettings }: Th
)
}
function GhosttyDiscoveryRow({
discovery,
importing,
disabled,
onImport
}: {
discovery: DiscoveryState
importing: boolean
disabled: boolean
onImport: (preview: GhosttyImportPreview) => void
}) {
// Why: 'idle' is the pre-effect state that persists on non-Mac (the
// discovery effect short-circuits there), so render nothing instead of
// showing the dashed-border "Looking for a Ghostty config…" placeholder.
if (discovery.status === 'absent' || discovery.status === 'idle') {
return null
}
if (discovery.status === 'detecting') {
return (
<div className="flex items-center gap-2.5 rounded-lg border border-dashed border-border bg-transparent px-3.5 py-2.5 text-[12px] text-muted-foreground">
<span className="size-1.5 animate-pulse rounded-full bg-muted-foreground/60" />
{translate(
'auto.components.onboarding.ThemeStep.2c3aa538f8',
'Looking for a Ghostty config…'
)}
</div>
)
}
if (discovery.status === 'imported') {
return (
<div className="flex items-center gap-2.5 rounded-lg border border-emerald-500/30 bg-emerald-500/[0.07] px-3.5 py-2.5 text-[12px] text-foreground">
<Check className="size-3.5 text-emerald-600 dark:text-emerald-400" strokeWidth={3} />
<span className="flex-1">
<span className="font-medium">
{translate('auto.components.onboarding.ThemeStep.78b6386140', 'Imported from Ghostty.')}
</span>
{discovery.fields.length > 0 && (
<span className="text-muted-foreground"> {discovery.fields.join(' · ')}</span>
)}
</span>
</div>
)
}
const { preview, fields } = discovery
return (
<div className="flex items-center gap-3 rounded-lg border border-violet-500/30 bg-violet-500/[0.06] px-3.5 py-2.5">
<img src={ghosttyIcon} alt="" className="size-4 shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-[12px] text-foreground">
<span className="font-medium">
{translate(
'auto.components.onboarding.ThemeStep.7ee9234e54',
'Ghostty config detected.'
)}
</span>{' '}
<span className="text-muted-foreground">
{translate('auto.components.onboarding.ThemeStep.248c812283', 'Import')}{' '}
{fields.length > 0
? fields.map((f) => f.toLowerCase()).join(', ')
: translate('auto.components.onboarding.ThemeStep.906c4373fe', 'settings')}
?
</span>
</div>
{preview.configPath && (
<div
className="mt-0.5 truncate font-mono text-[10.5px] text-muted-foreground"
title={preview.configPath}
>
{preview.configPath}
</div>
)}
</div>
<button
className="shrink-0 rounded-md bg-foreground px-3 py-1.5 text-[11.5px] font-semibold text-background hover:bg-foreground/90 disabled:opacity-50"
disabled={importing || disabled}
onClick={() => onImport(preview)}
>
{importing
? translate('auto.components.onboarding.ThemeStep.ad19e5c916', 'Importing…')
: translate('auto.components.onboarding.ThemeStep.248c812283', 'Import')}
</button>
</div>
)
}
function humanFields(diff: Partial<GlobalSettings>): string[] {
// Why: chip labels are a friendly summary, not a strict 1:1 of mapper keys.
// Group related diff keys (font weight + family + size → "Font") so the row
@@ -42,6 +42,10 @@ import {
PRCommentsList,
PRTriageStrip
} from './checks-panel-content'
import {
clearPRCommentsListSelection,
type PRCommentsListSelectionClearRequest
} from './pr-comments-list-selection'
import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from './checks-entry-refresh'
import type {
GitLabDiscussionResolveResult,
@@ -432,6 +436,8 @@ export default function ChecksPanel(): React.JSX.Element {
const [agentComposerState, setAgentComposerState] = useState<ChecksAgentComposerState | null>(
null
)
const [commentSelectionClearRequest, setCommentSelectionClearRequest] =
useState<PRCommentsListSelectionClearRequest | null>(null)
const [hostedReviewCreationSnapshot, setHostedReviewCreationSnapshot] =
useState<HostedReviewCreationSnapshot | null>(null)
const [gitStatusSnapshot, setGitStatusSnapshot] = useState<ChecksPanelGitStatusSnapshot | null>(
@@ -3307,10 +3313,10 @@ export default function ChecksPanel(): React.JSX.Element {
<PRCommentsList
comments={comments}
commentsLoading={commentsLoading}
reviewKind={reviewShortLabel}
commentsDisabled={!canTargetPRComments}
commentsDisabledReason={commentsDisabledReason}
selectionContextKey={stateRequestKey}
selectionClearRequest={commentSelectionClearRequest}
resolveCommentsWithAIDisabled={Boolean(resolveCommentsWithAIDisabledReason)}
resolveCommentsWithAIDisabledReason={resolveCommentsWithAIDisabledReason}
onAddComment={pr ? handleAddPRComment : undefined}
@@ -3374,6 +3380,14 @@ export default function ChecksPanel(): React.JSX.Element {
onLaunched={() => {
const launchedState = agentComposerState
if (launchedState?.actionId === 'resolveComments' && launchedState.commentResolution) {
const { reviewContextKey } = launchedState.commentResolution
// Why: once the selected comments are sent to the agent, the queue
// is spent even if the host later rejects thread resolution.
clearPRCommentsListSelection(reviewContextKey)
setCommentSelectionClearRequest((prev) => ({
contextKey: reviewContextKey,
token: (prev?.token ?? 0) + 1
}))
void resolveSelectedThreadsAfterLaunch(launchedState.commentResolution).catch((err) => {
console.warn('Failed to resolve selected review comments after AI launch:', err)
toast.error(
@@ -1,12 +1,5 @@
import React, { useCallback, useMemo } from 'react'
import {
LoaderCircle,
GitMerge,
ChevronDown,
Trash2,
GitPullRequestClosed,
CircleDot
} from 'lucide-react'
import { LoaderCircle, GitMerge, ChevronDown, GitPullRequestClosed } from 'lucide-react'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
@@ -23,6 +16,11 @@ import type { PRInfo, Repo, Worktree } from '../../../../shared/types'
import { resolveGitHubPRMergeMethods } from '../../../../shared/github-pr-merge-methods'
import { runWorktreeDelete } from '../sidebar/delete-worktree-flow'
import { presentGitLabMRMergeState } from './gitlab-mr-merge-state'
import {
ClosedReviewActions,
HostedReviewActionError,
MergedReviewActions
} from './HostedReviewStateActions'
import { useHostedReviewActions, type HostedReviewActionInfo } from './use-hosted-review-actions'
import { translate } from '@/i18n/i18n'
@@ -213,65 +211,27 @@ export default function HostedReviewActions({
</DropdownMenu>
</div>
</TooltipProvider>
{actionError && <div className="text-[10px] text-rose-500 break-words">{actionError}</div>}
<HostedReviewActionError message={actionError} />
</div>
)
}
if (review.state === 'closed') {
return (
<div className="space-y-1.5">
<Button
type="button"
variant="outline"
size="xs"
className="w-full cursor-pointer text-[11px] hover:cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => void handleReopenReview()}
disabled={isUpdatingReviewState}
>
{stateUpdating === 'open' ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<CircleDot className="size-3.5" />
)}
{stateUpdating === 'open'
? translate(
'auto.components.right.sidebar.HostedReviewActions.6645ac7dd1',
'Reopening...'
)
: translate(
'auto.components.right.sidebar.HostedReviewActions.3ce211ece6',
'Reopen {{value0}}',
{ value0: shortLabel }
)}
</Button>
{actionError && <div className="text-[10px] text-rose-500 break-words">{actionError}</div>}
</div>
<ClosedReviewActions
shortLabel={shortLabel}
stateUpdating={stateUpdating}
actionError={actionError}
onReopenReview={() => void handleReopenReview()}
/>
)
}
if (review.state === 'merged') {
return (
<Button
type="button"
variant="secondary"
size="xs"
className="w-full cursor-pointer text-[11px] hover:cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleDeleteWorktree}
disabled={isDeletingWorktree}
>
{isDeletingWorktree ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<Trash2 className="size-3.5" />
)}
{isDeletingWorktree
? translate('auto.components.right.sidebar.HostedReviewActions.eefd50457e', 'Deleting...')
: translate(
'auto.components.right.sidebar.HostedReviewActions.e4aca40024',
'Delete Workspace'
)}
</Button>
<MergedReviewActions
isDeletingWorktree={isDeletingWorktree}
onDeleteWorktree={handleDeleteWorktree}
/>
)
}
@@ -0,0 +1,84 @@
import { CircleDot, LoaderCircle, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
export function HostedReviewActionError({
message
}: {
message: string | null
}): React.JSX.Element | null {
return message ? <div className="text-[10px] text-rose-500 break-words">{message}</div> : null
}
export function ClosedReviewActions({
shortLabel,
stateUpdating,
actionError,
onReopenReview
}: {
shortLabel: string
stateUpdating: 'open' | 'closed' | null
actionError: string | null
onReopenReview: () => void
}): React.JSX.Element {
return (
<div className="space-y-1.5">
<Button
type="button"
variant="outline"
size="xs"
className="w-full cursor-pointer text-[11px] hover:cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
onClick={onReopenReview}
disabled={stateUpdating !== null}
>
{stateUpdating === 'open' ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<CircleDot className="size-3.5" />
)}
{stateUpdating === 'open'
? translate(
'auto.components.right.sidebar.HostedReviewActions.6645ac7dd1',
'Reopening...'
)
: translate(
'auto.components.right.sidebar.HostedReviewActions.3ce211ece6',
'Reopen {{value0}}',
{ value0: shortLabel }
)}
</Button>
<HostedReviewActionError message={actionError} />
</div>
)
}
export function MergedReviewActions({
isDeletingWorktree,
onDeleteWorktree
}: {
isDeletingWorktree: boolean
onDeleteWorktree: () => void
}): React.JSX.Element {
return (
<Button
type="button"
variant="secondary"
size="xs"
className="w-full cursor-pointer text-[11px] hover:cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
onClick={onDeleteWorktree}
disabled={isDeletingWorktree}
>
{isDeletingWorktree ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<Trash2 className="size-3.5" />
)}
{isDeletingWorktree
? translate('auto.components.right.sidebar.HostedReviewActions.eefd50457e', 'Deleting...')
: translate(
'auto.components.right.sidebar.HostedReviewActions.e4aca40024',
'Delete Workspace'
)}
</Button>
)
}
@@ -5219,7 +5219,7 @@ export function CommitArea({
id="commit-area-remote-error"
role="alert"
aria-live="polite"
className="mt-1 text-[11px] text-destructive"
className="mt-1 min-w-0 text-[11px] leading-4 break-words text-destructive [overflow-wrap:anywhere]"
>
{remoteActionError}
</p>
@@ -0,0 +1,293 @@
// @vitest-environment happy-dom
import path from 'node:path'
import React, { type ReactNode, useState } from 'react'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import type { SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions'
import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types'
const mocks = vi.hoisted(() => ({
ensureDetectedAgents: vi.fn(),
ensureRemoteDetectedAgents: vi.fn(),
onOpenChange: vi.fn(),
onSaveAgentDefault: vi.fn(),
onLaunched: vi.fn(),
onStart: vi.fn(),
planSourceControlAgentActionLaunch: vi.fn(),
toastError: vi.fn()
}))
vi.mock('@/components/agent/AgentCombobox', () => ({
default: ({ value }: { value: string | null }) =>
React.createElement('div', { 'data-agent-value': value ?? '' })
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ open, children }: { open: boolean; children?: ReactNode }) =>
open ? React.createElement('div', { 'data-dialog-open': 'true' }, children) : null,
DialogContent: ({ children }: { children?: ReactNode }) =>
React.createElement('div', null, children),
DialogDescription: ({ children }: { children?: ReactNode }) =>
React.createElement('p', null, children),
DialogFooter: ({ children }: { children?: ReactNode }) =>
React.createElement('div', null, children),
DialogHeader: ({ children }: { children?: ReactNode }) =>
React.createElement('div', null, children),
DialogTitle: ({ children }: { children?: ReactNode }) => React.createElement('h2', null, children)
}))
vi.mock('@/components/ui/select', () => ({
Select: ({ children }: { children?: ReactNode }) => React.createElement('div', null, children),
SelectContent: ({ children }: { children?: ReactNode }) =>
React.createElement('div', null, children),
SelectItem: ({ children, value }: { children?: ReactNode; value: string }) =>
React.createElement('div', { 'data-select-item': value }, children),
SelectTrigger: ({ children }: { children?: ReactNode }) =>
React.createElement('button', null, children),
SelectValue: () => React.createElement('span')
}))
vi.mock('../source-control/SourceControlActionVariableChips', () => ({
SourceControlActionVariableChips: () => React.createElement('div')
}))
vi.mock('@/lib/source-control-agent-action-plan', () => ({
planSourceControlAgentActionLaunch: mocks.planSourceControlAgentActionLaunch
}))
vi.mock('sonner', () => ({
toast: { error: mocks.toastError }
}))
import { useAppStore, type AppState } from '@/store'
import { SourceControlAgentActionDialog } from './SourceControlAgentActionDialog'
let container: HTMLDivElement
let root: Root
let initialState: AppState
function settingsWithGlobalRecipe(
recipe: SourceControlActionRecipe | null = {
agentId: 'codex',
commandInputTemplate: '{basePrompt}',
agentArgs: ''
},
disabledTuiAgents: GlobalSettings['disabledTuiAgents'] = []
): GlobalSettings {
const base = getDefaultSettings(path.resolve('tmp'))
return {
...base,
defaultTuiAgent: 'codex',
disabledTuiAgents,
sourceControlAi: {
...base.sourceControlAi!,
enabled: true,
agentId: 'codex',
customAgentCommand: '',
actions: recipe ? { resolveConflicts: recipe } : {}
}
}
}
function repoWithSavedRecipe(): Repo {
return {
id: 'repo-1',
sourceControlAi: {
enabled: true,
actionOverrides: {
resolveConflicts: {
agentId: 'codex',
commandInputTemplate: '{basePrompt}',
agentArgs: ''
}
}
}
} as Repo
}
function resetStore(settings: GlobalSettings, repos: Repo[] = []): void {
useAppStore.setState(
{
...initialState,
settings,
repos,
ensureDetectedAgents: mocks.ensureDetectedAgents,
ensureRemoteDetectedAgents: mocks.ensureRemoteDetectedAgents
},
true
)
}
function renderControlledDialog(
overrides: Partial<React.ComponentProps<typeof SourceControlAgentActionDialog>> = {},
options: { strictMode?: boolean } = {}
): void {
function Harness(): React.JSX.Element {
const [open, setOpen] = useState(true)
return (
<SourceControlAgentActionDialog
open={open}
onOpenChange={(nextOpen) => {
mocks.onOpenChange(nextOpen)
setOpen(nextOpen)
}}
actionId="resolveConflicts"
title="Launch agent"
description="Review the launch recipe before starting."
baseCommandInput="Resolve conflicts."
savedCommandInputTemplate="{basePrompt}"
savedAgentArgs=""
launchSource="source_control_recovery"
savedAgentId="codex"
onSaveAgentDefault={mocks.onSaveAgentDefault}
onLaunched={mocks.onLaunched}
onStart={mocks.onStart}
{...overrides}
/>
)
}
act(() => {
root.render(
options.strictMode ? (
<React.StrictMode>
<Harness />
</React.StrictMode>
) : (
<Harness />
)
)
})
}
async function flushEffects(): Promise<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
describe('SourceControlAgentActionDialog', () => {
beforeEach(() => {
;(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true
initialState = useAppStore.getState()
vi.clearAllMocks()
mocks.ensureDetectedAgents.mockResolvedValue(['codex'])
mocks.ensureRemoteDetectedAgents.mockResolvedValue(['codex'])
mocks.onStart.mockResolvedValue(true)
mocks.planSourceControlAgentActionLaunch.mockReturnValue({
ok: true,
summary: 'Ready to launch.',
commandLabel: 'codex',
caveat: 'The prompt will be submitted after the agent is ready.'
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
resetStore(settingsWithGlobalRecipe())
})
afterEach(() => {
act(() => {
root.unmount()
})
container.remove()
useAppStore.setState(initialState, true)
})
it('hides the dialog and auto-starts once when the saved global launch recipe matches', async () => {
renderControlledDialog()
expect(container.textContent).not.toContain('Launch agent')
await vi.waitFor(() => expect(mocks.onStart).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(mocks.onOpenChange).toHaveBeenCalledWith(false))
expect(mocks.ensureDetectedAgents).toHaveBeenCalledTimes(1)
expect(mocks.onStart).toHaveBeenCalledWith({
agent: 'codex',
commandInput: 'Resolve conflicts.',
agentArgs: ''
})
expect(mocks.onLaunched).toHaveBeenCalledTimes(1)
expect(mocks.onSaveAgentDefault).not.toHaveBeenCalled()
expect(container.textContent).not.toContain('Launch agent')
})
it('hides the dialog and auto-starts once when the saved repo launch recipe matches', async () => {
resetStore(
settingsWithGlobalRecipe({ agentId: 'claude', commandInputTemplate: '{basePrompt}' }),
[repoWithSavedRecipe()]
)
renderControlledDialog({ repoId: 'repo-1' })
expect(container.textContent).not.toContain('Launch agent')
await vi.waitFor(() => expect(mocks.onStart).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(mocks.onOpenChange).toHaveBeenCalledWith(false))
expect(mocks.ensureDetectedAgents).toHaveBeenCalledTimes(1)
expect(mocks.onLaunched).toHaveBeenCalledTimes(1)
expect(mocks.onSaveAgentDefault).not.toHaveBeenCalled()
expect(container.textContent).not.toContain('Launch agent')
})
it('renders the form and does not auto-start when the saved launch recipe mismatches', async () => {
resetStore(
settingsWithGlobalRecipe({ agentId: 'claude', commandInputTemplate: '{basePrompt}' })
)
renderControlledDialog()
await vi.waitFor(() => expect(mocks.ensureDetectedAgents).toHaveBeenCalledTimes(1))
await flushEffects()
expect(mocks.onStart).not.toHaveBeenCalled()
expect(container.textContent).toContain('Launch agent')
expect(container.textContent).toContain('Save & start agent')
})
it('reveals the form with status copy when the saved agent is unavailable', async () => {
mocks.ensureDetectedAgents.mockResolvedValue([])
resetStore(settingsWithGlobalRecipe())
renderControlledDialog()
expect(container.textContent).not.toContain('Launch agent')
await vi.waitFor(() =>
expect(container.textContent?.toLowerCase()).toContain('not enabled or was not detected')
)
expect(mocks.onStart).not.toHaveBeenCalled()
expect(container.textContent).toContain('Launch agent')
})
it('reveals the dialog and remains open when auto-start fails', async () => {
mocks.onStart.mockResolvedValue(false)
renderControlledDialog()
expect(container.textContent).not.toContain('Launch agent')
await vi.waitFor(() => expect(mocks.onStart).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(container.textContent).toContain('Launch agent'))
expect(mocks.onLaunched).not.toHaveBeenCalled()
expect(mocks.onOpenChange).not.toHaveBeenCalledWith(false)
expect(mocks.toastError).toHaveBeenCalledTimes(1)
})
it('does not auto-start when a saved receipt appears after the dialog is already open', async () => {
let setSavedAgentId: (agent: TuiAgent | null) => void = () => {}
function Harness(): React.JSX.Element {
const [savedAgentId, setNextSavedAgentId] = useState<TuiAgent | null>(null)
setSavedAgentId = setNextSavedAgentId
return (
<SourceControlAgentActionDialog
open
onOpenChange={mocks.onOpenChange}
actionId="resolveConflicts"
title="Launch agent"
description="Review the launch recipe before starting."
baseCommandInput="Resolve conflicts."
savedCommandInputTemplate="{basePrompt}"
savedAgentArgs=""
launchSource="source_control_recovery"
savedAgentId={savedAgentId}
onSaveAgentDefault={mocks.onSaveAgentDefault}
onLaunched={mocks.onLaunched}
onStart={mocks.onStart}
/>
)
}
act(() => {
root.render(<Harness />)
})
await vi.waitFor(() => expect(container.textContent).toContain('Launch agent'))
act(() => {
setSavedAgentId('codex')
})
await flushEffects()
expect(mocks.onStart).not.toHaveBeenCalled()
expect(container.textContent).toContain('Launch agent')
})
it('does not double-start during StrictMode effect replay', async () => {
renderControlledDialog({}, { strictMode: true })
await vi.waitFor(() => expect(mocks.onStart).toHaveBeenCalledTimes(1))
await flushEffects()
expect(mocks.onStart).toHaveBeenCalledTimes(1)
expect(mocks.onLaunched).toHaveBeenCalledTimes(1)
})
})
@@ -64,6 +64,7 @@ export function SourceControlAgentActionDialog(
} = props
const {
handleOpenChange,
shouldRenderDialog,
agentOptions,
selectedAgent,
hasEnabledAgents,
@@ -89,42 +90,46 @@ export function SourceControlAgentActionDialog(
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="flex max-h-[min(82vh,42rem)] min-w-0 flex-col overflow-hidden sm:max-w-2xl">
<DialogHeader className="shrink-0">
<DialogTitle className="text-sm">{title}</DialogTitle>
<DialogDescription className="text-xs">{description}</DialogDescription>
</DialogHeader>
<SourceControlAgentActionDialogForm
actionId={actionId}
baseCommandInput={baseCommandInput}
agentOptions={agentOptions}
selectedAgent={selectedAgent}
hasEnabledAgents={hasEnabledAgents}
detecting={detecting}
statusCopy={statusCopy}
agentArgs={agentArgs}
commandTemplate={commandTemplate}
savedCommandInputTemplate={savedCommandInputTemplate}
saveLaunchRecipe={saveLaunchRecipe}
saveTargetValue={saveTargetValue}
saveTargets={saveTargets}
settings={settings}
repo={repo}
canSaveAgentDefault={Boolean(onSaveAgentDefault)}
deliveryPlan={deliveryPlan}
canStart={canStart}
isStarting={isStarting}
startLabel={startLabel}
onSelectedAgentChange={onSelectedAgentChange}
onAgentArgsChange={onAgentArgsChange}
onCommandTemplateChange={onCommandTemplateChange}
onSaveLaunchRecipeChange={onSaveLaunchRecipeChange}
onSaveAgentDefaultChange={onSaveAgentDefaultChange}
onOpenSettings={onOpenSettings}
onCancel={() => handleOpenChange(false)}
onStart={() => void handleStart()}
/>
</DialogContent>
{/* Why: saved receipts auto-start in the background, so the fallback content
stays unmounted to avoid flashing a dialog the user already skipped. */}
{shouldRenderDialog ? (
<DialogContent className="flex max-h-[min(82vh,42rem)] min-w-0 flex-col overflow-hidden sm:max-w-2xl">
<DialogHeader className="shrink-0">
<DialogTitle className="text-sm">{title}</DialogTitle>
<DialogDescription className="text-xs">{description}</DialogDescription>
</DialogHeader>
<SourceControlAgentActionDialogForm
actionId={actionId}
baseCommandInput={baseCommandInput}
agentOptions={agentOptions}
selectedAgent={selectedAgent}
hasEnabledAgents={hasEnabledAgents}
detecting={detecting}
statusCopy={statusCopy}
agentArgs={agentArgs}
commandTemplate={commandTemplate}
savedCommandInputTemplate={savedCommandInputTemplate}
saveLaunchRecipe={saveLaunchRecipe}
saveTargetValue={saveTargetValue}
saveTargets={saveTargets}
settings={settings}
repo={repo}
canSaveAgentDefault={Boolean(onSaveAgentDefault)}
deliveryPlan={deliveryPlan}
canStart={canStart}
isStarting={isStarting}
startLabel={startLabel}
onSelectedAgentChange={onSelectedAgentChange}
onAgentArgsChange={onAgentArgsChange}
onCommandTemplateChange={onCommandTemplateChange}
onSaveLaunchRecipeChange={onSaveLaunchRecipeChange}
onSaveAgentDefaultChange={onSaveAgentDefaultChange}
onOpenSettings={onOpenSettings}
onCancel={() => handleOpenChange(false)}
onStart={() => void handleStart()}
/>
</DialogContent>
) : null}
</Dialog>
)
}
@@ -107,16 +107,6 @@ describe('SourceControlAgentActionDialogForm', () => {
expect(markup).toContain('Resolve the merge conflicts reported for this pull request.')
})
it('keeps save target controls visible when the current launch recipe is already saved', () => {
const settings = settingsWithSavedGlobalRecipe()
const markup = renderForm({ settings })
expect(markup).toContain('Launch recipe already saved')
expect(markup).toContain('Save for')
expect(markup).toContain('All repositories')
})
it('checks already-saved copy against the selected save target', () => {
const settings = settingsWithSavedGlobalRecipe()
const saveTargets = [
@@ -83,7 +83,10 @@ import {
RightPanelCommentComposer,
type RightPanelCommentSubmitResult
} from './right-panel-comment-composer'
import { usePRCommentsListSelection } from './pr-comments-list-selection'
import {
type PRCommentsListSelectionClearRequest,
usePRCommentsListSelection
} from './pr-comments-list-selection'
import { translate } from '@/i18n/i18n'
export const PullRequestIcon = GitPullRequest
@@ -1980,10 +1983,10 @@ function scrollElementBottomIntoView(element: HTMLElement): void {
export function PRCommentsList({
comments,
commentsLoading,
reviewKind = 'PR',
commentsDisabled,
commentsDisabledReason,
selectionContextKey,
selectionClearRequest,
resolveCommentsWithAIDisabled,
resolveCommentsWithAIDisabledReason,
onAddComment,
@@ -1995,10 +1998,10 @@ export function PRCommentsList({
}: {
comments: PRComment[]
commentsLoading: boolean
reviewKind?: 'PR' | 'MR'
commentsDisabled?: boolean
commentsDisabledReason?: string
selectionContextKey?: string
selectionClearRequest?: PRCommentsListSelectionClearRequest | null
resolveCommentsWithAIDisabled?: boolean
resolveCommentsWithAIDisabledReason?: string
onAddComment?: (body: string) => Promise<RightPanelCommentSubmitResult>
@@ -2023,7 +2026,7 @@ export function PRCommentsList({
addGroupToSelection,
clearSelection,
toggleGroupSelection
} = usePRCommentsListSelection(comments, selectionContextKey)
} = usePRCommentsListSelection(comments, selectionContextKey, selectionClearRequest)
const visibleComments = React.useMemo(
() => filterPRCommentsByAudience(comments, commentFilter),
[commentFilter, comments]
@@ -2165,7 +2168,7 @@ export function PRCommentsList({
return (
<div className="border-t border-border">
{/* Header */}
<div className="flex flex-col gap-2.5 border-b border-border px-3 py-2.5">
<div className="sticky top-0 z-10 flex flex-col gap-2.5 border-b border-border bg-background px-3 py-2.5">
<div className="flex min-w-0 items-center gap-2">
<MessageSquare className="size-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium text-foreground">
@@ -2186,8 +2189,7 @@ export function PRCommentsList({
className="text-muted-foreground hover:text-foreground"
aria-label={translate(
'auto.components.right.sidebar.checks.panel.content.d7a2f9c401',
'Send unresolved {{value0}} comments',
{ value0: reviewKind }
'Send all unresolved'
)}
disabled={commentsLoading || resolveCommentsWithAIDisabled}
title={
@@ -2205,8 +2207,7 @@ export function PRCommentsList({
? resolveCommentsWithAIDisabledReason
: translate(
'auto.components.right.sidebar.checks.panel.content.d7a2f9c401',
'Send unresolved {{value0}} comments',
{ value0: reviewKind }
'Send all unresolved'
)}
</TooltipContent>
</Tooltip>
@@ -7,6 +7,10 @@ import { TooltipProvider } from '@/components/ui/tooltip'
import type { PRComment } from '../../../../shared/types'
import type { PRCommentGroup } from '@/lib/pr-comment-groups'
import { PRCommentsList } from './checks-panel-content'
import {
clearPRCommentsListSelection,
type PRCommentsListSelectionClearRequest
} from './pr-comments-list-selection'
let container: HTMLDivElement
let root: Root
@@ -22,6 +26,8 @@ afterEach(() => {
root.unmount()
})
container.remove()
clearPRCommentsListSelection('review:42')
clearPRCommentsListSelection('review:43')
})
function comment(overrides: Partial<PRComment>): PRComment {
@@ -38,6 +44,8 @@ function comment(overrides: Partial<PRComment>): PRComment {
function renderList(props: {
comments: PRComment[]
selectionContextKey?: string
selectionClearRequest?: PRCommentsListSelectionClearRequest | null
onResolveSelectedCommentsWithAI?: (groups: PRCommentGroup[]) => void
}): void {
act(() => {
@@ -46,7 +54,8 @@ function renderList(props: {
<PRCommentsList
comments={props.comments}
commentsLoading={false}
selectionContextKey="review:42"
selectionContextKey={props.selectionContextKey ?? 'review:42'}
selectionClearRequest={props.selectionClearRequest}
onResolveSelectedCommentsWithAI={props.onResolveSelectedCommentsWithAI ?? vi.fn()}
/>
</TooltipProvider>
@@ -54,6 +63,13 @@ function renderList(props: {
})
}
function remountList(): void {
act(() => {
root.unmount()
})
root = createRoot(container)
}
function clickButton(label: string): void {
const button = [...container.querySelectorAll('button')].find(
(candidate) =>
@@ -85,13 +101,13 @@ describe('PRCommentsList comment resolution selection', () => {
]
})
expect(hasButton('Send unresolved PR comments')).toBe(false)
expect(hasButton('Send all unresolved')).toBe(false)
renderList({
comments: [comment({ id: 4 })]
})
expect(hasButton('Send unresolved PR comments')).toBe(true)
expect(hasButton('Send all unresolved')).toBe(true)
expect(container.textContent).toContain('Add')
})
@@ -129,7 +145,7 @@ describe('PRCommentsList comment resolution selection', () => {
})
clickButton('Humans')
clickButton('Send unresolved PR comments')
clickButton('Send all unresolved')
expect(onResolveSelectedCommentsWithAI).toHaveBeenCalledTimes(1)
const selectedGroups = onResolveSelectedCommentsWithAI.mock.calls[0]?.[0] as PRCommentGroup[]
@@ -211,6 +227,61 @@ describe('PRCommentsList comment resolution selection', () => {
expect(container.querySelector('button[role="checkbox"]')).toBeNull()
})
it('keeps queued comments when the comments list remounts', () => {
const comments = [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })]
renderList({ comments })
clickButton('Add')
remountList()
renderList({ comments })
expect(hasButton('Send 1 queued comments')).toBe(true)
})
it('restores the queued comments for the matching review context after switching contexts', () => {
const review42Comments = [
comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })
]
renderList({ comments: review42Comments, selectionContextKey: 'review:42' })
clickButton('Add')
renderList({
comments: [comment({ id: 2, threadId: 'thread-2', path: 'src/b.ts', isResolved: false })],
selectionContextKey: 'review:43'
})
expect(hasButton('Send 1 queued comments')).toBe(false)
renderList({ comments: review42Comments, selectionContextKey: 'review:42' })
expect(hasButton('Send 1 queued comments')).toBe(true)
})
it('does not drop persisted queued comments while comments reload empty', () => {
const comments = [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })]
renderList({ comments })
clickButton('Add')
renderList({ comments: [] })
renderList({ comments })
expect(hasButton('Send 1 queued comments')).toBe(true)
})
it('clears persisted queued comments when the launch path marks them sent', () => {
const comments = [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })]
renderList({ comments })
clickButton('Add')
expect(hasButton('Send 1 queued comments')).toBe(true)
clearPRCommentsListSelection('review:42')
renderList({
comments,
selectionClearRequest: { contextKey: 'review:42', token: 1 }
})
expect(hasButton('Send 1 queued comments')).toBe(false)
})
it('exits selection mode when refresh leaves no eligible loaded threads', () => {
renderList({
comments: [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })]
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
getPRCommentGroupId,
getPRCommentGroupRoot,
@@ -18,6 +18,11 @@ export type PRCommentsListSelection = {
toggleGroupSelection: (groupId: string, checked: boolean) => void
}
export type PRCommentsListSelectionClearRequest = {
contextKey: string
token: number
}
type PRCommentsListSelectionState = {
contextKey: string | undefined
isSelectingForAI: boolean
@@ -25,16 +30,72 @@ type PRCommentsListSelectionState = {
}
const EMPTY_SELECTED_GROUP_IDS = new Set<string>()
const persistedSelectionByContextKey = new Map<
string,
{ isSelectingForAI: boolean; selectedGroupIds: Set<string> }
>()
function persistSelectionState(state: PRCommentsListSelectionState): void {
if (!state.contextKey) {
return
}
if (!state.isSelectingForAI && state.selectedGroupIds.size === 0) {
persistedSelectionByContextKey.delete(state.contextKey)
return
}
persistedSelectionByContextKey.set(state.contextKey, {
isSelectingForAI: state.isSelectingForAI,
selectedGroupIds: new Set(state.selectedGroupIds)
})
}
function createSelectionState(contextKey: string | undefined): PRCommentsListSelectionState {
const persisted = contextKey ? persistedSelectionByContextKey.get(contextKey) : undefined
return {
contextKey,
isSelectingForAI: persisted?.isSelectingForAI ?? false,
selectedGroupIds: new Set(persisted?.selectedGroupIds ?? [])
}
}
export function clearPRCommentsListSelection(contextKey: string | undefined): void {
if (contextKey) {
persistedSelectionByContextKey.delete(contextKey)
}
}
export function usePRCommentsListSelection(
comments: PRComment[],
selectionContextKey: string | undefined
selectionContextKey: string | undefined,
clearRequest?: PRCommentsListSelectionClearRequest | null
): PRCommentsListSelection {
const [selectionState, setSelectionState] = useState<PRCommentsListSelectionState>(() => ({
contextKey: selectionContextKey,
isSelectingForAI: false,
selectedGroupIds: new Set()
}))
const lastClearRequestTokenRef = useRef<number | null>(clearRequest?.token ?? null)
const [selectionState, setSelectionState] = useState<PRCommentsListSelectionState>(() =>
createSelectionState(selectionContextKey)
)
useEffect(() => {
setSelectionState((prev) =>
prev.contextKey === selectionContextKey ? prev : createSelectionState(selectionContextKey)
)
}, [selectionContextKey])
useEffect(() => {
if (!clearRequest || clearRequest.token === lastClearRequestTokenRef.current) {
return
}
lastClearRequestTokenRef.current = clearRequest.token
if (clearRequest.contextKey !== selectionContextKey) {
return
}
const next = {
contextKey: selectionContextKey,
isSelectingForAI: false,
selectedGroupIds: new Set<string>()
}
persistSelectionState(next)
setSelectionState(next)
}, [clearRequest, selectionContextKey])
// Why: selectable groups come from the unfiltered list so switching the
// audience filter doesn't silently drop already-selected comments.
@@ -66,6 +127,31 @@ export function usePRCommentsListSelection(
}
return pruned ? next : candidateSelectedGroupIds
}, [candidateSelectedGroupIds, selectableGroupsById])
useEffect(() => {
if (
comments.length === 0 ||
!isCurrentSelectionContext ||
selectedGroupIds === candidateSelectedGroupIds
) {
return
}
const next = {
contextKey: selectionContextKey,
isSelectingForAI: selectionState.isSelectingForAI,
selectedGroupIds: new Set(selectedGroupIds)
}
persistSelectionState(next)
setSelectionState(next)
}, [
candidateSelectedGroupIds,
comments.length,
isCurrentSelectionContext,
selectedGroupIds,
selectionContextKey,
selectionState.isSelectingForAI
])
const isSelectingForAI =
isCurrentSelectionContext && selectionState.isSelectingForAI && selectableGroupsById.size > 0
const selectedGroups = useMemo(
@@ -81,21 +167,25 @@ export function usePRCommentsListSelection(
if (!selectableGroupsById.has(groupId)) {
return
}
setSelectionState({
const next = {
contextKey: selectionContextKey,
isSelectingForAI: true,
selectedGroupIds: new Set([groupId])
})
}
persistSelectionState(next)
setSelectionState(next)
},
[selectableGroupsById, selectionContextKey]
)
const clearSelection = useCallback((): void => {
setSelectionState({
const next = {
contextKey: selectionContextKey,
isSelectingForAI: false,
selectedGroupIds: new Set()
})
selectedGroupIds: new Set<string>()
}
persistSelectionState(next)
setSelectionState(next)
}, [selectionContextKey])
const toggleGroupSelection = useCallback(
@@ -112,11 +202,13 @@ export function usePRCommentsListSelection(
} else {
next.delete(groupId)
}
return {
const nextState = {
contextKey: selectionContextKey,
isSelectingForAI: true,
selectedGroupIds: next
}
persistSelectionState(nextState)
return nextState
})
},
[selectableGroupsById, selectionContextKey]
@@ -80,6 +80,33 @@ describe('sourceControlActionRecipeMatchesTarget', () => {
).toBe(true)
})
it('returns true when the resolve conflicts recipe matches the repo saved recipe', () => {
expect(
sourceControlActionRecipeMatchesTarget({
actionId: 'resolveConflicts',
target: { type: 'repo', repoId: 'repo-1' },
recipe: {
agentId: 'codex',
commandInputTemplate: '{basePrompt}',
agentArgs: ''
},
settings: settings(),
repo: {
sourceControlAi: {
enabled: true,
actionOverrides: {
resolveConflicts: {
agentId: 'codex',
commandInputTemplate: '{basePrompt}',
agentArgs: ''
}
}
}
} satisfies Pick<Repo, 'sourceControlAi'>
})
).toBe(true)
})
it('returns true when a repo recipe inherits the global command template', () => {
const currentSettings = settings()
currentSettings.sourceControlAi = {
@@ -6,6 +6,7 @@ import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlA
export type UseSourceControlAgentActionDialogResult = {
handleOpenChange: (nextOpen: boolean) => void
shouldRenderDialog: boolean
agentOptions: ReturnType<typeof getAgentCatalog>
selectedAgent: TuiAgent | null
hasEnabledAgents: boolean
@@ -0,0 +1,285 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type {
SourceControlActionRecipe,
SourceControlLaunchActionId
} from '../../../../shared/source-control-ai-actions'
import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types'
import { isSourceControlAgentDetectedAndEnabled } from './source-control-agent-action-dialog-support'
import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match'
type SavedSourceControlAgentActionTargetValue = 'repo' | 'global'
const NO_SAVED_RECEIPT_KEY = '__no_saved_receipt__'
type UseSavedSourceControlAgentActionAutoStartArgs = {
open: boolean
openCycle: number
detectionReady: boolean
actionId: SourceControlLaunchActionId
baseCommandInput: string
savedAgentId?: TuiAgent | null
savedCommandInputTemplate?: string | null
savedAgentArgs?: string | null
settings: Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'> | null | undefined
repo: Pick<Repo, 'sourceControlAi'> | null
repoId?: string | null
worktreeId?: string | null
connectionId?: string | null
selectedAgent: TuiAgent | null
trimmedCommandInput: string
connectionUnavailable: boolean
detecting: boolean
isStarting: boolean
detectedAgents: TuiAgent[]
disabledAgents: TuiAgent[] | undefined
onAutoStart: (args: {
detectedAgents: TuiAgent[]
saveTargetValue: SavedSourceControlAgentActionTargetValue
}) => Promise<boolean>
}
type SavedSourceControlAgentActionAutoStartResult = {
autoLaunchPending: boolean
matchedSavedReceiptTargetValue: SavedSourceControlAgentActionTargetValue | null
}
type AutoLaunchReceiptState = {
openCycle: number
receiptKey: string
revealed: boolean
}
function buildSavedLaunchRecipe(input: {
savedAgentId?: TuiAgent | null
savedCommandInputTemplate?: string | null
savedAgentArgs?: string | null
}): SourceControlActionRecipe | null {
if (!input.savedAgentId) {
return null
}
return {
agentId: input.savedAgentId,
commandInputTemplate: input.savedCommandInputTemplate ?? '{basePrompt}',
agentArgs: input.savedAgentArgs ?? ''
}
}
function getMatchedSavedReceiptTargetValue(input: {
actionId: SourceControlLaunchActionId
recipe: SourceControlActionRecipe | null
settings: Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'> | null | undefined
repo: Pick<Repo, 'sourceControlAi'> | null
repoId?: string | null
}): SavedSourceControlAgentActionTargetValue | null {
if (!input.recipe) {
return null
}
if (
input.repoId &&
input.repo &&
sourceControlActionRecipeMatchesTarget({
actionId: input.actionId,
target: { type: 'repo', repoId: input.repoId },
recipe: input.recipe,
settings: input.settings,
repo: input.repo
})
) {
return 'repo'
}
if (
sourceControlActionRecipeMatchesTarget({
actionId: input.actionId,
target: { type: 'global' },
recipe: input.recipe,
settings: input.settings,
repo: input.repo
})
) {
return 'global'
}
return null
}
function buildReceiptKey(input: {
actionId: SourceControlLaunchActionId
targetValue: SavedSourceControlAgentActionTargetValue
savedAgentId: TuiAgent
savedCommandInputTemplate?: string | null
savedAgentArgs?: string | null
repoId?: string | null
connectionId?: string | null
worktreeId?: string | null
baseCommandInput: string
}): string {
return JSON.stringify([
input.actionId,
input.targetValue,
input.savedAgentId,
input.savedCommandInputTemplate ?? '{basePrompt}',
input.savedAgentArgs ?? '',
input.repoId ?? null,
input.connectionId ?? null,
input.worktreeId ?? null,
input.baseCommandInput
])
}
export function useSavedSourceControlAgentActionAutoStart({
open,
openCycle,
detectionReady,
actionId,
baseCommandInput,
savedAgentId,
savedCommandInputTemplate,
savedAgentArgs,
settings,
repo,
repoId,
worktreeId,
connectionId,
selectedAgent,
trimmedCommandInput,
connectionUnavailable,
detecting,
isStarting,
detectedAgents,
disabledAgents,
onAutoStart
}: UseSavedSourceControlAgentActionAutoStartArgs): SavedSourceControlAgentActionAutoStartResult {
const autoStartedOpenCycleRef = useRef(0)
const [receiptState, setReceiptState] = useState<AutoLaunchReceiptState | null>(null)
const savedLaunchRecipe = useMemo(
() =>
buildSavedLaunchRecipe({
savedAgentId,
savedCommandInputTemplate,
savedAgentArgs
}),
[savedAgentArgs, savedAgentId, savedCommandInputTemplate]
)
const matchedSavedReceiptTargetValue = useMemo(
() =>
getMatchedSavedReceiptTargetValue({
actionId,
recipe: savedLaunchRecipe,
settings,
repo,
repoId
}),
[actionId, repo, repoId, savedLaunchRecipe, settings]
)
const receiptKey = useMemo(() => {
if (!savedAgentId || !matchedSavedReceiptTargetValue) {
return null
}
return buildReceiptKey({
actionId,
targetValue: matchedSavedReceiptTargetValue,
savedAgentId,
savedCommandInputTemplate,
savedAgentArgs,
repoId,
connectionId,
worktreeId,
baseCommandInput
})
}, [
actionId,
baseCommandInput,
connectionId,
matchedSavedReceiptTargetValue,
repoId,
savedAgentArgs,
savedAgentId,
savedCommandInputTemplate,
worktreeId
])
const currentReceiptState = receiptState?.openCycle === openCycle ? receiptState : null
const consideredDifferentReceipt = Boolean(
currentReceiptState && receiptKey && currentReceiptState.receiptKey !== receiptKey
)
const autoLaunchPending = Boolean(
open &&
matchedSavedReceiptTargetValue &&
receiptKey &&
!consideredDifferentReceipt &&
!currentReceiptState?.revealed
)
useEffect(() => {
if (!open) {
autoStartedOpenCycleRef.current = 0
setReceiptState(null)
return
}
if (receiptState?.openCycle !== openCycle) {
setReceiptState({
openCycle,
receiptKey: receiptKey ?? NO_SAVED_RECEIPT_KEY,
revealed: !receiptKey
})
}
if (!matchedSavedReceiptTargetValue || !receiptKey || !savedAgentId) {
return
}
if (receiptState?.openCycle === openCycle && receiptState.receiptKey !== receiptKey) {
return
}
if (receiptState?.openCycle === openCycle && receiptState.revealed) {
return
}
const revealDialog = (): void => {
setReceiptState({ openCycle, receiptKey, revealed: true })
}
if (!detectionReady || detecting || isStarting) {
return
}
if (
selectedAgent !== savedAgentId ||
!trimmedCommandInput ||
connectionUnavailable ||
!isSourceControlAgentDetectedAndEnabled(savedAgentId, detectedAgents, disabledAgents)
) {
revealDialog()
return
}
if (autoStartedOpenCycleRef.current === openCycle) {
return
}
autoStartedOpenCycleRef.current = openCycle
void onAutoStart({
detectedAgents,
saveTargetValue: matchedSavedReceiptTargetValue
})
.then((launched) => {
if (!launched) {
revealDialog()
}
})
.catch(() => {
revealDialog()
})
}, [
connectionUnavailable,
detectedAgents,
detectionReady,
detecting,
disabledAgents,
isStarting,
matchedSavedReceiptTargetValue,
onAutoStart,
open,
openCycle,
receiptKey,
receiptState,
savedAgentId,
selectedAgent,
trimmedCommandInput
])
return { autoLaunchPending, matchedSavedReceiptTargetValue }
}
@@ -1,22 +1,20 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getAgentCatalog } from '@/lib/agent-catalog'
import { pickSourceControlLaunchAgent } from '@/lib/source-control-launch-agent-selection'
import { buildSourceControlAgentDeliveryPlan } from './buildSourceControlAgentDeliveryPlan'
import { useAppStore } from '@/store'
import { useRepoById } from '@/store/selectors'
import { renderSourceControlActionCommandTemplate } from '../../../../shared/source-control-ai-actions'
import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
import type { TuiAgent } from '../../../../shared/types'
import { type SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm'
import type { SourceControlAgentActionDialogProps } from './SourceControlAgentActionDialog'
import type { UseSourceControlAgentActionDialogResult } from './source-control-agent-action-dialog-result'
import { useSavedSourceControlAgentActionAutoStart } from './useSavedSourceControlAgentActionAutoStart'
import {
buildSourceControlAgentConnectionErrorPlan,
buildSourceControlAgentSaveTargets,
buildSourceControlAgentStatusCopy,
isSourceControlAgentDetectedAndEnabled
} from './source-control-agent-action-dialog-support'
import { runSourceControlAgentActionStart } from './runSourceControlAgentActionStart'
import { useSourceControlAgentActionStart } from './useSourceControlAgentActionStart'
const DEFAULT_SAVE_TARGET_VALUE = 'global'
@@ -50,10 +48,10 @@ export function useSourceControlAgentActionDialog({
const [selectedAgent, setSelectedAgent] = useState<TuiAgent | null>(savedAgentId ?? null)
const [detectedAgents, setDetectedAgents] = useState<TuiAgent[]>([])
const [detecting, setDetecting] = useState(false)
const [deliveryPlan, setDeliveryPlan] = useState<SourceControlAgentActionDeliveryPlanState>({
status: 'idle'
})
const [isStarting, setIsStarting] = useState(false)
const openCycleRef = useRef(0)
const wasOpenRef = useRef(false)
const [openCycle, setOpenCycle] = useState(0)
const [detectedOpenCycle, setDetectedOpenCycle] = useState<number | null>(null)
const saveTargets = useMemo(() => buildSourceControlAgentSaveTargets(repoId), [repoId])
const [saveLaunchRecipe, setSaveLaunchRecipe] = useState(true)
const [saveTargetValue, setSaveTargetValue] = useState(DEFAULT_SAVE_TARGET_VALUE)
@@ -82,8 +80,16 @@ export function useSourceControlAgentActionDialog({
useEffect(() => {
if (!open) {
wasOpenRef.current = false
return
}
const cycle = wasOpenRef.current ? openCycleRef.current : openCycleRef.current + 1
if (!wasOpenRef.current) {
openCycleRef.current = cycle
setOpenCycle(cycle)
}
wasOpenRef.current = true
setDetectedOpenCycle(null)
setCommandTemplate(savedCommandInputTemplate ?? '{basePrompt}')
setAgentArgs(savedAgentArgs ?? '')
setSelectedAgent(savedAgentId ?? null)
@@ -91,7 +97,7 @@ export function useSourceControlAgentActionDialog({
setSaveTargetValue(DEFAULT_SAVE_TARGET_VALUE)
let stale = false
void refreshDetectedAgents().then((nextAgents) => {
if (stale) {
if (stale || openCycleRef.current !== cycle) {
return
}
setSelectedAgent(
@@ -104,6 +110,7 @@ export function useSourceControlAgentActionDialog({
disabledAgents
})
)
setDetectedOpenCycle(cycle)
})
return () => {
stale = true
@@ -119,17 +126,7 @@ export function useSourceControlAgentActionDialog({
settings?.defaultTuiAgent
])
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (!nextOpen) {
setDeliveryPlan({ status: 'idle' })
setSaveLaunchRecipe(true)
setSaveTargetValue(DEFAULT_SAVE_TARGET_VALUE)
}
onOpenChange(nextOpen)
},
[onOpenChange]
)
const closeDialog = useCallback(() => onOpenChange(false), [onOpenChange])
const enabledDetectedAgents = useMemo(
() => detectedAgents.filter((agent) => isTuiAgentEnabled(agent, disabledAgents)),
@@ -151,6 +148,33 @@ export function useSourceControlAgentActionDialog({
basePrompt: baseCommandInput
})
const trimmedCommandInput = commandInput.trim()
const { deliveryPlan, resetDeliveryPlan, isStarting, handleStart, startWithDetectedAgents } =
useSourceControlAgentActionStart({
selectedAgent,
commandInput,
trimmedCommandInput,
agentArgs,
commandTemplate,
saveLaunchRecipe,
saveTargetValue,
actionId,
repoId,
settings,
repo,
worktreeId,
groupId,
promptDelivery,
launchPlatform,
launchSource,
connectionUnavailable,
refreshDetectedAgents,
onStart,
onSaveAgentDefault,
onLaunched,
onClose: closeDialog
})
const canStart =
Boolean(trimmedCommandInput) &&
Boolean(selectedAgent) &&
@@ -159,95 +183,45 @@ export function useSourceControlAgentActionDialog({
!detecting &&
!isStarting
const buildPlan = useCallback(
async (agentsOverride?: TuiAgent[]): Promise<SourceControlAgentActionDeliveryPlanState> => {
const currentDetectedAgents = agentsOverride ?? (await refreshDetectedAgents())
return buildSourceControlAgentDeliveryPlan({
selectedAgent,
commandInput,
agentArgs,
promptDelivery,
detectedAgents: currentDetectedAgents,
connectionUnavailable,
launchPlatform
})
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (!nextOpen) {
resetDeliveryPlan()
setSaveLaunchRecipe(true)
setSaveTargetValue(DEFAULT_SAVE_TARGET_VALUE)
}
onOpenChange(nextOpen)
},
[
agentArgs,
commandInput,
connectionUnavailable,
promptDelivery,
refreshDetectedAgents,
selectedAgent,
launchPlatform
]
[onOpenChange, resetDeliveryPlan]
)
const handleStart = useCallback(async () => {
if (!selectedAgent || isStarting) {
return
}
if (connectionUnavailable) {
setDeliveryPlan(buildSourceControlAgentConnectionErrorPlan())
return
}
setIsStarting(true)
try {
const nextAgents = await refreshDetectedAgents()
const nextPlan = await buildPlan(nextAgents)
if (nextPlan.status === 'error') {
setDeliveryPlan(nextPlan)
return
}
setDeliveryPlan(nextPlan)
await runSourceControlAgentActionStart({
selectedAgent,
trimmedCommandInput,
agentArgs,
commandTemplate,
saveTargetValue: saveLaunchRecipe ? saveTargetValue : 'none',
actionId,
repoId,
settings,
repo,
worktreeId,
groupId,
promptDelivery,
launchPlatform,
launchSource,
onStart,
onSaveAgentDefault,
onLaunched,
onClose: () => handleOpenChange(false)
})
} finally {
setIsStarting(false)
}
}, [
const { autoLaunchPending } = useSavedSourceControlAgentActionAutoStart({
open,
openCycle,
detectionReady: detectedOpenCycle === openCycle,
actionId,
agentArgs,
buildPlan,
commandTemplate,
connectionUnavailable,
groupId,
isStarting,
launchSource,
launchPlatform,
handleOpenChange,
onLaunched,
onSaveAgentDefault,
onStart,
promptDelivery,
refreshDetectedAgents,
baseCommandInput,
savedAgentId,
savedCommandInputTemplate,
savedAgentArgs,
settings,
repo,
repoId,
saveLaunchRecipe,
saveTargetValue,
settings,
worktreeId,
connectionId,
selectedAgent,
trimmedCommandInput,
worktreeId
])
connectionUnavailable,
detecting,
isStarting,
detectedAgents,
disabledAgents,
onAutoStart: ({ detectedAgents: agentsForLaunch, saveTargetValue: matchedTargetValue }) =>
startWithDetectedAgents({
detectedAgents: agentsForLaunch,
saveTargetValueOverride: matchedTargetValue
})
})
const statusCopy = buildSourceControlAgentStatusCopy({
selectedAgent,
@@ -257,25 +231,38 @@ export function useSourceControlAgentActionDialog({
detecting
})
const onSelectedAgentChange = useCallback((agent: TuiAgent | null) => {
setSelectedAgent(agent)
setDeliveryPlan({ status: 'idle' })
}, [])
const onAgentArgsChange = useCallback((value: string) => {
setAgentArgs(value)
setDeliveryPlan({ status: 'idle' })
}, [])
const onCommandTemplateChange = useCallback((value: string) => {
setCommandTemplate(value)
setDeliveryPlan({ status: 'idle' })
}, [])
const onSaveLaunchRecipeChange = useCallback((value: boolean) => {
setSaveLaunchRecipe(value)
setDeliveryPlan({ status: 'idle' })
}, [])
const onSelectedAgentChange = useCallback(
(agent: TuiAgent | null) => {
setSelectedAgent(agent)
resetDeliveryPlan()
},
[resetDeliveryPlan]
)
const onAgentArgsChange = useCallback(
(value: string) => {
setAgentArgs(value)
resetDeliveryPlan()
},
[resetDeliveryPlan]
)
const onCommandTemplateChange = useCallback(
(value: string) => {
setCommandTemplate(value)
resetDeliveryPlan()
},
[resetDeliveryPlan]
)
const onSaveLaunchRecipeChange = useCallback(
(value: boolean) => {
setSaveLaunchRecipe(value)
resetDeliveryPlan()
},
[resetDeliveryPlan]
)
return {
handleOpenChange,
shouldRenderDialog: !autoLaunchPending,
agentOptions,
selectedAgent,
hasEnabledAgents,
@@ -0,0 +1,203 @@
import { useCallback, useRef, useState } from 'react'
import type { LaunchSource } from '../../../../shared/telemetry-events'
import type {
SourceControlActionRecipe,
SourceControlLaunchActionId
} from '../../../../shared/source-control-ai-actions'
import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types'
import { buildSourceControlAgentDeliveryPlan } from './buildSourceControlAgentDeliveryPlan'
import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm'
import { runSourceControlAgentActionStart } from './runSourceControlAgentActionStart'
import { buildSourceControlAgentConnectionErrorPlan } from './source-control-agent-action-dialog-support'
type UseSourceControlAgentActionStartArgs = {
selectedAgent: TuiAgent | null
commandInput: string
trimmedCommandInput: string
agentArgs: string
commandTemplate: string
saveLaunchRecipe: boolean
saveTargetValue: string
actionId: SourceControlLaunchActionId
repoId?: string | null
settings: GlobalSettings | null
repo: Pick<Repo, 'id' | 'sourceControlAi'> | null
worktreeId?: string | null
groupId?: string | null
promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready'
launchPlatform?: NodeJS.Platform
launchSource: LaunchSource
connectionUnavailable: boolean
refreshDetectedAgents: () => Promise<TuiAgent[]>
onStart?: (args: {
agent: TuiAgent
commandInput: string
agentArgs: string
}) => boolean | Promise<boolean>
onSaveAgentDefault?: (
target: SourceControlAiWriteTarget,
actionId: SourceControlLaunchActionId,
recipe: SourceControlActionRecipe
) => void | Promise<void>
onLaunched?: () => void
onClose: () => void
}
type SourceControlAgentActionStartWithDetectedAgentsArgs = {
detectedAgents: TuiAgent[]
saveTargetValueOverride?: string
}
type UseSourceControlAgentActionStartResult = {
deliveryPlan: SourceControlAgentActionDeliveryPlanState
resetDeliveryPlan: () => void
isStarting: boolean
handleStart: () => Promise<void>
startWithDetectedAgents: (
args: SourceControlAgentActionStartWithDetectedAgentsArgs
) => Promise<boolean>
}
export function useSourceControlAgentActionStart({
selectedAgent,
commandInput,
trimmedCommandInput,
agentArgs,
commandTemplate,
saveLaunchRecipe,
saveTargetValue,
actionId,
repoId,
settings,
repo,
worktreeId,
groupId,
promptDelivery,
launchPlatform,
launchSource,
connectionUnavailable,
refreshDetectedAgents,
onStart,
onSaveAgentDefault,
onLaunched,
onClose
}: UseSourceControlAgentActionStartArgs): UseSourceControlAgentActionStartResult {
const [deliveryPlan, setDeliveryPlan] = useState<SourceControlAgentActionDeliveryPlanState>({
status: 'idle'
})
const [isStarting, setIsStarting] = useState(false)
const isStartingRef = useRef(false)
const resetDeliveryPlan = useCallback(() => setDeliveryPlan({ status: 'idle' }), [])
const buildPlan = useCallback(
async (agentsOverride?: TuiAgent[]): Promise<SourceControlAgentActionDeliveryPlanState> => {
const currentDetectedAgents = agentsOverride ?? (await refreshDetectedAgents())
return buildSourceControlAgentDeliveryPlan({
selectedAgent,
commandInput,
agentArgs,
promptDelivery,
detectedAgents: currentDetectedAgents,
connectionUnavailable,
launchPlatform
})
},
[
agentArgs,
commandInput,
connectionUnavailable,
promptDelivery,
refreshDetectedAgents,
selectedAgent,
launchPlatform
]
)
const startWithDetectedAgents = useCallback(
async ({
detectedAgents: nextAgents,
saveTargetValueOverride
}: SourceControlAgentActionStartWithDetectedAgentsArgs): Promise<boolean> => {
if (!selectedAgent || isStartingRef.current) {
return false
}
if (connectionUnavailable) {
setDeliveryPlan(buildSourceControlAgentConnectionErrorPlan())
return false
}
isStartingRef.current = true
setIsStarting(true)
try {
const nextPlan = await buildPlan(nextAgents)
if (nextPlan.status === 'error') {
setDeliveryPlan(nextPlan)
return false
}
setDeliveryPlan(nextPlan)
return await runSourceControlAgentActionStart({
selectedAgent,
trimmedCommandInput,
agentArgs,
commandTemplate,
saveTargetValue: saveLaunchRecipe ? (saveTargetValueOverride ?? saveTargetValue) : 'none',
actionId,
repoId,
settings,
repo,
worktreeId,
groupId,
promptDelivery,
launchPlatform,
launchSource,
onStart,
onSaveAgentDefault,
onLaunched,
onClose: () => {
resetDeliveryPlan()
onClose()
}
})
} finally {
isStartingRef.current = false
setIsStarting(false)
}
},
[
actionId,
agentArgs,
buildPlan,
commandTemplate,
connectionUnavailable,
groupId,
launchSource,
launchPlatform,
onClose,
onLaunched,
onSaveAgentDefault,
onStart,
promptDelivery,
resetDeliveryPlan,
repo,
repoId,
saveLaunchRecipe,
saveTargetValue,
settings,
selectedAgent,
trimmedCommandInput,
worktreeId
]
)
const handleStart = useCallback(async () => {
if (!selectedAgent || isStartingRef.current) {
return
}
// Why: manual starts intentionally re-check the current host, while the
// saved-receipt bypass reuses the detection result that unlocked it.
const nextAgents = await refreshDetectedAgents()
await startWithDetectedAgents({ detectedAgents: nextAgents })
}, [refreshDetectedAgents, selectedAgent, startWithDetectedAgents])
return { deliveryPlan, resetDeliveryPlan, isStarting, handleStart, startWithDetectedAgents }
}
@@ -126,7 +126,7 @@ export function AutoRenameBranchFromWorkSetting({
<SearchableSetting
title={translate(
'auto.components.settings.AutoRenameBranchFromWorkSetting.ef787db0e3',
'Auto-Rename Branch'
'Auto-rename branch & worktree'
)}
description={translate(
'auto.components.settings.AutoRenameBranchFromWorkSetting.6a051586d2',
@@ -152,7 +152,7 @@ export function AutoRenameBranchFromWorkSetting({
<Label>
{translate(
'auto.components.settings.AutoRenameBranchFromWorkSetting.ef787db0e3',
'Auto-Rename Branch'
'Auto-rename branch & worktree'
)}
</Label>
<p className="text-xs text-muted-foreground">
@@ -16,6 +16,7 @@ import { extractIpcErrorMessage } from '../../lib/ipc-error'
import { Button } from '../ui/button'
import { isWindowsUserAgent } from '../terminal-pane/pane-helpers'
import { McpConfigFileRow, type LoadedMcpConfigInspection } from './McpConfigFileRow'
import { McpMissingConfigList } from './McpMissingConfigList'
import { loadMcpConfigInspections } from './mcp-config-inspection'
import { translate } from '@/i18n/i18n'
@@ -328,23 +329,7 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
</div>
)}
{missingConfigs.length > 0 && !inspectionUnavailable ? (
<div className="space-y-1.5 border-t border-border/50 px-3 py-2">
<p className="text-[11px] text-muted-foreground">
{translate('auto.components.settings.McpConfigSection.4d16a0d9ac', 'Checked')}
</p>
<div className="flex flex-wrap gap-1.5">
{missingConfigs.map((config) => (
<span
key={config.candidate.relativePath}
className="rounded-md border border-border/50 bg-background/40 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"
>
{config.candidate.relativePath}
</span>
))}
</div>
</div>
) : null}
{!inspectionUnavailable ? <McpMissingConfigList missingConfigs={missingConfigs} /> : null}
</div>
</div>
</section>
@@ -0,0 +1,30 @@
import type { LoadedMcpConfigInspection } from './McpConfigFileRow'
import { translate } from '@/i18n/i18n'
export function McpMissingConfigList({
missingConfigs
}: {
missingConfigs: LoadedMcpConfigInspection[]
}): React.JSX.Element | null {
if (missingConfigs.length === 0) {
return null
}
return (
<div className="space-y-1.5 border-t border-border/50 px-3 py-2">
<p className="text-[11px] text-muted-foreground">
{translate('auto.components.settings.McpConfigSection.4d16a0d9ac', 'Checked')}
</p>
<div className="flex flex-wrap gap-1.5">
{missingConfigs.map((config) => (
<span
key={config.candidate.relativePath}
className="rounded-md border border-border/50 bg-background/40 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"
>
{config.candidate.relativePath}
</span>
))}
</div>
</div>
)
}
@@ -7,7 +7,7 @@ export const getAutoRenameBranchParentSearchEntry = createLocalizedCatalog(
(): SettingsSearchEntry => ({
title: translate(
'auto.components.settings.auto.rename.branch.search.427f2cd1eb',
'Auto-Rename Branch'
'Auto-rename branch & worktree'
),
description: translate(
'auto.components.settings.auto.rename.branch.search.ea94b9da8a',
@@ -106,8 +106,8 @@ describe('AddRepoDialogStepContent nested imports', () => {
const html = renderNestedStep(0)
expect(html).toContain('Is this a monorepo?')
expect(html).toContain('aria-label="Monorepo name"')
expect(html).toContain('Yes, import as monorepo')
expect(html).toContain('aria-label="Group name"')
expect(html).toContain('Import as group')
expect(html).toContain('No, import separately')
expect(html).not.toContain('>Import</button>')
})
@@ -116,8 +116,8 @@ describe('AddRepoDialogStepContent nested imports', () => {
const html = renderNestedStep(1)
expect(html).toContain('Is this a monorepo?')
expect(html).toContain('aria-label="Monorepo name"')
expect(html).toContain('Yes, import as monorepo')
expect(html).toContain('aria-label="Group name"')
expect(html).toContain('Import as group')
expect(html).toContain('No, import separately')
expect(html).not.toContain('>Import</button>')
})
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { act, type ComponentProps } from 'react'
import { act, useState, type ComponentProps } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -78,13 +78,13 @@ describe('AddRepoNestedImportStep', () => {
expect(html).toContain('Import repositories from folder')
expect(html).toContain('Found 3 repositories in')
expect(html).toContain('/workspace/platform')
expect(html).toContain('aria-label="Monorepo name"')
expect(html).toContain('aria-label="What is a monorepo name?"')
expect(html).toContain('aria-label="Group name"')
expect(html).not.toContain('What is a')
expect(html).toContain('Is this a monorepo?')
expect(html).toContain('Choose this if these projects belong together')
expect(html).toContain('Import them as a group if they&#x27;re a monorepo')
expect(html).toContain('Orca will group them and let you work from the parent folder')
expect(html).toContain('No, import separately')
expect(html).toContain('Yes, import as monorepo')
expect(html).toContain('Import as group')
expect(html).toContain('payments/api')
expect(html).toContain('billing/api')
expect(html).not.toContain('disabled=""')
@@ -97,9 +97,9 @@ describe('AddRepoNestedImportStep', () => {
expect(html).toContain('Is this a monorepo?')
expect(html).toContain('No, import separately')
expect(html).toContain('Yes, import as monorepo')
expect(html).toContain('Import as group')
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>No, import separately<\/button>/)
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Yes, import as monorepo<\/button>/)
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Import as group<\/button>/)
})
it('maps the monorepo choice to grouped import and the non-monorepo choice to separate import', () => {
@@ -130,11 +130,55 @@ describe('AddRepoNestedImportStep', () => {
})
act(() => {
findButton(host, 'Yes, import as monorepo').click()
findButton(host, 'Import as group').click()
findButton(host, 'No, import separately').click()
})
expect(onImport).toHaveBeenNthCalledWith(1, 'group')
expect(onImport).toHaveBeenNthCalledWith(2, 'separate')
})
it('shows progress only on the clicked import action', () => {
const onImport = vi.fn()
const host = document.createElement('div')
container = host
document.body.appendChild(host)
root = createRoot(host)
function Harness(): React.JSX.Element {
const [isAdding, setIsAdding] = useState(false)
return (
<TooltipProvider>
<Dialog open>
<AddRepoNestedImportStep
scan={scan}
groupName=""
selectedPaths={new Set(scan.repos.map((repo) => repo.path))}
isAdding={isAdding}
scanInProgress={false}
onGroupNameChange={vi.fn()}
onSelectedPathsChange={vi.fn()}
onImport={(mode) => {
onImport(mode)
setIsAdding(true)
}}
onStopScan={vi.fn()}
/>
</Dialog>
</TooltipProvider>
)
}
act(() => {
root?.render(<Harness />)
})
act(() => {
findButton(host, 'Import as group').click()
})
expect(onImport).toHaveBeenCalledWith('group')
expect(findButton(host, 'Import as group').querySelector('.animate-spin')).not.toBeNull()
expect(findButton(host, 'No, import separately').querySelector('.animate-spin')).toBeNull()
})
})
@@ -1,5 +1,5 @@
import { useId, type Dispatch, type SetStateAction } from 'react'
import { CircleHelp, CircleStop, Loader2 } from 'lucide-react'
import { useEffect, useId, useState, type Dispatch, type SetStateAction } from 'react'
import { CircleStop, Loader2 } from 'lucide-react'
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -36,6 +36,20 @@ export function AddRepoNestedImportStep({
}: AddRepoNestedImportStepProps): React.JSX.Element {
const folderName = getRuntimePathBasename(scan.selectedPath) || scan.selectedPath
const groupNameInputId = useId()
const [pendingImportMode, setPendingImportMode] = useState<'group' | 'separate' | null>(null)
const showSeparateSpinner = isAdding && pendingImportMode === 'separate'
const showGroupSpinner = isAdding && pendingImportMode === 'group'
useEffect(() => {
if (!isAdding) {
setPendingImportMode(null)
}
}, [isAdding])
const handleImport = (mode: 'group' | 'separate'): void => {
setPendingImportMode(mode)
onImport(mode)
}
const repoCountLabel =
scan.repos.length === 1
? translate('auto.components.sidebar.AddRepoNestedImportStep.8401a7a0d0', '1 repository')
@@ -97,7 +111,7 @@ export function AddRepoNestedImportStep({
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.d75170194e',
'Choose this if these projects belong together. Orca will group them and let you work from the parent folder.'
"Import them as a group if they're a monorepo or otherwise belong together. Orca will group them and let you work from the parent folder."
)}
</p>
</div>
@@ -106,37 +120,15 @@ export function AddRepoNestedImportStep({
<Label htmlFor={groupNameInputId} className="text-[11px] text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.39d51212cc',
'Monorepo name'
'Group name'
)}
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.sidebar.AddRepoNestedImportStep.e907ec8935',
'What is a monorepo name?'
)}
className="size-5 text-muted-foreground hover:text-foreground"
>
<CircleHelp className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4} className="max-w-64">
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.b20bb7c24f',
'Keeps these repos together in one group. Best for related repos like microservices.'
)}
</TooltipContent>
</Tooltip>
</div>
<Input
id={groupNameInputId}
aria-label={translate(
'auto.components.sidebar.AddRepoNestedImportStep.39d51212cc',
'Monorepo name'
'Group name'
)}
value={groupName}
onChange={(event) => onGroupNameChange(event.target.value)}
@@ -147,22 +139,24 @@ export function AddRepoNestedImportStep({
</div>
<div className="flex shrink-0 flex-wrap justify-end gap-2">
<Button
onClick={() => onImport('separate')}
onClick={() => handleImport('separate')}
disabled={isAdding || scanInProgress || selectedPaths.size === 0}
variant="outline"
>
{showSeparateSpinner ? <Loader2 className="size-3.5 animate-spin" /> : null}
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.aa0247680d',
'No, import separately'
)}
</Button>
<Button
onClick={() => onImport('group')}
onClick={() => handleImport('group')}
disabled={isAdding || scanInProgress || selectedPaths.size === 0}
>
{showGroupSpinner ? <Loader2 className="size-3.5 animate-spin" /> : null}
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.a0bc4d1f8e',
'Yes, import as monorepo'
'Import as group'
)}
</Button>
</div>
@@ -337,17 +337,10 @@ export function FolderWorkspaceComposerDialog({
repoId={repoId}
selectedRepoIsGit={true}
onRepoChange={handleRepoChange}
primaryActionLabel={
quickAgent
? translate(
'auto.components.sidebar.FolderWorkspaceComposerDialog.createStart',
'Create & Start Agent'
)
: translate(
'auto.components.sidebar.FolderWorkspaceComposerDialog.create',
'Create Workspace'
)
}
primaryActionLabel={translate(
'auto.components.sidebar.FolderWorkspaceComposerDialog.create',
'Create workspace'
)}
projectLabel={translate(
'auto.components.sidebar.FolderWorkspaceComposerDialog.sourceProject',
'Task Source'
@@ -20,7 +20,7 @@ export function ScrollToCurrentWorkspaceToolbarButton(): React.JSX.Element {
onClick={requestScrollToCurrentWorkspaceReveal}
className="text-muted-foreground"
>
<Crosshair className="size-4" />
<Crosshair className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
@@ -173,7 +173,7 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element {
onPointerDown={(event) => revealAdminOptions(event.altKey)}
onClick={(event) => revealAdminOptions(event.altKey)}
>
<CircleHelp className="size-4" />
<CircleHelp className="size-3.5" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
@@ -81,7 +81,7 @@ const SidebarToolbar = React.memo(function SidebarToolbar({
onClick={handleWorkspaceBoardClick}
className="text-muted-foreground"
>
<Kanban className="size-4" />
<Kanban className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
@@ -15,6 +15,16 @@ type WorktreeListComponent = React.ComponentType<{
let WorktreeList: WorktreeListComponent
function makeFolderWorkspacePathStatusMockState(): Record<string, unknown> {
return {
fetchFolderWorkspacePathStatus: vi.fn(),
folderWorkspaces: [],
folderWorkspacePathStatuses: {},
getFolderWorkspacePathStatusCacheKey: (request: unknown) => JSON.stringify(request),
getFreshFolderWorkspacePathStatus: () => null
}
}
vi.mock('@/store', () => {
const useAppStore = ((selector: (state: Record<string, unknown>) => unknown) =>
selector(mockStore.state)) as ((
@@ -306,6 +316,7 @@ function setLineageFixtureState(
grandchild.isUnread = unreadWorktreeIds.has(grandchild.id)
mockStore.state = {
...makeFolderWorkspacePathStatusMockState(),
activeModal: '',
activeView: 'terminal',
activeWorktreeId: null,
@@ -385,6 +396,7 @@ function setProjectGroupWithoutWorktreeRowsState(filterRepoIds: string[] = []):
}
mockStore.state = {
...makeFolderWorkspacePathStatusMockState(),
activeModal: '',
activeView: 'terminal',
activeWorktreeId: null,
@@ -444,6 +456,7 @@ function setEmptyUngroupedProjectState(filterRepoIds: string[] = []): void {
}
mockStore.state = {
...makeFolderWorkspacePathStatusMockState(),
activeModal: '',
activeView: 'terminal',
activeWorktreeId: null,
@@ -668,7 +681,7 @@ describe('WorktreeList lineage child card renderer', () => {
const markup = await renderWorktreeListMarkup()
expect(getOptionOpeningTag(markup, 'child')).toContain('padding-left:14px')
expect(getCardOpeningTag(markup, 'child')).toContain('data-content-indent="20"')
expect(getCardOpeningTag(markup, 'child')).toContain('data-content-indent="6"')
expect(getCardOpeningTag(markup, 'child')).toContain('data-flush-surface="true"')
})
@@ -677,7 +690,7 @@ describe('WorktreeList lineage child card renderer', () => {
const markup = await renderWorktreeListMarkup()
expect(getOptionOpeningTag(markup, 'child')).toContain('padding-left:14px')
expect(getCardOpeningTag(markup, 'child')).toContain('data-content-indent="38"')
expect(getCardOpeningTag(markup, 'child')).toContain('data-content-indent="24"')
expect(getCardOpeningTag(markup, 'child')).toContain('data-flush-surface="true"')
})
@@ -687,8 +700,8 @@ describe('WorktreeList lineage child card renderer', () => {
const parentRow = getOptionOpeningTag(markup, 'parent')
expect(parentRow).not.toContain('padding-left')
expect(getCardOpeningTag(markup, 'parent')).toContain('data-content-indent="38"')
expect(parentRow).toContain('padding-left:14px')
expect(getCardOpeningTag(markup, 'parent')).toContain('data-content-indent="24"')
expect(getCardOpeningTag(markup, 'parent')).toContain('data-flush-surface="true"')
})
})
@@ -31,6 +31,16 @@ type WorktreeListComponent = React.ComponentType<{
let WorktreeList: WorktreeListComponent
function makeFolderWorkspacePathStatusMockState(): Record<string, unknown> {
return {
fetchFolderWorkspacePathStatus: vi.fn(),
folderWorkspaces: [],
folderWorkspacePathStatuses: {},
getFolderWorkspacePathStatusCacheKey: (request: unknown) => JSON.stringify(request),
getFreshFolderWorkspacePathStatus: () => null
}
}
vi.mock('@/store', () => {
const useAppStore = ((selector: (state: Record<string, unknown>) => unknown) =>
selector(mockStore.state)) as ((
@@ -224,6 +234,7 @@ function setLineageState(options: { deletingChild?: boolean } = {}): void {
}
})
mockStore.state = {
...makeFolderWorkspacePathStatusMockState(),
activeModal: '',
activeView: 'terminal',
activeWorktreeId: null,
@@ -0,0 +1,205 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { FolderWorkspace, ProjectGroup } from '../../../../shared/types'
const mocks = vi.hoisted(() => ({
activateAndRevealFolderWorkspace: vi.fn()
}))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealFolderWorkspace: mocks.activateAndRevealFolderWorkspace
}))
import { submitFolderWorkspaceCreate } from './folder-workspace-composer-submit'
function makeProjectGroup(): ProjectGroup {
return {
id: 'group-1',
name: 'Platform',
parentPath: '/repo/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
}
function makeFolderWorkspace(): FolderWorkspace {
return {
id: 'folder-workspace-1',
projectGroupId: 'group-1',
name: 'hi',
folderPath: '/repo/platform/hi',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1
}
}
describe('submitFolderWorkspaceCreate', () => {
afterEach(() => {
mocks.activateAndRevealFolderWorkspace.mockReset()
vi.restoreAllMocks()
})
it('closes the composer after creation even when reveal fails', async () => {
const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace())
const onOpenChange = vi.fn()
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mocks.activateAndRevealFolderWorkspace.mockImplementation(() => {
throw new Error('activation failed')
})
await submitFolderWorkspaceCreate({
projectGroup: makeProjectGroup(),
name: 'hi',
lastAutoName: '',
linkedWorkItem: null,
note: '',
quickAgent: null,
autoRenameBranchFromWork: false,
agentCmdOverrides: {},
createFolderWorkspace,
onOpenChange
})
expect(createFolderWorkspace).toHaveBeenCalledWith({
projectGroupId: 'group-1',
name: 'hi',
linkedTask: null
})
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith(
'folder-workspace-1',
undefined
)
expect(consoleError).toHaveBeenCalledWith(
'Failed to activate folder workspace after create:',
expect.any(Error)
)
})
it('marks a blank folder workspace for first-input rename when launching an agent with a note', async () => {
const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace())
const onOpenChange = vi.fn()
await submitFolderWorkspaceCreate({
projectGroup: makeProjectGroup(),
name: '',
lastAutoName: '',
linkedWorkItem: null,
note: 'Fix the flaky checkout flow',
quickAgent: 'codex',
autoRenameBranchFromWork: true,
agentCmdOverrides: {},
createFolderWorkspace,
onOpenChange
})
expect(createFolderWorkspace).toHaveBeenCalledWith({
projectGroupId: 'group-1',
name: 'Platform workspace',
linkedTask: null,
createdWithAgent: 'codex',
pendingFirstAgentMessageRename: true
})
expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith(
'folder-workspace-1',
expect.objectContaining({
startup: expect.objectContaining({
command: expect.stringContaining('codex')
})
})
)
})
it('does not mark first-input rename when the folder workspace has an explicit name', async () => {
const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace())
await submitFolderWorkspaceCreate({
projectGroup: makeProjectGroup(),
name: 'Checkout polish',
lastAutoName: '',
linkedWorkItem: null,
note: 'Fix the flaky checkout flow',
quickAgent: 'codex',
autoRenameBranchFromWork: true,
agentCmdOverrides: {},
createFolderWorkspace,
onOpenChange: vi.fn()
})
expect(createFolderWorkspace).toHaveBeenCalledWith({
projectGroupId: 'group-1',
name: 'Checkout polish',
linkedTask: null,
createdWithAgent: 'codex'
})
})
it('does not mark first-input rename when a linked work item owns the folder workspace name', async () => {
const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace())
const linkedWorkItem = {
provider: 'github' as const,
type: 'issue' as const,
number: 42,
title: 'Restore checkout polish',
url: 'https://github.com/stablyai/orca/issues/42',
repoId: 'repo-1'
}
await submitFolderWorkspaceCreate({
projectGroup: makeProjectGroup(),
name: '',
lastAutoName: '',
linkedWorkItem,
note: 'Use the issue context',
quickAgent: 'codex',
autoRenameBranchFromWork: true,
agentCmdOverrides: {},
createFolderWorkspace,
onOpenChange: vi.fn()
})
expect(createFolderWorkspace).toHaveBeenCalledWith({
projectGroupId: 'group-1',
name: 'Restore checkout polish',
linkedTask: linkedWorkItem,
createdWithAgent: 'codex'
})
})
it('does not mark first-input rename without submitted first input', async () => {
const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace())
await submitFolderWorkspaceCreate({
projectGroup: makeProjectGroup(),
name: '',
lastAutoName: '',
linkedWorkItem: null,
note: ' ',
quickAgent: 'codex',
autoRenameBranchFromWork: true,
agentCmdOverrides: {},
createFolderWorkspace,
onOpenChange: vi.fn()
})
expect(createFolderWorkspace).toHaveBeenCalledWith({
projectGroupId: 'group-1',
name: 'Platform workspace',
linkedTask: null,
createdWithAgent: 'codex'
})
})
})
@@ -4,8 +4,8 @@ import {
type LinkedWorkItemSummary
} from '@/lib/new-workspace'
import { getLinkedWorkItemPromptContext } from '@/lib/linked-work-item-context'
import { buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { isOrcaCliAvailableForLaunch } from '@/lib/orca-cli-launch-availability'
import { buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { tuiAgentToAgentKind } from '@/lib/telemetry'
import { activateAndRevealFolderWorkspace } from '@/lib/worktree-activation'
import { isWorkItemLookupText } from '@/lib/work-item-lookup-text'
@@ -32,7 +32,7 @@ type SubmitFolderWorkspaceCreateParams = {
quickAgent: TuiAgent | null
autoRenameBranchFromWork: boolean | undefined
agentCmdOverrides: Record<string, string> | undefined
isRemote: boolean
isRemote?: boolean
createFolderWorkspace: (input: FolderWorkspaceCreateInput) => Promise<FolderWorkspace | null>
onOpenChange: (open: boolean) => void
}
@@ -56,8 +56,28 @@ export async function submitFolderWorkspaceCreate({
nameIsAutoManaged && linkedName
? linkedName
: name.trim() || linkedName || `${projectGroup.name} workspace`
// Why: only suggest `orca linear` when the launched terminal can actually
// resolve the CLI; SSH launches get the relay shim, local launches may not.
const linearCliAvailable = linkedWorkItem?.linearIdentifier
? await isOrcaCliAvailableForLaunch({ remote: isRemote ?? projectGroup.connectionId != null })
: false
const linkedPromptContext = getLinkedWorkItemPromptContext(linkedWorkItem, {
cliAvailable: linearCliAvailable
})
const startupPrompt = buildAgentPromptWithContext(
note,
[],
linkedPromptContext.linkedUrls,
linkedPromptContext.linkedContextBlocks
)
// Why: the pending badge should only appear when the submitted prompt can
// actually produce the first agent message that names the workspace.
const pendingFirstAgentMessageRename =
autoRenameBranchFromWork === true && !name.trim() && !linkedWorkItem && Boolean(quickAgent)
autoRenameBranchFromWork === true &&
!name.trim() &&
!linkedWorkItem &&
Boolean(quickAgent) &&
startupPrompt.trim().length > 0
const workspace = await createFolderWorkspace({
projectGroupId: projectGroup.id,
@@ -70,20 +90,6 @@ export async function submitFolderWorkspaceCreate({
return
}
// Why: only suggest `orca linear` when the launched terminal can actually
// resolve the CLI; SSH launches get the relay shim, local launches may not.
const linearCliAvailable = linkedWorkItem?.linearIdentifier
? await isOrcaCliAvailableForLaunch({ remote: isRemote })
: false
const linkedPromptContext = getLinkedWorkItemPromptContext(linkedWorkItem, {
cliAvailable: linearCliAvailable
})
const startupPrompt = buildAgentPromptWithContext(
note,
[],
linkedPromptContext.linkedUrls,
linkedPromptContext.linkedContextBlocks
)
const startupPlan = quickAgent
? buildAgentStartupPlan({
agent: quickAgent,
@@ -105,6 +111,12 @@ export async function submitFolderWorkspaceCreate({
}
}
: undefined
activateAndRevealFolderWorkspace(workspace.id, startup ? { startup } : undefined)
onOpenChange(false)
try {
activateAndRevealFolderWorkspace(workspace.id, startup ? { startup } : undefined)
} catch (error) {
// Why: creation already succeeded. Do not leave the completed create modal
// open if the follow-up reveal/startup path hits a transient issue.
console.error('Failed to activate folder workspace after create:', error)
}
}
@@ -26,9 +26,9 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/
import { ClaudeUsageDailyChart } from './ClaudeUsageDailyChart'
import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState'
import { ShareUsageButton } from './ShareUsageButton'
import { ClaudeUsageRecentSessionsTable } from './ClaudeUsageRecentSessionsTable'
import { StatCard } from './StatCard'
import { UsageBreakdownSection } from './UsageBreakdownSection'
import { UsageSessionsTable } from './UsageSessionsTable'
import { formatCost, formatTokens, formatUpdatedAt } from './usage-formatters'
import { translate } from '@/i18n/i18n'
@@ -346,33 +346,7 @@ export function ClaudeUsagePane(): React.JSX.Element {
/>
</div>
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">
{translate('auto.components.stats.ClaudeUsagePane.7e76c84153', 'Recent sessions')}
</h4>
<p className="text-xs text-muted-foreground">
{translate('auto.components.stats.ClaudeUsagePane.abfc4a4943', 'Cache reuse rate:')}{' '}
{summary?.cacheReuseRate !== null && summary?.cacheReuseRate !== undefined
? `${Math.round(summary.cacheReuseRate * 100)}%`
: translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')}
</p>
</div>
<UsageSessionsTable
sessions={recentSessions.map((row) => ({
sessionId: row.sessionId,
lastActiveAt: row.lastActiveAt,
projectLabel: row.projectLabel,
model: row.model,
turns: row.turns,
inputTokens: row.inputTokens,
outputTokens: row.outputTokens,
cacheTokens: row.cacheReadTokens + row.cacheWriteTokens
}))}
eventsColumn="turns"
tokensColumn="cache"
/>
</section>
<ClaudeUsageRecentSessionsTable recentSessions={recentSessions} summary={summary} />
</>
)}
</div>
@@ -0,0 +1,81 @@
import type {
ClaudeUsageSessionRow,
ClaudeUsageSummary
} from '../../../../shared/claude-usage-types'
import { translate } from '@/i18n/i18n'
import { formatSessionTime, formatTokens } from './usage-formatters'
export function ClaudeUsageRecentSessionsTable({
recentSessions,
summary
}: {
recentSessions: ClaudeUsageSessionRow[]
summary: ClaudeUsageSummary | null
}): React.JSX.Element {
return (
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">
{translate('auto.components.stats.ClaudeUsagePane.7e76c84153', 'Recent sessions')}
</h4>
<p className="text-xs text-muted-foreground">
{translate('auto.components.stats.ClaudeUsagePane.abfc4a4943', 'Cache reuse rate:')}{' '}
{summary?.cacheReuseRate !== null && summary?.cacheReuseRate !== undefined
? `${Math.round(summary.cacheReuseRate * 100)}%`
: translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')}
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-border/60 text-left text-xs text-muted-foreground">
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.ClaudeUsagePane.01476891c7', 'Last active')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.ClaudeUsagePane.c17bed0416', 'Project')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.ClaudeUsagePane.1afc25eb06', 'Model')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.ClaudeUsagePane.0f03975d59', 'Turns')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.ClaudeUsagePane.faf3444859', 'Input')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.ClaudeUsagePane.a8b7487ff7', 'Output')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.ClaudeUsagePane.21ea00bfa8', 'Cache')}
</th>
</tr>
</thead>
<tbody>
{recentSessions.map((row) => (
<tr key={row.sessionId} className="border-b border-border/40 last:border-b-0">
<td className="px-2 py-2 text-muted-foreground">
{formatSessionTime(row.lastActiveAt)}
</td>
<td className="px-2 py-2 text-foreground">{row.projectLabel}</td>
<td className="px-2 py-2 text-muted-foreground">
{row.model ??
translate('auto.components.stats.ClaudeUsagePane.cfe2282ffa', 'Unknown')}
</td>
<td className="px-2 py-2 text-muted-foreground">{row.turns}</td>
<td className="px-2 py-2 text-muted-foreground">{formatTokens(row.inputTokens)}</td>
<td className="px-2 py-2 text-muted-foreground">
{formatTokens(row.outputTokens)}
</td>
<td className="px-2 py-2 text-muted-foreground">
{formatTokens(row.cacheReadTokens + row.cacheWriteTokens)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
@@ -25,9 +25,9 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/
import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState'
import { CodexUsageDailyChart } from './CodexUsageDailyChart'
import { ShareUsageButton } from './ShareUsageButton'
import { CodexUsageRecentSessionsTable } from './CodexUsageRecentSessionsTable'
import { StatCard } from './StatCard'
import { UsageBreakdownSection } from './UsageBreakdownSection'
import { UsageSessionsTable } from './UsageSessionsTable'
import { formatCost, formatTokens, formatUpdatedAt } from './usage-formatters'
import { translate } from '@/i18n/i18n'
@@ -328,34 +328,7 @@ export function CodexUsagePane(): React.JSX.Element {
/>
</div>
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">
{translate('auto.components.stats.CodexUsagePane.0cb0983c07', 'Recent sessions')}
</h4>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.stats.CodexUsagePane.0bd8655475',
'Most recent local Codex sessions in this scope.'
)}
</p>
</div>
<UsageSessionsTable
sessions={recentSessions.map((row) => ({
sessionId: row.sessionId,
lastActiveAt: row.lastActiveAt,
projectLabel: row.projectLabel,
model: row.model,
events: row.events,
inputTokens: row.inputTokens,
outputTokens: row.outputTokens,
totalTokens: row.totalTokens,
hasInferredPricing: row.hasInferredPricing
}))}
eventsColumn="events"
tokensColumn="total"
/>
</section>
<CodexUsageRecentSessionsTable recentSessions={recentSessions} />
</>
)}
</div>
@@ -0,0 +1,75 @@
import type { CodexUsageSessionRow } from '../../../../shared/codex-usage-types'
import { translate } from '@/i18n/i18n'
import { formatSessionTime, formatTokens } from './usage-formatters'
export function CodexUsageRecentSessionsTable({
recentSessions
}: {
recentSessions: CodexUsageSessionRow[]
}): React.JSX.Element {
return (
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">
{translate('auto.components.stats.CodexUsagePane.0cb0983c07', 'Recent sessions')}
</h4>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.stats.CodexUsagePane.0bd8655475',
'Most recent local Codex sessions in this scope.'
)}
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-border/60 text-left text-xs text-muted-foreground">
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.CodexUsagePane.0c36b100be', 'Last active')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.CodexUsagePane.1a65900aea', 'Project')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.CodexUsagePane.c2478bcc3c', 'Model')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.CodexUsagePane.bd0822ca47', 'Events')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.CodexUsagePane.3acc582214', 'Input')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.CodexUsagePane.bbd20344b8', 'Output')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.CodexUsagePane.e0b988599d', 'Total')}
</th>
</tr>
</thead>
<tbody>
{recentSessions.map((row) => (
<tr key={row.sessionId} className="border-b border-border/40 last:border-b-0">
<td className="px-2 py-2 text-muted-foreground">
{formatSessionTime(row.lastActiveAt)}
</td>
<td className="px-2 py-2 text-foreground">{row.projectLabel}</td>
<td className="px-2 py-2 text-muted-foreground">
{row.model ??
translate('auto.components.stats.CodexUsagePane.bf6cf2d4dd', 'Unknown')}
{row.hasInferredPricing ? ' *' : ''}
</td>
<td className="px-2 py-2 text-muted-foreground">{row.events}</td>
<td className="px-2 py-2 text-muted-foreground">{formatTokens(row.inputTokens)}</td>
<td className="px-2 py-2 text-muted-foreground">
{formatTokens(row.outputTokens)}
</td>
<td className="px-2 py-2 text-muted-foreground">{formatTokens(row.totalTokens)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
@@ -27,9 +27,9 @@ import {
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState'
import { CodexUsageDailyChart } from './CodexUsageDailyChart'
import { OpenCodeUsageRecentSessionsTable } from './OpenCodeUsageRecentSessionsTable'
import { StatCard } from './StatCard'
import { UsageBreakdownSection } from './UsageBreakdownSection'
import { UsageSessionsTable } from './UsageSessionsTable'
import { formatCost, formatTokens, formatUpdatedAt } from './usage-formatters'
import { translate } from '@/i18n/i18n'
@@ -351,33 +351,7 @@ export function OpenCodeUsagePane(): React.JSX.Element {
/>
</div>
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">
{translate('auto.components.stats.OpenCodeUsagePane.4799177b1c', 'Recent sessions')}
</h4>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.stats.OpenCodeUsagePane.81817a641a',
'Most recent local OpenCode sessions in this scope.'
)}
</p>
</div>
<UsageSessionsTable
sessions={recentSessions.map((row) => ({
sessionId: row.sessionId,
lastActiveAt: row.lastActiveAt,
projectLabel: row.projectLabel,
model: row.model,
events: row.events,
inputTokens: row.inputTokens,
outputTokens: row.outputTokens,
totalTokens: row.totalTokens
}))}
eventsColumn="events"
tokensColumn="total"
/>
</section>
<OpenCodeUsageRecentSessionsTable recentSessions={recentSessions} />
</>
)}
</div>
@@ -0,0 +1,74 @@
import type { OpenCodeUsageSessionRow } from '../../../../shared/opencode-usage-types'
import { translate } from '@/i18n/i18n'
import { formatSessionTime, formatTokens } from './usage-formatters'
export function OpenCodeUsageRecentSessionsTable({
recentSessions
}: {
recentSessions: OpenCodeUsageSessionRow[]
}): React.JSX.Element {
return (
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">
{translate('auto.components.stats.OpenCodeUsagePane.4799177b1c', 'Recent sessions')}
</h4>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.stats.OpenCodeUsagePane.81817a641a',
'Most recent local OpenCode sessions in this scope.'
)}
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-border/60 text-left text-xs text-muted-foreground">
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.OpenCodeUsagePane.d97bdf6e27', 'Last active')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.OpenCodeUsagePane.a4738de041', 'Project')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.OpenCodeUsagePane.08c78441b7', 'Model')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.OpenCodeUsagePane.d416f5cf92', 'Events')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.OpenCodeUsagePane.0f2f266c9d', 'Input')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.OpenCodeUsagePane.dfc4513657', 'Output')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.OpenCodeUsagePane.349f7c3f5c', 'Total')}
</th>
</tr>
</thead>
<tbody>
{recentSessions.map((row) => (
<tr key={row.sessionId} className="border-b border-border/40 last:border-b-0">
<td className="px-2 py-2 text-muted-foreground">
{formatSessionTime(row.lastActiveAt)}
</td>
<td className="px-2 py-2 text-foreground">{row.projectLabel}</td>
<td className="px-2 py-2 text-muted-foreground">
{row.model ??
translate('auto.components.stats.OpenCodeUsagePane.362231082f', 'Unknown')}
</td>
<td className="px-2 py-2 text-muted-foreground">{row.events}</td>
<td className="px-2 py-2 text-muted-foreground">{formatTokens(row.inputTokens)}</td>
<td className="px-2 py-2 text-muted-foreground">
{formatTokens(row.outputTokens)}
</td>
<td className="px-2 py-2 text-muted-foreground">{formatTokens(row.totalTokens)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
@@ -21,3 +21,16 @@ export function formatUpdatedAt(timestamp: number | null): string {
}
return `Updated ${new Date(timestamp).toLocaleString()}`
}
export function formatSessionTime(timestamp: string): string {
const parsed = new Date(timestamp)
if (Number.isNaN(parsed.getTime())) {
return timestamp
}
return parsed.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
}
@@ -4,7 +4,10 @@ import { MobileDriverOverlay } from './MobileDriverOverlay'
type OverlayProps = {
actionPending: boolean
allActionLabel?: string
allActionPending?: boolean
onAction: () => void | Promise<void>
onAllAction?: () => void | Promise<void>
rootRef: (node: HTMLDivElement | null) => void
}
@@ -53,13 +56,17 @@ vi.mock('react', async () => {
}
})
function renderOverlay(onAction: () => void | Promise<void>): OverlayElement {
function renderOverlay(
onAction: () => void | Promise<void>,
onAllAction?: () => void | Promise<void>
): OverlayElement {
hookRuntime.stateIndex = 0
hookRuntime.refIndex = 0
return MobileDriverOverlay({
driver: { kind: 'mobile', clientId: 'phone-1' } as never,
hasFitOverride: false,
onAction
onAction,
onAllAction
}) as OverlayElement
}
@@ -93,4 +100,18 @@ describe('MobileDriverOverlay', () => {
expect(overlay.props.actionPending).toBe(false)
})
it('exposes an all-terminals restore action when provided', async () => {
const onAction = vi.fn()
const onAllAction = vi.fn()
const overlay = renderOverlay(onAction, onAllAction)
expect(overlay.props.allActionLabel).toBe('Resize all terminals')
expect(overlay.props.allActionPending).toBe(false)
await overlay.props.onAllAction?.()
expect(onAction).not.toHaveBeenCalled()
expect(onAllAction).toHaveBeenCalledOnce()
})
})
@@ -13,6 +13,7 @@ type Props = {
driver: DriverState
hasFitOverride: boolean
onAction: () => void | Promise<void>
onAllAction?: () => void | Promise<void>
/** Identifier class on the rendered root, used by e2e selectors. */
rootClassName?: string
}
@@ -24,6 +25,7 @@ export function MobileDriverOverlay({
driver,
hasFitOverride,
onAction,
onAllAction,
rootClassName
}: Props): ReactElement | null {
const isMobileDriving = driver.kind === 'mobile'
@@ -34,14 +36,16 @@ export function MobileDriverOverlay({
createMobileDriverOverlayCollapseState(driverClientId)
)
const [actionPending, setActionPending] = useState(false)
const [allActionPending, setAllActionPending] = useState(false)
const mountedRef = useRef(false)
const setOverlayRootRef = useCallback((node: HTMLDivElement | null): void => {
mountedRef.current = node !== null
if (node) {
// Why: take-back can resolve after the overlay renders null; a later
// mobile session must not inherit the stale disabled state.
// Why: take-back/restore can resolve after the overlay renders null; a
// later mobile session must not inherit stale disabled state.
setActionPending(false)
setAllActionPending(false)
}
}, [])
@@ -57,7 +61,7 @@ export function MobileDriverOverlay({
}
const handleAction = async (): Promise<void> => {
if (actionPending) {
if (actionPending || allActionPending) {
return
}
setActionPending(true)
@@ -70,6 +74,20 @@ export function MobileDriverOverlay({
}
}
const handleAllAction = async (): Promise<void> => {
if (!onAllAction || actionPending || allActionPending) {
return
}
setAllActionPending(true)
try {
await onAllAction()
} finally {
if (mountedRef.current) {
setAllActionPending(false)
}
}
}
if (isHeldAtPhoneFit) {
return (
<LoudOverlay
@@ -81,7 +99,13 @@ export function MobileDriverOverlay({
body="The session is still being held at the dimensions your phone last reported. Restore to use it on your desktop."
actionLabel="Restore desktop size"
actionPending={actionPending}
allActionLabel={translate(
'auto.components.terminal.pane.MobileDriverOverlay.54f7d6f69d',
'Resize all terminals'
)}
allActionPending={allActionPending}
onAction={handleAction}
onAllAction={onAllAction ? handleAllAction : undefined}
tone="held"
rootRef={setOverlayRootRef}
rootClassName={rootClassName}
@@ -111,7 +135,13 @@ export function MobileDriverOverlay({
body="Output below is being typed from your phone. Take back to resume typing on the desktop, or collapse to keep watching."
actionLabel="Take back"
actionPending={actionPending}
allActionLabel={translate(
'auto.components.terminal.pane.MobileDriverOverlay.54f7d6f69d',
'Resize all terminals'
)}
allActionPending={allActionPending}
onAction={handleAction}
onAllAction={onAllAction ? handleAllAction : undefined}
onCollapse={() => setCollapseState({ driverClientId, collapsed: true })}
tone="driving"
rootRef={setOverlayRootRef}
@@ -126,7 +156,10 @@ type LoudOverlayProps = {
body: string
actionLabel: string
actionPending: boolean
allActionLabel?: string
allActionPending?: boolean
onAction: () => void | Promise<void>
onAllAction?: () => void | Promise<void>
onCollapse?: () => void
tone: 'driving' | 'held'
rootRef?: (node: HTMLDivElement | null) => void
@@ -139,7 +172,10 @@ function LoudOverlay({
body,
actionLabel,
actionPending,
allActionLabel,
allActionPending = false,
onAction,
onAllAction,
onCollapse,
tone,
rootRef: outerRootRef,
@@ -197,7 +233,7 @@ function LoudOverlay({
<div id={bodyId} className="text-sm leading-relaxed text-muted-foreground">
{body}
</div>
<div className="mt-1 flex justify-end gap-2">
<div className="mt-1 flex flex-wrap justify-end gap-2">
{onCollapse && (
<Button type="button" variant="outline" size="sm" onClick={onCollapse}>
{translate(
@@ -206,6 +242,17 @@ function LoudOverlay({
)}
</Button>
)}
{onAllAction && allActionLabel ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={onAllAction}
disabled={actionPending || allActionPending}
>
{allActionLabel}
</Button>
) : null}
{/* Focus is moved to this button only when no user input is active; see effect above. */}
<Button
ref={actionRef}
@@ -213,7 +260,7 @@ function LoudOverlay({
variant="default"
size="sm"
onClick={onAction}
disabled={actionPending}
disabled={actionPending || allActionPending}
>
{actionLabel}
</Button>
@@ -46,8 +46,13 @@ import type { PreparedAgentSessionFork } from './terminal-agent-session-fork'
import { useNotificationDispatch } from './use-notification-dispatch'
import { connectPanePty } from './pty-connection'
import { shouldPreserveTerminalScrollbackBuffers } from '../../../../shared/workspace-session-terminal-buffers'
import { getFitOverrideForPty, onOverrideChange } from '@/lib/pane-manager/mobile-fit-overrides'
import {
getAllOverrides,
getFitOverrideForPty,
onOverrideChange
} from '@/lib/pane-manager/mobile-fit-overrides'
import {
getAllDrivers,
getDriverForPty,
isPtyLocked,
onDriverChange
@@ -56,11 +61,6 @@ import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution
import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
import { captureTerminalShutdownLayout } from './terminal-shutdown-layout-capture'
import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import {
getRemoteRuntimePtyEnvironmentId,
getRemoteRuntimeTerminalHandle
} from '@/runtime/runtime-terminal-stream'
import { closeWebRuntimeTerminal } from '@/runtime/web-runtime-session'
import { isPrimarySelectionEnabled, readPrimarySelectionText } from '@/lib/primary-selection'
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
@@ -85,6 +85,7 @@ import {
import { keybindingMatchesAction } from '../../../../shared/keybindings'
import { pasteTerminalClipboard } from './terminal-clipboard-paste'
import { scheduleImagePasteWebglAtlasRecovery } from './terminal-webgl-paste-recovery'
import { restoreTerminalFitToDesktop, restoreTerminalFitsToDesktop } from './terminal-fit-restore'
// Why: registry lives in a leaf module so the store slice can import it
// without re-entering the `slice → TerminalPane → store → slice` cycle
@@ -1687,6 +1688,46 @@ export default function TerminalPane({
rightClickToPaste
})
const getMobileOwnedTerminalPtyIds = useCallback((): string[] => {
const ptyIds = new Set(getAllOverrides().keys())
for (const [ptyId, driver] of getAllDrivers()) {
if (driver.kind === 'mobile') {
ptyIds.add(ptyId)
}
}
return [...ptyIds]
}, [])
const restorePaneTerminalFit = useCallback(async (pane: ManagedPane): Promise<void> => {
// Why: local and remote runtime PTYs use different transports, but the
// desktop reclaim button should have one visible recovery behavior.
const id = paneTransportsRef.current.get(pane.id)?.getPtyId()
if (!id) {
return
}
const restored = await restoreTerminalFitToDesktop(id, settingsRef.current ?? undefined)
if (restored) {
// Why: after the overlay unmounts, focus would otherwise stay on the
// removed button/body instead of the terminal the user just reclaimed.
pane.terminal.focus()
}
}, [])
const restoreAllTerminalFits = useCallback(
async (focusPane: ManagedPane): Promise<void> => {
// Why: a mobile session can leave multiple PTYs held at phone size; bulk
// restore follows the same reclaim path as the per-pane button.
const restored = await restoreTerminalFitsToDesktop(
getMobileOwnedTerminalPtyIds(),
settingsRef.current ?? undefined
)
if (restored) {
focusPane.terminal.focus()
}
},
[getMobileOwnedTerminalPtyIds]
)
const terminalShouldHandleMiddleClick = useCallback(
(target: EventTarget | null): target is Node => {
if (!(target instanceof Element)) {
@@ -2028,8 +2069,8 @@ export default function TerminalPane({
// input paused (docs/mobile-presence-lock.md). (2) No mobile driver
// but a phone-fit override is still in place → indefinite hold
// (docs/mobile-fit-hold.md). MobileDriverOverlay owns the visual
// treatment and collapse-to-chip state; both branches share a
// single IPC route through restoreTerminalFit.
// treatment and collapse-to-chip state; both branches share the
// same local/remote desktop-restore route.
const driver = getDriverForPty(ptyId)
const isMobileDriving = driver.kind === 'mobile'
const hasFitOverride = getFitOverrideForPty(ptyId) !== null
@@ -2042,38 +2083,8 @@ export default function TerminalPane({
driver={driver}
hasFitOverride={hasFitOverride}
rootClassName="mobile-driver-banner"
onAction={async () => {
// Why: same restore intent has two transports. Remote-runtime PTYs
// must call the environment RPC; local PTYs use the Electron IPC
// handler. Both resolve active-mobile and held-no-subscriber states.
const transport = paneTransportsRef.current.get(pane.id)
const id = transport?.getPtyId()
if (!id) {
return
}
const remoteHandle = getRemoteRuntimeTerminalHandle(id)
const environmentId =
getRemoteRuntimePtyEnvironmentId(id) ??
settingsRef.current?.activeRuntimeEnvironmentId ??
null
const result =
remoteHandle && environmentId
? await callRuntimeRpc<{ restored: boolean }>(
{ kind: 'environment', environmentId },
'terminal.restoreFit',
{ terminal: remoteHandle },
{ timeoutMs: 15_000 }
).catch(() => ({ restored: false }))
: await window.api.runtime
.restoreTerminalFit(id)
.catch(() => ({ restored: false }))
if (result.restored) {
// Why: after the overlay unmounts, focus would otherwise stay on
// the removed button/body instead of the terminal the user just
// reclaimed.
pane.terminal.focus()
}
}}
onAction={() => restorePaneTerminalFit(pane)}
onAllAction={() => restoreAllTerminalFits(pane)}
/>,
pane.container,
`mobile-driver-banner-${pane.id}`
@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import {
getRemoteRuntimePtyEnvironmentId,
getRemoteRuntimeTerminalHandle
} from '@/runtime/runtime-terminal-stream'
import { restoreTerminalFitToDesktop, restoreTerminalFitsToDesktop } from './terminal-fit-restore'
vi.mock('@/runtime/runtime-rpc-client', () => ({
callRuntimeRpc: vi.fn()
}))
vi.mock('@/runtime/runtime-terminal-stream', () => ({
getRemoteRuntimePtyEnvironmentId: vi.fn(),
getRemoteRuntimeTerminalHandle: vi.fn()
}))
const restoreTerminalFit = vi.fn()
describe('terminal-fit-restore', () => {
beforeEach(() => {
restoreTerminalFit.mockReset()
vi.mocked(callRuntimeRpc).mockReset()
vi.mocked(getRemoteRuntimePtyEnvironmentId).mockReset()
vi.mocked(getRemoteRuntimeTerminalHandle).mockReset()
vi.stubGlobal('window', {
api: {
runtime: {
restoreTerminalFit
}
}
})
})
it('restores local terminals through desktop IPC', async () => {
vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue(null)
restoreTerminalFit.mockResolvedValue({ restored: true })
await expect(
restoreTerminalFitToDesktop('pty-local', { activeRuntimeEnvironmentId: 'env-unused' })
).resolves.toBe(true)
expect(restoreTerminalFit).toHaveBeenCalledWith('pty-local')
expect(callRuntimeRpc).not.toHaveBeenCalled()
})
it('restores remote terminals through the environment runtime RPC', async () => {
vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue('terminal-one')
vi.mocked(getRemoteRuntimePtyEnvironmentId).mockReturnValue('env-one')
vi.mocked(callRuntimeRpc).mockResolvedValue({ restored: true })
await expect(restoreTerminalFitToDesktop('remote:pty-1', undefined)).resolves.toBe(true)
expect(callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'env-one' },
'terminal.restoreFit',
{ terminal: 'terminal-one' },
{ timeoutMs: 15_000 }
)
expect(restoreTerminalFit).not.toHaveBeenCalled()
})
it('uses the active runtime environment when the remote PTY has no encoded environment', async () => {
vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue('terminal-two')
vi.mocked(getRemoteRuntimePtyEnvironmentId).mockReturnValue(null)
vi.mocked(callRuntimeRpc).mockResolvedValue({ restored: true })
await expect(
restoreTerminalFitToDesktop('remote:pty-2', { activeRuntimeEnvironmentId: 'env-active' })
).resolves.toBe(true)
expect(callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'env-active' },
'terminal.restoreFit',
{ terminal: 'terminal-two' },
{ timeoutMs: 15_000 }
)
})
it('deduplicates bulk restore PTYs and succeeds when any restore succeeds', async () => {
vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue(null)
restoreTerminalFit.mockImplementation(async (ptyId: string) => ({
restored: ptyId === 'pty-2'
}))
await expect(
restoreTerminalFitsToDesktop(['pty-1', 'pty-1', 'pty-2'], undefined)
).resolves.toBe(true)
expect(restoreTerminalFit).toHaveBeenCalledTimes(2)
expect(restoreTerminalFit).toHaveBeenNthCalledWith(1, 'pty-1')
expect(restoreTerminalFit).toHaveBeenNthCalledWith(2, 'pty-2')
})
it('treats failed local restore transport as not restored', async () => {
vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue(null)
restoreTerminalFit.mockRejectedValue(new Error('restore failed'))
await expect(restoreTerminalFitToDesktop('pty-local', undefined)).resolves.toBe(false)
})
it('treats failed remote RPC restore transport as not restored', async () => {
vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue('terminal-fail')
vi.mocked(getRemoteRuntimePtyEnvironmentId).mockReturnValue('env-fail')
vi.mocked(callRuntimeRpc).mockRejectedValue(new Error('RPC failed'))
await expect(restoreTerminalFitToDesktop('remote:pty-fail', undefined)).resolves.toBe(false)
})
})
@@ -0,0 +1,44 @@
import type { GlobalSettings } from '../../../../shared/types'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import {
getRemoteRuntimePtyEnvironmentId,
getRemoteRuntimeTerminalHandle
} from '@/runtime/runtime-terminal-stream'
type TerminalFitRestoreSettings = Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | undefined
const restoreFailedResult = (): { restored: boolean } => {
// Why: terminal fit restore is best-effort when mobile/remote transports disappear.
return { restored: false }
}
export async function restoreTerminalFitToDesktop(
ptyId: string,
settings: TerminalFitRestoreSettings
): Promise<boolean> {
const remoteHandle = getRemoteRuntimeTerminalHandle(ptyId)
const environmentId =
getRemoteRuntimePtyEnvironmentId(ptyId) ?? settings?.activeRuntimeEnvironmentId ?? null
const result =
remoteHandle && environmentId
? await callRuntimeRpc<{ restored: boolean }>(
{ kind: 'environment', environmentId },
'terminal.restoreFit',
{ terminal: remoteHandle },
{ timeoutMs: 15_000 }
).catch(restoreFailedResult)
: await window.api.runtime.restoreTerminalFit(ptyId).catch(restoreFailedResult)
return result.restored
}
export async function restoreTerminalFitsToDesktop(
ptyIds: Iterable<string>,
settings: TerminalFitRestoreSettings
): Promise<boolean> {
const uniquePtyIds = [...new Set(ptyIds)]
const results = await Promise.all(
uniquePtyIds.map((ptyId) => restoreTerminalFitToDesktop(ptyId, settings))
)
return results.some(Boolean)
}
+12 -12
View File
@@ -1665,7 +1665,9 @@
"331ebe1170": "Add this pull request to the GitHub merge queue",
"b169f943e1": "Merge when ready",
"62703b1dc4": "GitHub auto-merge is enabled for this pull request",
"48d75ae118": "Disable auto-merge"
"48d75ae118": "Disable auto-merge",
"a5b66afb58": "Checks passed",
"fbd4f57f0a": "Checks passed. Merge eligibility will be checked again before merging."
}
}
},
@@ -2158,7 +2160,8 @@
"c44659e09f": "Mobile driving",
"7cffad954c": "Collapse",
"3eed73394f": "Your keyboard is paused",
"faa367dc74": "This terminal is sized for your mobile app"
"faa367dc74": "This terminal is sized for your mobile app",
"54f7d6f69d": "Resize all terminals"
},
"TerminalAgentSessionForkDialog": {
"17fc841e59": "Copy context",
@@ -3119,7 +3122,6 @@
"2f8298f3c3": "Stop scan",
"c157f31a95": "Import as group",
"40199ef7b3": "Group name",
"b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.",
"787412361a": "What is a group name?",
"5f857ba8e6": "in",
"4df0d08cc5": "Found",
@@ -3128,11 +3130,10 @@
"cf9d382ca1": "Import",
"220dd32d83": "Scanning...",
"fb33359f69": "Is this a monorepo?",
"d75170194e": "Choose this if these projects belong together. Orca will group them and let you work from the parent folder.",
"39d51212cc": "Monorepo name",
"e907ec8935": "What is a monorepo name?",
"d75170194e": "Import them as a group if they're a monorepo or otherwise belong together. Orca will group them and let you work from the parent folder.",
"39d51212cc": "Group name",
"aa0247680d": "No, import separately",
"a0bc4d1f8e": "Yes, import as monorepo",
"a0bc4d1f8e": "Import as group",
"8401a7a0d0": "1 repository",
"d4f1df62ef": "{{value0}} repositories",
"b4263a2ac4": "Found {{value0}} in {{value1}}.",
@@ -3864,8 +3865,7 @@
"connectFailed": "Failed to connect to project.",
"noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.",
"title": "Create Folder Workspace",
"createStart": "Create & Start Agent",
"create": "Create Workspace",
"create": "Create workspace",
"sourceProject": "Task Source",
"chooseSourceProject": "Choose task source"
}
@@ -4117,7 +4117,7 @@
"e784ea62dc": "Advanced",
"d9b65054ef": ") to a short name summarizing the task. Only branches Orca named itself are renamed, and never after they have been pushed.",
"12ea4a408d": "When an agent starts working in a new workspace, Orca renames its auto-generated branch (e.g.",
"ef787db0e3": "Auto-Rename Branch",
"ef787db0e3": "Auto-rename branch & worktree",
"6a051586d2": "Rename the auto-generated branch based on the work once an agent starts.",
"ec3e0c388e": "Save",
"cfd82406dd": "Saving...",
@@ -6161,7 +6161,7 @@
"55a1860e47": "rename",
"9319bd9827": "branch",
"ea94b9da8a": "Rename the auto-generated branch based on the work once an agent starts.",
"427f2cd1eb": "Auto-Rename Branch"
"427f2cd1eb": "Auto-rename branch & worktree"
}
}
}
@@ -8040,7 +8040,7 @@
"ae8a04ef17": "Conflict file details are unavailable",
"73d0675356": "Refreshing conflict details…",
"5dc3af25c0": "Select comment",
"d7a2f9c401": "Send unresolved {{value0}} comments",
"d7a2f9c401": "Send all unresolved",
"d91f2a6c39": "Send {{value0}} queued comments",
"a6de3e5a20": "Clear queued comments",
"49ea0937e4": "Add comment to resolve list",
+10 -10
View File
@@ -1665,7 +1665,9 @@
"331ebe1170": "Agregue esta solicitud de extracción a la cola de combinación de GitHub",
"b169f943e1": "Fusionar cuando esté listo",
"62703b1dc4": "La fusión automática de GitHub está habilitada para esta solicitud de extracción",
"48d75ae118": "Deshabilitar la fusión automática"
"48d75ae118": "Deshabilitar la fusión automática",
"a5b66afb58": "Comprobaciones aprobadas",
"fbd4f57f0a": "Comprobaciones aprobadas. La elegibilidad de fusión se volverá a comprobar antes de fusionar."
}
}
},
@@ -2158,7 +2160,8 @@
"c44659e09f": "conducción móvil",
"7cffad954c": "Colapsar",
"3eed73394f": "Tu teclado está en pausa",
"faa367dc74": "Este terminal tiene el tamaño adecuado para tu aplicación móvil"
"faa367dc74": "Este terminal tiene el tamaño adecuado para tu aplicación móvil",
"54f7d6f69d": "Cambiar tamaño de todas las terminales"
},
"TerminalAgentSessionForkDialog": {
"17fc841e59": "Copiar contexto",
@@ -3119,7 +3122,6 @@
"2f8298f3c3": "Detener escaneo",
"c157f31a95": "Importar como grupo",
"40199ef7b3": "Nombre del grupo",
"b20bb7c24f": "Mantiene estos repos juntos en un grupo. Lo mejor para repos relacionados como microservicios.",
"787412361a": "¿Qué es el nombre de un grupo?",
"5f857ba8e6": "en",
"4df0d08cc5": "Encontró",
@@ -3128,11 +3130,10 @@
"cf9d382ca1": "Importar",
"220dd32d83": "Exploración...",
"fb33359f69": "¿Es esto un monorepo?",
"d75170194e": "Elige esto si estos proyectos pertenecen juntos. Orca los agrupará y te permitirá trabajar desde la carpeta principal.",
"39d51212cc": "Nombre del monorepo",
"e907ec8935": "¿Qué es un nombre de monorepo?",
"d75170194e": "Impórtalos como grupo si son un monorepo o si pertenecen juntos. Orca los agrupará y te permitirá trabajar desde la carpeta principal.",
"39d51212cc": "Nombre del grupo",
"aa0247680d": "No, importar por separado",
"a0bc4d1f8e": "Sí, importar como monorepo",
"a0bc4d1f8e": "Importar como grupo",
"8401a7a0d0": "1 repositorio",
"d4f1df62ef": "{{value0}} repositorios",
"b4263a2ac4": "Se encontraron {{value0}} en {{value1}}.",
@@ -3864,8 +3865,7 @@
"connectFailed": "Failed to connect to project.",
"noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.",
"title": "Create Folder Workspace",
"createStart": "Create & Start Agent",
"create": "Create Workspace",
"create": "Create workspace",
"sourceProject": "Task Source",
"chooseSourceProject": "Choose task source"
}
@@ -8040,7 +8040,7 @@
"ae8a04ef17": "Los detalles del archivo de conflicto no están disponibles",
"73d0675356": "Detalles refrescantes del conflicto...",
"5dc3af25c0": "Seleccionar comentario",
"d7a2f9c401": "Enviar comentarios sin resolver de {{value0}}",
"d7a2f9c401": "Enviar todos sin resolver",
"d91f2a6c39": "Enviar {{value0}} comentarios en cola",
"a6de3e5a20": "Borrar comentarios en cola",
"49ea0937e4": "Agregar comentario a la lista de resolución",
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -1665,7 +1665,9 @@
"331ebe1170": "GitHub 병합 대기열에 이 PR을 추가하세요.",
"b169f943e1": "준비되면 병합",
"62703b1dc4": "이 PR에 대해 GitHub 자동 병합이 활성화되었습니다.",
"48d75ae118": "자동 병합 비활성화"
"48d75ae118": "자동 병합 비활성화",
"a5b66afb58": "검사 통과",
"fbd4f57f0a": "검사를 통과했습니다. 병합 전에 병합 가능 여부를 다시 확인합니다."
}
}
},
@@ -2158,7 +2160,8 @@
"c44659e09f": "모바일 원격 조작",
"7cffad954c": "접기",
"3eed73394f": "키보드가 일시중지되었습니다.",
"faa367dc74": "이 terminal은 모바일 앱에 맞게 크기가 조정됩니다."
"faa367dc74": "이 terminal은 모바일 앱에 맞게 크기가 조정됩니다.",
"54f7d6f69d": "모든 terminal 크기 조정"
},
"TerminalAgentSessionForkDialog": {
"17fc841e59": "컨텍스트 복사",
@@ -3100,7 +3103,6 @@
"2f8298f3c3": "스캔 중지",
"c157f31a95": "그룹으로 가져오기",
"40199ef7b3": "그룹 이름",
"b20bb7c24f": "이러한 repos를 하나의 그룹에 함께 유지합니다. 마이크로서비스와 같은 관련 repos 에 가장 적합합니다.",
"787412361a": "그룹 이름은 무엇입니까?",
"5f857ba8e6": "~에",
"4df0d08cc5": "발견됨",
@@ -3109,11 +3111,10 @@
"cf9d382ca1": "수입",
"220dd32d83": "스캐닝...",
"fb33359f69": "이 폴더가 모노레포인가요?",
"d75170194e": "이 프로젝트들이 함께 속한다면 이것을 선택하세요. Orca가 그룹으로 묶고 상위 폴더에서 작업할 수 있게 합니다.",
"39d51212cc": "모노레포 이름",
"e907ec8935": "모노레포 이름이란 무엇인가요?",
"d75170194e": "모노레포이거나 함께 속한 프로젝트라면 그룹으로 가져오세요. Orca가 그룹으로 묶고 상위 폴더에서 작업할 수 있게 합니다.",
"39d51212cc": "그룹 이름",
"aa0247680d": "아니요, 별도로 가져오기",
"a0bc4d1f8e": "예, 모노레포로 가져오기",
"a0bc4d1f8e": "그룹으로 가져오기",
"8401a7a0d0": "저장소 1개",
"d4f1df62ef": "저장소 {{value0}}개",
"b4263a2ac4": "{{value1}}에서 {{value0}}을(를) 찾았습니다.",
@@ -3632,7 +3633,7 @@
"c1f4a31623": "{{value0}}개 하위 워크스페이스 표시",
"e97297cb75": "{{value0}}개 하위 워크스페이스 숨기기",
"0cd15956d4": "{{value0}}개 하위 워크스페이스 숨기기",
"bd37a57ac8": "Create workspace for {{value0}}",
"bd37a57ac8": "{{value0}}에 대한 워크스페이스 만들기",
"b667b59632": "Some projects could not be removed from Orca",
"f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.",
"groupDeleteFailed": "Failed to delete group",
@@ -3864,8 +3865,7 @@
"connectFailed": "Failed to connect to project.",
"noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.",
"title": "Create Folder Workspace",
"createStart": "Create & Start Agent",
"create": "Create Workspace",
"create": "워크스페이스 만들기",
"sourceProject": "Task Source",
"chooseSourceProject": "Choose task source"
}
@@ -8040,7 +8040,7 @@
"ae8a04ef17": "충돌 파일 세부정보를 사용할 수 없습니다.",
"73d0675356": "충돌 세부정보 새로고침 중…",
"5dc3af25c0": "댓글 선택",
"d7a2f9c401": "해결되지 않은 {{value0}} 댓글 보내기",
"d7a2f9c401": "해결되지 않은 항목 모두 보내기",
"d91f2a6c39": "대기 중인 댓글 {{value0}}개 보내기",
"a6de3e5a20": "대기 중인 댓글 지우기",
"49ea0937e4": "댓글을 해결 목록에 추가",
+11 -11
View File
@@ -1665,7 +1665,9 @@
"331ebe1170": "将此PR添加到 GitHub 合并队列",
"b169f943e1": "准备好后合并",
"62703b1dc4": "为此PR启用了 GitHub 自动合并",
"48d75ae118": "禁用自动合并"
"48d75ae118": "禁用自动合并",
"a5b66afb58": "检查已通过",
"fbd4f57f0a": "检查已通过。合并资格将在合并前再次验证。"
}
}
},
@@ -2158,7 +2160,8 @@
"c44659e09f": "手机驾驶",
"7cffad954c": "折叠",
"3eed73394f": "您的键盘已暂停",
"faa367dc74": "该 terminal 的尺寸适合您的手机应用程序"
"faa367dc74": "该 terminal 的尺寸适合您的手机应用程序",
"54f7d6f69d": "调整所有 terminal 尺寸"
},
"TerminalAgentSessionForkDialog": {
"17fc841e59": "复制上下文",
@@ -3100,7 +3103,6 @@
"2f8298f3c3": "停止扫描",
"c157f31a95": "作为组导入",
"40199ef7b3": "团体名称",
"b20bb7c24f": "将这些 repos 放在一组中。最适合微服务等相关 repos。",
"787412361a": "什么是群组名称?",
"5f857ba8e6": "在",
"4df0d08cc5": "成立",
@@ -3109,11 +3111,10 @@
"cf9d382ca1": "导入",
"220dd32d83": "扫描...",
"fb33359f69": "这是 Monorepo 吗?",
"d75170194e": "如果这些项目属于同一组,请选择此项。Orca 会将它们分组,并让你从父文件夹开始工作。",
"39d51212cc": "Monorepo 名称",
"e907ec8935": "什么是 Monorepo 名称?",
"d75170194e": "如果它们是 Monorepo,或本来就应该放在一起,请将它们作为组导入。Orca 会将它们分组,并让你从父文件夹开始工作。",
"39d51212cc": "组名称",
"aa0247680d": "否,单独导入",
"a0bc4d1f8e": "是,作为 Monorepo 导入",
"a0bc4d1f8e": "作为组导入",
"8401a7a0d0": "1 个仓库",
"d4f1df62ef": "{{value0}} 个仓库",
"b4263a2ac4": "在 {{value1}} 中找到 {{value0}}。",
@@ -3632,7 +3633,7 @@
"c1f4a31623": "显示 {{value0}} 个子工作区",
"e97297cb75": "隐藏 {{value0}} 个子工作区",
"0cd15956d4": "隐藏 {{value0}} 个子工作区",
"bd37a57ac8": "Create workspace for {{value0}}",
"bd37a57ac8": "为 {{value0}} 创建工作区",
"b667b59632": "Some projects could not be removed from Orca",
"f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.",
"groupDeleteFailed": "Failed to delete group",
@@ -3864,8 +3865,7 @@
"connectFailed": "Failed to connect to project.",
"noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.",
"title": "Create Folder Workspace",
"createStart": "Create & Start Agent",
"create": "Create Workspace",
"create": "创建工作区",
"sourceProject": "Task Source",
"chooseSourceProject": "Choose task source"
}
@@ -8040,7 +8040,7 @@
"ae8a04ef17": "冲突文件详细信息不可用",
"73d0675356": "刷新冲突细节...",
"5dc3af25c0": "选择评论",
"d7a2f9c401": "发送未解决的 {{value0}} 评论",
"d7a2f9c401": "发送所有未解决",
"d91f2a6c39": "发送 {{value0}} 条已排队评论",
"a6de3e5a20": "清除已排队评论",
"49ea0937e4": "将评论添加到解决列表",

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