mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 08:03:09 +00:00
Merge branch 'main' of https://github.com/stablyai/orca into bug-regression-gitlab-merge-request-creation-opt
This commit is contained in:
@@ -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 アカウント'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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'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)'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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) })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]!
|
||||
|
||||
@@ -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
@@ -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'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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) ────────────
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+285
@@ -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'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)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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": "댓글을 해결 목록에 추가",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user